Compare commits

..

13 Commits

Author SHA1 Message Date
Colin Adler 2d37eb42e7 Merge pull request from GHSA-7cc2-r658-7xpf
This fixes a vulnerability with the `CODER_OIDC_EMAIL_DOMAIN` option,
where users with a superset of the allowed email domain would be allowed
to login. For example, given `CODER_OIDC_EMAIL_DOMAIN=google.com`, a
user would be permitted entry if their email domain was
`colin-google.com`.

(cherry picked from commit 4439a920e4)
2024-03-04 18:46:34 +00:00
Colin Adler e7033b34dc chore: add patch notes for v2.8.4 2024-03-04 18:46:22 +00:00
Ben 01c6266e3e docs: add v2.8.3 changelog 2024-02-15 17:20:43 +00:00
Spike Curtis f9011dcba2 feat: expose DERP server debug metrics (#12135) (#12151)
Adds some debug endpoints for looking into the DERP server.

The `api/v2/debug/derp/traffic` endpoint requires the `ss` utility to be present in order to function.  I have *not* added the `iproute2` package to our base image as it adds 11MB, so this endpoint won't be useful by default.  However, in a debugging situation, we could exec into the container and then `apk add iproute2`, or build a special debug image.

The `api/v2/debug/expvar` handler contains DERP metrics as well as commandline and memstats.

Example:

```
{
"alert_failed": 0,
"alert_generated": 0,
"cmdline": ["/Users/spike/repos/coder/build/coder_darwin_arm64","--global-config","/Users/spike/repos/coder/.coderv2","server","--http-address","0.0.0.0:3000","--swagger-enable","--access-url","http://127.0.0.1:3000","--dangerous-allow-cors-requests=true"],
"derp": {"accepts": 1, "average_queue_duration_ms": 0, "bytes_received": 0, "bytes_sent": 0, "counter_packets_dropped_reason": {"gone_disconnected": 0, "gone_not_here": 0, "queue_head": 0, "queue_tail": 0, "unknown_dest": 0, "unknown_dest_on_fwd": 0, "write_error": 0}, "counter_packets_dropped_type": {"disco": 0, "other": 0}, "counter_packets_received_kind": {"disco": 0, "other": 0}, "counter_tcp_rtt": {}, "counter_total_dup_client_conns": 0, "gauge_clients_local": 1, "gauge_clients_remote": 0, "gauge_clients_total": 1, "gauge_current_connections": 1, "gauge_current_dup_client_conns": 0, "gauge_current_dup_client_keys": 0, "gauge_current_file_descriptors": 0, "gauge_current_home_connections": 1, "gauge_memstats_sys0": 20874504, "gauge_watchers": 0, "got_ping": 0, "home_moves_in": 0, "home_moves_out": 0, "multiforwarder_created": 0, "multiforwarder_deleted": 0, "packet_forwarder_delete_other_value": 0, "packets_dropped": 0, "packets_forwarded_in": 0, "packets_forwarded_out": 0, "packets_received": 0, "packets_sent": 0, "peer_gone_disconnected_frames": 0, "peer_gone_not_here_frames": 0, "sent_pong": 0, "unknown_frames": 0, "version": "1.47.0-dev20240214-t64db8c604"},
"memstats": {"Alloc":286506256,"TotalAlloc":297594632,"Sys":310621512,"Lookups":0,"Mallocs":304204,"Frees":171570,"HeapAlloc":286506256,"HeapSys":294060032,"HeapIdle":3694592,"HeapInuse":290365440,"HeapReleased":3620864,"HeapObjects":132634,"StackInuse":3735552,"StackSys":3735552,"MSpanInuse":347256,"MSpanSys":358512,"MCacheInuse":9600,"MCacheSys":15600,"BuckHashSys":1469877,"GCSys":9434896,"OtherSys":1547043,"NextGC":551867656,"LastGC":1707892877408883000,"PauseTotalNs":1247000,"PauseNs":[200333,229375,239875,209542,106958,203792,57125,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"PauseEnd":[1707892876217481000,1707892876219726000,1707892876222273000,1707892876226151000,1707892876234815000,1707892877398146000,1707892877408883000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"NumGC":7,"NumForcedGC":0,"GCCPUFraction":0.0022425810335762954,"EnableGC":true,"DebugGC":false,"BySize":[{"Size":0,"Mallocs":0,"Frees":0},{"Size":8,"Mallocs":14396,"Frees":9143},{"Size":16,"Mallocs":89090,"Frees":50507},{"Size":24,"Mallocs":40839,"Frees":24456},{"Size":32,"Mallocs":22404,"Frees":12379},{"Size":48,"Mallocs":51174,"Frees":23718},{"Size":64,"Mallocs":15406,"Frees":3501},{"Size":80,"Mallocs":6688,"Frees":2352},{"Size":96,"Mallocs":2567,"Frees":374},{"Size":112,"Mallocs":19371,"Frees":16883},{"Size":128,"Mallocs":2873,"Frees":1061},{"Size":144,"Mallocs":5600,"Frees":2742},{"Size":160,"Mallocs":2159,"Frees":622},{"Size":176,"Mallocs":454,"Frees":86},{"Size":192,"Mallocs":227,"Frees":128},{"Size":208,"Mallocs":1407,"Frees":732},{"Size":224,"Mallocs":1365,"Frees":1090},{"Size":240,"Mallocs":82,"Frees":48},{"Size":256,"Mallocs":310,"Frees":162},{"Size":288,"Mallocs":1945,"Frees":562},{"Size":320,"Mallocs":1200,"Frees":458},{"Size":352,"Mallocs":133,"Frees":33},{"Size":384,"Mallocs":582,"Frees":51},{"Size":416,"Mallocs":747,"Frees":200},{"Size":448,"Mallocs":113,"Frees":22},{"Size":480,"Mallocs":34,"Frees":21},{"Size":512,"Mallocs":951,"Frees":91},{"Size":576,"Mallocs":364,"Frees":122},{"Size":640,"Mallocs":532,"Frees":270},{"Size":704,"Mallocs":93,"Frees":39},{"Size":768,"Mallocs":83,"Frees":35},{"Size":896,"Mallocs":308,"Frees":175},{"Size":1024,"Mallocs":226,"Frees":122},{"Size":1152,"Mallocs":198,"Frees":100},{"Size":1280,"Mallocs":314,"Frees":171},{"Size":1408,"Mallocs":77,"Frees":47},{"Size":1536,"Mallocs":80,"Frees":54},{"Size":1792,"Mallocs":199,"Frees":107},{"Size":2048,"Mallocs":112,"Frees":48},{"Size":2304,"Mallocs":71,"Frees":32},{"Size":2688,"Mallocs":206,"Frees":81},{"Size":3072,"Mallocs":39,"Frees":15},{"Size":3200,"Mallocs":16,"Frees":7},{"Size":3456,"Mallocs":44,"Frees":29},{"Size":4096,"Mallocs":192,"Frees":83},{"Size":4864,"Mallocs":44,"Frees":25},{"Size":5376,"Mallocs":105,"Frees":43},{"Size":6144,"Mallocs":25,"Frees":5},{"Size":6528,"Mallocs":22,"Frees":7},{"Size":6784,"Mallocs":3,"Frees":0},{"Size":6912,"Mallocs":4,"Frees":2},{"Size":8192,"Mallocs":59,"Frees":10},{"Size":9472,"Mallocs":31,"Frees":12},{"Size":9728,"Mallocs":5,"Frees":2},{"Size":10240,"Mallocs":5,"Frees":0},{"Size":10880,"Mallocs":27,"Frees":11},{"Size":12288,"Mallocs":4,"Frees":1},{"Size":13568,"Mallocs":4,"Frees":2},{"Size":14336,"Mallocs":9,"Frees":2},{"Size":16384,"Mallocs":10,"Frees":2},{"Size":18432,"Mallocs":4,"Frees":2}]},
"warning_failed": 0,
"warning_generated": 0
}
```

If we find the DERP metrics useful we could consider how to include them in Prometheus scrapes based on the tailnet `varz` package.  That's for a later PR if at all.
2024-02-15 09:10:29 +04:00
Spike Curtis ae1be27ba6 fix: set node callback each time we reinit the coordinator in servertailnet (#12140) (#12150)
I think this will resolve #12136 but lets get a proper test at the system level before closing.

Before this change, we only register the node callback at start of day for the server tailnet.  If the coordinator changes, like we know happens when we are licensed for the PGCoordinator, we close the connection to the old coord, and open a new one to the new coord.

The callback is designed to direct the updates to the new coordinator, but there is nothing that specifically triggers it to fire after we connect to the new coordinator.

If we have STUN, then period re-STUNs will generally get it to fire eventually, but without STUN it we could go indefinitely without a callback.

This PR changes the servertailnet to re-register the callback each time we reconnect to the coordinator.  Registering a callback (even if it's the same callback) triggers an immediate call with our node information, so the new coordinator will have it.
2024-02-15 08:58:04 +04:00
Colin Adler c4a01a42ce chore(docs): add v2.8.2 changelog 2024-02-12 05:33:05 +00:00
Dean Sheather c0aeb2fc2e fix: copy app ID in healthcheck (#12087)
(cherry picked from commit 429144da22)
2024-02-12 05:30:34 +00:00
Bruno Quaresma 908d236a19 fix(site): enable submit when auto start and stop are both disabled (#12055) 2024-02-07 18:28:33 +00:00
Spike Curtis f519db88fb fix: allow startup scripts larger than 32k (#12060)
Fixes #12057 and adds a regression test.
2024-02-07 18:28:14 +00:00
Ben e996e8b7e8 fix typo 2024-02-07 00:10:37 +00:00
Ben da60671b33 formatting + add TF version 2024-02-07 00:08:19 +00:00
Ben 963a1404c0 fmt 2024-02-06 23:41:58 +00:00
Ben 002110228c docs: add v2.8.0 changelog 2024-02-06 23:39:03 +00:00
7894 changed files with 196859 additions and 411688 deletions
+10 -15
View File
@@ -1,18 +1,13 @@
{
"name": "Development environments on your infrastructure",
"image": "codercom/oss-dogfood:latest",
"name": "Development environments on your infrastructure",
"image": "codercom/oss-dogfood:latest",
"features": {
// See all possible options here https://github.com/devcontainers/features/tree/main/src/docker-in-docker
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": "false"
}
},
// SYS_PTRACE to enable go debugging
"runArgs": ["--cap-add=SYS_PTRACE"],
"customizations": {
"vscode": {
"extensions": ["biomejs.biome"]
}
}
"features": {
// See all possible options here https://github.com/devcontainers/features/tree/main/src/docker-in-docker
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": "false"
}
},
// SYS_PTRACE to enable go debugging
"runArgs": ["--cap-add=SYS_PTRACE"]
}
+6
View File
@@ -0,0 +1,6 @@
# Ignore all files and folders
**
# Include flake.nix and flake.lock
!flake.nix
!flake.lock
+1 -1
View File
@@ -7,7 +7,7 @@ trim_trailing_whitespace = true
insert_final_newline = true
indent_style = tab
[*.{yaml,yml,tf,tfvars,nix}]
[*.{md,json,yaml,yml,tf,tfvars,nix}]
indent_style = space
indent_size = 2
-2
View File
@@ -3,5 +3,3 @@
# chore: format code with semicolons when using prettier (#9555)
988c9af0153561397686c119da9d1336d2433fdd
# chore: use tabs for prettier and biome (#14283)
95a7c0c4f087744a22c2e88dd3c5d30024d5fb02
+2 -5
View File
@@ -1,18 +1,15 @@
# Generated files
agent/agentcontainers/acmock/acmock.go linguist-generated=true
coderd/apidoc/docs.go linguist-generated=true
docs/reference/api/*.md linguist-generated=true
docs/reference/cli/*.md linguist-generated=true
docs/api/*.md linguist-generated=true
docs/cli/*.md linguist-generated=true
coderd/apidoc/swagger.json linguist-generated=true
coderd/database/dump.sql linguist-generated=true
peerbroker/proto/*.go linguist-generated=true
provisionerd/proto/*.go linguist-generated=true
provisionerd/proto/version.go linguist-generated=false
provisionersdk/proto/*.go linguist-generated=true
*.tfplan.json linguist-generated=true
*.tfstate.json linguist-generated=true
*.tfstate.dot linguist-generated=true
*.tfplan.dot linguist-generated=true
site/e2e/provisionerGenerated.ts linguist-generated=true
site/src/api/typesGenerated.ts linguist-generated=true
site/src/pages/SetupPage/countries.tsx linguist-generated=true
-25
View File
@@ -1,25 +0,0 @@
dirs:
- docs
excludedDirs:
# Downstream bug in linkspector means large markdown files fail to parse
# but these are autogenerated and shouldn't need checking
- docs/reference
# Older changelogs may contain broken links
- docs/changelogs
ignorePatterns:
- pattern: "localhost"
- pattern: "example.com"
- pattern: "mailto:"
- pattern: "127.0.0.1"
- pattern: "0.0.0.0"
- pattern: "JFROG_URL"
- pattern: "coder.company.org"
# These real sites were blocking the linkspector action / GitHub runner IPs(?)
- pattern: "i.imgur.com"
- pattern: "code.visualstudio.com"
- pattern: "www.emacswiki.org"
- pattern: "linux.die.net/man"
- pattern: "www.gnu.org"
- pattern: "wiki.ubuntu.com"
aliveStatusCodes:
- 200
-78
View File
@@ -1,78 +0,0 @@
name: "🐞 Bug"
description: "File a bug report."
title: "<title>"
labels: ["needs-triage"]
body:
- type: checkboxes
id: existing_issues
attributes:
label: "Is there an existing issue for this?"
description: "Please search to see if an issue already exists for the bug you encountered."
options:
- label: "I have searched the existing issues"
required: true
- type: textarea
id: issue
attributes:
label: "Current Behavior"
description: "A concise description of what you're experiencing."
placeholder: "Tell us what you see!"
validations:
required: false
- type: textarea
id: logs
attributes:
label: "Relevant Log Output"
description: "Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks."
render: shell
- type: textarea
id: expected
attributes:
label: "Expected Behavior"
description: "A concise description of what you expected to happen."
validations:
required: false
- type: textarea
id: steps_to_reproduce
attributes:
label: "Steps to Reproduce"
description: "Provide step-by-step instructions to reproduce the issue."
placeholder: |
1. First step
2. Second step
3. Another step
4. Issue occurs
validations:
required: true
- type: textarea
id: environment
attributes:
label: "Environment"
description: |
Provide details about your environment:
- **Host OS**: (e.g., Ubuntu 24.04, Debian 12)
- **Coder Version**: (e.g., v2.18.4)
placeholder: |
Run `coder version` to get Coder version
value: |
- Host OS:
- Coder version:
validations:
required: false
- type: dropdown
id: additional_info
attributes:
label: "Additional Context"
description: "Select any applicable options:"
multiple: true
options:
- "The issue occurs consistently"
- "The issue is new (previously worked fine)"
- "The issue happens on multiple deployments"
- "I have tested this on the latest version"
-10
View File
@@ -1,10 +0,0 @@
contact_links:
- name: Questions, suggestion or feature requests?
url: https://github.com/coder/coder/discussions/new/choose
about: Our preferred starting point if you have any questions or suggestions about configuration, features or unexpected behavior.
- name: Coder Docs
url: https://coder.com/docs
about: Check our docs.
- name: Coder Discord Community
url: https://discord.gg/coder
about: Get in touch with the Coder developers and community for support.
+2 -2
View File
@@ -4,12 +4,12 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.22.8"
default: "1.21.5"
runs:
using: "composite"
steps:
- name: Setup Go
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
uses: buildjet/setup-go@v4
with:
go-version: ${{ inputs.version }}
-27
View File
@@ -1,27 +0,0 @@
name: "Setup ImDisk"
if: runner.os == 'Windows'
description: |
Sets up the ImDisk toolkit for Windows and creates a RAM disk on drive R:.
runs:
using: "composite"
steps:
- name: Download ImDisk
if: runner.os == 'Windows'
shell: bash
run: |
mkdir imdisk
cd imdisk
curl -L -o files.cab https://github.com/coder/imdisk-artifacts/raw/92a17839ebc0ee3e69be019f66b3e9b5d2de4482/files.cab
curl -L -o install.bat https://github.com/coder/imdisk-artifacts/raw/92a17839ebc0ee3e69be019f66b3e9b5d2de4482/install.bat
cd ..
- name: Install ImDisk
shell: cmd
run: |
cd imdisk
install.bat /silent
- name: Create RAM Disk
shell: cmd
run: |
imdisk -a -s 4096M -m R: -p "/fs:ntfs /q /y"
+6 -6
View File
@@ -11,16 +11,16 @@ runs:
using: "composite"
steps:
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0
- name: Setup Node
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
uses: pnpm/action-setup@v2
with:
node-version: 20.16.0
version: 8
- name: Setup Node
uses: buildjet/setup-node@v3
with:
node-version: 18.19.0
# See https://github.com/actions/setup-node#caching-global-packages-data
cache: "pnpm"
cache-dependency-path: ${{ inputs.directory }}/pnpm-lock.yaml
- name: Install root node_modules
shell: bash
run: ./scripts/pnpm_install.sh
+2 -2
View File
@@ -5,6 +5,6 @@ runs:
using: "composite"
steps:
- name: Setup sqlc
uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
uses: sqlc-dev/setup-sqlc@v4
with:
sqlc-version: "1.27.0"
sqlc-version: "1.25.0"
+2 -2
View File
@@ -5,7 +5,7 @@ runs:
using: "composite"
steps:
- name: Install Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.10.5
terraform_version: 1.5.7
terraform_wrapper: false
+1 -2
View File
@@ -1,6 +1,5 @@
name: Upload tests to datadog
description: |
Uploads the test results to datadog.
if: always()
inputs:
api-key:
description: "Datadog API key"
-2
View File
@@ -1,2 +0,0 @@
enabled: true
preservePullRequestTitle: true
+43
View File
@@ -0,0 +1,43 @@
codecov:
require_ci_to_pass: false
notify:
after_n_builds: 5
comment: false
github_checks:
annotations: false
coverage:
range: 50..75
round: down
precision: 2
status:
patch:
default:
informational: yes
project:
default:
target: 65%
informational: true
ignore:
# This is generated code.
- coderd/database/models.go
- coderd/database/queries.sql.go
- coderd/database/databasefake
# These are generated or don't require tests.
- cmd
- coderd/tunnel
- coderd/database/dump
- coderd/database/postgres
- peerbroker/proto
- provisionerd/proto
- provisionersdk/proto
- scripts
- site/.storybook
- rules.go
# Packages used for writing tests.
- cli/clitest
- coderd/coderdtest
- pty/ptytest
+67 -44
View File
@@ -9,6 +9,21 @@ updates:
labels: []
commit-message:
prefix: "ci"
ignore:
# These actions deliver the latest versions by updating the major
# release tag, so ignore minor and patch versions
- dependency-name: "actions/*"
update-types:
- version-update:semver-minor
- version-update:semver-patch
- dependency-name: "Apple-Actions/import-codesign-certs"
update-types:
- version-update:semver-minor
- version-update:semver-patch
- dependency-name: "marocchino/sticky-pull-request-comment"
update-types:
- version-update:semver-minor
- version-update:semver-patch
groups:
github-actions:
patterns:
@@ -24,10 +39,6 @@ updates:
prefix: "chore"
labels: []
open-pull-requests-limit: 15
groups:
x:
patterns:
- "golang.org/x/*"
ignore:
# Ignore patch updates for all dependencies
- dependency-name: "*"
@@ -36,13 +47,7 @@ updates:
# Update our Dockerfile.
- package-ecosystem: "docker"
directories:
- "/dogfood/contents"
- "/scripts"
- "/examples/templates/docker/build"
- "/examples/parameters/build"
- "/scaletest/templates/scaletest-runner"
- "/scripts/ironbank"
directory: "/scripts/"
schedule:
interval: "weekly"
time: "06:00"
@@ -56,12 +61,7 @@ updates:
- dependency-name: "terraform"
- package-ecosystem: "npm"
directories:
- "/site"
- "/offlinedocs"
- "/scripts"
- "/scripts/apidocgen"
directory: "/site/"
schedule:
interval: "monthly"
time: "06:00"
@@ -71,35 +71,58 @@ updates:
commit-message:
prefix: "chore"
labels: []
groups:
xterm:
patterns:
- "@xterm*"
mui:
patterns:
- "@mui*"
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
emotion:
patterns:
- "@emotion*"
exclude-patterns:
- "jest-runner-eslint"
jest:
patterns:
- "jest"
- "@types/jest"
vite:
patterns:
- "vite*"
- "@vitejs/plugin-react"
ignore:
# Ignore major version updates to avoid breaking changes
# Ignore patch updates for all dependencies
- dependency-name: "*"
update-types:
- version-update:semver-patch
# Ignore major updates to Node.js types, because they need to
# correspond to the Node.js engine version
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
open-pull-requests-limit: 15
groups:
site:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/offlinedocs/"
schedule:
interval: "monthly"
time: "06:00"
timezone: "America/Chicago"
reviewers:
- "coder/ts"
commit-message:
prefix: "chore"
labels: []
ignore:
# Ignore patch updates for all dependencies
- dependency-name: "*"
update-types:
- version-update:semver-patch
# Ignore major updates to Node.js types, because they need to
# correspond to the Node.js engine version
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
groups:
offlinedocs:
patterns:
- "*"
# Update dogfood.
- package-ecosystem: "terraform"
directory: "/dogfood/"
schedule:
interval: "weekly"
time: "06:00"
timezone: "America/Chicago"
commit-message:
prefix: "chore"
labels: []
ignore:
# We likely want to update this ourselves.
- dependency-name: "coder/coder"
-34
View File
@@ -1,34 +0,0 @@
app = "jnb-coder"
primary_region = "jnb"
[experimental]
entrypoint = ["/bin/sh", "-c", "CODER_DERP_SERVER_RELAY_URL=\"http://[${FLY_PRIVATE_IP}]:3000\" /opt/coder wsproxy server"]
auto_rollback = true
[build]
image = "ghcr.io/coder/coder-preview:main"
[env]
CODER_ACCESS_URL = "https://jnb.fly.dev.coder.com"
CODER_HTTP_ADDRESS = "0.0.0.0:3000"
CODER_PRIMARY_ACCESS_URL = "https://dev.coder.com"
CODER_WILDCARD_ACCESS_URL = "*--apps.jnb.fly.dev.coder.com"
CODER_VERBOSE = "true"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
[http_service.concurrency]
type = "requests"
soft_limit = 50
hard_limit = 100
[[vm]]
cpu_kind = "shared"
cpus = 2
memory_mb = 512
-6
View File
@@ -22,12 +22,6 @@ primary_region = "cdg"
auto_start_machines = true
min_machines_running = 0
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
[http_service.concurrency]
type = "requests"
soft_limit = 50
hard_limit = 100
[[vm]]
cpu_kind = "shared"
cpus = 2
@@ -22,12 +22,6 @@ primary_region = "gru"
auto_start_machines = true
min_machines_running = 0
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
[http_service.concurrency]
type = "requests"
soft_limit = 50
hard_limit = 100
[[vm]]
cpu_kind = "shared"
cpus = 2
-6
View File
@@ -22,12 +22,6 @@ primary_region = "syd"
auto_start_machines = true
min_machines_running = 0
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
[http_service.concurrency]
type = "requests"
soft_limit = 50
hard_limit = 100
[[vm]]
cpu_kind = "shared"
cpus = 2
+14 -14
View File
@@ -86,12 +86,12 @@ provider "kubernetes" {
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
startup_script = <<-EOT
os = "linux"
arch = "amd64"
startup_script_timeout = 180
startup_script = <<-EOT
set -e
# install and start code-server
@@ -176,21 +176,21 @@ resource "coder_app" "code-server" {
resource "kubernetes_persistent_volume_claim" "home" {
metadata {
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-home"
name = "coder-${lower(data.coder_workspace.me.owner)}-${lower(data.coder_workspace.me.name)}-home"
namespace = var.namespace
labels = {
"app.kubernetes.io/name" = "coder-pvc"
"app.kubernetes.io/instance" = "coder-pvc-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
"app.kubernetes.io/instance" = "coder-pvc-${lower(data.coder_workspace.me.owner)}-${lower(data.coder_workspace.me.name)}"
"app.kubernetes.io/part-of" = "coder"
//Coder-specific labels.
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
"com.coder.user.id" = data.coder_workspace.me.owner_id
"com.coder.user.username" = data.coder_workspace.me.owner
}
annotations = {
"com.coder.user.email" = data.coder_workspace_owner.me.email
"com.coder.user.email" = data.coder_workspace.me.owner_email
}
}
wait_until_bound = false
@@ -211,20 +211,20 @@ resource "kubernetes_deployment" "main" {
]
wait_for_rollout = false
metadata {
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
name = "coder-${lower(data.coder_workspace.me.owner)}-${lower(data.coder_workspace.me.name)}"
namespace = var.namespace
labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = "coder-workspace-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
"app.kubernetes.io/instance" = "coder-workspace-${lower(data.coder_workspace.me.owner)}-${lower(data.coder_workspace.me.name)}"
"app.kubernetes.io/part-of" = "coder"
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
"com.coder.user.id" = data.coder_workspace.me.owner_id
"com.coder.user.username" = data.coder_workspace.me.owner
}
annotations = {
"com.coder.user.email" = data.coder_workspace_owner.me.email
"com.coder.user.email" = data.coder_workspace.me.owner_email
}
}
+157 -646
View File
File diff suppressed because it is too large Load Diff
+17 -14
View File
@@ -2,7 +2,7 @@ name: contrib
on:
issue_comment:
types: [created, edited]
types: [created]
pull_request_target:
types:
- opened
@@ -10,30 +10,35 @@ on:
- synchronize
- labeled
- unlabeled
- opened
- reopened
- edited
# For jobs that don't run on draft PRs.
- ready_for_review
permissions:
contents: read
# Only run one instance per PR to ensure in-order execution.
concurrency: pr-${{ github.ref }}
jobs:
cla:
# Dependabot is annoying, but this makes it a bit less so.
auto-approve-dependabot:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target'
permissions:
pull-requests: write
steps:
- name: auto-approve dependabot
uses: hmarr/auto-approve-action@v4
if: github.actor == 'dependabot[bot]'
cla:
runs-on: ubuntu-latest
steps:
- name: cla
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
uses: contributor-assistant/github-action@v2.3.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CDRCI2_GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.CDRCOMMUNITY_GITHUB_TOKEN }}
with:
remote-organization-name: "coder"
remote-repository-name: "cla"
@@ -46,13 +51,11 @@ jobs:
release-labels:
runs-on: ubuntu-latest
permissions:
pull-requests: write
# Skip tagging for draft PRs.
if: ${{ github.event_name == 'pull_request_target' && !github.event.pull_request.draft }}
if: ${{ github.event_name == 'pull_request_target' && success() && !github.event.pull_request.draft }}
steps:
- name: release-labels
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@v7
with:
# This script ensures PR title and labels are in sync:
#
@@ -84,7 +87,7 @@ jobs:
repo: context.repo.repo,
}
if (action === "opened" || action === "reopened" || action === "ready_for_review") {
if (action === "opened" || action === "reopened") {
if (isBreakingTitle && !labels.includes(releaseLabels.breaking)) {
console.log('Add "%s" label', releaseLabels.breaking)
await github.rest.issues.addLabels({
-88
View File
@@ -1,88 +0,0 @@
name: dependabot
on:
pull_request:
types:
- opened
permissions:
contents: read
jobs:
dependabot-automerge:
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request' &&
github.event.action == 'opened' &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.actor_id == 49699333 &&
github.repository == 'coder/coder'
permissions:
pull-requests: write
contents: write
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7 # v2.3.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve the PR
run: |
echo "Approving $PR_URL"
gh pr review --approve "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Enable auto-merge
run: |
echo "Enabling auto-merge for $PR_URL"
gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Send Slack notification
env:
PR_URL: ${{github.event.pull_request.html_url}}
PR_TITLE: ${{github.event.pull_request.title}}
PR_NUMBER: ${{github.event.pull_request.number}}
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{
"username": "dependabot",
"icon_url": "https://avatars.githubusercontent.com/u/27347476",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": ":pr-merged: Auto merge enabled for Dependabot PR #${{ env.PR_NUMBER }}",
"emoji": true
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "${{ env.PR_TITLE }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View PR"
},
"url": "${{ env.PR_URL }}"
}
]
}
]
}' ${{ secrets.DEPENDABOT_PRS_SLACK_WEBHOOK }}
+9 -22
View File
@@ -8,11 +8,6 @@ on:
- scripts/Dockerfile.base
- scripts/Dockerfile
pull_request:
paths:
- scripts/Dockerfile.base
- .github/workflows/docker-base.yaml
schedule:
# Run every week at 09:43 on Monday, Wednesday and Friday. We build this
# frequently to ensure that packages are up-to-date.
@@ -22,6 +17,10 @@ on:
permissions:
contents: read
# Necessary to push docker images to ghcr.io.
packages: write
# Necessary for depot.dev authentication.
id-token: write
# Avoid running multiple jobs for the same commit.
concurrency:
@@ -29,24 +28,14 @@ concurrency:
jobs:
build:
permissions:
# Necessary for depot.dev authentication.
id-token: write
# Necessary to push docker images to ghcr.io.
packages: write
runs-on: ubuntu-latest
if: github.repository_owner == 'coder'
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Docker login
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -56,25 +45,23 @@ jobs:
run: mkdir base-build-context
- name: Install depot.dev CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
uses: depot/setup-action@v1
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
uses: depot/build-push-action@v1
with:
project: wl5hnrrkns
context: base-build-context
file: scripts/Dockerfile.base
platforms: linux/amd64,linux/arm64,linux/arm/v7
provenance: true
pull: true
no-cache: true
push: ${{ github.event_name != 'pull_request' }}
push: true
tags: |
ghcr.io/coder/coder-base:latest
- name: Verify that images are pushed properly
if: github.event_name != 'pull_request'
run: |
# retry 10 times with a 5 second delay as the images may not be
# available immediately
-48
View File
@@ -1,48 +0,0 @@
name: Docs CI
on:
push:
branches:
- main
paths:
- "docs/**"
- "**.md"
- ".github/workflows/docs-ci.yaml"
pull_request:
paths:
- "docs/**"
- "**.md"
- ".github/workflows/docs-ci.yaml"
permissions:
contents: read
jobs:
docs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: tj-actions/changed-files@dcc7a0cba800f454d79fff4b993e8c3555bcc0a8 # v45.0.7
id: changed-files
with:
files: |
docs/**
**.md
separator: ","
- name: lint
if: steps.changed-files.outputs.any_changed == 'true'
run: |
pnpm exec markdownlint-cli2 ${{ steps.changed-files.outputs.all_changed_files }}
- name: fmt
if: steps.changed-files.outputs.any_changed == 'true'
run: |
# markdown-table-formatter requires a space separated list of files
echo ${{ steps.changed-files.outputs.all_changed_files }} | tr ',' '\n' | pnpm exec markdown-table-formatter --check
+33 -59
View File
@@ -17,29 +17,16 @@ on:
- "flake.nix"
workflow_dispatch:
permissions:
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
id-token: write
jobs:
build_image:
if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Nix
uses: DeterminateSystems/nix-installer-action@e50d5f73bfe71c2dd0aa4218de8f4afa59f8f81d # v16
uses: actions/checkout@v4
- name: Get branch name
id: branch-name
uses: tj-actions/branch-names@6871f53176ad61624f978536bbf089c574dc19a2 # v8.0.1
uses: tj-actions/branch-names@v8
- name: "Branch name to Docker tag name"
id: docker-tag-name
@@ -50,75 +37,58 @@ jobs:
echo "tag=${tag}" >> $GITHUB_OUTPUT
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
uses: depot/setup-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.ref == 'refs/heads/main'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build and push Non-Nix image
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
uses: depot/build-push-action@v1
with:
project: b4q6ltmpzh
token: ${{ secrets.DEPOT_TOKEN }}
buildx-fallback: true
context: "{{defaultContext}}:dogfood/contents"
context: "{{defaultContext}}:dogfood"
pull: true
save: true
push: ${{ github.ref == 'refs/heads/main' }}
tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:latest"
- name: Build Nix image
run: nix build .#dev_image
- name: Push Nix image
if: github.ref == 'refs/heads/main'
run: |
docker load -i result
CURRENT_SYSTEM=$(nix eval --impure --raw --expr 'builtins.currentSystem')
docker image tag codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM codercom/oss-dogfood-nix:${{ steps.docker-tag-name.outputs.tag }}
docker image push codercom/oss-dogfood-nix:${{ steps.docker-tag-name.outputs.tag }}
docker image tag codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM codercom/oss-dogfood-nix:latest
docker image push codercom/oss-dogfood-nix:latest
- name: Build and push Nix image
uses: depot/build-push-action@v1
with:
project: b4q6ltmpzh
token: ${{ secrets.DEPOT_TOKEN }}
buildx-fallback: true
context: "."
file: "dogfood/Dockerfile.nix"
pull: true
save: true
push: ${{ github.ref == 'refs/heads/main' }}
tags: "codercom/oss-dogfood-nix:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood-nix:latest"
deploy_template:
needs: build_image
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8
with:
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
- name: Terraform init and validate
run: |
cd dogfood
terraform init -upgrade
terraform validate
cd contents
terraform init -upgrade
terraform validate
- name: Get short commit SHA
if: github.ref == 'refs/heads/main'
@@ -130,18 +100,22 @@ jobs:
id: message
run: echo "pr_title=$(git log --format=%s -n 1 ${{ github.sha }})" >> $GITHUB_OUTPUT
- name: "Get latest Coder binary from the server"
if: github.ref == 'refs/heads/main'
run: |
curl -fsSL "https://dev.coder.com/bin/coder-linux-amd64" -o "./coder"
chmod +x "./coder"
- name: "Push template"
if: github.ref == 'refs/heads/main'
run: |
cd dogfood
terraform apply -auto-approve
./coder templates push $CODER_TEMPLATE_NAME --directory $CODER_TEMPLATE_DIR --yes --name=$CODER_TEMPLATE_VERSION --message="$CODER_TEMPLATE_MESSAGE" --variable jfrog_url=${{ secrets.JFROG_URL }}
env:
# Consumed by coderd provider
# Consumed by Coder CLI
CODER_URL: https://dev.coder.com
CODER_SESSION_TOKEN: ${{ secrets.CODER_SESSION_TOKEN }}
# Template source & details
TF_VAR_CODER_TEMPLATE_NAME: ${{ secrets.CODER_TEMPLATE_NAME }}
TF_VAR_CODER_TEMPLATE_VERSION: ${{ steps.vars.outputs.sha_short }}
TF_VAR_CODER_TEMPLATE_DIR: ./contents
TF_VAR_CODER_TEMPLATE_MESSAGE: ${{ steps.message.outputs.pr_title }}
TF_LOG: info
CODER_TEMPLATE_NAME: ${{ secrets.CODER_TEMPLATE_NAME }}
CODER_TEMPLATE_VERSION: ${{ steps.vars.outputs.sha_short }}
CODER_TEMPLATE_DIR: ./dogfood
CODER_TEMPLATE_MESSAGE: ${{ steps.message.outputs.pr_title }}
+23
View File
@@ -0,0 +1,23 @@
{
"ignorePatterns": [
{
"pattern": "://localhost"
},
{
"pattern": "://.*.?example\\.com"
},
{
"pattern": "developer.github.com"
},
{
"pattern": "docs.github.com"
},
{
"pattern": "support.google.com"
},
{
"pattern": "tailscale.com"
}
],
"aliveStatusCodes": [200, 0]
}
+36 -117
View File
@@ -3,37 +3,21 @@
name: nightly-gauntlet
on:
schedule:
# Every day at 4AM
- cron: "0 4 * * 1-5"
# Every day at midnight
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
test-go-pg:
runs-on: ${{ matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'depot-macos-latest' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }}
if: github.ref == 'refs/heads/main'
# This timeout must be greater than the timeout set by `go test` in
# `make test-postgres` to ensure we receive a trace of running
# goroutines. Setting this to the timeout +5m should work quite well
# even if some of the preceding steps are slow.
timeout-minutes: 25
strategy:
matrix:
os:
- macos-latest
- windows-2022
go-race:
# While GitHub's toaster runners are likelier to flake, we want consistency
# between this environment and the regular test environment for DataDog
# statistics and to only show real workflow threats.
runs-on: "buildjet-8vcpu-ubuntu-2204"
# This runner costs 0.016 USD per minute,
# so 0.016 * 240 = 3.84 USD per run.
timeout-minutes: 240
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/setup-go
@@ -41,101 +25,36 @@ jobs:
- name: Setup Terraform
uses: ./.github/actions/setup-tf
# Sets up the ImDisk toolkit for Windows and creates a RAM disk on drive R:.
- name: Setup ImDisk
if: runner.os == 'Windows'
uses: ./.github/actions/setup-imdisk
- name: Test with PostgreSQL Database
env:
POSTGRES_VERSION: "13"
TS_DEBUG_DISCO: "true"
LC_CTYPE: "en_US.UTF-8"
LC_ALL: "en_US.UTF-8"
shell: bash
- name: Run Tests
run: |
# if macOS, install google-chrome for scaletests
# As another concern, should we really have this kind of external dependency
# requirement on standard CI?
if [ "${{ matrix.os }}" == "macos-latest" ]; then
brew install google-chrome
fi
# -race is likeliest to catch flaky tests
# due to correctness detection and its performance
# impact.
gotestsum --junitfile="gotests.xml" -- -timeout=240m -count=10 -race ./...
# By default Go will use the number of logical CPUs, which
# is a fine default.
PARALLEL_FLAG=""
# macOS will output "The default interactive shell is now zsh"
# intermittently in CI...
if [ "${{ matrix.os }}" == "macos-latest" ]; then
touch ~/.bash_profile && echo "export BASH_SILENCE_DEPRECATION_WARNING=1" >> ~/.bash_profile
fi
if [ "${{ runner.os }}" == "Windows" ]; then
# Create a temp dir on the R: ramdisk drive for Windows. The default
# C: drive is extremely slow: https://github.com/actions/runner-images/issues/8755
mkdir -p "R:/temp/embedded-pg"
go run scripts/embedded-pg/main.go -path "R:/temp/embedded-pg"
else
go run scripts/embedded-pg/main.go
fi
# Reduce test parallelism, mirroring what we do for race tests.
# We'd been encountering issues with timing related flakes, and
# this seems to help.
DB=ci gotestsum --format standard-quiet -- -v -short -count=1 -parallel 4 -p 4 ./...
- name: Upload test stats to Datadog
timeout-minutes: 1
continue-on-error: true
- name: Upload test results to DataDog
uses: ./.github/actions/upload-datadog
if: success() || failure()
if: always()
with:
api-key: ${{ secrets.DATADOG_API_KEY }}
notify-slack-on-failure:
needs:
- test-go-pg
runs-on: ubuntu-latest
if: failure() && github.ref == 'refs/heads/main'
go-timing:
# We run these tests with p=1 so we don't need a lot of compute.
runs-on: "buildjet-2vcpu-ubuntu-2204"
timeout-minutes: 10
steps:
- name: Send Slack notification
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Run Tests
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "❌ Nightly gauntlet failed",
"emoji": true
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Workflow:*\n${{ github.workflow }}"
},
{
"type": "mrkdwn",
"text": "*Committer:*\n${{ github.actor }}"
},
{
"type": "mrkdwn",
"text": "*Commit:*\n${{ github.sha }}"
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*View failure:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Click here>"
}
}
]
}' ${{ secrets.CI_FAILURE_SLACK_WEBHOOK }}
gotestsum --junitfile="gotests.xml" -- --tags="timing" -p=1 -run='_Timing/' ./...
- name: Upload test results to DataDog
uses: ./.github/actions/upload-datadog
if: always()
with:
api-key: ${{ secrets.DATADOG_API_KEY }}
+1 -6
View File
@@ -13,10 +13,5 @@ jobs:
assign-author:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Assign author
uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1
uses: toshimaru/auto-author-assign@v2.1.0
+2 -10
View File
@@ -9,20 +9,12 @@ on:
required: true
permissions:
contents: read
packages: write
jobs:
cleanup:
runs-on: "ubuntu-latest"
permissions:
# Necessary to delete docker images from ghcr.io.
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Get PR number
id: pr_number
run: |
@@ -34,7 +26,7 @@ jobs:
- name: Delete image
continue-on-error: true
uses: bots-house/ghcr-delete-image-action@3827559c68cb4dcdf54d813ea9853be6d468d3a4 # v1.1.0
uses: bots-house/ghcr-delete-image-action@v1.1.0
with:
owner: coder
name: coder-preview
+31 -75
View File
@@ -7,7 +7,6 @@ on:
push:
branches-ignore:
- main
- "temp-cherry-pick-*"
workflow_dispatch:
inputs:
experiments:
@@ -31,6 +30,8 @@ env:
permissions:
contents: read
packages: write
pull-requests: write # needed for commenting on PRs
jobs:
check_pr:
@@ -38,13 +39,8 @@ jobs:
outputs:
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Check if PR is open
id: check_pr
@@ -73,13 +69,8 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -110,8 +101,8 @@ jobs:
run: |
set -euo pipefail
mkdir -p ~/.kube
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
chmod 644 ~/.kube/config
export KUBECONFIG=~/.kube/config
- name: Check if the helm deployment already exists
@@ -128,7 +119,7 @@ jobs:
echo "NEW=$NEW" >> $GITHUB_OUTPUT
- name: Check changed files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: dorny/paths-filter@v3
id: filter
with:
base: ${{ github.ref }}
@@ -163,23 +154,16 @@ jobs:
set -euo pipefail
# build if the workflow is manually triggered and the deployment doesn't exist (first build or force rebuild)
echo "first_or_force_build=${{ (github.event_name == 'workflow_dispatch' && steps.check_deployment.outputs.NEW == 'true') || github.event.inputs.build == 'true' }}" >> $GITHUB_OUTPUT
# build if the deployment already exist and there are changes in the files that we care about (automatic updates)
# build if the deployment alreday exist and there are changes in the files that we care about (automatic updates)
echo "automatic_rebuild=${{ steps.check_deployment.outputs.NEW == 'false' && steps.filter.outputs.all_count > steps.filter.outputs.ignored_count }}" >> $GITHUB_OUTPUT
comment-pr:
needs: get_info
if: needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true'
runs-on: "ubuntu-latest"
permissions:
pull-requests: write # needed for commenting on PRs
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Find Comment
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
@@ -189,7 +173,7 @@ jobs:
- name: Comment on PR
id: comment_id
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
@@ -205,11 +189,8 @@ jobs:
needs: get_info
# Run build job only if there are changes in the files that we care about or if the workflow is manually triggered with --build flag
if: needs.get_info.outputs.BUILD == 'true'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
permissions:
# Necessary to push docker images to ghcr.io.
packages: write
# This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs changes.
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
# This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs chnages.
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}-${{ needs.get_info.outputs.BUILD }}
cancel-in-progress: true
@@ -217,13 +198,8 @@ jobs:
DOCKER_CLI_EXPERIMENTAL: "enabled"
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -237,7 +213,7 @@ jobs:
uses: ./.github/actions/setup-sqlc
- name: GHCR Login
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -266,8 +242,6 @@ jobs:
always() && (needs.build.result == 'success' || needs.build.result == 'skipped') &&
(needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true')
runs-on: "ubuntu-latest"
permissions:
pull-requests: write # needed for commenting on PRs
env:
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
PR_NUMBER: ${{ needs.get_info.outputs.PR_NUMBER }}
@@ -275,17 +249,12 @@ jobs:
PR_URL: ${{ needs.get_info.outputs.PR_URL }}
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Set up kubeconfig
run: |
set -euo pipefail
mkdir -p ~/.kube
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
chmod 644 ~/.kube/config
export KUBECONFIG=~/.kube/config
- name: Check if image exists
@@ -325,7 +294,7 @@ jobs:
kubectl create namespace "pr${{ env.PR_NUMBER }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Check and Create Certificate
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
@@ -422,14 +391,14 @@ jobs:
"${DEST}" version
mv "${DEST}" /usr/local/bin/coder
- name: Create first user
- name: Create first user, template and workspace
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
id: setup_deployment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Create first user
# create a masked random password 12 characters long
password=$(openssl rand -base64 16 | tr -d "=+/" | cut -c1-12)
@@ -438,22 +407,20 @@ jobs:
echo "password=$password" >> $GITHUB_OUTPUT
coder login \
--first-user-username pr${{ env.PR_NUMBER }}-admin \
--first-user-username coder \
--first-user-email pr${{ env.PR_NUMBER }}@coder.com \
--first-user-password $password \
--first-user-trial=false \
--first-user-trial \
--use-token-as-session \
https://${{ env.PR_HOSTNAME }}
# Create a user for the github.actor
# TODO: update once https://github.com/coder/coder/issues/15466 is resolved
# coder users create \
# --username ${{ github.actor }} \
# --login-type github
# Create template
cd ./.github/pr-deployments/template
coder templates push -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes
# promote the user to admin role
# coder org members edit-role ${{ github.actor }} organization-admin
# TODO: update once https://github.com/coder/internal/issues/207 is resolved
# Create workspace
coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
coder stop kube -y
- name: Send Slack notification
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
@@ -465,7 +432,7 @@ jobs:
"pr_url": "'"${{ env.PR_URL }}"'",
"pr_title": "'"${{ env.PR_TITLE }}"'",
"pr_access_url": "'"https://${{ env.PR_HOSTNAME }}"'",
"pr_username": "'"pr${{ env.PR_NUMBER }}-admin"'",
"pr_username": "'"test"'",
"pr_email": "'"pr${{ env.PR_NUMBER }}@coder.com"'",
"pr_password": "'"${{ steps.setup_deployment.outputs.password }}"'",
"pr_actor": "'"${{ github.actor }}"'"
@@ -474,7 +441,7 @@ jobs:
echo "Slack notification sent"
- name: Find Comment
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ env.PR_NUMBER }}
@@ -483,7 +450,7 @@ jobs:
direction: last
- name: Comment on PR
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
uses: peter-evans/create-or-update-comment@v4
env:
STATUS: ${{ needs.get_info.outputs.NEW == 'true' && 'Created' || 'Updated' }}
with:
@@ -498,14 +465,3 @@ jobs:
cc: @${{ github.actor }}
reactions: rocket
reactions-edit-mode: replace
- name: Create template and workspace
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
run: |
set -euo pipefail
cd .github/pr-deployments/template
coder templates push -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes
# Create workspace
coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
coder stop kube -y
-28
View File
@@ -1,28 +0,0 @@
name: release-validation
on:
push:
tags:
- "v*"
permissions:
contents: read
jobs:
network-performance:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Run Schmoder CI
uses: benc-uk/workflow-dispatch@e2e5e9a103e331dad343f381a29e654aea3cf8fc # v1.2.4
with:
workflow: ci.yaml
repo: coder/schmoder
inputs: '{ "num_releases": "3", "commit": "${{ github.sha }}" }'
token: ${{ secrets.CDRCI_SCHMODER_ACTIONS_TOKEN }}
ref: main
+39 -237
View File
@@ -1,16 +1,11 @@
# GitHub release workflow.
name: Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_channel:
type: choice
description: Release channel
options:
- mainline
- stable
release_notes:
description: Release notes for the publishing the release. This is required to create a release.
dry_run:
description: Perform a dry-run release (devel). Note that ref must be an annotated tag when run without dry-run.
type: boolean
@@ -18,7 +13,12 @@ on:
default: false
permissions:
contents: read
# Required to publish a release
contents: write
# Necessary to push docker images to ghcr.io.
packages: write
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
id-token: write
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -28,114 +28,19 @@ env:
# https://github.blog/changelog/2022-06-10-github-actions-inputs-unified-across-manual-and-reusable-workflows/
CODER_RELEASE: ${{ !inputs.dry_run }}
CODER_DRY_RUN: ${{ inputs.dry_run }}
CODER_RELEASE_CHANNEL: ${{ inputs.release_channel }}
CODER_RELEASE_NOTES: ${{ inputs.release_notes }}
jobs:
# build-dylib is a separate job to build the dylib on macOS.
build-dylib:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }}
steps:
# Harden Runner doesn't work on macOS.
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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: Setup build tools
run: |
brew install bash gnu-getopt make
echo "$(brew --prefix bash)/bin" >> $GITHUB_PATH
echo "$(brew --prefix gnu-getopt)/bin" >> $GITHUB_PATH
echo "$(brew --prefix make)/libexec/gnubin" >> $GITHUB_PATH
- name: Switch XCode Version
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: "16.0.0"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install rcodesign
run: |
set -euo pipefail
wget -O /tmp/rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-macos-universal.tar.gz
sudo tar -xzf /tmp/rcodesign.tar.gz \
-C /usr/local/bin \
--strip-components=1 \
apple-codesign-0.22.0-macos-universal/rcodesign
rm /tmp/rcodesign.tar.gz
- 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 dylibs
run: |
set -euxo pipefail
go mod download
make gen/mark-fresh
make build/coder-dylib
env:
CODER_SIGN_DARWIN: 1
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
- name: Upload build artifacts
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: dylibs
path: |
./build/*.h
./build/*.dylib
retention-days: 7
- name: Delete Apple Developer certificate and API key
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
release:
name: Build and publish
needs: build-dylib
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
permissions:
# Required to publish a release
contents: write
# Necessary to push docker images to ghcr.io.
packages: write
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
id-token: write
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
env:
# Necessary for Docker manifest
DOCKER_CLI_EXPERIMENTAL: "enabled"
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -157,45 +62,21 @@ jobs:
echo "CODER_FORCE_VERSION=$version" >> $GITHUB_ENV
echo "$version"
# Verify that all expectations for a release are met.
- name: Verify release input
if: ${{ !inputs.dry_run }}
run: |
set -euo pipefail
if [[ "${GITHUB_REF}" != "refs/tags/v"* ]]; then
echo "Ref must be a semver tag when creating a release, did you use scripts/release.sh?"
exit 1
fi
# 2.10.2 -> release/2.10
version="$(./scripts/version.sh)"
release_branch=release/${version%.*}
branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)')
if [[ -z "${branch_contains_tag}" ]]; then
echo "Ref tag must exist in a branch named ${release_branch} when creating a release, did you use scripts/release.sh?"
exit 1
fi
if [[ -z "${CODER_RELEASE_NOTES}" ]]; then
echo "Release notes are required to create a release, did you use scripts/release.sh?"
exit 1
fi
echo "Release inputs verified:"
echo
echo "- Ref: ${GITHUB_REF}"
echo "- Version: ${version}"
echo "- Release channel: ${CODER_RELEASE_CHANNEL}"
echo "- Release branch: ${release_branch}"
echo "- Release notes: true"
- name: Create release notes file
- name: Create release notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# We always have to set this since there might be commits on
# main that didn't have a PR.
CODER_IGNORE_MISSING_COMMIT_METADATA: "1"
run: |
set -euo pipefail
ref=HEAD
old_version="$(git describe --abbrev=0 "$ref^1")"
version="v$(./scripts/version.sh)"
# Generate notes.
release_notes_file="$(mktemp -t release_notes.XXXXXX)"
echo "$CODER_RELEASE_NOTES" > "$release_notes_file"
./scripts/release/generate_release_notes.sh --check-for-changelog --old-version "$old_version" --new-version "$version" --ref "$ref" >> "$release_notes_file"
echo CODER_RELEASE_NOTES_FILE="$release_notes_file" >> $GITHUB_ENV
- name: Show release notes
@@ -204,7 +85,7 @@ jobs:
cat "$CODER_RELEASE_NOTES_FILE"
- name: Docker Login
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -216,16 +97,6 @@ jobs:
- name: Setup Node
uses: ./.github/actions/setup-node
# Necessary for signing Windows binaries.
- name: Setup Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
with:
distribution: "zulu"
java-version: "11.0"
- name: Install go-winres
run: go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
- name: Install nsis and zstd
run: sudo apt-get install -y nsis zstd
@@ -259,44 +130,6 @@ jobs:
AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }}
- name: Setup Windows EV Signing Certificate
run: |
set -euo pipefail
touch /tmp/ev_cert.pem
chmod 600 /tmp/ev_cert.pem
echo "$EV_SIGNING_CERT" > /tmp/ev_cert.pem
wget https://github.com/ebourg/jsign/releases/download/6.0/jsign-6.0.jar -O /tmp/jsign-6.0.jar
env:
EV_SIGNING_CERT: ${{ secrets.EV_SIGNING_CERT }}
- name: Test migrations from current ref to main
run: |
POSTGRES_VERSION=13 make test-migrations
# Setup GCloud for signing Windows binaries.
- name: Authenticate to Google Cloud
id: gcloud_auth
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8
with:
workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
token_format: "access_token"
- name: Setup GCloud SDK
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
- name: Download dylibs
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: dylibs
path: ./build
- name: Insert dylibs
run: |
mv ./build/*amd64.dylib ./site/out/bin/coder-vpn-darwin-amd64.dylib
mv ./build/*arm64.dylib ./site/out/bin/coder-vpn-darwin-arm64.dylib
mv ./build/*arm64.h ./site/out/bin/coder-vpn-darwin-dylib.h
- name: Build binaries
run: |
set -euo pipefail
@@ -311,27 +144,16 @@ jobs:
build/coder_helm_"$version".tgz \
build/provisioner_helm_"$version".tgz
env:
CODER_SIGN_WINDOWS: "1"
CODER_SIGN_DARWIN: "1"
CODER_WINDOWS_RESOURCES: "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
EV_KEY: ${{ secrets.EV_KEY }}
EV_KEYSTORE: ${{ secrets.EV_KEYSTORE }}
EV_TSA_URL: ${{ secrets.EV_TSA_URL }}
EV_CERTIFICATE_PATH: /tmp/ev_cert.pem
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
JSIGN_PATH: /tmp/jsign-6.0.jar
- name: Delete Apple Developer certificate and API key
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
- name: Delete Windows EV Signing Cert
run: rm /tmp/ev_cert.pem
- name: Determine base image tag
id: image-base-tag
run: |
@@ -349,18 +171,17 @@ jobs:
- name: Install depot.dev CLI
if: steps.image-base-tag.outputs.tag != ''
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
uses: depot/setup-action@v1
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
if: steps.image-base-tag.outputs.tag != ''
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
uses: depot/build-push-action@v1
with:
project: wl5hnrrkns
context: base-build-context
file: scripts/Dockerfile.base
platforms: linux/amd64,linux/arm64,linux/arm/v7
provenance: true
pull: true
no-cache: true
push: true
@@ -368,7 +189,6 @@ jobs:
${{ steps.image-base-tag.outputs.tag }}
- name: Verify that images are pushed properly
if: steps.image-base-tag.outputs.tag != ''
run: |
# retry 10 times with a 5 second delay as the images may not be
# available immediately
@@ -401,6 +221,10 @@ jobs:
run: |
set -euxo pipefail
# 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
if [[ "$CODER_RELEASE" != *t* ]]; then
@@ -408,13 +232,9 @@ jobs:
exit 0
fi
# build Docker images for each architecture
version="$(./scripts/version.sh)"
make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
# build and push multi-arch manifest, this depends on the other images
# being pushed so will automatically push them.
make push/build/coder_"$version"_linux.tag
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
@@ -441,9 +261,6 @@ jobs:
set -euo pipefail
publish_args=()
if [[ $CODER_RELEASE_CHANNEL == "stable" ]]; then
publish_args+=(--stable)
fi
if [[ $CODER_DRY_RUN == *t* ]]; then
publish_args+=(--dry-run)
fi
@@ -464,13 +281,13 @@ jobs:
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
- name: Setup GCloud SDK
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # 2.1.4
uses: "google-github-actions/setup-gcloud@v2"
- name: Publish Helm Chart
if: ${{ !inputs.dry_run }}
@@ -489,7 +306,7 @@ jobs:
- name: Upload artifacts to actions (if dry-run)
if: ${{ inputs.dry_run }}
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@v4
with:
name: release-artifacts
path: |
@@ -502,14 +319,14 @@ jobs:
./build/*.rpm
retention-days: 7
- name: Send repository-dispatch event
- name: Start Packer builds
if: ${{ !inputs.dry_run }}
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
repository: coder/packages
event-type: coder-release
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}", "release_channel": "${{ inputs.release_channel }}"}'
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}'
publish-homebrew:
name: Publish to Homebrew tap
@@ -520,11 +337,6 @@ jobs:
steps:
# TODO: skip this if it's not a new release (i.e. a backport). This is
# fine right now because it just makes a PR that we can close.
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Update homebrew
env:
# Variables used by the `gh` command
@@ -596,18 +408,13 @@ jobs:
if: ${{ !inputs.dry_run }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Sync fork
run: gh repo sync cdrci/winget-pkgs -b master
env:
GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.WINGET_GH_TOKEN }}
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -686,13 +493,8 @@ jobs:
needs: release
if: ${{ !inputs.dry_run }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 1
-52
View File
@@ -1,52 +0,0 @@
name: OpenSSF Scorecard
on:
branch_protection_rule:
schedule:
- cron: "27 7 * * 3" # A random time to run weekly
push:
branches: ["main"]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
with:
results_file: results.sarif
results_format: sarif
repo_token: ${{ secrets.GITHUB_TOKEN }}
publish_results: true
# Upload the results as artifacts.
- name: "Upload artifact"
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif
+28 -42
View File
@@ -3,6 +3,7 @@ name: "security"
permissions:
actions: read
contents: read
security-events: write
on:
workflow_dispatch:
@@ -22,33 +23,26 @@ concurrency:
jobs:
codeql:
permissions:
security-events: write
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: go, javascript
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Initialize CodeQL
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
languages: go, javascript
# Workaround to prevent CodeQL from building the dashboard.
- name: Remove Makefile
run: |
rm Makefile
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/analyze@v3
- name: Send Slack notification on failure
if: ${{ failure() }}
@@ -62,17 +56,10 @@ jobs:
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
trivy:
permissions:
security-events: write
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -86,32 +73,25 @@ jobs:
uses: ./.github/actions/setup-sqlc
- name: Install yq
run: go run github.com/mikefarah/yq/v4@v4.44.3
run: go run github.com/mikefarah/yq/v4@v4.30.6
- name: Install mockgen
run: go install go.uber.org/mock/mockgen@v0.5.0
run: go install go.uber.org/mock/mockgen@v0.4.0
- name: Install protoc-gen-go
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
- name: Install protoc-gen-go-drpc
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.33
- name: Install Protoc
run: |
# protoc must be in lockstep with our dogfood Dockerfile or the
# version in the comments will differ. This is also defined in
# ci.yaml.
set -euxo pipefail
cd dogfood/contents
mkdir -p /usr/local/bin
mkdir -p /usr/local/include
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
# Copy the generated files to the include directory.
docker run --rm -v /usr/local/include:/target protoc cp -r /tmp/include/google /target/
ls -la /usr/local/include/google/protobuf/
stat /usr/local/include/google/protobuf/timestamp.proto
- name: Build Coder linux amd64 Docker image
id: build
@@ -130,13 +110,19 @@ jobs:
# the registry.
export CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
# We would like to use make -j here, but it doesn't work with the some recent additions
# to our code generation.
make "$image_job"
make -j "$image_job"
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
- name: Run Prisma Cloud image scan
uses: PaloAltoNetworks/prisma-cloud-scan@v1
with:
pcc_console_url: ${{ secrets.PRISMA_CLOUD_URL }}
pcc_user: ${{ secrets.PRISMA_CLOUD_ACCESS_KEY }}
pcc_pass: ${{ secrets.PRISMA_CLOUD_SECRET_KEY }}
image_name: ${{ steps.build.outputs.image }}
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0
uses: aquasecurity/trivy-action@d43c1f16c00cfd3978dde6c07f4bbcf9eb6993ca
with:
image-ref: ${{ steps.build.outputs.image }}
format: sarif
@@ -144,13 +130,13 @@ jobs:
severity: "CRITICAL,HIGH"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
category: "Trivy"
- name: Upload Trivy scan results as an artifact
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@v4
with:
name: trivy
path: trivy-results.sarif
+10 -39
View File
@@ -1,36 +1,23 @@
name: Stale Issue, Branch and Old Workflows Cleanup
name: Stale Issue, Banch and Old Workflows Cleanup
on:
schedule:
# Every day at midnight
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
issues:
runs-on: ubuntu-latest
permissions:
# Needed to close issues.
issues: write
# Needed to close PRs.
pull-requests: write
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: stale
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
uses: actions/stale@v9.0.0
with:
stale-issue-label: "stale"
stale-pr-label: "stale"
# days-before-stale: 180
# essentially disabled for now while we work through polish issues
days-before-stale: 3650
days-before-stale: 180
# Pull Requests become stale more quickly due to merge conflicts.
# Also, we promote minimizing WIP.
days-before-pr-stale: 7
@@ -44,7 +31,7 @@ jobs:
# Start with the oldest issues, always.
ascending: true
- name: "Close old issues labeled likely-no"
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -70,7 +57,7 @@ jobs:
});
const labelEvent = timeline.data.find(event => event.event === 'labeled' && event.label.name === 'likely-no');
if (labelEvent) {
console.log(`Issue #${issue.number} was labeled with 'likely-no' at ${labelEvent.created_at}`);
@@ -91,19 +78,11 @@ jobs:
branches:
runs-on: ubuntu-latest
permissions:
# Needed to delete branches.
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Run delete-old-branches-action
uses: beatlabs/delete-old-branches-action@6e94df089372a619c01ae2c2f666bf474f890911 # v0.0.10
uses: beatlabs/delete-old-branches-action@v0.0.10
with:
repo_token: ${{ github.token }}
date: "6 months ago"
@@ -113,17 +92,9 @@ jobs:
exclude_open_pr_branches: true
del_runs:
runs-on: ubuntu-latest
permissions:
# Needed to delete workflow runs.
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Delete PR Cleanup workflow runs
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
uses: Mattraks/delete-workflow-runs@v2
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
@@ -132,7 +103,7 @@ jobs:
delete_workflow_pattern: pr-cleanup.yaml
- name: Delete PR Deploy workflow skipped runs
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
uses: Mattraks/delete-workflow-runs@v2
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
+2 -13
View File
@@ -14,16 +14,7 @@ darcula = "darcula"
Hashi = "Hashi"
trialer = "trialer"
encrypter = "encrypter"
# as in helsinki
hel = "hel"
# this is used as proto node
pn = "pn"
# typos doesn't like the EDE in TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA
EDE = "EDE"
# HELO is an SMTP command
HELO = "HELO"
LKE = "LKE"
byt = "byt"
hel = "hel" # as in helsinki
[files]
extend-exclude = [
@@ -35,12 +26,10 @@ extend-exclude = [
# These files contain base64 strings that confuse the detector
"**XService**.ts",
"**identity.go",
"scripts/ci-report/testdata/**",
"**/*_test.go",
"**/*.test.tsx",
"**/pnpm-lock.yaml",
"tailnet/testdata/**",
"site/src/pages/SetupPage/countries.tsx",
"provisioner/terraform/testdata/**",
# notifications' golden files confuse the detector because of quoted-printable encoding
"coderd/notifications/testdata/**"
]
+9 -24
View File
@@ -4,42 +4,27 @@ on:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch: # allows to run manually for testing
pull_request:
branches:
- main
paths:
- "docs/**"
permissions:
contents: read
jobs:
check-docs:
# later versions of Ubuntu have disabled unprivileged user namespaces, which are required by the action
runs-on: ubuntu-22.04
permissions:
pull-requests: write # required to post PR review comments by the action
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@master
- name: Check Markdown links
uses: umbrelladocs/action-linkspector@de84085e0f51452a470558693d7d308fbb2fa261 # v1.2.5
uses: gaurav-nelson/github-action-markdown-link-check@v1
id: markdown-link-check
# checks all markdown files from /docs including all subfolders
with:
reporter: github-pr-review
config_file: ".github/.linkspector.yml"
fail_on_error: "true"
filter_mode: "nofilter"
use-quiet-mode: "yes"
use-verbose-mode: "yes"
config-file: ".github/workflows/mlc_config.json"
folder-path: "docs/"
file-path: "./README.md"
- name: Send Slack notification
if: failure() && github.event_name == 'schedule'
if: failure()
run: |
curl -X POST -H 'Content-type: application/json' -d '{"msg":"Broken links found in the documentation. Please check the logs at ${{ env.LOGS_URL }}"}' ${{ secrets.DOCS_LINK_SLACK_WEBHOOK }}
echo "Sent Slack notification"
-13
View File
@@ -17,8 +17,6 @@ yarn-error.log
# Allow VSCode recommendations and default settings in project root.
!/.vscode/extensions.json
!/.vscode/settings.json
# Allow code snippets
!/.vscode/*.code-snippets
# Front-end ignore patterns.
.next/
@@ -36,7 +34,6 @@ site/.swc
.gen-golden
# Build
bin/
build/
dist/
out/
@@ -55,7 +52,6 @@ site/stats/
# direnv
.envrc
.direnv
*.test
# Loadtesting
@@ -72,12 +68,3 @@ result
# Filebrowser.db
**/filebrowser.db
# pnpm
.pnpm-store/
# Zed
.zed_server
# dlv debug binaries for go tests
__debug_bin*
+3 -9
View File
@@ -175,6 +175,8 @@ linters-settings:
- name: modifies-value-receiver
- name: package-comments
- name: range
- name: range-val-address
- name: range-val-in-closure
- name: receiver-naming
- name: redefines-builtin-id
- name: string-of-int
@@ -193,15 +195,6 @@ linters-settings:
- name: var-naming
- name: waitgroup-by-value
# irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview
govet:
disable:
- loopclosure
gosec:
excludes:
# Implicit memory aliasing of items from a range statement (irrelevant as of Go v1.22)
- G601
issues:
# Rules listed here: https://github.com/securego/gosec#available-rules
exclude-rules:
@@ -240,6 +233,7 @@ linters:
- errname
- errorlint
- exhaustruct
- exportloopref
- forcetypeassert
- gocritic
# gocyclo is may be useful in the future when we start caring
-31
View File
@@ -1,31 +0,0 @@
// Example markdownlint configuration with all properties set to their default value
{
"MD010": { "spaces_per_tab": 4}, // No hard tabs: we use 4 spaces per tab
"MD013": false, // Line length: we are not following a strict line lnegth in markdown files
"MD024": { "siblings_only": true }, // Multiple headings with the same content:
"MD033": false, // Inline HTML: we use it in some places
"MD034": false, // Bare URL: we use it in some places in generated docs e.g.
// codersdk/deployment.go L597, L1177, L2287, L2495, L2533
// codersdk/workspaceproxy.go L196, L200-L201
// coderd/tracing/exporter.go L26
// cli/exp_scaletest.go L-9
"MD041": false, // First line in file should be a top level heading: All of our changelogs do not start with a top level heading
// TODO: We need to update /home/coder/repos/coder/coder/scripts/release/generate_release_notes.sh to generate changelogs that follow this rule
"MD052": false, // Image reference: Not a valid reference in generated docs
// docs/reference/cli/server.md L628
"MD055": false, // Table pipe style: Some of the generated tables do not have ending pipes
// docs/reference/api/schema.md
// docs/reference/api/templates.md
// docs/reference/cli/server.md
"MD056": false // Table column count: Some of the auto-generated tables have issues. TODO: This is probably because of splitting cell content to multiple lines.
// docs/reference/api/schema.md
// docs/reference/api/templates.md
}
+94
View File
@@ -0,0 +1,94 @@
# Code generated by Makefile (.gitignore .prettierignore.include). DO NOT EDIT.
# .gitignore:
# Common ignore patterns, these rules applies in both root and subdirectories.
.DS_Store
.eslintcache
.gitpod.yml
.idea
**/*.swp
gotests.coverage
gotests.xml
gotests_stats.json
gotests.json
node_modules/
vendor/
yarn-error.log
# VSCode settings.
**/.vscode/*
# Allow VSCode recommendations and default settings in project root.
!/.vscode/extensions.json
!/.vscode/settings.json
# Front-end ignore patterns.
.next/
site/build-storybook.log
site/coverage/
site/storybook-static/
site/test-results/*
site/e2e/test-results/*
site/e2e/states/*.json
site/e2e/.auth.json
site/playwright-report/*
site/.swc
# Make target for updating golden files (any dir).
.gen-golden
# Build
build/
dist/
out/
# Bundle analysis
site/stats/
*.tfstate
*.tfstate.backup
*.tfplan
*.lock.hcl
.terraform/
**/.coderv2/*
**/__debug_bin
# direnv
.envrc
*.test
# Loadtesting
./scaletest/terraform/.terraform
./scaletest/terraform/.terraform.lock.hcl
scaletest/terraform/secrets.tfvars
.terraform.tfstate.*
# Nix
result
# Data dumps from unit tests
**/*.test.sql
# Filebrowser.db
**/filebrowser.db
# .prettierignore.include:
# Helm templates contain variables that are invalid YAML and can't be formatted
# by Prettier.
helm/**/templates/*.yaml
# Terraform state files used in tests, these are automatically generated.
# Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json
**/testdata/**/*.tf*.json
# Testdata shouldn't be formatted.
scripts/apitypings/testdata/**/*.ts
enterprise/tailnet/testdata/*.golden.html
tailnet/testdata/*.golden.html
# Generated files shouldn't be formatted.
site/e2e/provisionerGenerated.ts
**/pnpm-lock.yaml
# Ignore generated JSON (e.g. examples/examples.gen.json).
**/*.gen.json
+20
View File
@@ -0,0 +1,20 @@
# Helm templates contain variables that are invalid YAML and can't be formatted
# by Prettier.
helm/**/templates/*.yaml
# Terraform state files used in tests, these are automatically generated.
# Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json
**/testdata/**/*.tf*.json
# Testdata shouldn't be formatted.
scripts/apitypings/testdata/**/*.ts
enterprise/tailnet/testdata/*.golden.html
tailnet/testdata/*.golden.html
# Generated files shouldn't be formatted.
site/e2e/provisionerGenerated.ts
**/pnpm-lock.yaml
# Ignore generated JSON (e.g. examples/examples.gen.json).
**/*.gen.json
+3 -3
View File
@@ -4,13 +4,13 @@
printWidth: 80
proseWrap: always
trailingComma: all
useTabs: true
useTabs: false
tabWidth: 2
overrides:
- files:
- README.md
- docs/reference/api/**/*.md
- docs/reference/cli/**/*.md
- docs/api/**/*.md
- docs/cli/**/*.md
- docs/changelogs/*.md
- .github/**/*.{yaml,yml,toml}
- scripts/**/*.{yaml,yml,toml}
+13 -14
View File
@@ -1,16 +1,15 @@
{
"recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"DavidAnson.vscode-markdownlint",
"EditorConfig.EditorConfig",
"emeraldwalk.runonsave",
"foxundermoon.shell-format",
"github.vscode-codeql",
"golang.go",
"hashicorp.terraform",
"redhat.vscode-yaml",
"tekumara.typos-vscode",
"zxh404.vscode-proto3"
]
"recommendations": [
"github.vscode-codeql",
"golang.go",
"hashicorp.terraform",
"esbenp.prettier-vscode",
"foxundermoon.shell-format",
"emeraldwalk.runonsave",
"zxh404.vscode-proto3",
"redhat.vscode-yaml",
"streetsidesoftware.code-spell-checker",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig"
]
}
-46
View File
@@ -1,46 +0,0 @@
{
// For info about snippets, visit https://code.visualstudio.com/docs/editor/userdefinedsnippets
"admonition": {
"prefix": "#callout",
"body": [
"<blockquote class=\"admonition ${1|caution,important,note,tip,warning|}\">\n",
"${TM_SELECTED_TEXT:${2:add info here}}\n",
"</blockquote>\n"
],
"description": "callout admonition caution info note tip warning"
},
"fenced code block": {
"prefix": "#codeblock",
"body": ["```${1|apache,bash,console,diff,Dockerfile,env,go,hcl,ini,json,lisp,md,powershell,shell,sql,text,tf,tsx,yaml|}", "${TM_SELECTED_TEXT}$0", "```"],
"description": "fenced code block"
},
"image": {
"prefix": "#image",
"body": "![${TM_SELECTED_TEXT:${1:alt}}](${2:url})$0",
"description": "image"
},
"premium-feature": {
"prefix": "#premium-feature",
"body": [
"<blockquote class=\"info\">\n",
"${1:feature} ${2|is,are|} an Enterprise and Premium feature. [Learn more](https://coder.com/pricing#compare-plans).\n",
"</blockquote>"
]
},
"tabs": {
"prefix": "#tabs",
"body": [
"<div class=\"tabs\">\n",
"${1:optional description}\n",
"## ${2:tab title}\n",
"${TM_SELECTED_TEXT:${3:first tab content}}\n",
"## ${4:tab title}\n",
"${5:second tab content}\n",
"## ${6:tab title}\n",
"${7:third tab content}\n",
"</div>\n"
],
"description": "tabs"
}
}
+223 -59
View File
@@ -1,61 +1,225 @@
{
"emeraldwalk.runonsave": {
"commands": [
{
"match": "database/queries/*.sql",
"cmd": "make gen"
},
{
"match": "provisionerd/proto/provisionerd.proto",
"cmd": "make provisionerd/proto/provisionerd.pb.go"
}
]
},
"search.exclude": {
"**.pb.go": true,
"**/*.gen.json": true,
"**/testdata/*": true,
"coderd/apidoc/**": true,
"docs/reference/api/*.md": true,
"docs/reference/cli/*.md": true,
"docs/templates/*.md": true,
"LICENSE": true,
"scripts/metricsdocgen/metrics": true,
"site/out/**": true,
"site/storybook-static/**": true,
"**.map": true,
"pnpm-lock.yaml": true
},
// Ensure files always have a newline.
"files.insertFinalNewline": true,
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"],
"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=./..."],
// We often use a version of TypeScript that's ahead of the version shipped
// with VS Code.
"typescript.tsdk": "./site/node_modules/typescript/lib",
// Playwright tests in VSCode will open a browser to live "view" the test.
"playwright.reuseBrowser": true,
"[javascript][javascriptreact][json][jsonc][typescript][typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit"
// "source.organizeImports.biome": "explicit"
}
},
"[css][html][markdown][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typos.config": ".github/workflows/typos.toml"
"cSpell.words": [
"afero",
"agentsdk",
"apps",
"ASKPASS",
"authcheck",
"autostop",
"awsidentity",
"bodyclose",
"buildinfo",
"buildname",
"circbuf",
"cliflag",
"cliui",
"codecov",
"coderd",
"coderdenttest",
"coderdtest",
"codersdk",
"contravariance",
"cronstrue",
"databasefake",
"dbgen",
"dbmem",
"dbtype",
"DERP",
"derphttp",
"derpmap",
"devel",
"devtunnel",
"dflags",
"drpc",
"drpcconn",
"drpcmux",
"drpcserver",
"Dsts",
"embeddedpostgres",
"enablements",
"enterprisemeta",
"errgroup",
"eventsourcemock",
"externalauth",
"Failf",
"fatih",
"Formik",
"gitauth",
"gitsshkey",
"goarch",
"gographviz",
"goleak",
"gonet",
"gossh",
"gsyslog",
"GTTY",
"hashicorp",
"hclsyntax",
"httpapi",
"httpmw",
"idtoken",
"Iflag",
"incpatch",
"initialisms",
"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",
"promhttp",
"protobuf",
"provisionerd",
"provisionerdserver",
"provisionersdk",
"ptty",
"ptys",
"ptytest",
"quickstart",
"reconfig",
"replicasync",
"retrier",
"rpty",
"SCIM",
"sdkproto",
"sdktrace",
"Signup",
"slogtest",
"sourcemapped",
"Srcs",
"stdbuf",
"stretchr",
"STTY",
"stuntest",
"tailbroker",
"tailcfg",
"tailexchange",
"tailnet",
"tailnettest",
"Tailscale",
"tanstack",
"tbody",
"TCGETS",
"tcpip",
"TCSETS",
"templateversions",
"testdata",
"testid",
"testutil",
"tfexec",
"tfjson",
"tfplan",
"tfstate",
"thead",
"tios",
"tmpdir",
"tokenconfig",
"Topbar",
"tparallel",
"trialer",
"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",
"wsjson",
"xerrors",
"xlarge",
"xsmall",
"yamux"
],
"cSpell.ignorePaths": ["site/package.json", ".vscode/settings.json"],
"emeraldwalk.runonsave": {
"commands": [
{
"match": "database/queries/*.sql",
"cmd": "make gen"
},
{
"match": "provisionerd/proto/provisionerd.proto",
"cmd": "make provisionerd/proto/provisionerd.pb.go"
}
]
},
"eslint.workingDirectories": ["./site"],
"search.exclude": {
"**.pb.go": true,
"**/*.gen.json": true,
"**/testdata/*": true,
"**Generated.ts": true,
"coderd/apidoc/**": true,
"docs/api/*.md": true,
"docs/templates/*.md": true,
"LICENSE": true,
"scripts/metricsdocgen/metrics": true,
"site/out/**": true,
"site/storybook-static/**": true,
"**.map": true,
"pnpm-lock.yaml": true
},
// Ensure files always have a newline.
"files.insertFinalNewline": true,
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"],
"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=./..."],
// We often use a version of TypeScript that's ahead of the version shipped
// with VS Code.
"typescript.tsdk": "./site/node_modules/typescript/lib"
}
-6
View File
@@ -1,6 +0,0 @@
# These APIs are versioned, so any changes need to be carefully reviewed for whether
# to bump API major or minor versions.
agent/proto/ @spikecurtis @johnstcn
tailnet/proto/ @spikecurtis @johnstcn
vpn/vpn.proto @spikecurtis @johnstcn
vpn/version.go @spikecurtis @johnstcn
-2
View File
@@ -1,2 +0,0 @@
<!-- markdownlint-disable MD041 -->
[https://coder.com/docs/contributing/CODE_OF_CONDUCT](https://coder.com/docs/contributing/CODE_OF_CONDUCT)
-2
View File
@@ -1,2 +0,0 @@
<!-- markdownlint-disable MD041 -->
[https://coder.com/docs/CONTRIBUTING](https://coder.com/docs/CONTRIBUTING)
+147 -319
View File
@@ -36,7 +36,6 @@ GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
GOOS_BIN_EXT := $(if $(filter windows, $(GOOS)),.exe,)
VERSION := $(shell ./scripts/version.sh)
POSTGRES_VERSION ?= 16
# Use the highest ZSTD compression level in CI.
ifdef CI
@@ -57,9 +56,6 @@ GO_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -not -nam
# All the shell files in the repo, excluding ignored files.
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
# Ensure we don't use the user's git configs which might cause side-effects
GIT_FLAGS = GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
# All ${OS}_${ARCH} combos we build for. Windows binaries have the .exe suffix.
OS_ARCHES := \
linux_amd64 linux_arm64 linux_armv7 \
@@ -79,12 +75,8 @@ PACKAGE_OS_ARCHES := linux_amd64 linux_armv7 linux_arm64
# All architectures we build Docker images for (Linux only).
DOCKER_ARCHES := amd64 arm64 armv7
# All ${OS}_${ARCH} combos we build the desktop dylib for.
DYLIB_ARCHES := darwin_amd64 darwin_arm64
# Computed variables based on the above.
CODER_SLIM_BINARIES := $(addprefix build/coder-slim_$(VERSION)_,$(OS_ARCHES))
CODER_DYLIBS := $(foreach os_arch, $(DYLIB_ARCHES), build/coder-vpn_$(VERSION)_$(os_arch).dylib)
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)
@@ -116,7 +108,7 @@ endif
clean:
rm -rf build/ site/build/ site/out/
mkdir -p build/
mkdir -p build/ site/out/bin/
git restore site/out/
.PHONY: clean
@@ -208,8 +200,7 @@ endef
# calling this manually.
$(CODER_ALL_BINARIES): go.mod go.sum \
$(GO_SRC_FILES) \
$(shell find ./examples/templates) \
site/static/error.html
$(shell find ./examples/templates)
$(get-mode-os-arch-ext)
if [[ "$$os" != "windows" ]] && [[ "$$ext" != "" ]]; then
@@ -242,26 +233,6 @@ $(CODER_ALL_BINARIES): go.mod go.sum \
cp "$@" "./site/out/bin/coder-$$os-$$arch$$dot_ext"
fi
# This task builds Coder Desktop dylibs
$(CODER_DYLIBS): go.mod go.sum $(GO_SRC_FILES)
@if [ "$(shell uname)" = "Darwin" ]; then
$(get-mode-os-arch-ext)
./scripts/build_go.sh \
--os "$$os" \
--arch "$$arch" \
--version "$(VERSION)" \
--output "$@" \
--dylib
else
echo "ERROR: Can't build dylib on non-Darwin OS" 1>&2
exit 1
fi
# This task builds both dylibs
build/coder-dylib: $(CODER_DYLIBS)
.PHONY: build/coder-dylib
# 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}
@@ -388,35 +359,13 @@ $(foreach chart,$(charts),build/$(chart)_helm_$(VERSION).tgz): build/%_helm_$(VE
--chart $* \
--output "$@"
node_modules/.installed: package.json
./scripts/pnpm_install.sh
offlinedocs/node_modules/.installed: offlinedocs/package.json
cd offlinedocs/
site/out/index.html: site/package.json $(shell find ./site $(FIND_EXCLUSIONS) -type f \( -name '*.ts' -o -name '*.tsx' \))
cd site
../scripts/pnpm_install.sh
site/node_modules/.installed: site/package.json
cd site/
../scripts/pnpm_install.sh
SITE_GEN_FILES := \
site/src/api/typesGenerated.ts \
site/src/api/rbacresourcesGenerated.ts \
site/src/api/countriesGenerated.ts \
site/src/theme/icons.json
site/out/index.html: \
site/node_modules/.installed \
site/static/install.sh \
$(SITE_GEN_FILES) \
$(shell find ./site $(FIND_EXCLUSIONS) -type f \( -name '*.ts' -o -name '*.tsx' \))
cd site/
# prevents this directory from getting to big, and causing "too much data" errors
rm -rf out/assets/
pnpm build
offlinedocs/out/index.html: offlinedocs/node_modules/.installed $(shell find ./offlinedocs $(FIND_EXCLUSIONS) -type f) $(shell find ./docs $(FIND_EXCLUSIONS) -type f | sed 's: :\\ :g')
cd offlinedocs/
offlinedocs/out/index.html: $(shell find ./offlinedocs $(FIND_EXCLUSIONS) -type f) $(shell find ./docs $(FIND_EXCLUSIONS) -type f | sed 's: :\\ :g')
cd offlinedocs
../scripts/pnpm_install.sh
pnpm export
@@ -431,52 +380,32 @@ install: build/coder_$(VERSION)_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
cp "$<" "$$output_file"
.PHONY: install
BOLD := $(shell tput bold 2>/dev/null)
GREEN := $(shell tput setaf 2 2>/dev/null)
RESET := $(shell tput sgr0 2>/dev/null)
fmt: fmt/ts fmt/go fmt/terraform fmt/shfmt fmt/biome fmt/markdown
fmt: fmt/prettier fmt/terraform fmt/shfmt fmt/go
.PHONY: fmt
fmt/go:
go mod tidy
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/go$(RESET)"
# VS Code users should check out
# https://github.com/mvdan/gofumpt#visual-studio-code
find . $(FIND_EXCLUSIONS) -type f -name '*.go' -print0 | \
xargs -0 grep --null -L "DO NOT EDIT" | \
xargs -0 go run mvdan.cc/gofumpt@v0.4.0 -w -l
go run mvdan.cc/gofumpt@v0.4.0 -w -l .
.PHONY: fmt/go
fmt/ts: site/node_modules/.installed
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/ts$(RESET)"
fmt/prettier:
echo "--- prettier"
cd site
# Avoid writing files in CI to reduce file write activity
ifdef CI
pnpm run check --linter-enabled=false
else
pnpm run check:fix
endif
.PHONY: fmt/ts
fmt/biome: site/node_modules/.installed
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/biome$(RESET)"
cd site/
# Avoid writing files in CI to reduce file write activity
ifdef CI
pnpm run format:check
else
pnpm run format
pnpm run format:write
endif
.PHONY: fmt/biome
.PHONY: fmt/prettier
fmt/terraform: $(wildcard *.tf)
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/terraform$(RESET)"
terraform fmt -recursive
.PHONY: fmt/terraform
fmt/shfmt: $(SHELL_SRC_FILES)
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/shfmt$(RESET)"
echo "--- shfmt"
# Only do diff check in CI, errors on diff.
ifdef CI
shfmt -d $(SHELL_SRC_FILES)
@@ -485,34 +414,25 @@ else
endif
.PHONY: fmt/shfmt
fmt/markdown: node_modules/.installed
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/markdown$(RESET)"
pnpm format-docs
.PHONY: fmt/markdown
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown
lint: lint/shellcheck lint/go lint/ts lint/helm lint/site-icons
.PHONY: lint
lint/site-icons:
./scripts/check_site_icons.sh
.PHONY: lint/site-icons
lint/ts: site/node_modules/.installed
cd site/
pnpm lint
lint/ts:
cd site
pnpm i && pnpm lint
.PHONY: lint/ts
lint/go:
./scripts/check_enterprise_imports.sh
./scripts/check_codersdk_imports.sh
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/contents/Dockerfile | cut -d '=' -f 2)
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/Dockerfile | cut -d '=' -f 2)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver
golangci-lint run
.PHONY: lint/go
lint/examples:
go run ./scripts/examplegen/main.go -lint
.PHONY: lint/examples
# Use shfmt to determine the shell files, takes editorconfig into consideration.
lint/shellcheck: $(SHELL_SRC_FILES)
echo "--- shellcheck"
@@ -520,18 +440,13 @@ lint/shellcheck: $(SHELL_SRC_FILES)
.PHONY: lint/shellcheck
lint/helm:
cd helm/
cd helm
make lint
.PHONY: lint/helm
lint/markdown: node_modules/.installed
pnpm lint-docs
.PHONY: lint/markdown
# All files generated by the database should be added here, and this can be used
# as a target for jobs that need to run after the database is generated.
DB_GEN_FILES := \
coderd/database/dump.sql \
coderd/database/querier.go \
coderd/database/unique_constraint.go \
coderd/database/dbmem/dbmem.go \
@@ -539,42 +454,33 @@ DB_GEN_FILES := \
coderd/database/dbauthz/dbauthz.go \
coderd/database/dbmock/dbmock.go
TAILNETTEST_MOCKS := \
tailnet/tailnettest/coordinatormock.go \
tailnet/tailnettest/coordinateemock.go \
tailnet/tailnettest/workspaceupdatesprovidermock.go \
tailnet/tailnettest/subscriptionmock.go
GEN_FILES := \
# all gen targets should be added here and to gen/mark-fresh
gen: \
tailnet/proto/tailnet.pb.go \
agent/proto/agent.pb.go \
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
vpn/vpn.pb.go \
coderd/database/dump.sql \
$(DB_GEN_FILES) \
$(SITE_GEN_FILES) \
site/src/api/typesGenerated.ts \
coderd/rbac/object_gen.go \
codersdk/rbacresources_gen.go \
docs/admin/integrations/prometheus.md \
docs/reference/cli/index.md \
docs/admin/security/audit-logs.md \
docs/admin/prometheus.md \
docs/cli.md \
docs/admin/audit-logs.md \
coderd/apidoc/swagger.json \
provisioner/terraform/testdata/version \
.prettierignore.include \
.prettierignore \
site/.prettierrc.yaml \
site/.prettierignore \
site/.eslintignore \
site/e2e/provisionerGenerated.ts \
site/src/theme/icons.json \
examples/examples.gen.json \
$(TAILNETTEST_MOCKS) \
coderd/database/pubsub/psmock/psmock.go \
agent/agentcontainers/acmock/acmock.go \
coderd/httpmw/loggermw/loggermock/loggermock.go
# all gen targets should be added here and to gen/mark-fresh
gen: gen/db $(GEN_FILES)
tailnet/tailnettest/coordinatormock.go \
tailnet/tailnettest/coordinateemock.go \
tailnet/tailnettest/multiagentmock.go
.PHONY: gen
gen/db: $(DB_GEN_FILES)
.PHONY: gen/db
# 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:
@@ -583,27 +489,26 @@ gen/mark-fresh:
agent/proto/agent.pb.go \
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
vpn/vpn.pb.go \
coderd/database/dump.sql \
$(DB_GEN_FILES) \
site/src/api/typesGenerated.ts \
coderd/rbac/object_gen.go \
codersdk/rbacresources_gen.go \
site/src/api/rbacresourcesGenerated.ts \
site/src/api/countriesGenerated.ts \
docs/admin/integrations/prometheus.md \
docs/reference/cli/index.md \
docs/admin/security/audit-logs.md \
docs/admin/prometheus.md \
docs/cli.md \
docs/admin/audit-logs.md \
coderd/apidoc/swagger.json \
.prettierignore.include \
.prettierignore \
site/.prettierrc.yaml \
site/.prettierignore \
site/.eslintignore \
site/e2e/provisionerGenerated.ts \
site/src/theme/icons.json \
examples/examples.gen.json \
$(TAILNETTEST_MOCKS) \
coderd/database/pubsub/psmock/psmock.go \
agent/agentcontainers/acmock/acmock.go \
coderd/httpmw/loggermw/loggermock/loggermock.go
"
tailnet/tailnettest/coordinatormock.go \
tailnet/tailnettest/coordinateemock.go \
tailnet/tailnettest/multiagentmock.go \
"
for file in $$files; do
echo "$$file"
if [ ! -f "$$file" ]; then
@@ -612,7 +517,7 @@ gen/mark-fresh:
fi
# touch sets the mtime of the file to the current time
touch "$$file"
touch $$file
done
.PHONY: gen/mark-fresh
@@ -630,16 +535,7 @@ coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $
coderd/database/dbmock/dbmock.go: coderd/database/db.go coderd/database/querier.go
go generate ./coderd/database/dbmock/
coderd/database/pubsub/psmock/psmock.go: coderd/database/pubsub/pubsub.go
go generate ./coderd/database/pubsub/psmock
agent/agentcontainers/acmock/acmock.go: agent/agentcontainers/containers.go
go generate ./agent/agentcontainers/acmock/
coderd/httpmw/loggermw/loggermock/loggermock.go: coderd/httpmw/loggermw/logger.go
go generate ./coderd/httpmw/loggermw/loggermock/
$(TAILNETTEST_MOCKS): tailnet/coordinator.go tailnet/service.go
tailnet/tailnettest/coordinatormock.go tailnet/tailnettest/multiagentmock.go tailnet/tailnettest/coordinateemock.go: tailnet/coordinator.go tailnet/multiagent.go
go generate ./tailnet/tailnettest/
tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto
@@ -674,105 +570,55 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
--go-drpc_opt=paths=source_relative \
./provisionerd/proto/provisionerd.proto
vpn/vpn.pb.go: vpn/vpn.proto
protoc \
--go_out=. \
--go_opt=paths=source_relative \
./vpn/vpn.proto
site/src/api/typesGenerated.ts: $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
go run ./scripts/apitypings/ > $@
pnpm run format:write:only "$@"
site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
# -C sets the directory for the go run command
go run -C ./scripts/apitypings main.go > $@
cd site/
pnpm exec biome format --write src/api/typesGenerated.ts
site/e2e/provisionerGenerated.ts: site/node_modules/.installed provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go
cd site/
site/e2e/provisionerGenerated.ts: provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go
cd site
../scripts/pnpm_install.sh
pnpm run gen:provisioner
site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
site/src/theme/icons.json: $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
go run ./scripts/gensite/ -icons "$@"
cd site/
pnpm exec biome format --write src/theme/icons.json
pnpm run format:write:only "$@"
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates)
go run ./scripts/examplegen/main.go > examples/examples.gen.json
coderd/rbac/object_gen.go: scripts/typegen/rbacobject.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
tempdir=$(shell mktemp -d /tmp/typegen_rbac_object.XXXXXX)
go run ./scripts/typegen/main.go rbac object > "$$tempdir/object_gen.go"
mv -v "$$tempdir/object_gen.go" coderd/rbac/object_gen.go
rmdir -v "$$tempdir"
coderd/rbac/object_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go
go run scripts/rbacgen/main.go ./coderd/rbac > coderd/rbac/object_gen.go
codersdk/rbacresources_gen.go: scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
# Do no overwrite codersdk/rbacresources_gen.go directly, as it would make the file empty, breaking
# the `codersdk` package and any parallel build targets.
go run scripts/typegen/main.go rbac codersdk > /tmp/rbacresources_gen.go
mv /tmp/rbacresources_gen.go codersdk/rbacresources_gen.go
site/src/api/rbacresourcesGenerated.ts: site/node_modules/.installed scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
go run scripts/typegen/main.go rbac typescript > "$@"
cd site/
pnpm exec biome format --write src/api/rbacresourcesGenerated.ts
site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen/countries.tstmpl scripts/typegen/main.go codersdk/countries.go
go run scripts/typegen/main.go countries > "$@"
cd site/
pnpm exec biome format --write src/api/countriesGenerated.ts
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
go run scripts/metricsdocgen/main.go
pnpm exec markdownlint-cli2 --fix ./docs/admin/integrations/prometheus.md
pnpm exec markdown-table-formatter ./docs/admin/integrations/prometheus.md
pnpm run format:write:only ./docs/admin/prometheus.md
docs/reference/cli/index.md: node_modules/.installed site/node_modules/.installed scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
docs/cli.md: scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
CI=true BASE_PATH="." go run ./scripts/clidocgen
pnpm exec markdownlint-cli2 --fix ./docs/reference/cli/*.md
pnpm exec markdown-table-formatter ./docs/reference/cli/*.md
cd site/
pnpm exec biome format --write ../docs/manifest.json
pnpm run format:write:only ./docs/cli.md ./docs/cli/*.md ./docs/manifest.json
docs/admin/security/audit-logs.md: node_modules/.installed coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
docs/admin/audit-logs.md: coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
go run scripts/auditdocgen/main.go
pnpm exec markdownlint-cli2 --fix ./docs/admin/security/audit-logs.md
pnpm exec markdown-table-formatter ./docs/admin/security/audit-logs.md
pnpm run format:write:only ./docs/admin/audit-logs.md
coderd/apidoc/swagger.json: node_modules/.installed site/node_modules/.installed $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) $(wildcard enterprise/wsproxy/wsproxysdk/*.go) $(DB_GEN_FILES) .swaggo docs/manifest.json coderd/rbac/object_gen.go
coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) $(wildcard enterprise/wsproxy/wsproxysdk/*.go) $(DB_GEN_FILES) .swaggo docs/manifest.json coderd/rbac/object_gen.go
./scripts/apidocgen/generate.sh
pnpm exec markdownlint-cli2 --fix ./docs/reference/api/*.md
pnpm exec markdown-table-formatter ./docs/reference/api/*.md
cd site/
pnpm exec biome format --write ../docs/manifest.json ../coderd/apidoc/swagger.json
pnpm run format:write:only ./docs/api ./docs/manifest.json ./coderd/apidoc/swagger.json
update-golden-files: \
cli/testdata/.gen-golden \
coderd/.gen-golden \
coderd/notifications/.gen-golden \
enterprise/cli/testdata/.gen-golden \
enterprise/tailnet/testdata/.gen-golden \
helm/coder/tests/testdata/.gen-golden \
helm/provisioner/tests/testdata/.gen-golden \
provisioner/terraform/testdata/.gen-golden \
tailnet/testdata/.gen-golden
scripts/ci-report/testdata/.gen-golden \
enterprise/cli/testdata/.gen-golden \
enterprise/tailnet/testdata/.gen-golden \
tailnet/testdata/.gen-golden \
coderd/.gen-golden \
provisioner/terraform/testdata/.gen-golden
.PHONY: update-golden-files
clean/golden-files:
find . -type f -name '.gen-golden' -delete
find \
cli/testdata \
coderd/notifications/testdata \
coderd/testdata \
enterprise/cli/testdata \
enterprise/tailnet/testdata \
helm/coder/tests/testdata \
helm/provisioner/tests/testdata \
provisioner/terraform/testdata \
tailnet/testdata \
-type f -name '*.golden' -delete
.PHONY: clean/golden-files
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
go test ./cli -run="Test(CommandHelp|ServerYAML|ErrorExamples|.*Golden)" -update
go test ./cli -run="Test(CommandHelp|ServerYAML)" -update
touch "$@"
enterprise/cli/testdata/.gen-golden: $(wildcard enterprise/cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard enterprise/cli/*_test.go)
@@ -799,28 +645,78 @@ coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wil
go test ./coderd -run="Test.*Golden$$" -update
touch "$@"
coderd/notifications/.gen-golden: $(wildcard coderd/notifications/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/notifications/*_test.go)
go test ./coderd/notifications -run="Test.*Golden$$" -update
touch "$@"
provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
go test ./provisioner/terraform -run="Test.*Golden$$" -update
touch "$@"
provisioner/terraform/testdata/version:
if [[ "$(shell cat provisioner/terraform/testdata/version.txt)" != "$(shell terraform version -json | jq -r '.terraform_version')" ]]; then
./provisioner/terraform/testdata/generate.sh
fi
.PHONY: provisioner/terraform/testdata/version
scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go)
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -update
touch "$@"
# Generate a prettierrc for the site package that uses relative paths for
# overrides. This allows us to share the same prettier config between the
# site and the root of the repo.
site/.prettierrc.yaml: .prettierrc.yaml
. ./scripts/lib.sh
dependencies yq
echo "# Code generated by Makefile (../$<). DO NOT EDIT." > "$@"
echo "" >> "$@"
# Replace all listed override files with relative paths inside site/.
# - ./ -> ../
# - ./site -> ./
yq \
'.overrides[].files |= map(. | sub("^./"; "") | sub("^"; "../") | sub("../site/"; "./") | sub("../!"; "!../"))' \
"$<" >> "$@"
# Combine .gitignore with .prettierignore.include to generate .prettierignore.
.prettierignore: .gitignore .prettierignore.include
echo "# Code generated by Makefile ($^). DO NOT EDIT." > "$@"
echo "" >> "$@"
for f in $^; do
echo "# $${f}:" >> "$@"
cat "$$f" >> "$@"
done
# Generate ignore files based on gitignore into the site directory. We turn all
# rules into relative paths for the `site/` directory (where applicable),
# following the pattern format defined by git:
# https://git-scm.com/docs/gitignore#_pattern_format
#
# This is done for compatibility reasons, see:
# https://github.com/prettier/prettier/issues/8048
# https://github.com/prettier/prettier/issues/8506
# https://github.com/prettier/prettier/issues/8679
site/.eslintignore site/.prettierignore: .prettierignore Makefile
rm -f "$@"
touch "$@"
# Skip generated by header, inherit `.prettierignore` header as-is.
while read -r rule; do
# Remove leading ! if present to simplify rule, added back at the end.
tmp="$${rule#!}"
ignore="$${rule%"$$tmp"}"
rule="$$tmp"
case "$$rule" in
# Comments or empty lines (include).
\#*|'') ;;
# Generic rules (include).
\*\**) ;;
# Site prefixed rules (include).
site/*) rule="$${rule#site/}";;
./site/*) rule="$${rule#./site/}";;
# Rules that are non-generic and don't start with site (rewrite).
/*) rule=.."$$rule";;
*/?*) rule=../"$$rule";;
*) ;;
esac
echo "$${ignore}$${rule}" >> "$@"
done < "$<"
test:
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./... $(if $(RUN),-run $(RUN))
gotestsum --format standard-quiet -- -v -short -count=1 ./...
.PHONY: test
test-cli:
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./cli/...
.PHONY: test-cli
# sqlc-cloud-is-setup will fail if no SQLc auth token is set. Use this as a
# dependency for any sqlc-cloud related targets.
sqlc-cloud-is-setup:
@@ -854,7 +750,7 @@ sqlc-vet: test-postgres-docker
test-postgres: test-postgres-docker
# The postgres test is prone to failure, so we limit parallelism for
# more consistent execution.
$(GIT_FLAGS) DB=ci gotestsum \
DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum \
--junitfile="gotests.xml" \
--jsonfile="gotests.json" \
--packages="./..." -- \
@@ -863,45 +759,8 @@ test-postgres: test-postgres-docker
-count=1
.PHONY: test-postgres
test-migrations: test-postgres-docker
echo "--- test migrations"
set -euo pipefail
COMMIT_FROM=$(shell git log -1 --format='%h' HEAD)
echo "COMMIT_FROM=$${COMMIT_FROM}"
COMMIT_TO=$(shell git log -1 --format='%h' origin/main)
echo "COMMIT_TO=$${COMMIT_TO}"
if [[ "$${COMMIT_FROM}" == "$${COMMIT_TO}" ]]; then echo "Nothing to do!"; exit 0; fi
echo "DROP DATABASE IF EXISTS migrate_test_$${COMMIT_FROM}; CREATE DATABASE migrate_test_$${COMMIT_FROM};" | psql 'postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable'
go run ./scripts/migrate-test/main.go --from="$$COMMIT_FROM" --to="$$COMMIT_TO" --postgres-url="postgresql://postgres:postgres@localhost:5432/migrate_test_$${COMMIT_FROM}?sslmode=disable"
.PHONY: test-migrations
# NOTE: we set --memory to the same size as a GitHub runner.
test-postgres-docker:
docker rm -f test-postgres-docker-${POSTGRES_VERSION} || true
# Try pulling up to three times to avoid CI flakes.
docker pull gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} || {
retries=2
for try in $(seq 1 ${retries}); do
echo "Failed to pull image, retrying (${try}/${retries})..."
sleep 1
if docker pull gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION}; then
break
fi
done
}
# Make sure to not overallocate work_mem and max_connections as each
# connection will be allowed to use this much memory. Try adjusting
# shared_buffers instead, if needed.
#
# - work_mem=8MB * max_connections=1000 = 8GB
# - shared_buffers=2GB + effective_cache_size=1GB = 3GB
#
# This leaves 5GB for the rest of the system _and_ storing the
# database in memory (--tmpfs).
#
# https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM
docker rm -f test-postgres-docker || true
docker run \
--env POSTGRES_PASSWORD=postgres \
--env POSTGRES_USER=postgres \
@@ -909,14 +768,13 @@ test-postgres-docker:
--env PGDATA=/tmp \
--tmpfs /tmp \
--publish 5432:5432 \
--name test-postgres-docker-${POSTGRES_VERSION} \
--name test-postgres-docker \
--restart no \
--detach \
--memory 16GB \
gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} \
-c shared_buffers=2GB \
gcr.io/coder-dev-1/postgres:13 \
-c shared_buffers=1GB \
-c work_mem=1GB \
-c effective_cache_size=1GB \
-c work_mem=8MB \
-c max_connections=1000 \
-c fsync=off \
-c synchronous_commit=off \
@@ -931,42 +789,12 @@ test-postgres-docker:
# Make sure to keep this in sync with test-go-race from .github/workflows/ci.yaml.
test-race:
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -race -count=1 -parallel 4 -p 4 ./...
gotestsum --junitfile="gotests.xml" -- -race -count=1 ./...
.PHONY: test-race
test-tailnet-integration:
env \
CODER_TAILNET_TESTS=true \
CODER_MAGICSOCK_DEBUG_LOGGING=true \
TS_DEBUG_NETCHECK=true \
GOTRACEBACK=single \
go test \
-exec "sudo -E" \
-timeout=5m \
-count=1 \
./tailnet/test/integration
.PHONY: test-tailnet-integration
# Note: we used to add this to the test target, but it's not necessary and we can
# achieve the desired result by specifying -count=1 in the go test invocation
# instead. Keeping it here for convenience.
test-clean:
go clean -testcache
.PHONY: test-clean
site/e2e/bin/coder: go.mod go.sum $(GO_SRC_FILES)
go build -o $@ \
-tags ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube \
./enterprise/cmd/coder
test-e2e: site/e2e/bin/coder site/node_modules/.installed site/out/index.html
cd site/
ifdef CI
DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1
else
pnpm playwright:test
endif
.PHONY: test-e2e
dogfood/contents/nix.hash: flake.nix flake.lock
sha256sum flake.nix flake.lock >./dogfood/contents/nix.hash
+37 -44
View File
@@ -1,10 +1,9 @@
<!-- markdownlint-disable MD041 -->
<div align="center">
<a href="https://coder.com#gh-light-mode-only">
<img src="./docs/images/logo-black.png" alt="Coder Logo Light" style="width: 128px">
<img src="./docs/images/logo-black.png" style="width: 128px">
</a>
<a href="https://coder.com#gh-dark-mode-only">
<img src="./docs/images/logo-white.png" alt="Coder Logo Dark" style="width: 128px">
<img src="./docs/images/logo-white.png" style="width: 128px">
</a>
<h1>
@@ -12,28 +11,27 @@
</h1>
<a href="https://coder.com#gh-light-mode-only">
<img src="./docs/images/banner-black.png" alt="Coder Banner Light" style="width: 650px">
<img src="./docs/images/banner-black.png" style="width: 650px">
</a>
<a href="https://coder.com#gh-dark-mode-only">
<img src="./docs/images/banner-white.png" alt="Coder Banner Dark" style="width: 650px">
<img src="./docs/images/banner-white.png" style="width: 650px">
</a>
<br>
<br>
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Premium](https://coder.com/pricing#compare-plans)
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/v2/latest/enterprise)
[![discord](https://img.shields.io/discord/747933592273027093?label=discord)](https://discord.gg/coder)
[![codecov](https://codecov.io/gh/coder/coder/branch/main/graph/badge.svg?token=TNLW3OAP6G)](https://codecov.io/gh/coder/coder)
[![release](https://img.shields.io/github/v/release/coder/coder)](https://github.com/coder/coder/releases/latest)
[![godoc](https://pkg.go.dev/badge/github.com/coder/coder.svg)](https://pkg.go.dev/github.com/coder/coder)
[![Go Report Card](https://goreportcard.com/badge/github.com/coder/coder/v2)](https://goreportcard.com/report/github.com/coder/coder/v2)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9511/badge)](https://www.bestpractices.dev/projects/9511)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/coder/coder/badge)](https://scorecard.dev/viewer/?uri=github.com%2Fcoder%2Fcoder)
[![Go Report Card](https://goreportcard.com/badge/github.com/coder/coder)](https://goreportcard.com/report/github.com/coder/coder)
[![license](https://img.shields.io/github/license/coder/coder)](./LICENSE)
</div>
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and automatically shut down when not used to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads most beneficial to them.
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and are automatically shut down when not in use to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads that are most beneficial to them.
- Define cloud development environments in Terraform
- EC2 VMs, Kubernetes Pods, Docker Containers, etc.
@@ -41,22 +39,22 @@
- Onboard developers in seconds instead of days
<p align="center">
<img src="./docs/images/hero-image.png" alt="Coder Hero Image">
<img src="./docs/images/hero-image.png">
</p>
## Quickstart
The most convenient way to try Coder is to install it on your local machine and experiment with provisioning cloud development environments using Docker (works on Linux, macOS, and Windows).
```shell
```
# First, install Coder
curl -L https://coder.com/install.sh | sh
# Start the Coder server (caches data in ~/.cache/coder)
coder server
# Navigate to http://localhost:3000 to create your initial user,
# create a Docker template and provision a workspace
# Navigate to http://localhost:3000 to create your initial user
# Create a Docker template, and provision a workspace
```
## Install
@@ -66,15 +64,15 @@ The easiest way to install Coder is to use our
and macOS. For Windows, use the latest `..._installer.exe` file from GitHub
Releases.
```shell
```bash
curl -L https://coder.com/install.sh | sh
```
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. Run the install script with `--help` for additional flags.
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. You can modify the installation process by including flags. Run the install script with `--help` for reference.
> See [install](https://coder.com/docs/install) for additional methods.
> See [install](https://coder.com/docs/v2/latest/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:
```shell
# Automatically sets up an external access URL on *.try.coder.app
@@ -84,49 +82,44 @@ coder server
coder server --postgres-url <url> --access-url <url>
```
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/install) for a complete walkthrough.
> <sup>1</sup> For production deployments, set up an external PostgreSQL instance for reliability.
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/v2/latest/install) for a full walkthrough.
## Documentation
Browse our docs [here](https://coder.com/docs) or visit a specific section below:
Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below:
- [**Templates**](https://coder.com/docs/templates): Templates are written in Terraform and describe the infrastructure for workspaces
- [**Workspaces**](https://coder.com/docs/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
- [**IDEs**](https://coder.com/docs/ides): Connect your existing editor to a workspace
- [**Administration**](https://coder.com/docs/admin): Learn how to operate Coder
- [**Premium**](https://coder.com/pricing#compare-plans): Learn about our paid features built for large teams
- [**Templates**](https://coder.com/docs/v2/latest/templates): Templates are written in Terraform and describe the infrastructure for workspaces
- [**Workspaces**](https://coder.com/docs/v2/latest/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
- [**IDEs**](https://coder.com/docs/v2/latest/ides): Connect your existing editor to a workspace
- [**Administration**](https://coder.com/docs/v2/latest/admin): Learn how to operate Coder
- [**Enterprise**](https://coder.com/docs/v2/latest/enterprise): Learn about our paid features built for large teams
## Support
## Community and Support
Feel free to [open an issue](https://github.com/coder/coder/issues/new) if you have questions, run into bugs, or have a feature request.
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features and chat with the community using Coder!
[Join our Discord](https://discord.gg/coder) or [Slack](https://cdr.co/join-community) to provide feedback on in-progress features, and chat with the community using Coder!
## Integrations
## Contributing
We are always working on new integrations. Please feel free to open an issue and ask for an integration. Contributions are welcome in any official or community repositories.
Contributions are welcome! Read the [contributing docs](https://coder.com/docs/v2/latest/CONTRIBUTING) to get started.
Find our list of contributors [here](https://github.com/coder/coder/graphs/contributors).
## Related
We are always working on new integrations. Feel free to open an issue to request an integration. Contributions are welcome in any official or community repositories.
### Official
- [**VS Code Extension**](https://marketplace.visualstudio.com/items?itemName=coder.coder-remote): Open any Coder workspace in VS Code with a single click
- [**JetBrains Gateway Extension**](https://plugins.jetbrains.com/plugin/19620-coder): Open any Coder workspace in JetBrains Gateway with a single click
- [**Dev Container Builder**](https://github.com/coder/envbuilder): Build development environments using `devcontainer.json` on Docker, Kubernetes, and OpenShift
- [**Module Registry**](https://registry.coder.com): Extend development environments with common use-cases
- [**Kubernetes Log Stream**](https://github.com/coder/coder-logstream-kube): Stream Kubernetes Pod events to the Coder startup logs
- [**Self-Hosted VS Code Extension Marketplace**](https://github.com/coder/code-marketplace): A private extension marketplace that works in restricted or airgapped networks integrating with [code-server](https://github.com/coder/code-server).
- [**Setup Coder**](https://github.com/marketplace/actions/setup-coder): An action to setup coder CLI in GitHub workflows.
### Community
- [**Provision Coder with Terraform**](https://github.com/ElliotG/coder-oss-tf): Provision Coder on Google GKE, Azure AKS, AWS EKS, DigitalOcean DOKS, IBMCloud K8s, OVHCloud K8s, and Scaleway K8s Kapsule with Terraform
- [**Coder Template GitHub Action**](https://github.com/marketplace/actions/update-coder-template): A GitHub Action that updates Coder templates
## Contributing
We are always happy to see new contributors to Coder. If you are new to the Coder codebase, we have
[a guide on how to get started](https://coder.com/docs/CONTRIBUTING). We'd love to see your
contributions!
## Hiring
Apply [here](https://jobs.ashbyhq.com/coder?utm_source=github&utm_medium=readme&utm_campaign=unknown) if you're interested in joining our team.
- [**Coder GitHub Action**](https://github.com/marketplace/actions/update-coder-template): A GitHub Action that updates Coder templates
- [**Various Templates**](./examples/templates/community-templates.md): Hetzner Cloud, Docker in Docker, and other templates the community has built.
+6 -6
View File
@@ -8,7 +8,7 @@ to us, what we expect, what you can expect from us.
You can see the pretty version [here](https://coder.com/security/policy)
## Why Coder's security matters
# Why Coder's security matters
If an attacker could fully compromise a Coder installation, they could spin up
expensive workstations, steal valuable credentials, or steal proprietary source
@@ -16,13 +16,13 @@ code. We take this risk very seriously and employ routine pen testing,
vulnerability scanning, and code reviews. We also welcome the contributions from
the community that helped make this product possible.
## Where should I report security issues?
# Where should I report security issues?
Please report security issues to <security@coder.com>, providing all relevant
Please report security issues to security@coder.com, providing all relevant
information. The more details you provide, the easier it will be for us to
triage and fix the issue.
## Out of Scope
# Out of Scope
Our primary concern is around an abuse of the Coder application that allows an
attacker to gain access to another users workspace, or spin up unwanted
@@ -40,7 +40,7 @@ workspaces.
out-of-scope systems should be reported to the appropriate vendor or
applicable authority.
## Our Commitments
# Our Commitments
When working with us, according to this policy, you can expect us to:
@@ -53,7 +53,7 @@ When working with us, according to this policy, you can expect us to:
- Extend Safe Harbor for your vulnerability research that is related to this
policy.
## Our Expectations
# Our Expectations
In participating in our vulnerability disclosure program in good faith, we ask
that you:
+781 -1058
View File
File diff suppressed because it is too large Load Diff
+384 -571
View File
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: .. (interfaces: Lister)
//
// Generated by this command:
//
// mockgen -destination ./acmock.go -package acmock .. Lister
//
// Package acmock is a generated GoMock package.
package acmock
import (
context "context"
reflect "reflect"
codersdk "github.com/coder/coder/v2/codersdk"
gomock "go.uber.org/mock/gomock"
)
// MockLister is a mock of Lister interface.
type MockLister struct {
ctrl *gomock.Controller
recorder *MockListerMockRecorder
isgomock struct{}
}
// MockListerMockRecorder is the mock recorder for MockLister.
type MockListerMockRecorder struct {
mock *MockLister
}
// NewMockLister creates a new mock instance.
func NewMockLister(ctrl *gomock.Controller) *MockLister {
mock := &MockLister{ctrl: ctrl}
mock.recorder = &MockListerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLister) EXPECT() *MockListerMockRecorder {
return m.recorder
}
// List mocks base method.
func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx)
ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockListerMockRecorder) List(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLister)(nil).List), ctx)
}
-4
View File
@@ -1,4 +0,0 @@
// Package acmock contains a mock implementation of agentcontainers.Lister for use in tests.
package acmock
//go:generate mockgen -destination ./acmock.go -package acmock .. Lister
-151
View File
@@ -1,151 +0,0 @@
package agentcontainers
import (
"context"
"errors"
"net/http"
"slices"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)
const (
defaultGetContainersCacheDuration = 10 * time.Second
dockerCreatedAtTimeFormat = "2006-01-02 15:04:05 -0700 MST"
getContainersTimeout = 5 * time.Second
)
type devcontainersHandler struct {
cacheDuration time.Duration
cl Lister
clock quartz.Clock
// lockCh protects the below fields. We use a channel instead of a mutex so we
// can handle cancellation properly.
lockCh chan struct{}
containers *codersdk.WorkspaceAgentListContainersResponse
mtime time.Time
}
// Option is a functional option for devcontainersHandler.
type Option func(*devcontainersHandler)
// WithLister sets the agentcontainers.Lister implementation to use.
// The default implementation uses the Docker CLI to list containers.
func WithLister(cl Lister) Option {
return func(ch *devcontainersHandler) {
ch.cl = cl
}
}
// New returns a new devcontainersHandler with the given options applied.
func New(options ...Option) http.Handler {
ch := &devcontainersHandler{
lockCh: make(chan struct{}, 1),
}
for _, opt := range options {
opt(ch)
}
return ch
}
func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
// Client went away.
return
default:
ct, err := ch.getContainers(r.Context())
if err != nil {
if errors.Is(err, context.Canceled) {
httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{
Message: "Could not get containers.",
Detail: "Took too long to list containers.",
})
return
}
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Could not get containers.",
Detail: err.Error(),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, ct)
}
}
func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
select {
case <-ctx.Done():
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
default:
ch.lockCh <- struct{}{}
}
defer func() {
<-ch.lockCh
}()
// make zero-value usable
if ch.cacheDuration == 0 {
ch.cacheDuration = defaultGetContainersCacheDuration
}
if ch.cl == nil {
ch.cl = &DockerCLILister{}
}
if ch.containers == nil {
ch.containers = &codersdk.WorkspaceAgentListContainersResponse{}
}
if ch.clock == nil {
ch.clock = quartz.NewReal()
}
now := ch.clock.Now()
if now.Sub(ch.mtime) < ch.cacheDuration {
// Return a copy of the cached data to avoid accidental modification by the caller.
cpy := codersdk.WorkspaceAgentListContainersResponse{
Containers: slices.Clone(ch.containers.Containers),
Warnings: slices.Clone(ch.containers.Warnings),
}
return cpy, nil
}
timeoutCtx, timeoutCancel := context.WithTimeout(ctx, getContainersTimeout)
defer timeoutCancel()
updated, err := ch.cl.List(timeoutCtx)
if err != nil {
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("get containers: %w", err)
}
ch.containers = &updated
ch.mtime = now
// Return a copy of the cached data to avoid accidental modification by the
// caller.
cpy := codersdk.WorkspaceAgentListContainersResponse{
Containers: slices.Clone(ch.containers.Containers),
Warnings: slices.Clone(ch.containers.Warnings),
}
return cpy, nil
}
// Lister is an interface for listing containers visible to the
// workspace agent.
type Lister interface {
// List returns a list of containers visible to the workspace agent.
// This should include running and stopped containers.
List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error)
}
// NoopLister is a Lister interface that never returns any containers.
type NoopLister struct{}
var _ Lister = NoopLister{}
func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
return codersdk.WorkspaceAgentListContainersResponse{}, nil
}
@@ -1,448 +0,0 @@
package agentcontainers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/user"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk"
"golang.org/x/exp/maps"
"golang.org/x/xerrors"
)
// DockerCLILister is a ContainerLister that lists containers using the docker CLI
type DockerCLILister struct {
execer agentexec.Execer
}
var _ Lister = &DockerCLILister{}
func NewDocker(execer agentexec.Execer) Lister {
return &DockerCLILister{
execer: agentexec.DefaultExecer,
}
}
// DockerEnvInfoer is an implementation of agentssh.EnvInfoer that returns
// information about a container.
type DockerEnvInfoer struct {
container string
user *user.User
userShell string
env []string
}
// EnvInfo returns information about the environment of a container.
func EnvInfo(ctx context.Context, execer agentexec.Execer, container, containerUser string) (*DockerEnvInfoer, error) {
var dei DockerEnvInfoer
dei.container = container
if containerUser == "" {
// Get the "default" user of the container if no user is specified.
// TODO: handle different container runtimes.
cmd, args := wrapDockerExec(container, "", "whoami")
stdout, stderr, err := run(ctx, execer, cmd, args...)
if err != nil {
return nil, xerrors.Errorf("get container user: run whoami: %w: %s", err, stderr)
}
if len(stdout) == 0 {
return nil, xerrors.Errorf("get container user: run whoami: empty output")
}
containerUser = stdout
}
// Now that we know the username, get the required info from the container.
// We can't assume the presence of `getent` so we'll just have to sniff /etc/passwd.
cmd, args := wrapDockerExec(container, containerUser, "cat", "/etc/passwd")
stdout, stderr, err := run(ctx, execer, cmd, args...)
if err != nil {
return nil, xerrors.Errorf("get container user: read /etc/passwd: %w: %q", err, stderr)
}
scanner := bufio.NewScanner(strings.NewReader(stdout))
var foundLine string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, containerUser+":") {
continue
}
foundLine = line
break
}
if err := scanner.Err(); err != nil {
return nil, xerrors.Errorf("get container user: scan /etc/passwd: %w", err)
}
if foundLine == "" {
return nil, xerrors.Errorf("get container user: no matching entry for %q found in /etc/passwd", containerUser)
}
// Parse the output of /etc/passwd. It looks like this:
// postgres:x:999:999::/var/lib/postgresql:/bin/bash
passwdFields := strings.Split(foundLine, ":")
if len(passwdFields) != 7 {
return nil, xerrors.Errorf("get container user: invalid line in /etc/passwd: %q", foundLine)
}
// The fifth entry in /etc/passwd contains GECOS information, which is a
// comma-separated list of fields. The first field is the user's full name.
gecos := strings.Split(passwdFields[4], ",")
fullName := ""
if len(gecos) > 1 {
fullName = gecos[0]
}
dei.user = &user.User{
Gid: passwdFields[3],
HomeDir: passwdFields[5],
Name: fullName,
Uid: passwdFields[2],
Username: containerUser,
}
dei.userShell = passwdFields[6]
// We need to inspect the container labels for remoteEnv and append these to
// the resulting docker exec command.
// ref: https://code.visualstudio.com/docs/devcontainers/attach-container
env, err := devcontainerEnv(ctx, execer, container)
if err != nil { // best effort.
return nil, xerrors.Errorf("read devcontainer remoteEnv: %w", err)
}
dei.env = env
return &dei, nil
}
func (dei *DockerEnvInfoer) CurrentUser() (*user.User, error) {
// Clone the user so that the caller can't modify it
u := *dei.user
return &u, nil
}
func (*DockerEnvInfoer) Environ() []string {
// Return a clone of the environment so that the caller can't modify it
return os.Environ()
}
func (*DockerEnvInfoer) UserHomeDir() (string, error) {
// We default the working directory of the command to the user's home
// directory. Since this came from inside the container, we cannot guarantee
// that this exists on the host. Return the "real" home directory of the user
// instead.
return os.UserHomeDir()
}
func (dei *DockerEnvInfoer) UserShell(string) (string, error) {
return dei.userShell, nil
}
func (dei *DockerEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) {
// Wrap the command with `docker exec` and run it as the container user.
// There is some additional munging here regarding the container user and environment.
dockerArgs := []string{
"exec",
// The assumption is that this command will be a shell command, so allocate a PTY.
"--interactive",
"--tty",
// Run the command as the user in the container.
"--user",
dei.user.Username,
// Set the working directory to the user's home directory as a sane default.
"--workdir",
dei.user.HomeDir,
}
// Append the environment variables from the container.
for _, e := range dei.env {
dockerArgs = append(dockerArgs, "--env", e)
}
// Append the container name and the command.
dockerArgs = append(dockerArgs, dei.container, cmd)
return "docker", append(dockerArgs, args...)
}
// devcontainerEnv is a helper function that inspects the container labels to
// find the required environment variables for running a command in the container.
func devcontainerEnv(ctx context.Context, execer agentexec.Execer, container string) ([]string, error) {
ins, stderr, err := runDockerInspect(ctx, execer, container)
if err != nil {
return nil, xerrors.Errorf("inspect container: %w: %q", err, stderr)
}
if len(ins) != 1 {
return nil, xerrors.Errorf("inspect container: expected 1 container, got %d", len(ins))
}
in := ins[0]
if in.Config.Labels == nil {
return nil, nil
}
// We want to look for the devcontainer metadata, which is in the
// value of the label `devcontainer.metadata`.
rawMeta, ok := in.Config.Labels["devcontainer.metadata"]
if !ok {
return nil, nil
}
meta := struct {
RemoteEnv map[string]string `json:"remoteEnv"`
}{}
if err := json.Unmarshal([]byte(rawMeta), &meta); err != nil {
return nil, xerrors.Errorf("unmarshal devcontainer.metadata: %w", err)
}
// The environment variables are stored in the `remoteEnv` key.
env := make([]string, 0, len(meta.RemoteEnv))
for k, v := range meta.RemoteEnv {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
slices.Sort(env)
return env, nil
}
// wrapDockerExec is a helper function that wraps the given command and arguments
// with a docker exec command that runs as the given user in the given
// container. This is used to fetch information about a container prior to
// running the actual command.
func wrapDockerExec(containerName, userName, cmd string, args ...string) (string, []string) {
dockerArgs := []string{"exec", "--interactive"}
if userName != "" {
dockerArgs = append(dockerArgs, "--user", userName)
}
dockerArgs = append(dockerArgs, containerName, cmd)
return "docker", append(dockerArgs, args...)
}
// Helper function to run a command and return its stdout and stderr.
// We want to differentiate stdout and stderr instead of using CombinedOutput.
// We also want to differentiate between a command running successfully with
// output to stderr and a non-zero exit code.
func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr string, err error) {
var stdoutBuf, stderrBuf strings.Builder
execCmd := execer.CommandContext(ctx, cmd, args...)
execCmd.Stdout = &stdoutBuf
execCmd.Stderr = &stderrBuf
err = execCmd.Run()
stdout = strings.TrimSpace(stdoutBuf.String())
stderr = strings.TrimSpace(stderrBuf.String())
return stdout, stderr, err
}
func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
var stdoutBuf, stderrBuf bytes.Buffer
// List all container IDs, one per line, with no truncation
cmd := dcl.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc")
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
// TODO(Cian): detect specific errors:
// - docker not installed
// - docker not running
// - no permissions to talk to docker
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker ps: %w: %q", err, strings.TrimSpace(stderrBuf.String()))
}
ids := make([]string, 0)
scanner := bufio.NewScanner(&stdoutBuf)
for scanner.Scan() {
tmp := strings.TrimSpace(scanner.Text())
if tmp == "" {
continue
}
ids = append(ids, tmp)
}
if err := scanner.Err(); err != nil {
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("scan docker ps output: %w", err)
}
dockerPsStderr := strings.TrimSpace(stderrBuf.String())
if len(ids) == 0 {
return codersdk.WorkspaceAgentListContainersResponse{
Warnings: []string{dockerPsStderr},
}, nil
}
// now we can get the detailed information for each container
// Run `docker inspect` on each container ID.
// NOTE: There is an unavoidable potential race condition where a
// container is removed between `docker ps` and `docker inspect`.
// In this case, stderr will contain an error message but stdout
// will still contain valid JSON. We will just end up missing
// information about the removed container. We could potentially
// log this error, but I'm not sure it's worth it.
ins, dockerInspectStderr, err := runDockerInspect(ctx, dcl.execer, ids...)
if err != nil {
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w", err)
}
res := codersdk.WorkspaceAgentListContainersResponse{
Containers: make([]codersdk.WorkspaceAgentDevcontainer, len(ins)),
}
for idx, in := range ins {
out, warns := convertDockerInspect(in)
res.Warnings = append(res.Warnings, warns...)
res.Containers[idx] = out
}
if dockerPsStderr != "" {
res.Warnings = append(res.Warnings, dockerPsStderr)
}
if dockerInspectStderr != "" {
res.Warnings = append(res.Warnings, dockerInspectStderr)
}
return res, nil
}
// runDockerInspect is a helper function that runs `docker inspect` on the given
// container IDs and returns the parsed output.
// The stderr output is also returned for logging purposes.
func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...string) ([]dockerInspect, string, error) {
var stdoutBuf, stderrBuf bytes.Buffer
cmd := execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
err := cmd.Run()
stderr := strings.TrimSpace(stderrBuf.String())
if err != nil {
return nil, stderr, err
}
var ins []dockerInspect
if err := json.NewDecoder(&stdoutBuf).Decode(&ins); err != nil {
return nil, stderr, xerrors.Errorf("decode docker inspect output: %w", err)
}
return ins, stderr, nil
}
// To avoid a direct dependency on the Docker API, we use the docker CLI
// to fetch information about containers.
type dockerInspect struct {
ID string `json:"Id"`
Created time.Time `json:"Created"`
Config dockerInspectConfig `json:"Config"`
HostConfig dockerInspectHostConfig `json:"HostConfig"`
Name string `json:"Name"`
Mounts []dockerInspectMount `json:"Mounts"`
State dockerInspectState `json:"State"`
}
type dockerInspectConfig struct {
Image string `json:"Image"`
Labels map[string]string `json:"Labels"`
}
type dockerInspectHostConfig struct {
PortBindings map[string]any `json:"PortBindings"`
}
type dockerInspectMount struct {
Source string `json:"Source"`
Destination string `json:"Destination"`
Type string `json:"Type"`
}
type dockerInspectState struct {
Running bool `json:"Running"`
ExitCode int `json:"ExitCode"`
Error string `json:"Error"`
}
func (dis dockerInspectState) String() string {
if dis.Running {
return "running"
}
var sb strings.Builder
_, _ = sb.WriteString("exited")
if dis.ExitCode != 0 {
_, _ = sb.WriteString(fmt.Sprintf(" with code %d", dis.ExitCode))
} else {
_, _ = sb.WriteString(" successfully")
}
if dis.Error != "" {
_, _ = sb.WriteString(fmt.Sprintf(": %s", dis.Error))
}
return sb.String()
}
func convertDockerInspect(in dockerInspect) (codersdk.WorkspaceAgentDevcontainer, []string) {
var warns []string
out := codersdk.WorkspaceAgentDevcontainer{
CreatedAt: in.Created,
// Remove the leading slash from the container name
FriendlyName: strings.TrimPrefix(in.Name, "/"),
ID: in.ID,
Image: in.Config.Image,
Labels: in.Config.Labels,
Ports: make([]codersdk.WorkspaceAgentListeningPort, 0),
Running: in.State.Running,
Status: in.State.String(),
Volumes: make(map[string]string, len(in.Mounts)),
}
if in.HostConfig.PortBindings == nil {
in.HostConfig.PortBindings = make(map[string]any)
}
portKeys := maps.Keys(in.HostConfig.PortBindings)
// Sort the ports for deterministic output.
sort.Strings(portKeys)
for _, p := range portKeys {
if port, network, err := convertDockerPort(p); err != nil {
warns = append(warns, err.Error())
} else {
out.Ports = append(out.Ports, codersdk.WorkspaceAgentListeningPort{
Network: network,
Port: port,
})
}
}
if in.Mounts == nil {
in.Mounts = []dockerInspectMount{}
}
// Sort the mounts for deterministic output.
sort.Slice(in.Mounts, func(i, j int) bool {
return in.Mounts[i].Source < in.Mounts[j].Source
})
for _, k := range in.Mounts {
out.Volumes[k.Source] = k.Destination
}
return out, warns
}
// convertDockerPort converts a Docker port string to a port number and network
// example: "8080/tcp" -> 8080, "tcp"
//
// "8080" -> 8080, "tcp"
func convertDockerPort(in string) (uint16, string, error) {
parts := strings.Split(in, "/")
switch len(parts) {
case 1:
// assume it's a TCP port
p, err := strconv.Atoi(parts[0])
if err != nil {
return 0, "", xerrors.Errorf("invalid port format: %s", in)
}
return uint16(p), "tcp", nil
case 2:
p, err := strconv.Atoi(parts[0])
if err != nil {
return 0, "", xerrors.Errorf("invalid port format: %s", in)
}
return uint16(p), parts[1], nil
default:
return 0, "", xerrors.Errorf("invalid port format: %s", in)
}
}
@@ -1,564 +0,0 @@
package agentcontainers
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"testing"
"time"
"go.uber.org/mock/gomock"
"github.com/google/uuid"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
// TestIntegrationDocker tests agentcontainers functionality using a real
// Docker container. It starts a container with a known
// label, lists the containers, and verifies that the expected container is
// returned. It also executes a sample command inside the container.
// The container is deleted after the test is complete.
// As this test creates containers, it is skipped by default.
// It can be run manually as follows:
//
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerCLIContainerLister
func TestIntegrationDocker(t *testing.T) {
t.Parallel()
if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" {
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
}
pool, err := dockertest.NewPool("")
require.NoError(t, err, "Could not connect to docker")
testLabelValue := uuid.New().String()
// Create a temporary directory to validate that we surface mounts correctly.
testTempDir := t.TempDir()
// Pick a random port to expose for testing port bindings.
testRandPort := testutil.RandomPortNoListen(t)
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "busybox",
Tag: "latest",
Cmd: []string{"sleep", "infnity"},
Labels: map[string]string{
"com.coder.test": testLabelValue,
"devcontainer.metadata": `{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}`,
},
Mounts: []string{testTempDir + ":" + testTempDir},
ExposedPorts: []string{fmt.Sprintf("%d/tcp", testRandPort)},
PortBindings: map[docker.Port][]docker.PortBinding{
docker.Port(fmt.Sprintf("%d/tcp", testRandPort)): {
{
HostIP: "0.0.0.0",
HostPort: strconv.FormatInt(int64(testRandPort), 10),
},
},
},
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
require.NoError(t, err, "Could not start test docker container")
t.Logf("Created container %q", ct.Container.Name)
t.Cleanup(func() {
assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name)
t.Logf("Purged container %q", ct.Container.Name)
})
// Wait for container to start
require.Eventually(t, func() bool {
ct, ok := pool.ContainerByName(ct.Container.Name)
return ok && ct.Container.State.Running
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
dcl := NewDocker(agentexec.DefaultExecer)
ctx := testutil.Context(t, testutil.WaitShort)
actual, err := dcl.List(ctx)
require.NoError(t, err, "Could not list containers")
require.Empty(t, actual.Warnings, "Expected no warnings")
var found bool
for _, foundContainer := range actual.Containers {
if foundContainer.ID == ct.Container.ID {
found = true
assert.Equal(t, ct.Container.Created, foundContainer.CreatedAt)
// ory/dockertest pre-pends a forward slash to the container name.
assert.Equal(t, strings.TrimPrefix(ct.Container.Name, "/"), foundContainer.FriendlyName)
// ory/dockertest returns the sha256 digest of the image.
assert.Equal(t, "busybox:latest", foundContainer.Image)
assert.Equal(t, ct.Container.Config.Labels, foundContainer.Labels)
assert.True(t, foundContainer.Running)
assert.Equal(t, "running", foundContainer.Status)
if assert.Len(t, foundContainer.Ports, 1) {
assert.Equal(t, testRandPort, foundContainer.Ports[0].Port)
assert.Equal(t, "tcp", foundContainer.Ports[0].Network)
}
if assert.Len(t, foundContainer.Volumes, 1) {
assert.Equal(t, testTempDir, foundContainer.Volumes[testTempDir])
}
// Test that EnvInfo is able to correctly modify a command to be
// executed inside the container.
dei, err := EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, "")
require.NoError(t, err, "Expected no error from DockerEnvInfo()")
ptyWrappedCmd, ptyWrappedArgs := dei.ModifyCommand("/bin/sh", "--norc")
ptyCmd, ptyPs, err := pty.Start(agentexec.DefaultExecer.PTYCommandContext(ctx, ptyWrappedCmd, ptyWrappedArgs...))
require.NoError(t, err, "failed to start pty command")
t.Cleanup(func() {
_ = ptyPs.Kill()
_ = ptyCmd.Close()
})
tr := testutil.NewTerminalReader(t, ptyCmd.OutputReader())
matchPrompt := func(line string) bool {
return strings.Contains(line, "#")
}
matchHostnameCmd := func(line string) bool {
return strings.Contains(strings.TrimSpace(line), "hostname")
}
matchHostnameOuput := func(line string) bool {
return strings.Contains(strings.TrimSpace(line), ct.Container.Config.Hostname)
}
matchEnvCmd := func(line string) bool {
return strings.Contains(strings.TrimSpace(line), "env")
}
matchEnvOutput := func(line string) bool {
return strings.Contains(line, "FOO=bar") || strings.Contains(line, "MULTILINE=foo")
}
require.NoError(t, tr.ReadUntil(ctx, matchPrompt), "failed to match prompt")
t.Logf("Matched prompt")
_, err = ptyCmd.InputWriter().Write([]byte("hostname\r\n"))
require.NoError(t, err, "failed to write to pty")
t.Logf("Wrote hostname command")
require.NoError(t, tr.ReadUntil(ctx, matchHostnameCmd), "failed to match hostname command")
t.Logf("Matched hostname command")
require.NoError(t, tr.ReadUntil(ctx, matchHostnameOuput), "failed to match hostname output")
t.Logf("Matched hostname output")
_, err = ptyCmd.InputWriter().Write([]byte("env\r\n"))
require.NoError(t, err, "failed to write to pty")
t.Logf("Wrote env command")
require.NoError(t, tr.ReadUntil(ctx, matchEnvCmd), "failed to match env command")
t.Logf("Matched env command")
require.NoError(t, tr.ReadUntil(ctx, matchEnvOutput), "failed to match env output")
t.Logf("Matched env output")
break
}
}
assert.True(t, found, "Expected to find container with label 'com.coder.test=%s'", testLabelValue)
}
func TestWrapDockerExec(t *testing.T) {
t.Parallel()
tests := []struct {
name string
containerUser string
cmdArgs []string
wantCmd []string
}{
{
name: "cmd with no args",
containerUser: "my-user",
cmdArgs: []string{"my-cmd"},
wantCmd: []string{"docker", "exec", "--interactive", "--user", "my-user", "my-container", "my-cmd"},
},
{
name: "cmd with args",
containerUser: "my-user",
cmdArgs: []string{"my-cmd", "arg1", "--arg2", "arg3", "--arg4"},
wantCmd: []string{"docker", "exec", "--interactive", "--user", "my-user", "my-container", "my-cmd", "arg1", "--arg2", "arg3", "--arg4"},
},
{
name: "no user specified",
containerUser: "",
cmdArgs: []string{"my-cmd"},
wantCmd: []string{"docker", "exec", "--interactive", "my-container", "my-cmd"},
},
}
for _, tt := range tests {
tt := tt // appease the linter even though this isn't needed anymore
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
actualCmd, actualArgs := wrapDockerExec("my-container", tt.containerUser, tt.cmdArgs[0], tt.cmdArgs[1:]...)
assert.Equal(t, tt.wantCmd[0], actualCmd)
assert.Equal(t, tt.wantCmd[1:], actualArgs)
})
}
}
// TestContainersHandler tests the containersHandler.getContainers method using
// a mock implementation. It specifically tests caching behavior.
func TestContainersHandler(t *testing.T) {
t.Parallel()
t.Run("list", func(t *testing.T) {
t.Parallel()
fakeCt := fakeContainer(t)
fakeCt2 := fakeContainer(t)
makeResponse := func(cts ...codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentListContainersResponse {
return codersdk.WorkspaceAgentListContainersResponse{Containers: cts}
}
// Each test case is called multiple times to ensure idempotency
for _, tc := range []struct {
name string
// data to be stored in the handler
cacheData codersdk.WorkspaceAgentListContainersResponse
// duration of cache
cacheDur time.Duration
// relative age of the cached data
cacheAge time.Duration
// function to set up expectations for the mock
setupMock func(*acmock.MockLister)
// expected result
expected codersdk.WorkspaceAgentListContainersResponse
// expected error
expectedErr string
}{
{
name: "no cache",
setupMock: func(mcl *acmock.MockLister) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
},
expected: makeResponse(fakeCt),
},
{
name: "no data",
cacheData: makeResponse(),
cacheAge: 2 * time.Second,
cacheDur: time.Second,
setupMock: func(mcl *acmock.MockLister) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
},
expected: makeResponse(fakeCt),
},
{
name: "cached data",
cacheAge: time.Second,
cacheData: makeResponse(fakeCt),
cacheDur: 2 * time.Second,
expected: makeResponse(fakeCt),
},
{
name: "lister error",
setupMock: func(mcl *acmock.MockLister) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes()
},
expectedErr: assert.AnError.Error(),
},
{
name: "stale cache",
cacheAge: 2 * time.Second,
cacheData: makeResponse(fakeCt),
cacheDur: time.Second,
setupMock: func(mcl *acmock.MockLister) {
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes()
},
expected: makeResponse(fakeCt2),
},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
clk = quartz.NewMock(t)
ctrl = gomock.NewController(t)
mockLister = acmock.NewMockLister(ctrl)
now = time.Now().UTC()
ch = devcontainersHandler{
cacheDuration: tc.cacheDur,
cl: mockLister,
clock: clk,
containers: &tc.cacheData,
lockCh: make(chan struct{}, 1),
}
)
if tc.cacheAge != 0 {
ch.mtime = now.Add(-tc.cacheAge)
}
if tc.setupMock != nil {
tc.setupMock(mockLister)
}
clk.Set(now).MustWait(ctx)
// Repeat the test to ensure idempotency
for i := 0; i < 2; i++ {
actual, err := ch.getContainers(ctx)
if tc.expectedErr != "" {
require.Empty(t, actual, "expected no data (attempt %d)", i)
require.ErrorContains(t, err, tc.expectedErr, "expected error (attempt %d)", i)
} else {
require.NoError(t, err, "expected no error (attempt %d)", i)
require.Equal(t, tc.expected, actual, "expected containers to be equal (attempt %d)", i)
}
}
})
}
})
}
func TestConvertDockerPort(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in string
expectPort uint16
expectNetwork string
expectError string
}{
{
name: "empty port",
in: "",
expectError: "invalid port",
},
{
name: "valid tcp port",
in: "8080/tcp",
expectPort: 8080,
expectNetwork: "tcp",
},
{
name: "valid udp port",
in: "8080/udp",
expectPort: 8080,
expectNetwork: "udp",
},
{
name: "valid port no network",
in: "8080",
expectPort: 8080,
expectNetwork: "tcp",
},
{
name: "invalid port",
in: "invalid/tcp",
expectError: "invalid port",
},
{
name: "invalid port no network",
in: "invalid",
expectError: "invalid port",
},
{
name: "multiple network",
in: "8080/tcp/udp",
expectError: "invalid port",
},
} {
tc := tc // not needed anymore but makes the linter happy
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
actualPort, actualNetwork, actualErr := convertDockerPort(tc.in)
if tc.expectError != "" {
assert.Zero(t, actualPort, "expected no port")
assert.Empty(t, actualNetwork, "expected no network")
assert.ErrorContains(t, actualErr, tc.expectError)
} else {
assert.NoError(t, actualErr, "expected no error")
assert.Equal(t, tc.expectPort, actualPort, "expected port to match")
assert.Equal(t, tc.expectNetwork, actualNetwork, "expected network to match")
}
})
}
}
func TestConvertDockerVolume(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in string
expectHostPath string
expectContainerPath string
expectError string
}{
{
name: "empty volume",
in: "",
expectError: "invalid volume",
},
{
name: "length 1 volume",
in: "/path/to/something",
expectHostPath: "/path/to/something",
expectContainerPath: "/path/to/something",
},
{
name: "length 2 volume",
in: "/path/to/something=/path/to/something/else",
expectHostPath: "/path/to/something",
expectContainerPath: "/path/to/something/else",
},
{
name: "invalid length volume",
in: "/path/to/something=/path/to/something/else=/path/to/something/else/else",
expectError: "invalid volume",
},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
})
}
}
// TestDockerEnvInfoer tests the ability of EnvInfo to extract information from
// running containers. Containers are deleted after the test is complete.
// As this test creates containers, it is skipped by default.
// It can be run manually as follows:
//
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerEnvInfoer
func TestDockerEnvInfoer(t *testing.T) {
t.Parallel()
if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" {
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
}
pool, err := dockertest.NewPool("")
require.NoError(t, err, "Could not connect to docker")
// nolint:paralleltest // variable recapture no longer required
for idx, tt := range []struct {
image string
labels map[string]string
expectedEnv []string
containerUser string
expectedUsername string
expectedUserShell string
}{
{
image: "busybox:latest",
labels: map[string]string{`devcontainer.metadata`: `{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}`},
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
expectedUsername: "root",
expectedUserShell: "/bin/sh",
},
{
image: "busybox:latest",
labels: map[string]string{`devcontainer.metadata`: `{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}`},
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
containerUser: "root",
expectedUsername: "root",
expectedUserShell: "/bin/sh",
},
{
image: "codercom/enterprise-minimal:ubuntu",
labels: map[string]string{`devcontainer.metadata`: `{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}`},
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
expectedUsername: "coder",
expectedUserShell: "/bin/bash",
},
{
image: "codercom/enterprise-minimal:ubuntu",
labels: map[string]string{`devcontainer.metadata`: `{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}`},
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
containerUser: "coder",
expectedUsername: "coder",
expectedUserShell: "/bin/bash",
},
{
image: "codercom/enterprise-minimal:ubuntu",
labels: map[string]string{`devcontainer.metadata`: `{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}`},
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
containerUser: "root",
expectedUsername: "root",
expectedUserShell: "/bin/bash",
},
} {
t.Run(fmt.Sprintf("#%d", idx), func(t *testing.T) {
t.Parallel()
// Start a container with the given image
// and environment variables
image := strings.Split(tt.image, ":")[0]
tag := strings.Split(tt.image, ":")[1]
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: image,
Tag: tag,
Cmd: []string{"sleep", "infinity"},
Labels: tt.labels,
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
require.NoError(t, err, "Could not start test docker container")
t.Logf("Created container %q", ct.Container.Name)
t.Cleanup(func() {
assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name)
t.Logf("Purged container %q", ct.Container.Name)
})
ctx := testutil.Context(t, testutil.WaitShort)
dei, err := EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, tt.containerUser)
require.NoError(t, err, "Expected no error from DockerEnvInfo()")
u, err := dei.CurrentUser()
require.NoError(t, err, "Expected no error from CurrentUser()")
require.Equal(t, tt.expectedUsername, u.Username, "Expected username to match")
hd, err := dei.UserHomeDir()
require.NoError(t, err, "Expected no error from UserHomeDir()")
require.NotEmpty(t, hd, "Expected user homedir to be non-empty")
sh, err := dei.UserShell(tt.containerUser)
require.NoError(t, err, "Expected no error from UserShell()")
require.Equal(t, tt.expectedUserShell, sh, "Expected user shell to match")
// We don't need to test the actual environment variables here.
environ := dei.Environ()
require.NotEmpty(t, environ, "Expected environ to be non-empty")
// Test that the environment variables are present in modified command
// output.
envCmd, envArgs := dei.ModifyCommand("env")
for _, env := range tt.expectedEnv {
require.Subset(t, envArgs, []string{"--env", env})
}
// Run the command in the container and check the output
// HACK: we remove the --tty argument because we're not running in a tty
envArgs = slices.DeleteFunc(envArgs, func(s string) bool { return s == "--tty" })
stdout, stderr, err := run(ctx, agentexec.DefaultExecer, envCmd, envArgs...)
require.Empty(t, stderr, "Expected no stderr output")
require.NoError(t, err, "Expected no error from running command")
for _, env := range tt.expectedEnv {
require.Contains(t, stdout, env)
}
})
}
}
func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentDevcontainer)) codersdk.WorkspaceAgentDevcontainer {
t.Helper()
ct := codersdk.WorkspaceAgentDevcontainer{
CreatedAt: time.Now().UTC(),
ID: uuid.New().String(),
FriendlyName: testutil.GetRandomName(t),
Image: testutil.GetRandomName(t) + ":" + strings.Split(uuid.New().String(), "-")[0],
Labels: map[string]string{
testutil.GetRandomName(t): testutil.GetRandomName(t),
},
Running: true,
Ports: []codersdk.WorkspaceAgentListeningPort{
{
Network: "tcp",
Port: testutil.RandomPortNoListen(t),
},
},
Status: testutil.MustRandString(t, 10),
Volumes: map[string]string{testutil.GetRandomName(t): testutil.GetRandomName(t)},
}
for _, m := range mut {
m(&ct)
}
return ct
}
-202
View File
@@ -1,202 +0,0 @@
//go:build linux
// +build linux
package agentexec
import (
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"slices"
"strconv"
"strings"
"syscall"
"golang.org/x/sys/unix"
"golang.org/x/xerrors"
"kernel.org/pub/linux/libs/security/libcap/cap"
)
// CLI runs the agent-exec command. It should only be called by the cli package.
func CLI() error {
// We lock the OS thread here to avoid a race condition where the nice priority
// we set gets applied to a different thread than the one we exec the provided
// command on.
runtime.LockOSThread()
// Nop on success but we do it anyway in case of an error.
defer runtime.UnlockOSThread()
var (
fs = flag.NewFlagSet("agent-exec", flag.ExitOnError)
nice = fs.Int("coder-nice", unset, "")
oom = fs.Int("coder-oom", unset, "")
)
if len(os.Args) < 3 {
return xerrors.Errorf("malformed command %+v", os.Args)
}
// Parse everything after "coder agent-exec".
err := fs.Parse(os.Args[2:])
if err != nil {
return xerrors.Errorf("parse flags: %w", err)
}
// Get everything after "coder agent-exec --"
args := execArgs(os.Args)
if len(args) == 0 {
return xerrors.Errorf("no exec command provided %+v", os.Args)
}
if *oom == unset {
// If an explicit oom score isn't set, we use the default.
*oom, err = defaultOOMScore()
if err != nil {
return xerrors.Errorf("get default oom score: %w", err)
}
}
if *nice == unset {
// If an explicit nice score isn't set, we use the default.
*nice, err = defaultNiceScore()
if err != nil {
return xerrors.Errorf("get default nice score: %w", err)
}
}
// We drop effective caps prior to setting dumpable so that we limit the
// impact of someone attempting to hijack the process (i.e. with a debugger)
// to take advantage of the capabilities of the agent process. We encourage
// users to set cap_net_admin on the agent binary for improved networking
// performance and doing so results in the process having its SET_DUMPABLE
// attribute disabled (meaning we cannot adjust the oom score).
err = dropEffectiveCaps()
if err != nil {
printfStdErr("failed to drop effective caps: %v", err)
}
// Set dumpable to 1 so that we can adjust the oom score. If the process
// doesn't have capabilities or has an suid/sgid bit set, this is already
// set.
err = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0)
if err != nil {
printfStdErr("failed to set dumpable: %v", err)
}
err = writeOOMScoreAdj(*oom)
if err != nil {
// We alert the user instead of failing the command since it can be difficult to debug
// for a template admin otherwise. It's quite possible (and easy) to set an
// inappriopriate value for oom_score_adj.
printfStdErr("failed to adjust oom score to %d for cmd %+v: %v", *oom, execArgs(os.Args), err)
}
// Set dumpable back to 0 just to be safe. It's not inherited for execve anyways.
err = unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0)
if err != nil {
printfStdErr("failed to unset dumpable: %v", err)
}
err = unix.Setpriority(unix.PRIO_PROCESS, 0, *nice)
if err != nil {
// We alert the user instead of failing the command since it can be difficult to debug
// for a template admin otherwise. It's quite possible (and easy) to set an
// inappriopriate value for niceness.
printfStdErr("failed to adjust niceness to %d for cmd %+v: %v", *nice, args, err)
}
path, err := exec.LookPath(args[0])
if err != nil {
return xerrors.Errorf("look path: %w", err)
}
// Remove environment variables specific to the agentexec command. This is
// especially important for environments that are attempting to develop Coder in Coder.
env := os.Environ()
env = slices.DeleteFunc(env, func(e string) bool {
return strings.HasPrefix(e, EnvProcPrioMgmt) ||
strings.HasPrefix(e, EnvProcOOMScore) ||
strings.HasPrefix(e, EnvProcNiceScore)
})
return syscall.Exec(path, args, env)
}
func defaultNiceScore() (int, error) {
score, err := unix.Getpriority(unix.PRIO_PROCESS, 0)
if err != nil {
return 0, xerrors.Errorf("get nice score: %w", err)
}
// See https://linux.die.net/man/2/setpriority#Notes
score = 20 - score
score += 5
if score > 19 {
return 19, nil
}
return score, nil
}
func defaultOOMScore() (int, error) {
score, err := oomScoreAdj()
if err != nil {
return 0, xerrors.Errorf("get oom score: %w", err)
}
// If the agent has a negative oom_score_adj, we set the child to 0
// so it's treated like every other process.
if score < 0 {
return 0, nil
}
// If the agent is already almost at the maximum then set it to the max.
if score >= 998 {
return 1000, nil
}
// If the agent oom_score_adj is >=0, we set the child to slightly
// less than the maximum. If users want a different score they set it
// directly.
return 998, nil
}
func oomScoreAdj() (int, error) {
scoreStr, err := os.ReadFile("/proc/self/oom_score_adj")
if err != nil {
return 0, xerrors.Errorf("read oom_score_adj: %w", err)
}
return strconv.Atoi(strings.TrimSpace(string(scoreStr)))
}
func writeOOMScoreAdj(score int) error {
return os.WriteFile(fmt.Sprintf("/proc/%d/oom_score_adj", os.Getpid()), []byte(fmt.Sprintf("%d", score)), 0o600)
}
// execArgs returns the arguments to pass to syscall.Exec after the "--" delimiter.
func execArgs(args []string) []string {
for i, arg := range args {
if arg == "--" {
return args[i+1:]
}
}
return nil
}
func printfStdErr(format string, a ...any) {
_, _ = fmt.Fprintf(os.Stderr, "coder-agent: %s\n", fmt.Sprintf(format, a...))
}
func dropEffectiveCaps() error {
proc := cap.GetProc()
err := proc.ClearFlag(cap.Effective)
if err != nil {
return xerrors.Errorf("clear effective caps: %w", err)
}
err = proc.SetProc()
if err != nil {
return xerrors.Errorf("set proc: %w", err)
}
return nil
}
-252
View File
@@ -1,252 +0,0 @@
//go:build linux
// +build linux
package agentexec_test
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/testutil"
)
//nolint:paralleltest // This test is sensitive to environment variables
func TestCLI(t *testing.T) {
t.Run("OK", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
cmd, path := cmd(ctx, t, 123, 12)
err := cmd.Start()
require.NoError(t, err)
go cmd.Wait()
waitForSentinel(ctx, t, cmd, path)
requireOOMScore(t, cmd.Process.Pid, 123)
requireNiceScore(t, cmd.Process.Pid, 12)
})
t.Run("FiltersEnv", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
cmd, path := cmd(ctx, t, 123, 12)
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=true", agentexec.EnvProcPrioMgmt))
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=123", agentexec.EnvProcOOMScore))
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=12", agentexec.EnvProcNiceScore))
// Ensure unrelated environment variables are preserved.
cmd.Env = append(cmd.Env, "CODER_TEST_ME_AGENTEXEC=true")
err := cmd.Start()
require.NoError(t, err)
go cmd.Wait()
waitForSentinel(ctx, t, cmd, path)
env := procEnv(t, cmd.Process.Pid)
hasExecEnvs := slices.ContainsFunc(
env,
func(e string) bool {
return strings.HasPrefix(e, agentexec.EnvProcPrioMgmt) ||
strings.HasPrefix(e, agentexec.EnvProcOOMScore) ||
strings.HasPrefix(e, agentexec.EnvProcNiceScore)
})
require.False(t, hasExecEnvs, "expected environment variables to be filtered")
userEnv := slices.Contains(env, "CODER_TEST_ME_AGENTEXEC=true")
require.True(t, userEnv, "expected user environment variables to be preserved")
})
t.Run("Defaults", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
cmd, path := cmd(ctx, t, 0, 0)
err := cmd.Start()
require.NoError(t, err)
go cmd.Wait()
waitForSentinel(ctx, t, cmd, path)
expectedNice := expectedNiceScore(t)
expectedOOM := expectedOOMScore(t)
requireOOMScore(t, cmd.Process.Pid, expectedOOM)
requireNiceScore(t, cmd.Process.Pid, expectedNice)
})
t.Run("Capabilities", func(t *testing.T) {
testdir := filepath.Dir(TestBin)
capDir := filepath.Join(testdir, "caps")
err := os.Mkdir(capDir, 0o755)
require.NoError(t, err)
bin := buildBinary(capDir)
// Try to set capabilities on the binary. This should work fine in CI but
// it's possible some developers may be working in an environment where they don't have the necessary permissions.
err = setCaps(t, bin, "cap_net_admin")
if os.Getenv("CI") != "" {
require.NoError(t, err)
} else if err != nil {
t.Skipf("unable to set capabilities for test: %v", err)
}
ctx := testutil.Context(t, testutil.WaitMedium)
cmd, path := binCmd(ctx, t, bin, 123, 12)
err = cmd.Start()
require.NoError(t, err)
go cmd.Wait()
waitForSentinel(ctx, t, cmd, path)
// This is what we're really testing, a binary with added capabilities requires setting dumpable.
requireOOMScore(t, cmd.Process.Pid, 123)
requireNiceScore(t, cmd.Process.Pid, 12)
})
}
func requireNiceScore(t *testing.T, pid int, score int) {
t.Helper()
nice, err := unix.Getpriority(unix.PRIO_PROCESS, pid)
require.NoError(t, err)
// See https://linux.die.net/man/2/setpriority#Notes
require.Equal(t, score, 20-nice)
}
func requireOOMScore(t *testing.T, pid int, expected int) {
t.Helper()
actual, err := os.ReadFile(fmt.Sprintf("/proc/%d/oom_score_adj", pid))
require.NoError(t, err)
score := strings.TrimSpace(string(actual))
require.Equal(t, strconv.Itoa(expected), score)
}
func waitForSentinel(ctx context.Context, t *testing.T, cmd *exec.Cmd, path string) {
t.Helper()
ticker := time.NewTicker(testutil.IntervalFast)
defer ticker.Stop()
// RequireEventually doesn't work well with require.NoError or similar require functions.
for {
err := cmd.Process.Signal(syscall.Signal(0))
require.NoError(t, err)
_, err = os.Stat(path)
if err == nil {
return
}
select {
case <-ticker.C:
case <-ctx.Done():
require.NoError(t, ctx.Err())
}
}
}
func binCmd(ctx context.Context, t *testing.T, bin string, oom, nice int) (*exec.Cmd, string) {
var (
args = execArgs(oom, nice)
dir = t.TempDir()
file = filepath.Join(dir, "sentinel")
)
args = append(args, "sh", "-c", fmt.Sprintf("touch %s && sleep 10m", file))
//nolint:gosec
cmd := exec.CommandContext(ctx, bin, args...)
// We set this so we can also easily kill the sleep process the shell spawns.
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
cmd.Env = os.Environ()
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
t.Cleanup(func() {
// Print output of a command if the test fails.
if t.Failed() {
t.Logf("cmd %q output: %s", cmd.Args, buf.String())
}
if cmd.Process != nil {
// We use -cmd.Process.Pid to kill the whole process group.
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT)
}
})
return cmd, file
}
func cmd(ctx context.Context, t *testing.T, oom, nice int) (*exec.Cmd, string) {
return binCmd(ctx, t, TestBin, oom, nice)
}
func expectedOOMScore(t *testing.T) int {
t.Helper()
score, err := os.ReadFile(fmt.Sprintf("/proc/%d/oom_score_adj", os.Getpid()))
require.NoError(t, err)
scoreInt, err := strconv.Atoi(strings.TrimSpace(string(score)))
require.NoError(t, err)
if scoreInt < 0 {
return 0
}
if scoreInt >= 998 {
return 1000
}
return 998
}
// procEnv returns the environment variables for a given process.
func procEnv(t *testing.T, pid int) []string {
t.Helper()
env, err := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
require.NoError(t, err)
return strings.Split(string(env), "\x00")
}
func expectedNiceScore(t *testing.T) int {
t.Helper()
score, err := unix.Getpriority(unix.PRIO_PROCESS, os.Getpid())
require.NoError(t, err)
// Priority is niceness + 20.
score = 20 - score
score += 5
if score > 19 {
return 19
}
return score
}
func execArgs(oom int, nice int) []string {
execArgs := []string{"agent-exec"}
if oom != 0 {
execArgs = append(execArgs, fmt.Sprintf("--coder-oom=%d", oom))
}
if nice != 0 {
execArgs = append(execArgs, fmt.Sprintf("--coder-nice=%d", nice))
}
execArgs = append(execArgs, "--")
return execArgs
}
func setCaps(t *testing.T, bin string, caps ...string) error {
t.Helper()
setcap := fmt.Sprintf("sudo -n setcap %s=ep %s", strings.Join(caps, ", "), bin)
out, err := exec.CommandContext(context.Background(), "sh", "-c", setcap).CombinedOutput()
if err != nil {
return xerrors.Errorf("setcap %q (%s): %w", setcap, out, err)
}
return nil
}
-10
View File
@@ -1,10 +0,0 @@
//go:build !linux
// +build !linux
package agentexec
import "golang.org/x/xerrors"
func CLI() error {
return xerrors.New("agent-exec is only supported on Linux")
}
-19
View File
@@ -1,19 +0,0 @@
//go:build linux
// +build linux
package main
import (
"fmt"
"os"
"github.com/coder/coder/v2/agent/agentexec"
)
func main() {
err := agentexec.CLI()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
-149
View File
@@ -1,149 +0,0 @@
package agentexec
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/pty"
)
const (
// EnvProcPrioMgmt is the environment variable that determines whether
// we attempt to manage process CPU and OOM Killer priority.
EnvProcPrioMgmt = "CODER_PROC_PRIO_MGMT"
EnvProcOOMScore = "CODER_PROC_OOM_SCORE"
EnvProcNiceScore = "CODER_PROC_NICE_SCORE"
// unset is set to an invalid value for nice and oom scores.
unset = -2000
)
var DefaultExecer Execer = execer{}
// Execer defines an abstraction for creating exec.Cmd variants. It's unfortunately
// necessary because we need to be able to wrap child processes with "coder agent-exec"
// for templates that expect the agent to manage process priority.
type Execer interface {
// CommandContext returns an exec.Cmd that calls "coder agent-exec" prior to exec'ing
// the provided command if CODER_PROC_PRIO_MGMT is set, otherwise a normal exec.Cmd
// is returned. All instances of exec.Cmd should flow through this function to ensure
// proper resource constraints are applied to the child process.
CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd
// PTYCommandContext returns an pty.Cmd that calls "coder agent-exec" prior to exec'ing
// the provided command if CODER_PROC_PRIO_MGMT is set, otherwise a normal pty.Cmd
// is returned. All instances of pty.Cmd should flow through this function to ensure
// proper resource constraints are applied to the child process.
PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd
}
func NewExecer() (Execer, error) {
_, enabled := os.LookupEnv(EnvProcPrioMgmt)
if runtime.GOOS != "linux" || !enabled {
return DefaultExecer, nil
}
executable, err := os.Executable()
if err != nil {
return nil, xerrors.Errorf("get executable: %w", err)
}
bin, err := filepath.EvalSymlinks(executable)
if err != nil {
return nil, xerrors.Errorf("eval symlinks: %w", err)
}
oomScore, ok := envValInt(EnvProcOOMScore)
if !ok {
oomScore = unset
}
niceScore, ok := envValInt(EnvProcNiceScore)
if !ok {
niceScore = unset
}
return priorityExecer{
binPath: bin,
oomScore: oomScore,
niceScore: niceScore,
}, nil
}
type execer struct{}
func (execer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, cmd, args...)
}
func (execer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
return pty.CommandContext(ctx, cmd, args...)
}
type priorityExecer struct {
binPath string
oomScore int
niceScore int
}
func (e priorityExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
cmd, args = e.agentExecCmd(cmd, args...)
return exec.CommandContext(ctx, cmd, args...)
}
func (e priorityExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
cmd, args = e.agentExecCmd(cmd, args...)
return pty.CommandContext(ctx, cmd, args...)
}
func (e priorityExecer) agentExecCmd(cmd string, args ...string) (string, []string) {
execArgs := []string{"agent-exec"}
if e.oomScore != unset {
execArgs = append(execArgs, oomScoreArg(e.oomScore))
}
if e.niceScore != unset {
execArgs = append(execArgs, niceScoreArg(e.niceScore))
}
execArgs = append(execArgs, "--", cmd)
execArgs = append(execArgs, args...)
return e.binPath, execArgs
}
// envValInt searches for a key in a list of environment variables and parses it to an int.
// If the key is not found or cannot be parsed, returns 0 and false.
func envValInt(key string) (int, bool) {
val, ok := os.LookupEnv(key)
if !ok {
return 0, false
}
i, err := strconv.Atoi(val)
if err != nil {
return 0, false
}
return i, true
}
// The following are flags used by the agent-exec command. We use flags instead of
// environment variables to avoid having to deal with a caller overriding the
// environment variables.
const (
niceFlag = "coder-nice"
oomFlag = "coder-oom"
)
func niceScoreArg(score int) string {
return fmt.Sprintf("--%s=%d", niceFlag, score)
}
func oomScoreArg(score int) string {
return fmt.Sprintf("--%s=%d", oomFlag, score)
}
-84
View File
@@ -1,84 +0,0 @@
package agentexec
import (
"context"
"os/exec"
"testing"
"github.com/stretchr/testify/require"
)
func TestExecer(t *testing.T) {
t.Parallel()
t.Run("Default", func(t *testing.T) {
t.Parallel()
cmd := DefaultExecer.CommandContext(context.Background(), "sh", "-c", "sleep")
path, err := exec.LookPath("sh")
require.NoError(t, err)
require.Equal(t, path, cmd.Path)
require.Equal(t, []string{"sh", "-c", "sleep"}, cmd.Args)
})
t.Run("Priority", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
e := priorityExecer{
binPath: "/foo/bar/baz",
oomScore: unset,
niceScore: unset,
}
cmd := e.CommandContext(context.Background(), "sh", "-c", "sleep")
require.Equal(t, e.binPath, cmd.Path)
require.Equal(t, []string{e.binPath, "agent-exec", "--", "sh", "-c", "sleep"}, cmd.Args)
})
t.Run("Nice", func(t *testing.T) {
t.Parallel()
e := priorityExecer{
binPath: "/foo/bar/baz",
oomScore: unset,
niceScore: 10,
}
cmd := e.CommandContext(context.Background(), "sh", "-c", "sleep")
require.Equal(t, e.binPath, cmd.Path)
require.Equal(t, []string{e.binPath, "agent-exec", "--coder-nice=10", "--", "sh", "-c", "sleep"}, cmd.Args)
})
t.Run("OOM", func(t *testing.T) {
t.Parallel()
e := priorityExecer{
binPath: "/foo/bar/baz",
oomScore: 123,
niceScore: unset,
}
cmd := e.CommandContext(context.Background(), "sh", "-c", "sleep")
require.Equal(t, e.binPath, cmd.Path)
require.Equal(t, []string{e.binPath, "agent-exec", "--coder-oom=123", "--", "sh", "-c", "sleep"}, cmd.Args)
})
t.Run("Both", func(t *testing.T) {
t.Parallel()
e := priorityExecer{
binPath: "/foo/bar/baz",
oomScore: 432,
niceScore: 14,
}
cmd := e.CommandContext(context.Background(), "sh", "-c", "sleep")
require.Equal(t, e.binPath, cmd.Path)
require.Equal(t, []string{e.binPath, "agent-exec", "--coder-oom=432", "--coder-nice=14", "--", "sh", "-c", "sleep"}, cmd.Args)
})
})
}
-46
View File
@@ -1,46 +0,0 @@
//go:build linux
// +build linux
package agentexec_test
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
)
var TestBin string
func TestMain(m *testing.M) {
code := func() int {
// We generate a unique directory per test invocation to avoid collisions between two
// processes attempting to create the same temp file.
dir := genDir()
defer os.RemoveAll(dir)
TestBin = buildBinary(dir)
return m.Run()
}()
os.Exit(code)
}
func buildBinary(dir string) string {
path := filepath.Join(dir, "agent-test")
out, err := exec.Command("go", "build", "-o", path, "./cmdtest").CombinedOutput()
mustf(err, "build binary: %s", out)
return path
}
func mustf(err error, msg string, args ...any) {
if err != nil {
panic(fmt.Sprintf(msg, args...))
}
}
func genDir() string {
dir, err := os.MkdirTemp(os.TempDir(), "agentexec")
mustf(err, "create temp dir: %v", err)
return dir
}
+5
View File
@@ -0,0 +1,5 @@
// Package agentproctest contains utility functions
// for testing process management in the agent.
package agentproctest
//go:generate mockgen -destination ./syscallermock.go -package agentproctest github.com/coder/coder/v2/agent/agentproc Syscaller
+49
View File
@@ -0,0 +1,49 @@
package agentproctest
import (
"fmt"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentproc"
"github.com/coder/coder/v2/cryptorand"
)
func GenerateProcess(t *testing.T, fs afero.Fs, muts ...func(*agentproc.Process)) agentproc.Process {
t.Helper()
pid, err := cryptorand.Intn(1<<31 - 1)
require.NoError(t, err)
arg1, err := cryptorand.String(5)
require.NoError(t, err)
arg2, err := cryptorand.String(5)
require.NoError(t, err)
arg3, err := cryptorand.String(5)
require.NoError(t, err)
cmdline := fmt.Sprintf("%s\x00%s\x00%s", arg1, arg2, arg3)
process := agentproc.Process{
CmdLine: cmdline,
PID: int32(pid),
}
for _, mut := range muts {
mut(&process)
}
process.Dir = fmt.Sprintf("%s/%d", "/proc", process.PID)
err = fs.MkdirAll(process.Dir, 0o555)
require.NoError(t, err)
err = afero.WriteFile(fs, fmt.Sprintf("%s/cmdline", process.Dir), []byte(process.CmdLine), 0o444)
require.NoError(t, err)
return process
}
@@ -0,0 +1,83 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/coder/coder/v2/agent/agentproc (interfaces: Syscaller)
//
// Generated by this command:
//
// mockgen -destination ./syscallermock.go -package agentproctest github.com/coder/coder/v2/agent/agentproc Syscaller
//
// Package agentproctest is a generated GoMock package.
package agentproctest
import (
reflect "reflect"
syscall "syscall"
gomock "go.uber.org/mock/gomock"
)
// MockSyscaller is a mock of Syscaller interface.
type MockSyscaller struct {
ctrl *gomock.Controller
recorder *MockSyscallerMockRecorder
}
// MockSyscallerMockRecorder is the mock recorder for MockSyscaller.
type MockSyscallerMockRecorder struct {
mock *MockSyscaller
}
// NewMockSyscaller creates a new mock instance.
func NewMockSyscaller(ctrl *gomock.Controller) *MockSyscaller {
mock := &MockSyscaller{ctrl: ctrl}
mock.recorder = &MockSyscallerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockSyscaller) EXPECT() *MockSyscallerMockRecorder {
return m.recorder
}
// GetPriority mocks base method.
func (m *MockSyscaller) GetPriority(arg0 int32) (int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPriority", arg0)
ret0, _ := ret[0].(int)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetPriority indicates an expected call of GetPriority.
func (mr *MockSyscallerMockRecorder) GetPriority(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriority", reflect.TypeOf((*MockSyscaller)(nil).GetPriority), arg0)
}
// Kill mocks base method.
func (m *MockSyscaller) Kill(arg0 int32, arg1 syscall.Signal) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Kill", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// Kill indicates an expected call of Kill.
func (mr *MockSyscallerMockRecorder) Kill(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kill", reflect.TypeOf((*MockSyscaller)(nil).Kill), arg0, arg1)
}
// SetPriority mocks base method.
func (m *MockSyscaller) SetPriority(arg0 int32, arg1 int) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPriority", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// SetPriority indicates an expected call of SetPriority.
func (mr *MockSyscallerMockRecorder) SetPriority(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriority", reflect.TypeOf((*MockSyscaller)(nil).SetPriority), arg0, arg1)
}
+3
View File
@@ -0,0 +1,3 @@
// Package agentproc contains logic for interfacing with local
// processes running in the same context as the agent.
package agentproc
+24
View File
@@ -0,0 +1,24 @@
//go:build !linux
// +build !linux
package agentproc
import (
"github.com/spf13/afero"
)
func (*Process) Niceness(Syscaller) (int, error) {
return 0, errUnimplemented
}
func (*Process) SetNiceness(Syscaller, int) error {
return errUnimplemented
}
func (*Process) Cmd() string {
return ""
}
func List(afero.Fs, Syscaller) ([]*Process, error) {
return nil, errUnimplemented
}
+166
View File
@@ -0,0 +1,166 @@
package agentproc_test
import (
"runtime"
"syscall"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent/agentproc"
"github.com/coder/coder/v2/agent/agentproc/agentproctest"
)
func TestList(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
t.Skipf("skipping non-linux environment")
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
var (
fs = afero.NewMemMapFs()
sc = agentproctest.NewMockSyscaller(gomock.NewController(t))
expectedProcs = make(map[int32]agentproc.Process)
)
for i := 0; i < 4; i++ {
proc := agentproctest.GenerateProcess(t, fs)
expectedProcs[proc.PID] = proc
sc.EXPECT().
Kill(proc.PID, syscall.Signal(0)).
Return(nil)
}
actualProcs, err := agentproc.List(fs, sc)
require.NoError(t, err)
require.Len(t, actualProcs, len(expectedProcs))
for _, proc := range actualProcs {
expected, ok := expectedProcs[proc.PID]
require.True(t, ok)
require.Equal(t, expected.PID, proc.PID)
require.Equal(t, expected.CmdLine, proc.CmdLine)
require.Equal(t, expected.Dir, proc.Dir)
}
})
t.Run("FinishedProcess", func(t *testing.T) {
t.Parallel()
var (
fs = afero.NewMemMapFs()
sc = agentproctest.NewMockSyscaller(gomock.NewController(t))
expectedProcs = make(map[int32]agentproc.Process)
)
for i := 0; i < 3; i++ {
proc := agentproctest.GenerateProcess(t, fs)
expectedProcs[proc.PID] = proc
sc.EXPECT().
Kill(proc.PID, syscall.Signal(0)).
Return(nil)
}
// Create a process that's already finished. We're not adding
// it to the map because it should be skipped over.
proc := agentproctest.GenerateProcess(t, fs)
sc.EXPECT().
Kill(proc.PID, syscall.Signal(0)).
Return(xerrors.New("os: process already finished"))
actualProcs, err := agentproc.List(fs, sc)
require.NoError(t, err)
require.Len(t, actualProcs, len(expectedProcs))
for _, proc := range actualProcs {
expected, ok := expectedProcs[proc.PID]
require.True(t, ok)
require.Equal(t, expected.PID, proc.PID)
require.Equal(t, expected.CmdLine, proc.CmdLine)
require.Equal(t, expected.Dir, proc.Dir)
}
})
t.Run("NoSuchProcess", func(t *testing.T) {
t.Parallel()
var (
fs = afero.NewMemMapFs()
sc = agentproctest.NewMockSyscaller(gomock.NewController(t))
expectedProcs = make(map[int32]agentproc.Process)
)
for i := 0; i < 3; i++ {
proc := agentproctest.GenerateProcess(t, fs)
expectedProcs[proc.PID] = proc
sc.EXPECT().
Kill(proc.PID, syscall.Signal(0)).
Return(nil)
}
// Create a process that doesn't exist. We're not adding
// it to the map because it should be skipped over.
proc := agentproctest.GenerateProcess(t, fs)
sc.EXPECT().
Kill(proc.PID, syscall.Signal(0)).
Return(syscall.ESRCH)
actualProcs, err := agentproc.List(fs, sc)
require.NoError(t, err)
require.Len(t, actualProcs, len(expectedProcs))
for _, proc := range actualProcs {
expected, ok := expectedProcs[proc.PID]
require.True(t, ok)
require.Equal(t, expected.PID, proc.PID)
require.Equal(t, expected.CmdLine, proc.CmdLine)
require.Equal(t, expected.Dir, proc.Dir)
}
})
}
// These tests are not very interesting but they provide some modicum of
// confidence.
func TestProcess(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
t.Skipf("skipping non-linux environment")
}
t.Run("SetNiceness", func(t *testing.T) {
t.Parallel()
var (
sc = agentproctest.NewMockSyscaller(gomock.NewController(t))
proc = &agentproc.Process{
PID: 32,
}
score = 20
)
sc.EXPECT().SetPriority(proc.PID, score).Return(nil)
err := proc.SetNiceness(sc, score)
require.NoError(t, err)
})
t.Run("Cmd", func(t *testing.T) {
t.Parallel()
var (
proc = &agentproc.Process{
CmdLine: "helloworld\x00--arg1\x00--arg2",
}
expectedName = "helloworld --arg1 --arg2"
)
require.Equal(t, expectedName, proc.Cmd())
})
}
+109
View File
@@ -0,0 +1,109 @@
//go:build linux
// +build linux
package agentproc
import (
"errors"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/spf13/afero"
"golang.org/x/xerrors"
)
func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
d, err := fs.Open(defaultProcDir)
if err != nil {
return nil, xerrors.Errorf("open dir %q: %w", defaultProcDir, err)
}
defer d.Close()
entries, err := d.Readdirnames(0)
if err != nil {
return nil, xerrors.Errorf("readdirnames: %w", err)
}
processes := make([]*Process, 0, len(entries))
for _, entry := range entries {
pid, err := strconv.ParseInt(entry, 10, 32)
if err != nil {
continue
}
// Check that the process still exists.
exists, err := isProcessExist(syscaller, int32(pid))
if err != nil {
return nil, xerrors.Errorf("check process exists: %w", err)
}
if !exists {
continue
}
cmdline, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "cmdline"))
if err != nil {
var errNo syscall.Errno
if xerrors.As(err, &errNo) && errNo == syscall.EPERM {
continue
}
return nil, xerrors.Errorf("read cmdline: %w", err)
}
processes = append(processes, &Process{
PID: int32(pid),
CmdLine: string(cmdline),
Dir: filepath.Join(defaultProcDir, entry),
})
}
return processes, nil
}
func isProcessExist(syscaller Syscaller, pid int32) (bool, error) {
err := syscaller.Kill(pid, syscall.Signal(0))
if err == nil {
return true, nil
}
if err.Error() == "os: process already finished" {
return false, nil
}
var errno syscall.Errno
if !errors.As(err, &errno) {
return false, err
}
switch errno {
case syscall.ESRCH:
return false, nil
case syscall.EPERM:
return true, nil
}
return false, xerrors.Errorf("kill: %w", err)
}
func (p *Process) Niceness(sc Syscaller) (int, error) {
nice, err := sc.GetPriority(p.PID)
if err != nil {
return 0, xerrors.Errorf("get priority for %q: %w", p.CmdLine, err)
}
return nice, nil
}
func (p *Process) SetNiceness(sc Syscaller, score int) error {
err := sc.SetPriority(p.PID, score)
if err != nil {
return xerrors.Errorf("set priority for %q: %w", p.CmdLine, err)
}
return nil
}
func (p *Process) Cmd() string {
return strings.Join(p.cmdLine(), " ")
}
func (p *Process) cmdLine() []string {
return strings.Split(p.CmdLine, "\x00")
}
+20
View File
@@ -0,0 +1,20 @@
package agentproc
import (
"syscall"
)
type Syscaller interface {
SetPriority(pid int32, priority int) error
GetPriority(pid int32) (int, error)
Kill(pid int32, sig syscall.Signal) error
}
// nolint: unused // used on some but no all platforms
const defaultProcDir = "/proc"
type Process struct {
Dir string
CmdLine string
PID int32
}
+30
View File
@@ -0,0 +1,30 @@
//go:build !linux
// +build !linux
package agentproc
import (
"syscall"
"golang.org/x/xerrors"
)
func NewSyscaller() Syscaller {
return nopSyscaller{}
}
var errUnimplemented = xerrors.New("unimplemented")
type nopSyscaller struct{}
func (nopSyscaller) SetPriority(int32, int) error {
return errUnimplemented
}
func (nopSyscaller) GetPriority(int32) (int, error) {
return 0, errUnimplemented
}
func (nopSyscaller) Kill(int32, syscall.Signal) error {
return errUnimplemented
}
+42
View File
@@ -0,0 +1,42 @@
//go:build linux
// +build linux
package agentproc
import (
"syscall"
"golang.org/x/sys/unix"
"golang.org/x/xerrors"
)
func NewSyscaller() Syscaller {
return UnixSyscaller{}
}
type UnixSyscaller struct{}
func (UnixSyscaller) SetPriority(pid int32, nice int) error {
err := unix.Setpriority(unix.PRIO_PROCESS, int(pid), nice)
if err != nil {
return xerrors.Errorf("set priority: %w", err)
}
return nil
}
func (UnixSyscaller) GetPriority(pid int32) (int, error) {
nice, err := unix.Getpriority(0, int(pid))
if err != nil {
return 0, xerrors.Errorf("get priority: %w", err)
}
return nice, nil
}
func (UnixSyscaller) Kill(pid int32, sig syscall.Signal) error {
err := syscall.Kill(int(pid), sig)
if err != nil {
return xerrors.Errorf("kill: %w", err)
}
return nil
}
+37 -152
View File
@@ -13,19 +13,15 @@ import (
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/robfig/cron/v3"
"github.com/spf13/afero"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/timestamppb"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
)
@@ -45,19 +41,13 @@ var (
parser = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.DowOptional)
)
type ScriptLogger interface {
Send(ctx context.Context, log ...agentsdk.Log) error
Flush(context.Context) error
}
// Options are a set of options for the runner.
type Options struct {
DataDirBase string
LogDir string
Logger slog.Logger
SSHServer *agentssh.Server
Filesystem afero.Fs
GetScriptLogger func(logSourceID uuid.UUID) ScriptLogger
LogDir string
Logger slog.Logger
SSHServer *agentssh.Server
Filesystem afero.Fs
PatchLogs func(ctx context.Context, req agentsdk.PatchLogs) error
}
// New creates a runner for the provided scripts.
@@ -69,7 +59,6 @@ func New(opts Options) *Runner {
cronCtxCancel: cronCtxCancel,
cron: cron.New(cron.WithParser(parser)),
closed: make(chan struct{}),
dataDir: filepath.Join(opts.DataDirBase, "coder-script-data"),
scriptsExecuted: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "agent",
Subsystem: "scripts",
@@ -78,21 +67,17 @@ func New(opts Options) *Runner {
}
}
type ScriptCompletedFunc func(context.Context, *proto.WorkspaceAgentScriptCompletedRequest) (*proto.WorkspaceAgentScriptCompletedResponse, error)
type Runner struct {
Options
cronCtx context.Context
cronCtxCancel context.CancelFunc
cmdCloseWait sync.WaitGroup
closed chan struct{}
closeMutex sync.Mutex
cron *cron.Cron
initialized atomic.Bool
scripts []codersdk.WorkspaceAgentScript
dataDir string
scriptCompleted ScriptCompletedFunc
cronCtx context.Context
cronCtxCancel context.CancelFunc
cmdCloseWait sync.WaitGroup
closed chan struct{}
closeMutex sync.Mutex
cron *cron.Cron
initialized atomic.Bool
scripts []codersdk.WorkspaceAgentScript
// scriptsExecuted includes all scripts executed by the workspace agent. Agents
// execute startup scripts, and scripts on a cron schedule. Both will increment
@@ -100,17 +85,6 @@ type Runner struct {
scriptsExecuted *prometheus.CounterVec
}
// DataDir returns the directory where scripts data is stored.
func (r *Runner) DataDir() string {
return r.dataDir
}
// ScriptBinDir returns the directory where scripts can store executable
// binaries.
func (r *Runner) ScriptBinDir() string {
return filepath.Join(r.dataDir, "bin")
}
func (r *Runner) RegisterMetrics(reg prometheus.Registerer) {
if reg == nil {
// If no registry, do nothing.
@@ -122,27 +96,21 @@ func (r *Runner) RegisterMetrics(reg prometheus.Registerer) {
// Init initializes the runner with the provided scripts.
// It also schedules any scripts that have a schedule.
// This function must be called before Execute.
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted ScriptCompletedFunc) error {
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript) error {
if r.initialized.Load() {
return xerrors.New("init: already initialized")
}
r.initialized.Store(true)
r.scripts = scripts
r.scriptCompleted = scriptCompleted
r.Logger.Info(r.cronCtx, "initializing agent scripts", slog.F("script_count", len(scripts)), slog.F("log_dir", r.LogDir))
err := r.Filesystem.MkdirAll(r.ScriptBinDir(), 0o700)
if err != nil {
return xerrors.Errorf("create script bin dir: %w", err)
}
for _, script := range scripts {
if script.Cron == "" {
continue
}
script := script
_, err := r.cron.AddFunc(script.Cron, func() {
err := r.trackRun(r.cronCtx, script, ExecuteCronScripts)
err := r.trackRun(r.cronCtx, script)
if err != nil {
r.Logger.Warn(context.Background(), "run agent script on schedule", slog.Error(err))
}
@@ -179,33 +147,22 @@ func (r *Runner) StartCron() {
}
}
// ExecuteOption describes what scripts we want to execute.
type ExecuteOption int
// ExecuteOption enums.
const (
ExecuteAllScripts ExecuteOption = iota
ExecuteStartScripts
ExecuteStopScripts
ExecuteCronScripts
)
// Execute runs a set of scripts according to a filter.
func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
func (r *Runner) Execute(ctx context.Context, filter func(script codersdk.WorkspaceAgentScript) bool) error {
if filter == nil {
// Execute em' all!
filter = func(script codersdk.WorkspaceAgentScript) bool {
return true
}
}
var eg errgroup.Group
for _, script := range r.scripts {
runScript := (option == ExecuteStartScripts && script.RunOnStart) ||
(option == ExecuteStopScripts && script.RunOnStop) ||
(option == ExecuteCronScripts && script.Cron != "") ||
option == ExecuteAllScripts
if !runScript {
if !filter(script) {
continue
}
script := script
eg.Go(func() error {
err := r.trackRun(ctx, script, option)
err := r.trackRun(ctx, script)
if err != nil {
return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err)
}
@@ -216,8 +173,8 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
}
// trackRun wraps "run" with metrics.
func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
err := r.run(ctx, script, option)
func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScript) error {
err := r.run(ctx, script)
if err != nil {
r.scriptsExecuted.WithLabelValues("false").Add(1)
} else {
@@ -230,7 +187,7 @@ func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScr
// If the timeout is exceeded, the process is sent an interrupt signal.
// If the process does not exit after a few seconds, it is forcefully killed.
// This function immediately returns after a timeout, and does not wait for the process to exit.
func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript) error {
logPath := script.LogPath
if logPath == "" {
logPath = fmt.Sprintf("coder-script-%s.log", script.LogSourceID)
@@ -251,18 +208,7 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript,
if !filepath.IsAbs(logPath) {
logPath = filepath.Join(r.LogDir, logPath)
}
scriptDataDir := filepath.Join(r.DataDir(), script.LogSourceID.String())
err := r.Filesystem.MkdirAll(scriptDataDir, 0o700)
if err != nil {
return xerrors.Errorf("%s script: create script temp dir: %w", scriptDataDir, err)
}
logger := r.Logger.With(
slog.F("log_source_id", script.LogSourceID),
slog.F("log_path", logPath),
slog.F("script_data_dir", scriptDataDir),
)
logger := r.Logger.With(slog.F("log_path", logPath))
logger.Info(ctx, "running agent script", slog.F("script", script.Script))
fileWriter, err := r.Filesystem.OpenFile(logPath, os.O_CREATE|os.O_RDWR, 0o600)
@@ -283,43 +229,36 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript,
cmdCtx, ctxCancel = context.WithTimeout(ctx, script.Timeout)
defer ctxCancel()
}
cmdPty, err := r.SSHServer.CreateCommand(cmdCtx, script.Script, nil, nil)
cmdPty, err := r.SSHServer.CreateCommand(cmdCtx, script.Script, nil)
if err != nil {
return xerrors.Errorf("%s script: create command: %w", logPath, err)
}
cmd = cmdPty.AsExec()
cmd.SysProcAttr = cmdSysProcAttr()
cmd.WaitDelay = 10 * time.Second
cmd.Cancel = cmdCancel(ctx, logger, cmd)
cmd.Cancel = cmdCancel(cmd)
// Expose env vars that can be used in the script for storing data
// and binaries. In the future, we may want to expose more env vars
// for the script to use, like CODER_SCRIPT_DATA_DIR for persistent
// storage.
cmd.Env = append(cmd.Env, "CODER_SCRIPT_DATA_DIR="+scriptDataDir)
cmd.Env = append(cmd.Env, "CODER_SCRIPT_BIN_DIR="+r.ScriptBinDir())
scriptLogger := r.GetScriptLogger(script.LogSourceID)
send, flushAndClose := agentsdk.LogsSender(script.LogSourceID, r.PatchLogs, logger)
// If ctx is canceled here (or in a writer below), we may be
// discarding logs, but that's okay because we're shutting down
// anyway. We could consider creating a new context here if we
// want better control over flush during shutdown.
defer func() {
if err := scriptLogger.Flush(ctx); err != nil {
if err := flushAndClose(ctx); err != nil {
logger.Warn(ctx, "flush startup logs failed", slog.Error(err))
}
}()
infoW := agentsdk.LogsWriter(ctx, scriptLogger.Send, script.LogSourceID, codersdk.LogLevelInfo)
infoW := agentsdk.LogsWriter(ctx, send, script.LogSourceID, codersdk.LogLevelInfo)
defer infoW.Close()
errW := agentsdk.LogsWriter(ctx, scriptLogger.Send, script.LogSourceID, codersdk.LogLevelError)
errW := agentsdk.LogsWriter(ctx, send, script.LogSourceID, codersdk.LogLevelError)
defer errW.Close()
cmd.Stdout = io.MultiWriter(fileWriter, infoW)
cmd.Stderr = io.MultiWriter(fileWriter, errW)
start := dbtime.Now()
start := time.Now()
defer func() {
end := dbtime.Now()
end := time.Now()
execTime := end.Sub(start)
exitCode := 0
if err != nil {
@@ -332,60 +271,6 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript,
} else {
logger.Info(ctx, fmt.Sprintf("%s script completed", logPath), slog.F("execution_time", execTime), slog.F("exit_code", exitCode))
}
if r.scriptCompleted == nil {
logger.Debug(ctx, "r.scriptCompleted unexpectedly nil")
return
}
// We want to check this outside of the goroutine to avoid a race condition
timedOut := errors.Is(err, ErrTimeout)
pipesLeftOpen := errors.Is(err, ErrOutputPipesOpen)
err = r.trackCommandGoroutine(func() {
var stage proto.Timing_Stage
switch option {
case ExecuteStartScripts:
stage = proto.Timing_START
case ExecuteStopScripts:
stage = proto.Timing_STOP
case ExecuteCronScripts:
stage = proto.Timing_CRON
}
var status proto.Timing_Status
switch {
case timedOut:
status = proto.Timing_TIMED_OUT
case pipesLeftOpen:
status = proto.Timing_PIPES_LEFT_OPEN
case exitCode != 0:
status = proto.Timing_EXIT_FAILURE
default:
status = proto.Timing_OK
}
reportTimeout := 30 * time.Second
reportCtx, cancel := context.WithTimeout(context.Background(), reportTimeout)
defer cancel()
_, err := r.scriptCompleted(reportCtx, &proto.WorkspaceAgentScriptCompletedRequest{
Timing: &proto.Timing{
ScriptId: script.ID[:],
Start: timestamppb.New(start),
End: timestamppb.New(end),
ExitCode: int32(exitCode),
Stage: stage,
Status: status,
},
})
if err != nil {
logger.Error(ctx, fmt.Sprintf("reporting script completed: %s", err.Error()))
}
})
if err != nil {
logger.Error(ctx, fmt.Sprintf("reporting script completed: track command goroutine: %s", err.Error()))
}
}()
err = cmd.Start()
@@ -421,7 +306,7 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript,
"This usually means a child process was started with references to stdout or stderr. As a result, this " +
"process may now have been terminated. Consider redirecting the output or using a separate " +
"\"coder_script\" for the process, see " +
"https://coder.com/docs/templates/troubleshooting#startup-script-issues for more information.",
"https://coder.com/docs/v2/latest/templates/troubleshooting#startup-script-issues for more information.",
)
// Inform the user by propagating the message via log writers.
_, _ = fmt.Fprintf(cmd.Stderr, "WARNING: %s. %s\n", message, details)
+1 -5
View File
@@ -3,11 +3,8 @@
package agentscripts
import (
"context"
"os/exec"
"syscall"
"cdr.dev/slog"
)
func cmdSysProcAttr() *syscall.SysProcAttr {
@@ -16,9 +13,8 @@ func cmdSysProcAttr() *syscall.SysProcAttr {
}
}
func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
func cmdCancel(cmd *exec.Cmd) func() error {
return func() error {
logger.Debug(ctx, "cmdCancel: sending SIGHUP to process and children", slog.F("pid", cmd.Process.Pid))
return syscall.Kill(-cmd.Process.Pid, syscall.SIGHUP)
}
}
+31 -153
View File
@@ -2,144 +2,55 @@ package agentscripts_test
import (
"context"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"go.uber.org/goleak"
"github.com/coder/coder/v2/agent/agentexec"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentscripts"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/testutil"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
goleak.VerifyTestMain(m)
}
func TestExecuteBasic(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fLogger := newFakeScriptLogger()
runner := setup(t, func(uuid2 uuid.UUID) agentscripts.ScriptLogger {
return fLogger
logs := make(chan agentsdk.PatchLogs, 1)
runner := setup(t, func(ctx context.Context, req agentsdk.PatchLogs) error {
logs <- req
return nil
})
defer runner.Close()
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
err := runner.Init([]codersdk.WorkspaceAgentScript{{
LogSourceID: uuid.New(),
Script: "echo hello",
}}, aAPI.ScriptCompleted)
Script: "echo hello",
}})
require.NoError(t, err)
require.NoError(t, runner.Execute(context.Background(), agentscripts.ExecuteAllScripts))
log := testutil.RequireRecvCtx(ctx, t, fLogger.logs)
require.Equal(t, "hello", log.Output)
}
func TestEnv(t *testing.T) {
t.Parallel()
fLogger := newFakeScriptLogger()
runner := setup(t, func(uuid2 uuid.UUID) agentscripts.ScriptLogger {
return fLogger
})
defer runner.Close()
id := uuid.New()
script := "echo $CODER_SCRIPT_DATA_DIR\necho $CODER_SCRIPT_BIN_DIR\n"
if runtime.GOOS == "windows" {
script = `
cmd.exe /c echo %CODER_SCRIPT_DATA_DIR%
cmd.exe /c echo %CODER_SCRIPT_BIN_DIR%
`
}
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
err := runner.Init([]codersdk.WorkspaceAgentScript{{
LogSourceID: id,
Script: script,
}}, aAPI.ScriptCompleted)
require.NoError(t, err)
ctx := testutil.Context(t, testutil.WaitLong)
done := testutil.Go(t, func() {
err := runner.Execute(ctx, agentscripts.ExecuteAllScripts)
assert.NoError(t, err)
})
defer func() {
select {
case <-ctx.Done():
case <-done:
}
}()
var log []agentsdk.Log
for {
select {
case <-ctx.Done():
require.Fail(t, "timed out waiting for logs")
case l := <-fLogger.logs:
t.Logf("log: %s", l.Output)
log = append(log, l)
}
if len(log) >= 2 {
break
}
}
require.Contains(t, log[0].Output, filepath.Join(runner.DataDir(), id.String()))
require.Contains(t, log[1].Output, runner.ScriptBinDir())
require.NoError(t, runner.Execute(context.Background(), func(script codersdk.WorkspaceAgentScript) bool {
return true
}))
log := <-logs
require.Equal(t, "hello", log.Logs[0].Output)
}
func TestTimeout(t *testing.T) {
t.Parallel()
runner := setup(t, nil)
defer runner.Close()
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
err := runner.Init([]codersdk.WorkspaceAgentScript{{
LogSourceID: uuid.New(),
Script: "sleep infinity",
Timeout: time.Millisecond,
}}, aAPI.ScriptCompleted)
Script: "sleep infinity",
Timeout: time.Millisecond,
}})
require.NoError(t, err)
require.ErrorIs(t, runner.Execute(context.Background(), agentscripts.ExecuteAllScripts), agentscripts.ErrTimeout)
}
func TestScriptReportsTiming(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fLogger := newFakeScriptLogger()
runner := setup(t, func(uuid2 uuid.UUID) agentscripts.ScriptLogger {
return fLogger
})
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
err := runner.Init([]codersdk.WorkspaceAgentScript{{
DisplayName: "say-hello",
LogSourceID: uuid.New(),
Script: "echo hello",
}}, aAPI.ScriptCompleted)
require.NoError(t, err)
require.NoError(t, runner.Execute(ctx, agentscripts.ExecuteAllScripts))
runner.Close()
log := testutil.RequireRecvCtx(ctx, t, fLogger.logs)
require.Equal(t, "hello", log.Output)
timings := aAPI.GetTimings()
require.Equal(t, 1, len(timings))
timing := timings[0]
require.Equal(t, int32(0), timing.ExitCode)
require.GreaterOrEqual(t, timing.End.AsTime(), timing.Start.AsTime())
require.ErrorIs(t, runner.Execute(context.Background(), nil), agentscripts.ErrTimeout)
}
// TestCronClose exists because cron.Run() can happen after cron.Close().
@@ -151,61 +62,28 @@ func TestCronClose(t *testing.T) {
require.NoError(t, runner.Close(), "close runner")
}
func setup(t *testing.T, getScriptLogger func(logSourceID uuid.UUID) agentscripts.ScriptLogger) *agentscripts.Runner {
func setup(t *testing.T, patchLogs func(ctx context.Context, req agentsdk.PatchLogs) error) *agentscripts.Runner {
t.Helper()
if getScriptLogger == nil {
if patchLogs == nil {
// noop
getScriptLogger = func(uuid uuid.UUID) agentscripts.ScriptLogger {
return noopScriptLogger{}
patchLogs = func(ctx context.Context, req agentsdk.PatchLogs) error {
return nil
}
}
fs := afero.NewMemMapFs()
logger := testutil.Logger(t)
s, err := agentssh.NewServer(context.Background(), logger, prometheus.NewRegistry(), fs, agentexec.DefaultExecer, nil)
logger := slogtest.Make(t, nil)
s, err := agentssh.NewServer(context.Background(), logger, prometheus.NewRegistry(), fs, 0, "")
require.NoError(t, err)
s.AgentToken = func() string { return "" }
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
t.Cleanup(func() {
_ = s.Close()
})
return agentscripts.New(agentscripts.Options{
LogDir: t.TempDir(),
DataDirBase: t.TempDir(),
Logger: logger,
SSHServer: s,
Filesystem: fs,
GetScriptLogger: getScriptLogger,
LogDir: t.TempDir(),
Logger: logger,
SSHServer: s,
Filesystem: fs,
PatchLogs: patchLogs,
})
}
type noopScriptLogger struct{}
func (noopScriptLogger) Send(context.Context, ...agentsdk.Log) error {
return nil
}
func (noopScriptLogger) Flush(context.Context) error {
return nil
}
type fakeScriptLogger struct {
logs chan agentsdk.Log
}
func (f *fakeScriptLogger) Send(ctx context.Context, logs ...agentsdk.Log) error {
for _, log := range logs {
select {
case <-ctx.Done():
return ctx.Err()
case f.logs <- log:
// OK!
}
}
return nil
}
func (*fakeScriptLogger) Flush(context.Context) error {
return nil
}
func newFakeScriptLogger() *fakeScriptLogger {
return &fakeScriptLogger{make(chan agentsdk.Log, 100)}
}
+1 -5
View File
@@ -1,21 +1,17 @@
package agentscripts
import (
"context"
"os"
"os/exec"
"syscall"
"cdr.dev/slog"
)
func cmdSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{}
}
func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
func cmdCancel(cmd *exec.Cmd) func() error {
return func() error {
logger.Debug(ctx, "cmdCancel: sending interrupt to process", slog.F("pid", cmd.Process.Pid))
return cmd.Process.Signal(os.Interrupt)
}
}
+150 -380
View File
@@ -3,12 +3,11 @@ package agentssh
import (
"bufio"
"context"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"io"
"math/big"
"math/rand"
"net"
"os"
"os/exec"
@@ -27,14 +26,13 @@ import (
"github.com/spf13/afero"
"go.uber.org/atomic"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/pty"
)
@@ -44,66 +42,19 @@ const (
// unlikely to shadow other exit codes, which are typically 1, 2, 3, etc.
MagicSessionErrorCode = 229
// MagicProcessCmdlineJetBrains is a string in a process's command line that
// uniquely identifies it as JetBrains software.
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"
// BlockedFileTransferErrorCode indicates that SSH server restricted the raw command from performing
// the file transfer.
BlockedFileTransferErrorCode = 65 // Error code: host not allowed to connect
BlockedFileTransferErrorMessage = "File transfer has been disabled."
)
// MagicSessionType is a type that represents the type of session that is being
// established.
type MagicSessionType string
const (
// MagicSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection.
// This is stripped from any commands being executed, and is counted towards connection stats.
MagicSessionTypeEnvironmentVariable = "CODER_SSH_SESSION_TYPE"
)
// MagicSessionType enums.
const (
// MagicSessionTypeUnknown means the session type could not be determined.
MagicSessionTypeUnknown MagicSessionType = "unknown"
// MagicSessionTypeSSH is the default session type.
MagicSessionTypeSSH MagicSessionType = "ssh"
// MagicSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself.
MagicSessionTypeVSCode MagicSessionType = "vscode"
MagicSessionTypeVSCode = "vscode"
// MagicSessionTypeJetBrains is set in the SSH config by the JetBrains
// extension to identify itself.
MagicSessionTypeJetBrains MagicSessionType = "jetbrains"
MagicSessionTypeJetBrains = "jetbrains"
// MagicProcessCmdlineJetBrains is a string in a process's command line that
// uniquely identifies it as JetBrains software.
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"
)
// BlockedFileTransferCommands contains a list of restricted file transfer commands.
var BlockedFileTransferCommands = []string{"nc", "rsync", "scp", "sftp"}
// Config sets configuration parameters for the agent SSH server.
type Config struct {
// MaxTimeout sets the absolute connection timeout, none if empty. If set to
// 3 seconds or more, keep alive will be used instead.
MaxTimeout time.Duration
// MOTDFile returns the path to the message of the day file. If set, the
// file will be displayed to the user upon login.
MOTDFile func() string
// ServiceBanner returns the configuration for the Coder service banner.
AnnouncementBanners func() *[]codersdk.BannerConfig
// UpdateEnv updates the environment variables for the command to be
// executed. It can be used to add, modify or replace environment variables.
UpdateEnv func(current []string) (updated []string, err error)
// WorkingDirectory sets the working directory for commands and defines
// where users will land when they connect via SSH. Default is the home
// directory of the user.
WorkingDirectory func() string
// X11DisplayOffset is the offset to add to the X11 display number.
// Default is 10.
X11DisplayOffset *int
// BlockFileTransfer restricts use of file transfer applications.
BlockFileTransfer bool
}
type Server struct {
mu sync.RWMutex // Protects following.
fs afero.Fs
@@ -115,11 +66,14 @@ type Server struct {
// a lock on mu but protected by closing.
wg sync.WaitGroup
Execer agentexec.Execer
logger slog.Logger
srv *ssh.Server
logger slog.Logger
srv *ssh.Server
x11SocketDir string
config *Config
Env map[string]string
AgentToken func() string
Manifest *atomic.Pointer[agentsdk.Manifest]
ServiceBanner *atomic.Pointer[codersdk.ServiceBannerConfig]
connCountVSCode atomic.Int64
connCountJetBrains atomic.Int64
@@ -128,31 +82,20 @@ type Server struct {
metrics *sshServerMetrics
}
func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prometheus.Registry, fs afero.Fs, execer agentexec.Execer, config *Config) (*Server, error) {
if config == nil {
config = &Config{}
func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prometheus.Registry, fs afero.Fs, maxTimeout time.Duration, x11SocketDir string) (*Server, error) {
// Clients' should ignore the host key when connecting.
// The agent needs to authenticate with coderd to SSH,
// so SSH authentication doesn't improve security.
randomHostKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
if config.X11DisplayOffset == nil {
offset := X11DefaultDisplayOffset
config.X11DisplayOffset = &offset
randomSigner, err := gossh.NewSignerFromKey(randomHostKey)
if err != nil {
return nil, err
}
if config.UpdateEnv == nil {
config.UpdateEnv = func(current []string) ([]string, error) { return current, nil }
}
if config.MOTDFile == nil {
config.MOTDFile = func() string { return "" }
}
if config.AnnouncementBanners == nil {
config.AnnouncementBanners = func() *[]codersdk.BannerConfig { return &[]codersdk.BannerConfig{} }
}
if config.WorkingDirectory == nil {
config.WorkingDirectory = func() string {
home, err := userHomeDir()
if err != nil {
return ""
}
return home
}
if x11SocketDir == "" {
x11SocketDir = filepath.Join(os.TempDir(), ".X11-unix")
}
forwardHandler := &ssh.ForwardedTCPHandler{}
@@ -160,14 +103,12 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
metrics := newSSHServerMetrics(prometheusRegistry)
s := &Server{
Execer: execer,
listeners: make(map[net.Listener]struct{}),
fs: fs,
conns: make(map[net.Conn]struct{}),
sessions: make(map[ssh.Session]struct{}),
logger: logger,
config: config,
listeners: make(map[net.Listener]struct{}),
fs: fs,
conns: make(map[net.Conn]struct{}),
sessions: make(map[ssh.Session]struct{}),
logger: logger,
x11SocketDir: x11SocketDir,
metrics: metrics,
}
@@ -195,10 +136,8 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
slog.F("local_addr", conn.LocalAddr()),
slog.Error(err))
},
Handler: s.sessionHandler,
// HostSigners are intentionally empty, as the host key will
// be set before we start listening.
HostSigners: []ssh.Signer{},
Handler: s.sessionHandler,
HostSigners: []ssh.Signer{randomSigner},
LocalPortForwardingCallback: func(ctx ssh.Context, destinationHost string, destinationPort uint32) bool {
// Allow local port forwarding all!
s.logger.Debug(ctx, "local port forward",
@@ -233,16 +172,14 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
},
}
// The MaxTimeout functionality has been substituted with the introduction
// of the KeepAlive feature. In cases where very short timeouts are set, the
// SSH server will automatically switch to the connection timeout for both
// read and write operations.
if config.MaxTimeout >= 3*time.Second {
// The MaxTimeout functionality has been substituted with the introduction of the KeepAlive feature.
// In cases where very short timeouts are set, the SSH server will automatically switch to the connection timeout for both read and write operations.
if maxTimeout >= 3*time.Second {
srv.ClientAliveCountMax = 3
srv.ClientAliveInterval = config.MaxTimeout / time.Duration(srv.ClientAliveCountMax)
srv.ClientAliveInterval = maxTimeout / time.Duration(srv.ClientAliveCountMax)
srv.MaxTimeout = 0
} else {
srv.MaxTimeout = config.MaxTimeout
srv.MaxTimeout = maxTimeout
}
s.srv = srv
@@ -263,42 +200,14 @@ func (s *Server) ConnStats() ConnStats {
}
}
func extractMagicSessionType(env []string) (magicType MagicSessionType, rawType string, filteredEnv []string) {
for _, kv := range env {
if !strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable) {
continue
}
rawType = strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"=")
// Keep going, we'll use the last instance of the env.
}
// Always force lowercase checking to be case-insensitive.
switch MagicSessionType(strings.ToLower(rawType)) {
case MagicSessionTypeVSCode:
magicType = MagicSessionTypeVSCode
case MagicSessionTypeJetBrains:
magicType = MagicSessionTypeJetBrains
case "", MagicSessionTypeSSH:
magicType = MagicSessionTypeSSH
default:
magicType = MagicSessionTypeUnknown
}
return magicType, rawType, slices.DeleteFunc(env, func(kv string) bool {
return strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable+"=")
})
}
func (s *Server) sessionHandler(session ssh.Session) {
ctx := session.Context()
id := uuid.New()
logger := s.logger.With(
slog.F("remote_addr", session.RemoteAddr()),
slog.F("local_addr", session.LocalAddr()),
// Assigning a random uuid for each session is useful for tracking
// logs for the same ssh session.
slog.F("id", id.String()),
slog.F("id", uuid.NewString()),
)
logger.Info(ctx, "handling ssh session")
@@ -310,33 +219,16 @@ func (s *Server) sessionHandler(session ssh.Session) {
}
defer s.trackSession(session, false)
env := session.Environ()
magicType, magicTypeRaw, env := extractMagicSessionType(env)
switch magicType {
case MagicSessionTypeVSCode:
s.connCountVSCode.Add(1)
defer s.connCountVSCode.Add(-1)
case MagicSessionTypeJetBrains:
// Do nothing here because JetBrains launches hundreds of ssh sessions.
// We instead track JetBrains in the single persistent tcp forwarding channel.
case MagicSessionTypeSSH:
s.connCountSSHSession.Add(1)
defer s.connCountSSHSession.Add(-1)
case MagicSessionTypeUnknown:
logger.Warn(ctx, "invalid magic ssh session type specified", slog.F("raw_type", magicTypeRaw))
}
if s.fileTransferBlocked(session) {
s.logger.Warn(ctx, "file transfer blocked", slog.F("session_subsystem", session.Subsystem()), slog.F("raw_command", session.RawCommand()))
if session.Subsystem() == "" { // sftp does not expect error, otherwise it fails with "package too long"
// Response format: <status_code><message body>\n
errorMessage := fmt.Sprintf("\x02%s\n", BlockedFileTransferErrorMessage)
_, _ = session.Write([]byte(errorMessage))
extraEnv := make([]string, 0)
x11, hasX11 := session.X11()
if hasX11 {
handled := s.x11Handler(session.Context(), x11)
if !handled {
_ = session.Exit(1)
logger.Error(ctx, "x11 handler failed")
return
}
_ = session.Exit(BlockedFileTransferErrorCode)
return
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=:%d.0", x11.ScreenNumber))
}
switch ss := session.Subsystem(); ss {
@@ -350,18 +242,7 @@ func (s *Server) sessionHandler(session ssh.Session) {
return
}
x11, hasX11 := session.X11()
if hasX11 {
display, handled := s.x11Handler(session.Context(), x11)
if !handled {
_ = session.Exit(1)
logger.Error(ctx, "x11 handler failed")
return
}
env = append(env, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber))
}
err := s.sessionStart(logger, session, env, magicType)
err := s.sessionStart(logger, session, extraEnv)
var exitError *exec.ExitError
if xerrors.As(err, &exitError) {
code := exitError.ExitCode()
@@ -400,44 +281,37 @@ func (s *Server) sessionHandler(session ssh.Session) {
_ = session.Exit(0)
}
// fileTransferBlocked method checks if the file transfer commands should be blocked.
//
// Warning: consider this mechanism as "Do not trespass" sign, as a violator can still ssh to the host,
// smuggle the `scp` binary, or just manually send files outside with `curl` or `ftp`.
// If a user needs a more sophisticated and battle-proof solution, consider full endpoint security.
func (s *Server) fileTransferBlocked(session ssh.Session) bool {
if !s.config.BlockFileTransfer {
return false // file transfers are permitted
}
// File transfers are restricted.
if session.Subsystem() == "sftp" {
return true
}
cmd := session.Command()
if len(cmd) == 0 {
return false // no command?
}
c := cmd[0]
c = filepath.Base(c) // in case the binary is absolute path, /usr/sbin/scp
for _, cmd := range BlockedFileTransferCommands {
if cmd == c {
return true
}
}
return false
}
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType MagicSessionType) (retErr error) {
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, extraEnv []string) (retErr error) {
ctx := session.Context()
env := append(session.Environ(), extraEnv...)
var magicType string
for index, kv := range env {
if !strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable) {
continue
}
magicType = strings.ToLower(strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"="))
env = append(env[:index], env[index+1:]...)
}
// Always force lowercase checking to be case-insensitive.
switch magicType {
case MagicSessionTypeVSCode:
s.connCountVSCode.Add(1)
defer s.connCountVSCode.Add(-1)
case MagicSessionTypeJetBrains:
// Do nothing here because JetBrains launches hundreds of ssh sessions.
// We instead track JetBrains in the single persistent tcp forwarding channel.
case "":
s.connCountSSHSession.Add(1)
defer s.connCountSSHSession.Add(-1)
default:
logger.Warn(ctx, "invalid magic ssh session type specified", slog.F("type", magicType))
}
magicTypeLabel := magicTypeMetricLabel(magicType)
sshPty, windowSize, isPty := session.Pty()
cmd, err := s.CreateCommand(ctx, session.RawCommand(), env, nil)
cmd, err := s.CreateCommand(ctx, session.RawCommand(), env)
if err != nil {
ptyLabel := "no"
if isPty {
@@ -501,7 +375,7 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
}()
go func() {
for sig := range sigs {
handleSignal(logger, sig, cmd.Process, s.metrics, magicTypeLabel)
s.handleSignal(logger, sig, cmd.Process, magicTypeLabel)
}
}()
return cmd.Wait()
@@ -526,24 +400,26 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
session.DisablePTYEmulation()
if isLoginShell(session.RawCommand()) {
banners := s.config.AnnouncementBanners()
if banners != nil {
for _, banner := range *banners {
err := showAnnouncementBanner(session, banner)
if err != nil {
logger.Error(ctx, "agent failed to show announcement banner", slog.Error(err))
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "announcement_banner").Add(1)
break
}
serviceBanner := s.ServiceBanner.Load()
if serviceBanner != nil {
err := showServiceBanner(session, serviceBanner)
if err != nil {
logger.Error(ctx, "agent failed to show service banner", slog.Error(err))
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "service_banner").Add(1)
}
}
}
if !isQuietLogin(s.fs, session.RawCommand()) {
err := showMOTD(s.fs, session, s.config.MOTDFile())
if err != nil {
logger.Error(ctx, "agent failed to show MOTD", slog.Error(err))
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "motd").Add(1)
manifest := s.Manifest.Load()
if manifest != nil {
err := showMOTD(s.fs, session, manifest.MOTDFile)
if err != nil {
logger.Error(ctx, "agent failed to show MOTD", slog.Error(err))
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "motd").Add(1)
}
} else {
logger.Warn(ctx, "metadata lookup failed, unable to show MOTD")
}
}
@@ -586,7 +462,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
sigs = nil
continue
}
handleSignal(logger, sig, process, s.metrics, magicTypeLabel)
s.handleSignal(logger, sig, process, magicTypeLabel)
case win, ok := <-windowSize:
if !ok {
windowSize = nil
@@ -640,7 +516,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
return nil
}
func handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signal(os.Signal) error }, metrics *sshServerMetrics, magicTypeLabel string) {
func (s *Server) handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signal(os.Signal) error }, magicTypeLabel string) {
ctx := context.Background()
sig := osSignalFrom(ssig)
logger = logger.With(slog.F("ssh_signal", ssig), slog.F("signal", sig.String()))
@@ -648,7 +524,7 @@ func handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signa
err := signaler.Signal(sig)
if err != nil {
logger.Warn(ctx, "signaling the process failed", slog.Error(err))
metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "signal").Add(1)
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "signal").Add(1)
}
}
@@ -681,7 +557,7 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) {
defer server.Close()
err = server.Serve()
if err == nil || errors.Is(err, io.EOF) {
if errors.Is(err, io.EOF) {
// Unless we call `session.Exit(0)` here, the client won't
// receive `exit-status` because `(*sftp.Server).Close()`
// calls `Close()` on the underlying connection (session),
@@ -698,67 +574,26 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) {
_ = session.Exit(1)
}
// EnvInfoer encapsulates external information required by CreateCommand.
type EnvInfoer interface {
// CurrentUser returns the current user.
CurrentUser() (*user.User, error)
// Environ returns the environment variables of the current process.
Environ() []string
// UserHomeDir returns the home directory of the current user.
UserHomeDir() (string, error)
// UserShell returns the shell of the given user.
UserShell(username string) (string, error)
}
type systemEnvInfoer struct{}
var defaultEnvInfoer EnvInfoer = &systemEnvInfoer{}
// DefaultEnvInfoer returns a default implementation of
// EnvInfoer. This reads information using the default Go
// implementations.
func DefaultEnvInfoer() EnvInfoer {
return defaultEnvInfoer
}
func (systemEnvInfoer) CurrentUser() (*user.User, error) {
return user.Current()
}
func (systemEnvInfoer) Environ() []string {
return os.Environ()
}
func (systemEnvInfoer) UserHomeDir() (string, error) {
return userHomeDir()
}
func (systemEnvInfoer) UserShell(username string) (string, error) {
return usershell.Get(username)
}
// CreateCommand processes raw command input with OpenSSH-like behavior.
// If the script provided is empty, it will default to the users shell.
// This injects environment variables specified by the user at launch too.
// The final argument is an interface that allows the caller to provide
// alternative implementations for the dependencies of CreateCommand.
// This is useful when creating a command to be run in a separate environment
// (for example, a Docker container). Pass in nil to use the default.
func (s *Server) CreateCommand(ctx context.Context, script string, env []string, deps EnvInfoer) (*pty.Cmd, error) {
if deps == nil {
deps = DefaultEnvInfoer()
}
currentUser, err := deps.CurrentUser()
func (s *Server) CreateCommand(ctx context.Context, script string, env []string) (*pty.Cmd, error) {
currentUser, err := user.Current()
if err != nil {
return nil, xerrors.Errorf("get current user: %w", err)
}
username := currentUser.Username
shell, err := deps.UserShell(username)
shell, err := usershell.Get(username)
if err != nil {
return nil, xerrors.Errorf("get user shell: %w", err)
}
manifest := s.Manifest.Load()
if manifest == nil {
return nil, xerrors.Errorf("no metadata was provided")
}
// OpenSSH executes all commands with the users current shell.
// We replicate that behavior for IDE support.
caller := "-c"
@@ -802,22 +637,38 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
}
}
cmd := s.Execer.PTYCommandContext(ctx, name, args...)
cmd.Dir = s.config.WorkingDirectory()
cmd := pty.CommandContext(ctx, name, args...)
cmd.Dir = manifest.Directory
// If the metadata directory doesn't exist, we run the command
// in the users home directory.
_, err = os.Stat(cmd.Dir)
if cmd.Dir == "" || err != nil {
// Default to user home if a directory is not set.
homedir, err := deps.UserHomeDir()
homedir, err := userHomeDir()
if err != nil {
return nil, xerrors.Errorf("get home dir: %w", err)
}
cmd.Dir = homedir
}
cmd.Env = append(deps.Environ(), env...)
cmd.Env = append(os.Environ(), env...)
executablePath, err := os.Executable()
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, "CODER_WORKSPACE_NAME="+manifest.WorkspaceName)
cmd.Env = append(cmd.Env, "CODER_WORKSPACE_AGENT_NAME="+manifest.AgentName)
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))
// Specific Coder subcommands require the agent token exposed!
cmd.Env = append(cmd.Env, fmt.Sprintf("CODER_AGENT_TOKEN=%s", s.AgentToken()))
// Set SSH connection environment variables (these are also set by OpenSSH
// and thus expected to be present by SSH clients). Since the agent does
@@ -828,21 +679,36 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
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))
cmd.Env, err = s.config.UpdateEnv(cmd.Env)
if err != nil {
return nil, xerrors.Errorf("apply env: %w", err)
// This adds the ports dialog to code-server that enables
// proxying a port dynamically.
// If this is empty string, do not set anything. Code-server auto defaults
// using its basepath to construct a path based port proxy.
if manifest.VSCodePortProxyURI != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("VSCODE_PROXY_URI=%s", manifest.VSCodePortProxyURI))
}
// Hide Coder message on code-server's "Getting Started" page
cmd.Env = append(cmd.Env, "CS_DISABLE_GETTING_STARTED_OVERRIDE=true")
// Load environment variables passed via the agent.
// These should override all variables we manually specify.
for envKey, value := range manifest.EnvironmentVariables {
// Expanding environment variables allows for customization
// of the $PATH, among other variables. Customers can prepend
// or append to the $PATH, so allowing expand is required!
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", envKey, os.ExpandEnv(value)))
}
// Agent-level environment variables should take over all!
// This is used for setting agent-specific variables like "CODER_AGENT_TOKEN".
for envKey, value := range s.Env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", envKey, value))
}
return cmd, nil
}
// Serve starts the server to handle incoming connections on the provided listener.
// It returns an error if no host keys are set or if there is an issue accepting connections.
func (s *Server) Serve(l net.Listener) (retErr error) {
if len(s.srv.HostSigners) == 0 {
return xerrors.New("no host keys set")
}
s.logger.Info(context.Background(), "started serving listener", slog.F("listen_addr", l.Addr()))
defer func() {
s.logger.Info(context.Background(), "stopped serving listener",
@@ -1031,9 +897,9 @@ func isQuietLogin(fs afero.Fs, rawCommand string) bool {
return err == nil
}
// showAnnouncementBanner will write the service banner if enabled and not blank
// showServiceBanner will write the service banner if enabled and not blank
// along with a blank line for spacing.
func showAnnouncementBanner(session io.Writer, banner codersdk.BannerConfig) error {
func showServiceBanner(session io.Writer, banner *codersdk.ServiceBannerConfig) error {
if banner.Enabled && banner.Message != "" {
// The banner supports Markdown so we might want to parse it but Markdown is
// still fairly readable in its raw form.
@@ -1097,99 +963,3 @@ func userHomeDir() (string, error) {
}
return u.HomeDir, nil
}
// UpdateHostSigner updates the host signer with a new key generated from the provided seed.
// If an existing host key exists with the same algorithm, it is overwritten
func (s *Server) UpdateHostSigner(seed int64) error {
key, err := CoderSigner(seed)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.srv.AddHostKey(key)
return nil
}
// CoderSigner generates a deterministic SSH signer based on the provided seed.
// It uses RSA with a key size of 2048 bits.
func CoderSigner(seed int64) (gossh.Signer, error) {
// Clients should ignore the host key when connecting.
// The agent needs to authenticate with coderd to SSH,
// so SSH authentication doesn't improve security.
// Since the standard lib purposefully does not generate
// deterministic rsa keys, we need to do it ourselves.
coderHostKey := func() *rsa.PrivateKey {
// Create deterministic random source
// nolint: gosec
deterministicRand := rand.New(rand.NewSource(seed))
// Use fixed values for p and q based on the seed
p := big.NewInt(0)
q := big.NewInt(0)
e := big.NewInt(65537) // Standard RSA public exponent
// Generate deterministic primes using the seeded random
// Each prime should be ~1024 bits to get a 2048-bit key
for {
p.SetBit(p, 1024, 1) // Ensure it's large enough
for i := 0; i < 1024; i++ {
if deterministicRand.Int63()%2 == 1 {
p.SetBit(p, i, 1)
} else {
p.SetBit(p, i, 0)
}
}
if p.ProbablyPrime(20) {
break
}
}
for {
q.SetBit(q, 1024, 1) // Ensure it's large enough
for i := 0; i < 1024; i++ {
if deterministicRand.Int63()%2 == 1 {
q.SetBit(q, i, 1)
} else {
q.SetBit(q, i, 0)
}
}
if q.ProbablyPrime(20) && p.Cmp(q) != 0 {
break
}
}
// Calculate n = p * q
n := new(big.Int).Mul(p, q)
// Calculate phi = (p-1) * (q-1)
p1 := new(big.Int).Sub(p, big.NewInt(1))
q1 := new(big.Int).Sub(q, big.NewInt(1))
phi := new(big.Int).Mul(p1, q1)
// Calculate private exponent d
d := new(big.Int).ModInverse(e, phi)
// Create the private key
privateKey := &rsa.PrivateKey{
PublicKey: rsa.PublicKey{
N: n,
E: int(e.Int64()),
},
D: d,
Primes: []*big.Int{p, q},
}
// Compute precomputed values
privateKey.Precompute()
return privateKey
}()
coderSigner, err := gossh.NewSignerFromKey(coderHostKey)
return coderSigner, err
}
+4 -5
View File
@@ -15,9 +15,10 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/testutil"
"cdr.dev/slog/sloggers/slogtest"
)
const longScript = `
@@ -35,12 +36,10 @@ func Test_sessionStart_orphan(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
defer cancel()
logger := testutil.Logger(t)
s, err := NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
logger := slogtest.Make(t, nil)
s, err := NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), 0, "")
require.NoError(t, err)
defer s.Close()
err = s.UpdateHostSigner(42)
assert.NoError(t, err)
// Here we're going to call the handler directly with a faked SSH session
// that just uses io.Pipes instead of a network socket. There is a large
+32 -55
View File
@@ -8,7 +8,6 @@ import (
"context"
"fmt"
"net"
"os/user"
"runtime"
"strings"
"sync"
@@ -18,31 +17,34 @@ import (
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"go.uber.org/goleak"
"golang.org/x/crypto/ssh"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
goleak.VerifyTestMain(m)
}
func TestNewServer_ServeClient(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := testutil.Logger(t)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
logger := slogtest.Make(t, nil)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), 0, "")
require.NoError(t, err)
defer s.Close()
err = s.UpdateHostSigner(42)
assert.NoError(t, err)
// The assumption is that these are set before serving SSH connections.
s.AgentToken = func() string { return "" }
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
@@ -80,17 +82,19 @@ func TestNewServer_ExecuteShebang(t *testing.T) {
}
ctx := context.Background()
logger := testutil.Logger(t)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
logger := slogtest.Make(t, nil)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), 0, "")
require.NoError(t, err)
t.Cleanup(func() {
_ = s.Close()
})
s.AgentToken = func() string { return "" }
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
t.Run("Basic", func(t *testing.T) {
t.Parallel()
cmd, err := s.CreateCommand(ctx, `#!/bin/bash
echo test`, nil, nil)
echo test`, nil)
require.NoError(t, err)
output, err := cmd.AsExec().CombinedOutput()
require.NoError(t, err)
@@ -99,45 +103,12 @@ func TestNewServer_ExecuteShebang(t *testing.T) {
t.Run("Args", func(t *testing.T) {
t.Parallel()
cmd, err := s.CreateCommand(ctx, `#!/usr/bin/env bash
echo test`, nil, nil)
echo test`, nil)
require.NoError(t, err)
output, err := cmd.AsExec().CombinedOutput()
require.NoError(t, err)
require.Equal(t, "test\n", string(output))
})
t.Run("CustomEnvInfoer", func(t *testing.T) {
t.Parallel()
ei := &fakeEnvInfoer{
CurrentUserFn: func() (u *user.User, err error) {
return nil, assert.AnError
},
}
_, err := s.CreateCommand(ctx, `whatever`, nil, ei)
require.ErrorIs(t, err, assert.AnError)
})
}
type fakeEnvInfoer struct {
CurrentUserFn func() (*user.User, error)
EnvironFn func() []string
UserHomeDirFn func() (string, error)
UserShellFn func(string) (string, error)
}
func (f *fakeEnvInfoer) CurrentUser() (u *user.User, err error) {
return f.CurrentUserFn()
}
func (f *fakeEnvInfoer) Environ() []string {
return f.EnvironFn()
}
func (f *fakeEnvInfoer) UserHomeDir() (string, error) {
return f.UserHomeDirFn()
}
func (f *fakeEnvInfoer) UserShell(u string) (string, error) {
return f.UserShellFn(u)
}
func TestNewServer_CloseActiveConnections(t *testing.T) {
@@ -145,11 +116,13 @@ func TestNewServer_CloseActiveConnections(t *testing.T) {
ctx := context.Background()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), 0, "")
require.NoError(t, err)
defer s.Close()
err = s.UpdateHostSigner(42)
assert.NoError(t, err)
// The assumption is that these are set before serving SSH connections.
s.AgentToken = func() string { return "" }
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
@@ -197,12 +170,14 @@ func TestNewServer_Signal(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := testutil.Logger(t)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
logger := slogtest.Make(t, nil)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), 0, "")
require.NoError(t, err)
defer s.Close()
err = s.UpdateHostSigner(42)
assert.NoError(t, err)
// The assumption is that these are set before serving SSH connections.
s.AgentToken = func() string { return "" }
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
@@ -264,12 +239,14 @@ func TestNewServer_Signal(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := testutil.Logger(t)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
logger := slogtest.Make(t, nil)
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), 0, "")
require.NoError(t, err)
defer s.Close()
err = s.UpdateHostSigner(42)
assert.NoError(t, err)
// The assumption is that these are set before serving SSH connections.
s.AgentToken = func() string { return "" }
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
+5 -5
View File
@@ -71,15 +71,15 @@ func newSSHServerMetrics(registerer prometheus.Registerer) *sshServerMetrics {
}
}
func magicTypeMetricLabel(magicType MagicSessionType) string {
func magicTypeMetricLabel(magicType string) string {
switch magicType {
case MagicSessionTypeVSCode:
case MagicSessionTypeJetBrains:
case MagicSessionTypeSSH:
case MagicSessionTypeUnknown:
case "":
magicType = "ssh"
default:
magicType = MagicSessionTypeUnknown
magicType = "unknown"
}
// Always be case insensitive
return strings.ToLower(string(magicType))
return strings.ToLower(magicType)
}
+55 -90
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"math"
"net"
"os"
"path/filepath"
@@ -23,69 +22,61 @@ import (
"cdr.dev/slog"
)
const (
// X11StartPort is the starting port for X11 forwarding, this is the
// port used for "DISPLAY=localhost:0".
X11StartPort = 6000
// X11DefaultDisplayOffset is the default offset for X11 forwarding.
X11DefaultDisplayOffset = 10
)
// x11Callback is called when the client requests X11 forwarding.
func (*Server) x11Callback(_ ssh.Context, _ ssh.X11) bool {
// Always allow.
// It adds an Xauthority entry to the Xauthority file.
func (s *Server) x11Callback(ctx ssh.Context, x11 ssh.X11) bool {
hostname, err := os.Hostname()
if err != nil {
s.logger.Warn(ctx, "failed to get hostname", slog.Error(err))
s.metrics.x11HandlerErrors.WithLabelValues("hostname").Add(1)
return false
}
err = s.fs.MkdirAll(s.x11SocketDir, 0o700)
if err != nil {
s.logger.Warn(ctx, "failed to make the x11 socket dir", slog.F("dir", s.x11SocketDir), slog.Error(err))
s.metrics.x11HandlerErrors.WithLabelValues("socker_dir").Add(1)
return false
}
err = addXauthEntry(ctx, s.fs, hostname, strconv.Itoa(int(x11.ScreenNumber)), x11.AuthProtocol, x11.AuthCookie)
if err != nil {
s.logger.Warn(ctx, "failed to add Xauthority entry", slog.Error(err))
s.metrics.x11HandlerErrors.WithLabelValues("xauthority").Add(1)
return false
}
return true
}
// x11Handler is called when a session has requested X11 forwarding.
// It listens for X11 connections and forwards them to the client.
func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) (displayNumber int, handled bool) {
func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) bool {
serverConn, valid := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn)
if !valid {
s.logger.Warn(ctx, "failed to get server connection")
return -1, false
return false
}
hostname, err := os.Hostname()
// We want to overwrite the socket so that subsequent connections will succeed.
socketPath := filepath.Join(s.x11SocketDir, fmt.Sprintf("X%d", x11.ScreenNumber))
err := os.Remove(socketPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
s.logger.Warn(ctx, "failed to remove existing X11 socket", slog.Error(err))
return false
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
s.logger.Warn(ctx, "failed to get hostname", slog.Error(err))
s.metrics.x11HandlerErrors.WithLabelValues("hostname").Add(1)
return -1, false
}
ln, display, err := createX11Listener(ctx, *s.config.X11DisplayOffset)
if err != nil {
s.logger.Warn(ctx, "failed to create X11 listener", slog.Error(err))
s.metrics.x11HandlerErrors.WithLabelValues("listen").Add(1)
return -1, false
}
s.trackListener(ln, true)
defer func() {
if !handled {
s.trackListener(ln, false)
_ = ln.Close()
}
}()
err = addXauthEntry(ctx, s.fs, hostname, strconv.Itoa(display), x11.AuthProtocol, x11.AuthCookie)
if err != nil {
s.logger.Warn(ctx, "failed to add Xauthority entry", slog.Error(err))
s.metrics.x11HandlerErrors.WithLabelValues("xauthority").Add(1)
return -1, false
s.logger.Warn(ctx, "failed to listen for X11", slog.Error(err))
return false
}
s.trackListener(listener, true)
go func() {
// Don't leave the listener open after the session is gone.
<-ctx.Done()
_ = ln.Close()
}()
go func() {
defer ln.Close()
defer s.trackListener(ln, false)
defer listener.Close()
defer s.trackListener(listener, false)
handledFirstConnection := false
for {
conn, err := ln.Accept()
conn, err := listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
@@ -93,66 +84,40 @@ func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) (displayNumber int, ha
s.logger.Warn(ctx, "failed to accept X11 connection", slog.Error(err))
return
}
if x11.SingleConnection {
s.logger.Debug(ctx, "single connection requested, closing X11 listener")
_ = ln.Close()
if x11.SingleConnection && handledFirstConnection {
s.logger.Warn(ctx, "X11 connection rejected because single connection is enabled")
_ = conn.Close()
continue
}
handledFirstConnection = true
tcpConn, ok := conn.(*net.TCPConn)
unixConn, ok := conn.(*net.UnixConn)
if !ok {
s.logger.Warn(ctx, fmt.Sprintf("failed to cast connection to TCPConn. got: %T", conn))
_ = conn.Close()
continue
s.logger.Warn(ctx, fmt.Sprintf("failed to cast connection to UnixConn. got: %T", conn))
return
}
tcpAddr, ok := tcpConn.LocalAddr().(*net.TCPAddr)
unixAddr, ok := unixConn.LocalAddr().(*net.UnixAddr)
if !ok {
s.logger.Warn(ctx, fmt.Sprintf("failed to cast local address to TCPAddr. got: %T", tcpConn.LocalAddr()))
_ = conn.Close()
continue
s.logger.Warn(ctx, fmt.Sprintf("failed to cast local address to UnixAddr. got: %T", unixConn.LocalAddr()))
return
}
channel, reqs, err := serverConn.OpenChannel("x11", gossh.Marshal(struct {
OriginatorAddress string
OriginatorPort uint32
}{
OriginatorAddress: tcpAddr.IP.String(),
OriginatorPort: uint32(tcpAddr.Port),
OriginatorAddress: unixAddr.Name,
OriginatorPort: 0,
}))
if err != nil {
s.logger.Warn(ctx, "failed to open X11 channel", slog.Error(err))
_ = conn.Close()
continue
return
}
go gossh.DiscardRequests(reqs)
if !s.trackConn(ln, conn, true) {
s.logger.Warn(ctx, "failed to track X11 connection")
_ = conn.Close()
continue
}
go func() {
defer s.trackConn(ln, conn, false)
Bicopy(ctx, conn, channel)
}()
go Bicopy(ctx, conn, channel)
}
}()
return display, true
}
// createX11Listener creates a listener for X11 forwarding, it will use
// the next available port starting from X11StartPort and displayOffset.
func createX11Listener(ctx context.Context, displayOffset int) (ln net.Listener, display int, err error) {
var lc net.ListenConfig
// Look for an open port to listen on.
for port := X11StartPort + displayOffset; port < math.MaxUint16; port++ {
ln, err = lc.Listen(ctx, "tcp", fmt.Sprintf("localhost:%d", port))
if err == nil {
display = port - X11StartPort
return ln, display, nil
}
}
return nil, -1, xerrors.Errorf("failed to find open port for X11 listener: %w", err)
return true
}
// addXauthEntry adds an Xauthority entry to the Xauthority file.
+14 -37
View File
@@ -1,17 +1,12 @@
package agentssh_test
import (
"bufio"
"bytes"
"context"
"encoding/hex"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"github.com/gliderlabs/ssh"
@@ -19,10 +14,13 @@ import (
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
gossh "golang.org/x/crypto/ssh"
"github.com/coder/coder/v2/agent/agentexec"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/testutil"
)
@@ -33,13 +31,16 @@ func TestServer_X11(t *testing.T) {
}
ctx := context.Background()
logger := testutil.Logger(t)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
fs := afero.NewOsFs()
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, agentexec.DefaultExecer, &agentssh.Config{})
dir := t.TempDir()
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, 0, dir)
require.NoError(t, err)
defer s.Close()
err = s.UpdateHostSigner(42)
assert.NoError(t, err)
// The assumption is that these are set before serving SSH connections.
s.AgentToken = func() string { return "" }
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
@@ -56,45 +57,21 @@ func TestServer_X11(t *testing.T) {
sess, err := c.NewSession()
require.NoError(t, err)
wantScreenNumber := 1
reply, err := sess.SendRequest("x11-req", true, gossh.Marshal(ssh.X11{
AuthProtocol: "MIT-MAGIC-COOKIE-1",
AuthCookie: hex.EncodeToString([]byte("cookie")),
ScreenNumber: uint32(wantScreenNumber),
ScreenNumber: 0,
}))
require.NoError(t, err)
assert.True(t, reply)
// Want: ~DISPLAY=localhost:10.1
out, err := sess.Output("echo DISPLAY=$DISPLAY")
err = sess.Shell()
require.NoError(t, err)
sc := bufio.NewScanner(bytes.NewReader(out))
displayNumber := -1
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
t.Log(line)
if strings.HasPrefix(line, "DISPLAY=") {
parts := strings.SplitN(line, "=", 2)
display := parts[1]
parts = strings.SplitN(display, ":", 2)
parts = strings.SplitN(parts[1], ".", 2)
displayNumber, err = strconv.Atoi(parts[0])
require.NoError(t, err)
assert.GreaterOrEqual(t, displayNumber, 10, "display number should be >= 10")
gotScreenNumber, err := strconv.Atoi(parts[1])
require.NoError(t, err)
assert.Equal(t, wantScreenNumber, gotScreenNumber, "screen number should match")
break
}
}
require.NoError(t, sc.Err())
require.NotEqual(t, -1, displayNumber)
x11Chans := c.HandleChannelOpen("x11")
payload := "hello world"
require.Eventually(t, func() bool {
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", agentssh.X11StartPort+displayNumber))
conn, err := net.Dial("unix", filepath.Join(dir, "X0"))
if err == nil {
_, err = conn.Write([]byte(payload))
assert.NoError(t, err)
+3 -2
View File
@@ -7,9 +7,10 @@ import (
"github.com/stretchr/testify/assert"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/testutil"
)
// New starts a new agent for use in tests.
@@ -23,7 +24,7 @@ func New(t testing.TB, coderURL *url.URL, agentToken string, opts ...func(*agent
t.Helper()
var o agent.Options
log := testutil.Logger(t).Named("agent")
log := slogtest.Make(t, nil).Leveled(slog.LevelDebug).Named("agent")
o.Logger = log
for _, opt := range opts {
+127 -189
View File
@@ -9,13 +9,10 @@ import (
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/emptypb"
"storj.io/drpc"
"storj.io/drpc/drpcmux"
"storj.io/drpc/drpcserver"
"tailscale.com/tailcfg"
@@ -30,13 +27,11 @@ import (
"github.com/coder/coder/v2/testutil"
)
const statsInterval = 500 * time.Millisecond
func NewClient(t testing.TB,
logger slog.Logger,
agentID uuid.UUID,
manifest agentsdk.Manifest,
statsChan chan *agentproto.Stats,
statsChan chan *agentsdk.Stats,
coordinator tailnet.Coordinator,
) *Client {
if manifest.AgentID == uuid.Nil {
@@ -48,7 +43,7 @@ func NewClient(t testing.TB,
derpMapUpdates := make(chan *tailcfg.DERPMap)
drpcService := &tailnet.DRPCService{
CoordPtr: &coordPtr,
Logger: logger.Named("tailnetsvc"),
Logger: logger,
DerpMapUpdateFrequency: time.Microsecond,
DerpMapFn: func() *tailcfg.DERPMap { return <-derpMapUpdates },
}
@@ -56,7 +51,7 @@ func NewClient(t testing.TB,
require.NoError(t, err)
mp, err := agentsdk.ProtoFromManifest(manifest)
require.NoError(t, err)
fakeAAPI := NewFakeAgentAPI(t, logger, mp, statsChan)
fakeAAPI := NewFakeAgentAPI(t, logger, mp)
err = agentproto.DRPCRegisterAgent(mux, fakeAAPI)
require.NoError(t, err)
server := drpcserver.NewWithOptions(mux, drpcserver.Options{
@@ -71,6 +66,8 @@ func NewClient(t testing.TB,
t: t,
logger: logger.Named("client"),
agentID: agentID,
statsChan: statsChan,
coordinator: coordinator,
server: server,
fakeAgentAPI: fakeAAPI,
derpMapUpdates: derpMapUpdates,
@@ -81,14 +78,19 @@ type Client struct {
t testing.TB
logger slog.Logger
agentID uuid.UUID
metadata map[string]agentsdk.Metadata
statsChan chan *agentsdk.Stats
coordinator tailnet.Coordinator
server *drpcserver.Server
fakeAgentAPI *FakeAgentAPI
LastWorkspaceAgent func()
PatchWorkspaceLogs func() error
mu sync.Mutex // Protects following.
logs []agentsdk.Log
derpMapUpdates chan *tailcfg.DERPMap
derpMapOnce sync.Once
mu sync.Mutex // Protects following.
lifecycleStates []codersdk.WorkspaceAgentLifecycle
logs []agentsdk.Log
derpMapUpdates chan *tailcfg.DERPMap
derpMapOnce sync.Once
}
func (*Client) RewriteDERPMap(*tailcfg.DERPMap) {}
@@ -97,9 +99,7 @@ func (c *Client) Close() {
c.derpMapOnce.Do(func() { close(c.derpMapUpdates) })
}
func (c *Client) ConnectRPC24(ctx context.Context) (
agentproto.DRPCAgentClient24, proto.DRPCTailnetClient24, error,
) {
func (c *Client) ConnectRPC(ctx context.Context) (drpc.Conn, error) {
conn, lis := drpcsdk.MemTransportPipe()
c.LastWorkspaceAgent = func() {
_ = conn.Close()
@@ -108,20 +108,63 @@ func (c *Client) ConnectRPC24(ctx context.Context) (
c.t.Cleanup(c.LastWorkspaceAgent)
serveCtx, cancel := context.WithCancel(ctx)
c.t.Cleanup(cancel)
auth := tailnet.AgentTunnelAuth{}
streamID := tailnet.StreamID{
Name: "agenttest",
ID: c.agentID,
Auth: tailnet.AgentCoordinateeAuth{ID: c.agentID},
Auth: auth,
}
serveCtx = tailnet.WithStreamID(serveCtx, streamID)
go func() {
_ = c.server.Serve(serveCtx, lis)
}()
return agentproto.NewDRPCAgentClient(conn), proto.NewDRPCTailnetClient(conn), nil
return conn, nil
}
func (c *Client) ReportStats(ctx context.Context, _ slog.Logger, statsChan <-chan *agentsdk.Stats, setInterval func(time.Duration)) (io.Closer, error) {
doneCh := make(chan struct{})
ctx, cancel := context.WithCancel(ctx)
go func() {
defer close(doneCh)
setInterval(500 * time.Millisecond)
for {
select {
case <-ctx.Done():
return
case stat := <-statsChan:
select {
case c.statsChan <- stat:
case <-ctx.Done():
return
default:
// We don't want to send old stats.
continue
}
}
}
}()
return closeFunc(func() error {
cancel()
<-doneCh
close(c.statsChan)
return nil
}), nil
}
func (c *Client) GetLifecycleStates() []codersdk.WorkspaceAgentLifecycle {
return c.fakeAgentAPI.GetLifecycleStates()
c.mu.Lock()
defer c.mu.Unlock()
return c.lifecycleStates
}
func (c *Client) PostLifecycle(ctx context.Context, req agentsdk.PostLifecycleRequest) error {
c.mu.Lock()
defer c.mu.Unlock()
c.lifecycleStates = append(c.lifecycleStates, req.State)
c.logger.Debug(ctx, "post lifecycle", slog.F("req", req))
return nil
}
func (c *Client) GetStartup() <-chan *agentproto.Startup {
@@ -129,7 +172,22 @@ func (c *Client) GetStartup() <-chan *agentproto.Startup {
}
func (c *Client) GetMetadata() map[string]agentsdk.Metadata {
return c.fakeAgentAPI.GetMetadata()
c.mu.Lock()
defer c.mu.Unlock()
return maps.Clone(c.metadata)
}
func (c *Client) PostMetadata(ctx context.Context, req agentsdk.PostMetadataRequest) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.metadata == nil {
c.metadata = make(map[string]agentsdk.Metadata)
}
for _, md := range req.Metadata {
c.metadata[md.Key] = md
c.logger.Debug(ctx, "post metadata", slog.F("key", md.Key), slog.F("md", md))
}
return nil
}
func (c *Client) GetStartupLogs() []agentsdk.Log {
@@ -138,8 +196,19 @@ func (c *Client) GetStartupLogs() []agentsdk.Log {
return c.logs
}
func (c *Client) SetAnnouncementBannersFunc(f func() ([]codersdk.BannerConfig, error)) {
c.fakeAgentAPI.SetAnnouncementBannersFunc(f)
func (c *Client) PatchLogs(ctx context.Context, logs agentsdk.PatchLogs) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.PatchWorkspaceLogs != nil {
return c.PatchWorkspaceLogs()
}
c.logs = append(c.logs, logs.Logs...)
c.logger.Debug(ctx, "patch startup logs", slog.F("req", logs))
return nil
}
func (c *Client) SetServiceBannerFunc(f func() (codersdk.ServiceBannerConfig, error)) {
c.fakeAgentAPI.SetServiceBannerFunc(f)
}
func (c *Client) PushDERPMapUpdate(update *tailcfg.DERPMap) error {
@@ -154,8 +223,10 @@ func (c *Client) PushDERPMapUpdate(update *tailcfg.DERPMap) error {
return nil
}
func (c *Client) SetLogsChannel(ch chan<- *agentproto.BatchCreateLogsRequest) {
c.fakeAgentAPI.SetLogsChannel(ch)
type closeFunc func() error
func (c closeFunc) Close() error {
return c()
}
type FakeAgentAPI struct {
@@ -163,204 +234,71 @@ type FakeAgentAPI struct {
t testing.TB
logger slog.Logger
manifest *agentproto.Manifest
startupCh chan *agentproto.Startup
statsCh chan *agentproto.Stats
appHealthCh chan *agentproto.BatchUpdateAppHealthRequest
logsCh chan<- *agentproto.BatchCreateLogsRequest
lifecycleStates []codersdk.WorkspaceAgentLifecycle
metadata map[string]agentsdk.Metadata
timings []*agentproto.Timing
connections []*agentproto.Connection
manifest *agentproto.Manifest
startupCh chan *agentproto.Startup
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error)
pushResourcesMonitoringUsageFunc func(*agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error)
getServiceBannerFunc func() (codersdk.ServiceBannerConfig, error)
}
func (f *FakeAgentAPI) GetManifest(context.Context, *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
return f.manifest, nil
}
func (*FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBannerRequest) (*agentproto.ServiceBanner, error) {
return &agentproto.ServiceBanner{}, nil
}
func (f *FakeAgentAPI) GetTimings() []*agentproto.Timing {
func (f *FakeAgentAPI) SetServiceBannerFunc(fn func() (codersdk.ServiceBannerConfig, error)) {
f.Lock()
defer f.Unlock()
return slices.Clone(f.timings)
f.getServiceBannerFunc = fn
f.logger.Info(context.Background(), "updated ServiceBannerFunc")
}
func (f *FakeAgentAPI) SetAnnouncementBannersFunc(fn func() ([]codersdk.BannerConfig, error)) {
func (f *FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBannerRequest) (*agentproto.ServiceBanner, error) {
f.Lock()
defer f.Unlock()
f.getAnnouncementBannersFunc = fn
f.logger.Info(context.Background(), "updated notification banners")
}
func (f *FakeAgentAPI) GetAnnouncementBanners(context.Context, *agentproto.GetAnnouncementBannersRequest) (*agentproto.GetAnnouncementBannersResponse, error) {
f.Lock()
defer f.Unlock()
if f.getAnnouncementBannersFunc == nil {
return &agentproto.GetAnnouncementBannersResponse{AnnouncementBanners: []*agentproto.BannerConfig{}}, nil
if f.getServiceBannerFunc == nil {
return &agentproto.ServiceBanner{}, nil
}
banners, err := f.getAnnouncementBannersFunc()
sb, err := f.getServiceBannerFunc()
if err != nil {
return nil, err
}
bannersProto := make([]*agentproto.BannerConfig, 0, len(banners))
for _, banner := range banners {
bannersProto = append(bannersProto, agentsdk.ProtoFromBannerConfig(banner))
}
return &agentproto.GetAnnouncementBannersResponse{AnnouncementBanners: bannersProto}, nil
return agentsdk.ProtoFromServiceBanner(sb), nil
}
func (f *FakeAgentAPI) GetResourcesMonitoringConfiguration(_ context.Context, _ *agentproto.GetResourcesMonitoringConfigurationRequest) (*agentproto.GetResourcesMonitoringConfigurationResponse, error) {
f.Lock()
defer f.Unlock()
if f.getResourcesMonitoringConfigurationFunc == nil {
return &agentproto.GetResourcesMonitoringConfigurationResponse{
Config: &agentproto.GetResourcesMonitoringConfigurationResponse_Config{
CollectionIntervalSeconds: 10,
NumDatapoints: 20,
},
}, nil
}
return f.getResourcesMonitoringConfigurationFunc()
func (*FakeAgentAPI) UpdateStats(context.Context, *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {
// TODO implement me
panic("implement me")
}
func (f *FakeAgentAPI) PushResourcesMonitoringUsage(_ context.Context, req *agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error) {
f.Lock()
defer f.Unlock()
if f.pushResourcesMonitoringUsageFunc == nil {
return &agentproto.PushResourcesMonitoringUsageResponse{}, nil
}
return f.pushResourcesMonitoringUsageFunc(req)
}
func (f *FakeAgentAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {
f.logger.Debug(ctx, "update stats called", slog.F("req", req))
// empty request is sent to get the interval; but our tests don't want empty stats requests
if req.Stats != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
case f.statsCh <- req.Stats:
// OK!
}
}
return &agentproto.UpdateStatsResponse{ReportInterval: durationpb.New(statsInterval)}, nil
}
func (f *FakeAgentAPI) GetLifecycleStates() []codersdk.WorkspaceAgentLifecycle {
f.Lock()
defer f.Unlock()
return slices.Clone(f.lifecycleStates)
}
func (f *FakeAgentAPI) UpdateLifecycle(_ context.Context, req *agentproto.UpdateLifecycleRequest) (*agentproto.Lifecycle, error) {
f.Lock()
defer f.Unlock()
s, err := agentsdk.LifecycleStateFromProto(req.GetLifecycle().GetState())
if assert.NoError(f.t, err) {
f.lifecycleStates = append(f.lifecycleStates, s)
}
return req.GetLifecycle(), nil
func (*FakeAgentAPI) UpdateLifecycle(context.Context, *agentproto.UpdateLifecycleRequest) (*agentproto.Lifecycle, error) {
// TODO implement me
panic("implement me")
}
func (f *FakeAgentAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.BatchUpdateAppHealthRequest) (*agentproto.BatchUpdateAppHealthResponse, error) {
f.logger.Debug(ctx, "batch update app health", slog.F("req", req))
select {
case <-ctx.Done():
return nil, ctx.Err()
case f.appHealthCh <- req:
return &agentproto.BatchUpdateAppHealthResponse{}, nil
}
return &agentproto.BatchUpdateAppHealthResponse{}, nil
}
func (f *FakeAgentAPI) AppHealthCh() <-chan *agentproto.BatchUpdateAppHealthRequest {
return f.appHealthCh
func (f *FakeAgentAPI) UpdateStartup(_ context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) {
f.startupCh <- req.GetStartup()
return req.GetStartup(), nil
}
func (f *FakeAgentAPI) UpdateStartup(ctx context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case f.startupCh <- req.GetStartup():
return req.GetStartup(), nil
}
func (*FakeAgentAPI) BatchUpdateMetadata(context.Context, *agentproto.BatchUpdateMetadataRequest) (*agentproto.BatchUpdateMetadataResponse, error) {
// TODO implement me
panic("implement me")
}
func (f *FakeAgentAPI) GetMetadata() map[string]agentsdk.Metadata {
f.Lock()
defer f.Unlock()
return maps.Clone(f.metadata)
func (*FakeAgentAPI) BatchCreateLogs(context.Context, *agentproto.BatchCreateLogsRequest) (*agentproto.BatchCreateLogsResponse, error) {
// TODO implement me
panic("implement me")
}
func (f *FakeAgentAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.BatchUpdateMetadataRequest) (*agentproto.BatchUpdateMetadataResponse, error) {
f.Lock()
defer f.Unlock()
if f.metadata == nil {
f.metadata = make(map[string]agentsdk.Metadata)
}
for _, md := range req.Metadata {
smd := agentsdk.MetadataFromProto(md)
f.metadata[md.Key] = smd
f.logger.Debug(ctx, "post metadata", slog.F("key", md.Key), slog.F("md", md))
}
return &agentproto.BatchUpdateMetadataResponse{}, nil
}
func (f *FakeAgentAPI) SetLogsChannel(ch chan<- *agentproto.BatchCreateLogsRequest) {
f.Lock()
defer f.Unlock()
f.logsCh = ch
}
func (f *FakeAgentAPI) BatchCreateLogs(ctx context.Context, req *agentproto.BatchCreateLogsRequest) (*agentproto.BatchCreateLogsResponse, error) {
f.logger.Info(ctx, "batch create logs called", slog.F("req", req))
f.Lock()
ch := f.logsCh
f.Unlock()
if ch != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
case ch <- req:
// ok
}
}
return &agentproto.BatchCreateLogsResponse{}, nil
}
func (f *FakeAgentAPI) ScriptCompleted(_ context.Context, req *agentproto.WorkspaceAgentScriptCompletedRequest) (*agentproto.WorkspaceAgentScriptCompletedResponse, error) {
f.Lock()
f.timings = append(f.timings, req.GetTiming())
f.Unlock()
return &agentproto.WorkspaceAgentScriptCompletedResponse{}, nil
}
func (f *FakeAgentAPI) ReportConnection(_ context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) {
f.Lock()
f.connections = append(f.connections, req.GetConnection())
f.Unlock()
return &emptypb.Empty{}, nil
}
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest) *FakeAgentAPI {
return &FakeAgentAPI{
t: t,
logger: logger.Named("FakeAgentAPI"),
manifest: manifest,
statsCh: statsCh,
startupCh: make(chan *agentproto.Startup, 100),
appHealthCh: make(chan *agentproto.BatchUpdateAppHealthRequest, 100),
t: t,
logger: logger.Named("FakeAgentAPI"),
manifest: manifest,
startupCh: make(chan *agentproto.Startup, 100),
}
}
-10
View File
@@ -7,7 +7,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)
@@ -36,16 +35,7 @@ func (a *agent) apiHandler() http.Handler {
ignorePorts: cpy,
cacheDuration: cacheDuration,
}
ch := agentcontainers.New(agentcontainers.WithLister(a.lister))
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
r.Get("/api/v0/containers", ch.ServeHTTP)
r.Get("/api/v0/listening-ports", lp.handler)
r.Get("/api/v0/netcheck", a.HandleNetcheck)
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
r.Get("/debug/manifest", a.HandleHTTPDebugManifest)
r.Get("/debug/prometheus", promHandler.ServeHTTP)
return r
}
+58 -67
View File
@@ -12,9 +12,12 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/quartz"
"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, agentsdk.PostAppHealthsRequest) error
@@ -23,26 +26,10 @@ type WorkspaceAppHealthReporter func(ctx context.Context)
// NewWorkspaceAppHealthReporter creates a WorkspaceAppHealthReporter that reports app health to coderd.
func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.WorkspaceApp, postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth) WorkspaceAppHealthReporter {
return NewAppHealthReporterWithClock(logger, apps, postWorkspaceAgentAppHealth, quartz.NewReal())
}
// NewAppHealthReporterWithClock is only called directly by test code. Product code should call
// NewAppHealthReporter.
func NewAppHealthReporterWithClock(
logger slog.Logger,
apps []codersdk.WorkspaceApp,
postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth,
clk quartz.Clock,
) WorkspaceAppHealthReporter {
logger = logger.Named("apphealth")
return func(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
runHealthcheckLoop := func(ctx context.Context) error {
// no need to run this loop if no apps for this workspace.
if len(apps) == 0 {
return
return nil
}
hasHealthchecksEnabled := false
@@ -57,7 +44,7 @@ func NewAppHealthReporterWithClock(
// no need to run this loop if no health checks are configured.
if !hasHealthchecksEnabled {
return
return nil
}
// run a ticker for each app health check.
@@ -69,29 +56,25 @@ func NewAppHealthReporterWithClock(
}
app := nextApp
go func() {
_ = clk.TickerFunc(ctx, time.Duration(app.Healthcheck.Interval)*time.Second, func() error {
// We time out at the healthcheck interval to prevent getting too backed up, but
// set it 1ms early so that it's not simultaneous with the next tick in testing,
// which makes the test easier to understand.
//
// It would be idiomatic to use the http.Client.Timeout or a context.WithTimeout,
// but we are passing this off to the native http library, which is not aware
// of the clock library we are using. That means in testing, with a mock clock
// it will compare mocked times with real times, and we will get strange results.
// So, we just implement the timeout as a context we cancel with an AfterFunc
reqCtx, reqCancel := context.WithCancel(ctx)
timeout := clk.AfterFunc(
time.Duration(app.Healthcheck.Interval)*time.Second-time.Millisecond,
reqCancel,
"timeout", app.Slug)
defer timeout.Stop()
t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second)
defer t.Stop()
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(reqCtx, http.MethodGet, app.Healthcheck.URL, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, app.Healthcheck.URL, nil)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
res, err := client.Do(req)
if err != nil {
return err
}
@@ -104,7 +87,6 @@ func NewAppHealthReporterWithClock(
return nil
}()
if err != nil {
nowUnhealthy := false
mu.Lock()
if failures[app.ID] < int(app.Healthcheck.Threshold) {
// increment the failure count and keep status the same.
@@ -114,52 +96,61 @@ func NewAppHealthReporterWithClock(
// 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.ID] = codersdk.WorkspaceAppHealthUnhealthy
nowUnhealthy = true
}
mu.Unlock()
logger.Debug(ctx, "error checking app health",
slog.F("id", app.ID.String()),
slog.F("slug", app.Slug),
slog.F("now_unhealthy", nowUnhealthy), slog.Error(err),
)
} else {
mu.Lock()
// we only need one successful health check to be considered healthy.
health[app.ID] = codersdk.WorkspaceAppHealthHealthy
failures[app.ID] = 0
mu.Unlock()
logger.Debug(ctx, "workspace app healthy", slog.F("id", app.ID.String()), slog.F("slug", app.Slug))
}
return nil
}, "healthcheck", app.Slug)
t.Reset(time.Duration(app.Healthcheck.Interval) * time.Second)
}
}()
}
mu.Lock()
lastHealth := copyHealth(health)
mu.Unlock()
reportTicker := clk.TickerFunc(ctx, time.Second, func() error {
mu.RLock()
changed := healthChanged(lastHealth, health)
mu.RUnlock()
if !changed {
reportTicker := time.NewTicker(time.Second)
defer reportTicker.Stop()
// 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, agentsdk.PostAppHealthsRequest{
Healths: lastHealth,
})
if err != nil {
logger.Error(ctx, "failed to report workspace app health", slog.Error(err))
} else {
logger.Debug(ctx, "sent workspace app health", slog.F("health", lastHealth))
mu.Lock()
lastHealth = copyHealth(health)
mu.Unlock()
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
Healths: lastHealth,
})
if err != nil {
logger.Error(ctx, "failed to report workspace app stat", slog.Error(err))
}
}
return nil
}, "report")
_ = reportTicker.Wait() // only possible error is context done
}
}
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))
}
}
}
+94 -175
View File
@@ -4,37 +4,33 @@ import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestAppHealth_Healthy(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
ID: uuid.UUID{1},
Slug: "app1",
Healthcheck: codersdk.Healthcheck{},
Health: codersdk.WorkspaceAppHealthDisabled,
},
{
ID: uuid.UUID{2},
Slug: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
@@ -44,81 +40,34 @@ func TestAppHealth_Healthy(t *testing.T) {
},
Health: codersdk.WorkspaceAppHealthInitializing,
},
{
ID: uuid.UUID{3},
Slug: "app3",
Healthcheck: codersdk.Healthcheck{
Interval: 2,
Threshold: 1,
},
Health: codersdk.WorkspaceAppHealthInitializing,
},
}
checks2 := 0
checks3 := 0
handlers := []http.Handler{
nil,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checks2++
httpapi.Write(r.Context(), w, http.StatusOK, nil)
}),
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checks3++
httpapi.Write(r.Context(), w, http.StatusOK, nil)
}),
}
mClock := quartz.NewMock(t)
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
defer healthcheckTrap.Close()
reportTrap := mClock.Trap().TickerFunc("report")
defer reportTrap.Close()
fakeAPI, closeFn := setupAppReporter(ctx, t, slices.Clone(apps), handlers, mClock)
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
healthchecksStarted := make([]string, 2)
for i := 0; i < 2; i++ {
c := healthcheckTrap.MustWait(ctx)
c.Release()
healthchecksStarted[i] = c.Tags[1]
}
slices.Sort(healthchecksStarted)
require.Equal(t, []string{"app2", "app3"}, healthchecksStarted)
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
}
// advance the clock 1ms before the report ticker starts, so that it's not
// simultaneous with the checks.
mClock.Advance(time.Millisecond).MustWait(ctx)
reportTrap.MustWait(ctx).Release()
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app2 is now healthy
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
require.Len(t, update.GetUpdates(), 2)
applyUpdate(t, apps, update)
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
require.Equal(t, codersdk.WorkspaceAppHealthInitializing, apps[2].Health)
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app3 is now healthy
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
update = testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
require.Len(t, update.GetUpdates(), 2)
applyUpdate(t, apps, update)
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[2].Health)
// ensure we aren't spamming
require.Equal(t, 2, checks2)
require.Equal(t, 1, checks3)
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy
}, testutil.WaitLong, testutil.IntervalSlow)
}
func TestAppHealth_500(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
ID: uuid.UUID{2},
Slug: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
@@ -134,40 +83,59 @@ func TestAppHealth_500(t *testing.T) {
httpapi.Write(r.Context(), w, http.StatusInternalServerError, nil)
}),
}
mClock := quartz.NewMock(t)
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
defer healthcheckTrap.Close()
reportTrap := mClock.Trap().TickerFunc("report")
defer reportTrap.Close()
fakeAPI, closeFn := setupAppReporter(ctx, t, slices.Clone(apps), handlers, mClock)
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
healthcheckTrap.MustWait(ctx).Release()
// advance the clock 1ms before the report ticker starts, so that it's not
// simultaneous with the checks.
mClock.Advance(time.Millisecond).MustWait(ctx)
reportTrap.MustWait(ctx).Release()
require.Eventually(t, func() bool {
apps, err := getApps(ctx)
if err != nil {
return false
}
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // check gets triggered
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered, but unsent since we are at the threshold
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // 2nd check, crosses threshold
mClock.Advance(time.Millisecond).MustWait(ctx) // 2nd report, sends update
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
require.Len(t, update.GetUpdates(), 1)
applyUpdate(t, apps, update)
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
}, testutil.WaitLong, testutil.IntervalSlow)
}
func TestAppHealth_Timeout(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
Slug: "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 TestAppHealth_NotSpamming(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
apps := []codersdk.WorkspaceApp{
{
ID: uuid.UUID{2},
Slug: "app2",
Healthcheck: codersdk.Healthcheck{
// URL: We don't set the URL for this test because the setup will
@@ -179,66 +147,22 @@ func TestAppHealth_Timeout(t *testing.T) {
},
}
counter := new(int32)
handlers := []http.Handler{
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
// allow the request to time out
<-r.Context().Done()
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(counter, 1)
}),
}
mClock := quartz.NewMock(t)
start := mClock.Now()
// for this test, it's easier to think in the number of milliseconds elapsed
// since start.
ms := func(n int) time.Time {
return start.Add(time.Duration(n) * time.Millisecond)
}
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
defer healthcheckTrap.Close()
reportTrap := mClock.Trap().TickerFunc("report")
defer reportTrap.Close()
timeoutTrap := mClock.Trap().AfterFunc("timeout")
defer timeoutTrap.Close()
fakeAPI, closeFn := setupAppReporter(ctx, t, apps, handlers, mClock)
_, closeFn := setupAppReporter(ctx, t, apps, handlers)
defer closeFn()
healthcheckTrap.MustWait(ctx).Release()
// advance the clock 1ms before the report ticker starts, so that it's not
// simultaneous with the checks.
mClock.Set(ms(1)).MustWait(ctx)
reportTrap.MustWait(ctx).Release()
w := mClock.Set(ms(1000)) // 1st check starts
timeoutTrap.MustWait(ctx).Release()
mClock.Set(ms(1001)).MustWait(ctx) // report tick, no change
mClock.Set(ms(1999)) // timeout pops
w.MustWait(ctx) // 1st check finished
w = mClock.Set(ms(2000)) // 2nd check starts
timeoutTrap.MustWait(ctx).Release()
mClock.Set(ms(2001)).MustWait(ctx) // report tick, no change
mClock.Set(ms(2999)) // timeout pops
w.MustWait(ctx) // 2nd check finished
// app is now unhealthy after 2 timeouts
mClock.Set(ms(3000)) // 3rd check starts
timeoutTrap.MustWait(ctx).Release()
mClock.Set(ms(3001)).MustWait(ctx) // report tick, sends changes
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
require.Len(t, update.GetUpdates(), 1)
applyUpdate(t, apps, update)
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
// Ensure we haven't made more than 2 (expected 1 + 1 for buffer) requests in the last second.
// if there is a bug where we are spamming the healthcheck route this will catch it.
time.Sleep(time.Second)
require.LessOrEqual(t, atomic.LoadInt32(counter), int32(2))
}
func setupAppReporter(
ctx context.Context, t *testing.T,
apps []codersdk.WorkspaceApp,
handlers []http.Handler,
clk quartz.Clock,
) (*agenttest.FakeAgentAPI, func()) {
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
closers := []func(){}
for _, app := range apps {
require.NotEqual(t, uuid.Nil, app.ID, "all apps must have ID set")
}
for i, handler := range handlers {
if handler == nil {
continue
@@ -250,39 +174,34 @@ func setupAppReporter(
closers = append(closers, ts.Close)
}
// We don't care about manifest or stats in this test since it's not using
// a full agent and these RPCs won't get called.
//
// We use a proper fake agent API so we can test the conversion code and the
// request code as well. Before we were bypassing these by using a custom
// post function.
fakeAAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
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 agentsdk.PostAppHealthsRequest) error {
mu.Lock()
for id, health := range req.Healths {
for i, app := range apps {
if app.ID != id {
continue
}
app.Health = health
apps[i] = app
}
}
mu.Unlock()
go agent.NewAppHealthReporterWithClock(
testutil.Logger(t),
apps, agentsdk.AppHealthPoster(fakeAAPI), clk,
)(ctx)
return nil
}
return fakeAAPI, func() {
go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), apps, postWorkspaceAgentAppHealth)(ctx)
return workspaceAgentApps, func() {
for _, closeFn := range closers {
closeFn()
}
}
}
func applyUpdate(t *testing.T, apps []codersdk.WorkspaceApp, req *proto.BatchUpdateAppHealthRequest) {
t.Helper()
for _, update := range req.Updates {
updateID, err := uuid.FromBytes(update.Id)
require.NoError(t, err)
updateHealth := codersdk.WorkspaceAppHealth(strings.ToLower(proto.AppHealth_name[int32(update.Health)]))
for i, app := range apps {
if app.ID != updateID {
continue
}
app.Health = updateHealth
apps[i] = app
}
}
}
-51
View File
@@ -1,51 +0,0 @@
package agent
import (
"context"
"runtime"
"sync"
"cdr.dev/slog"
)
// checkpoint allows a goroutine to communicate when it is OK to proceed beyond some async condition
// to other dependent goroutines.
type checkpoint struct {
logger slog.Logger
mu sync.Mutex
called bool
done chan struct{}
err error
}
// complete the checkpoint. Pass nil to indicate the checkpoint was ok. It is an error to call this
// more than once.
func (c *checkpoint) complete(err error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.called {
b := make([]byte, 2048)
n := runtime.Stack(b, false)
c.logger.Critical(context.Background(), "checkpoint complete called more than once", slog.F("stacktrace", b[:n]))
return
}
c.called = true
c.err = err
close(c.done)
}
func (c *checkpoint) wait(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.done:
return c.err
}
}
func newCheckpoint(logger slog.Logger) *checkpoint {
return &checkpoint{
logger: logger,
done: make(chan struct{}),
}
}
-49
View File
@@ -1,49 +0,0 @@
package agent
import (
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/testutil"
)
func TestCheckpoint_CompleteWait(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitShort)
uut := newCheckpoint(logger)
err := xerrors.New("test")
uut.complete(err)
got := uut.wait(ctx)
require.Equal(t, err, got)
}
func TestCheckpoint_CompleteTwice(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
ctx := testutil.Context(t, testutil.WaitShort)
uut := newCheckpoint(logger)
err := xerrors.New("test")
uut.complete(err)
uut.complete(nil) // drops CRITICAL log
got := uut.wait(ctx)
require.Equal(t, err, got)
}
func TestCheckpoint_WaitComplete(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitShort)
uut := newCheckpoint(logger)
err := xerrors.New("test")
errCh := make(chan error, 1)
go func() {
errCh <- uut.wait(ctx)
}()
uut.complete(err)
got := testutil.RequireRecvCtx(ctx, t, errCh)
require.Equal(t, err, got)
}
-31
View File
@@ -1,31 +0,0 @@
package agent
import (
"net/http"
"github.com/coder/coder/v2/coderd/healthcheck/health"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/healthsdk"
)
func (a *agent) HandleNetcheck(rw http.ResponseWriter, r *http.Request) {
ni := a.TailnetConn().GetNetInfo()
ifReport, err := healthsdk.RunInterfacesReport()
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to run interfaces report",
Detail: err.Error(),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, healthsdk.AgentNetcheckReport{
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
NetInfo: ni,
Interfaces: ifReport,
})
}
+15 -24
View File
@@ -10,7 +10,8 @@ import (
"tailscale.com/util/clientmetric"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk/agentsdk"
)
type agentMetrics struct {
@@ -19,7 +20,6 @@ type agentMetrics struct {
// startupScriptSeconds is the time in seconds that the start script(s)
// took to run. This is reported once per agent.
startupScriptSeconds *prometheus.GaugeVec
currentConnections *prometheus.GaugeVec
}
func newAgentMetrics(registerer prometheus.Registerer) *agentMetrics {
@@ -46,24 +46,15 @@ func newAgentMetrics(registerer prometheus.Registerer) *agentMetrics {
}, []string{"success"})
registerer.MustRegister(startupScriptSeconds)
currentConnections := prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "coderd",
Subsystem: "agentstats",
Name: "currently_reachable_peers",
Help: "The number of peers (e.g. clients) that are currently reachable over the encrypted network.",
}, []string{"connection_type"})
registerer.MustRegister(currentConnections)
return &agentMetrics{
connectionsTotal: connectionsTotal,
reconnectingPTYErrors: reconnectingPTYErrors,
startupScriptSeconds: startupScriptSeconds,
currentConnections: currentConnections,
}
}
func (a *agent) collectMetrics(ctx context.Context) []*proto.Stats_Metric {
var collected []*proto.Stats_Metric
func (a *agent) collectMetrics(ctx context.Context) []agentsdk.AgentMetric {
var collected []agentsdk.AgentMetric
// Tailscale internal metrics
metrics := clientmetric.Metrics()
@@ -72,7 +63,7 @@ func (a *agent) collectMetrics(ctx context.Context) []*proto.Stats_Metric {
continue
}
collected = append(collected, &proto.Stats_Metric{
collected = append(collected, agentsdk.AgentMetric{
Name: m.Name(),
Type: asMetricType(m.Type()),
Value: float64(m.Value()),
@@ -90,16 +81,16 @@ func (a *agent) collectMetrics(ctx context.Context) []*proto.Stats_Metric {
labels := toAgentMetricLabels(metric.Label)
if metric.Counter != nil {
collected = append(collected, &proto.Stats_Metric{
collected = append(collected, agentsdk.AgentMetric{
Name: metricFamily.GetName(),
Type: proto.Stats_Metric_COUNTER,
Type: agentsdk.AgentMetricTypeCounter,
Value: metric.Counter.GetValue(),
Labels: labels,
})
} else if metric.Gauge != nil {
collected = append(collected, &proto.Stats_Metric{
collected = append(collected, agentsdk.AgentMetric{
Name: metricFamily.GetName(),
Type: proto.Stats_Metric_GAUGE,
Type: agentsdk.AgentMetricTypeGauge,
Value: metric.Gauge.GetValue(),
Labels: labels,
})
@@ -111,14 +102,14 @@ func (a *agent) collectMetrics(ctx context.Context) []*proto.Stats_Metric {
return collected
}
func toAgentMetricLabels(metricLabels []*prompb.LabelPair) []*proto.Stats_Metric_Label {
func toAgentMetricLabels(metricLabels []*prompb.LabelPair) []agentsdk.AgentMetricLabel {
if len(metricLabels) == 0 {
return nil
}
labels := make([]*proto.Stats_Metric_Label, 0, len(metricLabels))
labels := make([]agentsdk.AgentMetricLabel, 0, len(metricLabels))
for _, metricLabel := range metricLabels {
labels = append(labels, &proto.Stats_Metric_Label{
labels = append(labels, agentsdk.AgentMetricLabel{
Name: metricLabel.GetName(),
Value: metricLabel.GetValue(),
})
@@ -139,12 +130,12 @@ func isIgnoredMetric(metricName string) bool {
return false
}
func asMetricType(typ clientmetric.Type) proto.Stats_Metric_Type {
func asMetricType(typ clientmetric.Type) agentsdk.AgentMetricType {
switch typ {
case clientmetric.TypeGauge:
return proto.Stats_Metric_GAUGE
return agentsdk.AgentMetricTypeGauge
case clientmetric.TypeCounter:
return proto.Stats_Metric_COUNTER
return agentsdk.AgentMetricTypeCounter
default:
panic(fmt.Sprintf("unknown metric type: %d", typ))
}
+1 -2
View File
@@ -9,7 +9,6 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
@@ -33,7 +32,7 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentL
seen := make(map[uint16]struct{}, len(tabs))
ports := []codersdk.WorkspaceAgentListeningPort{}
for _, tab := range tabs {
if tab.LocalAddr == nil || tab.LocalAddr.Port < workspacesdk.AgentMinimumListeningPort {
if tab.LocalAddr == nil || tab.LocalAddr.Port < codersdk.WorkspaceAgentMinimumListeningPort {
continue
}
+489 -2188
View File
File diff suppressed because it is too large Load Diff

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