From b723da9e918ad05f5eb3132387832ecc29f59483 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Sun, 2 Jun 2024 12:10:28 -0500 Subject: [PATCH 001/168] chore: upgrade terraform to `v1.8.5` (#13429) --- .github/actions/setup-tf/action.yaml | 2 +- .github/workflows/typos.toml | 1 + docs/install/offline.md | 2 +- dogfood/Dockerfile | 2 +- install.sh | 2 +- provisioner/terraform/install.go | 4 +-- .../calling-module/calling-module.tfplan.json | 9 ++++-- .../calling-module.tfstate.json | 10 +++--- .../chaining-resources.tfplan.json | 9 ++++-- .../chaining-resources.tfstate.json | 10 +++--- .../conflicting-resources.tfplan.json | 9 ++++-- .../conflicting-resources.tfstate.json | 10 +++--- .../display-apps-disabled.tfplan.json | 9 ++++-- .../display-apps-disabled.tfstate.json | 8 ++--- .../display-apps/display-apps.tfplan.json | 9 ++++-- .../display-apps/display-apps.tfstate.json | 8 ++--- .../external-auth-providers.tfplan.json | 11 ++++--- .../external-auth-providers.tfstate.json | 8 ++--- .../git-auth-providers.tfplan.json | 11 ++++--- .../git-auth-providers.tfstate.json | 8 ++--- .../instance-id/instance-id.tfplan.json | 9 ++++-- .../instance-id/instance-id.tfstate.json | 12 +++---- .../mapped-apps/mapped-apps.tfplan.json | 9 ++++-- .../mapped-apps/mapped-apps.tfstate.json | 16 +++++----- .../multiple-agents.tfplan.json | 18 +++++++---- .../multiple-agents.tfstate.json | 20 ++++++------ .../multiple-apps/multiple-apps.tfplan.json | 9 ++++-- .../multiple-apps/multiple-apps.tfstate.json | 20 ++++++------ .../resource-metadata-duplicate.tfplan.json | 9 ++++-- .../resource-metadata-duplicate.tfstate.json | 16 +++++----- .../resource-metadata.tfplan.json | 9 ++++-- .../resource-metadata.tfstate.json | 12 +++---- .../rich-parameters-order.tfplan.json | 15 +++++---- .../rich-parameters-order.tfstate.json | 12 +++---- .../rich-parameters-validation.tfplan.json | 23 ++++++++------ .../rich-parameters-validation.tfstate.json | 20 ++++++------ .../rich-parameters.tfplan.json | 31 ++++++++++--------- .../rich-parameters.tfstate.json | 28 ++++++++--------- provisioner/terraform/testdata/version.txt | 2 +- scripts/Dockerfile.base | 2 +- 40 files changed, 243 insertions(+), 191 deletions(-) diff --git a/.github/actions/setup-tf/action.yaml b/.github/actions/setup-tf/action.yaml index 0fa40bdbfdefc..e660e6f3c3f5f 100644 --- a/.github/actions/setup-tf/action.yaml +++ b/.github/actions/setup-tf/action.yaml @@ -7,5 +7,5 @@ runs: - name: Install Terraform uses: hashicorp/setup-terraform@v3 with: - terraform_version: 1.7.5 + terraform_version: 1.8.4 terraform_wrapper: false diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 559260e0f7f32..7ee9554f0cdc3 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -33,4 +33,5 @@ extend-exclude = [ "**/pnpm-lock.yaml", "tailnet/testdata/**", "site/src/pages/SetupPage/countries.tsx", + "provisioner/terraform/testdata/**", ] diff --git a/docs/install/offline.md b/docs/install/offline.md index 120aa5c9f76b7..d4d8d24c0c111 100644 --- a/docs/install/offline.md +++ b/docs/install/offline.md @@ -54,7 +54,7 @@ RUN mkdir -p /opt/terraform # The below step is optional if you wish to keep the existing version. # See https://github.com/coder/coder/blob/main/provisioner/terraform/install.go#L23-L24 # for supported Terraform versions. -ARG TERRAFORM_VERSION=1.7.5 +ARG TERRAFORM_VERSION=1.8.4 RUN apk update && \ apk del terraform && \ curl -LOs https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ diff --git a/dogfood/Dockerfile b/dogfood/Dockerfile index 4aa46e83c8fd7..19723853aa7ac 100644 --- a/dogfood/Dockerfile +++ b/dogfood/Dockerfile @@ -171,7 +171,7 @@ RUN apt-get update --quiet && apt-get install --yes \ # NOTE: In scripts/Dockerfile.base we specifically install Terraform version 1.7.5. # Installing the same version here to match. -RUN wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_amd64.zip" && \ +RUN wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.8.4/terraform_1.8.4_linux_amd64.zip" && \ unzip /tmp/terraform.zip -d /usr/local/bin && \ rm -f /tmp/terraform.zip && \ chmod +x /usr/local/bin/terraform && \ diff --git a/install.sh b/install.sh index cabbdc685f2c6..9b76d1b204b21 100755 --- a/install.sh +++ b/install.sh @@ -250,7 +250,7 @@ EOF main() { MAINLINE=1 STABLE=0 - TERRAFORM_VERSION="1.7.5" + TERRAFORM_VERSION="1.8.4" if [ "${TRACE-}" ]; then set -x diff --git a/provisioner/terraform/install.go b/provisioner/terraform/install.go index e3014fb8758be..7ebceb5820035 100644 --- a/provisioner/terraform/install.go +++ b/provisioner/terraform/install.go @@ -20,10 +20,10 @@ var ( // when Terraform is not available on the system. // NOTE: Keep this in sync with the version in scripts/Dockerfile.base. // NOTE: Keep this in sync with the version in install.sh. - TerraformVersion = version.Must(version.NewVersion("1.7.5")) + TerraformVersion = version.Must(version.NewVersion("1.8.4")) minTerraformVersion = version.Must(version.NewVersion("1.1.0")) - maxTerraformVersion = version.Must(version.NewVersion("1.7.9")) // use .9 to automatically allow patch releases + maxTerraformVersion = version.Must(version.NewVersion("1.8.9")) // use .9 to automatically allow patch releases terraformMinorVersionMismatch = xerrors.New("Terraform binary minor version mismatch.") ) diff --git a/provisioner/terraform/testdata/calling-module/calling-module.tfplan.json b/provisioner/terraform/testdata/calling-module/calling-module.tfplan.json index 28a2b055ecf10..e4693c3057db2 100644 --- a/provisioner/terraform/testdata/calling-module/calling-module.tfplan.json +++ b/provisioner/terraform/testdata/calling-module/calling-module.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } } ], @@ -259,6 +260,8 @@ ] } ], - "timestamp": "2024-05-22T17:02:40Z", + "timestamp": "2024-05-31T22:25:19Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/calling-module/calling-module.tfstate.json b/provisioner/terraform/testdata/calling-module/calling-module.tfstate.json index 5f8a795e2a894..eed7ec7b0fe61 100644 --- a/provisioner/terraform/testdata/calling-module/calling-module.tfstate.json +++ b/provisioner/terraform/testdata/calling-module/calling-module.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "f26b1d53-799e-4fbb-9fd3-71e60b37eacd", + "id": "2941e1eb-40f5-41cf-9e08-8f0f1a80d430", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "ce663074-ebea-44cb-b6d1-321f590f7982", + "token": "3105121f-9b54-4c91-b497-9da9bb05c5b6", "troubleshooting_url": null }, "sensitive_values": { @@ -69,7 +69,7 @@ "outputs": { "script": "" }, - "random": "8031375470547649400" + "random": "3895262600016319159" }, "sensitive_values": { "inputs": {}, @@ -84,7 +84,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "3370916843136140681", + "id": "5027788252939043492", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfplan.json b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfplan.json index 9717ddd34b128..8b02d13cdc75e 100644 --- a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfplan.json +++ b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -204,6 +205,8 @@ ] } }, - "timestamp": "2024-05-22T17:02:43Z", + "timestamp": "2024-05-31T22:25:20Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfstate.json b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfstate.json index 304e9703b9073..95db4fc47c82c 100644 --- a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfstate.json +++ b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "9d869fc3-c185-4278-a5d2-873f809a4449", + "id": "da093356-6550-4e76-bb9e-0269cede7e31", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "418bb1d6-49d8-4340-ac84-ed6991457ff9", + "token": "ebcb7f0e-4b80-4972-b434-1a42aa650d78", "troubleshooting_url": null }, "sensitive_values": { @@ -57,7 +57,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "3681188688307687011", + "id": "2686005653093770315", "triggers": null }, "sensitive_values": {}, @@ -74,7 +74,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "6055360096088266226", + "id": "1732714319726388691", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfplan.json b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfplan.json index a62fa814bea53..948ce6580b63b 100644 --- a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfplan.json +++ b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -204,6 +205,8 @@ ] } }, - "timestamp": "2024-05-22T17:02:45Z", + "timestamp": "2024-05-31T22:25:22Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfstate.json b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfstate.json index 4aa66de56d2c9..15bfeec63e134 100644 --- a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfstate.json +++ b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "d9c497fe-1dc4-4551-b46d-282f775e9509", + "id": "e56c4e1a-6b1a-4007-880c-875dc6400b73", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "6fa01f69-de93-4610-b942-b787118146f8", + "token": "b3666f42-cc88-454e-93bd-553f71306dbe", "troubleshooting_url": null }, "sensitive_values": { @@ -57,7 +57,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "2012753940926517215", + "id": "8818573993093135925", "triggers": null }, "sensitive_values": {}, @@ -73,7 +73,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "2163283012438694669", + "id": "2487290649323445841", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/display-apps-disabled/display-apps-disabled.tfplan.json b/provisioner/terraform/testdata/display-apps-disabled/display-apps-disabled.tfplan.json index de8d982bef577..e2bd6410a62c4 100644 --- a/provisioner/terraform/testdata/display-apps-disabled/display-apps-disabled.tfplan.json +++ b/provisioner/terraform/testdata/display-apps-disabled/display-apps-disabled.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -42,7 +42,8 @@ "display_apps": [ {} ], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -203,6 +204,8 @@ ] } }, - "timestamp": "2024-05-22T17:02:50Z", + "timestamp": "2024-05-31T22:25:26Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/display-apps-disabled/display-apps-disabled.tfstate.json b/provisioner/terraform/testdata/display-apps-disabled/display-apps-disabled.tfstate.json index 3567c75133732..ce2facb3c5a1c 100644 --- a/provisioner/terraform/testdata/display-apps-disabled/display-apps-disabled.tfstate.json +++ b/provisioner/terraform/testdata/display-apps-disabled/display-apps-disabled.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "c55cfcad-5422-46e5-a144-e933660bacd3", + "id": "cd49cbe2-97f4-4980-9b13-4e4008f4d594", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "e170615d-a3a2-4dc4-a65e-4990ceeb79e5", + "token": "4b1c44cb-d960-42ef-b19e-60d169085657", "troubleshooting_url": null }, "sensitive_values": { @@ -57,7 +57,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "3512108359019802900", + "id": "6613171819431602989", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/display-apps/display-apps.tfplan.json b/provisioner/terraform/testdata/display-apps/display-apps.tfplan.json index d41c6e03541d0..c3fe9046116ae 100644 --- a/provisioner/terraform/testdata/display-apps/display-apps.tfplan.json +++ b/provisioner/terraform/testdata/display-apps/display-apps.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -42,7 +42,8 @@ "display_apps": [ {} ], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -203,6 +204,8 @@ ] } }, - "timestamp": "2024-05-22T17:02:48Z", + "timestamp": "2024-05-31T22:25:24Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/display-apps/display-apps.tfstate.json b/provisioner/terraform/testdata/display-apps/display-apps.tfstate.json index 79b2e6dd6490f..3ce1d2d34a181 100644 --- a/provisioner/terraform/testdata/display-apps/display-apps.tfstate.json +++ b/provisioner/terraform/testdata/display-apps/display-apps.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "3fb63a4e-bb0e-4380-9ed9-8b1581943b1f", + "id": "dac3e164-c9d2-43e2-89ee-54ce5955e551", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "eb5720a7-91fd-4e37-8085-af3c8205702c", + "token": "99ccf297-47b1-4c7c-819e-0bac896b12bd", "troubleshooting_url": null }, "sensitive_values": { @@ -57,7 +57,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "2929624824161973000", + "id": "5268162908997861371", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/external-auth-providers/external-auth-providers.tfplan.json b/provisioner/terraform/testdata/external-auth-providers/external-auth-providers.tfplan.json index 837d50255a3a1..77cac08ba071d 100644 --- a/provisioner/terraform/testdata/external-auth-providers/external-auth-providers.tfplan.json +++ b/provisioner/terraform/testdata/external-auth-providers/external-auth-providers.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -118,7 +119,7 @@ ], "prior_state": { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -227,6 +228,8 @@ ] } }, - "timestamp": "2024-05-22T17:02:52Z", + "timestamp": "2024-05-31T22:25:28Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/external-auth-providers/external-auth-providers.tfstate.json b/provisioner/terraform/testdata/external-auth-providers/external-auth-providers.tfstate.json index 125cea74bcc3c..481e197946226 100644 --- a/provisioner/terraform/testdata/external-auth-providers/external-auth-providers.tfstate.json +++ b/provisioner/terraform/testdata/external-auth-providers/external-auth-providers.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -54,7 +54,7 @@ } ], "env": null, - "id": "923df4d0-cf96-4cf8-aaff-426e58927a81", + "id": "2fcac464-b22b-4567-8391-7cdf592dae14", "init_script": "", "login_before_ready": true, "metadata": [], @@ -66,7 +66,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "f5328221-90c7-4056-83b4-7b76d6f46580", + "token": "57bcc78a-ed9b-46f9-9901-ffbdfb325871", "troubleshooting_url": null }, "sensitive_values": { @@ -85,7 +85,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "4621387386750422041", + "id": "7076770981685522602", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfplan.json b/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfplan.json index bd9286692d328..ca6e7765c7a5b 100644 --- a/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfplan.json +++ b/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -118,7 +119,7 @@ ], "prior_state": { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -222,6 +223,8 @@ ] } }, - "timestamp": "2024-05-22T17:02:55Z", + "timestamp": "2024-05-31T22:25:30Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfstate.json b/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfstate.json index 509c6d5a9d7fc..ae548c8f97f82 100644 --- a/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfstate.json +++ b/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -52,7 +52,7 @@ } ], "env": null, - "id": "48a24332-1a90-48d9-9e03-b4e9f09c6eab", + "id": "c924e5b7-e2cb-4eb5-993e-3cc489ed5213", "init_script": "", "login_before_ready": true, "metadata": [], @@ -64,7 +64,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "6a2ae93f-3f25-423d-aa97-b2f1c5d9c20b", + "token": "cc8ceb98-822f-4b8f-b645-2162fada1dfb", "troubleshooting_url": null }, "sensitive_values": { @@ -83,7 +83,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "8095584601893320918", + "id": "7049248910828562611", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/instance-id/instance-id.tfplan.json b/provisioner/terraform/testdata/instance-id/instance-id.tfplan.json index fe875367359c0..2cdfdcf13345a 100644 --- a/provisioner/terraform/testdata/instance-id/instance-id.tfplan.json +++ b/provisioner/terraform/testdata/instance-id/instance-id.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -224,6 +225,8 @@ ] } ], - "timestamp": "2024-05-22T17:02:57Z", + "timestamp": "2024-05-31T22:25:32Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/instance-id/instance-id.tfstate.json b/provisioner/terraform/testdata/instance-id/instance-id.tfstate.json index ef5346a2ac822..40519b8266850 100644 --- a/provisioner/terraform/testdata/instance-id/instance-id.tfstate.json +++ b/provisioner/terraform/testdata/instance-id/instance-id.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "3bc8e20f-2024-4014-ac11-806e7e1a1e24", + "id": "b691d6a2-76de-4441-ac90-3260282dc1fb", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "6ef0492b-8dbe-4c61-8eb8-a37acb671278", + "token": "244bf23b-b483-46f9-b2ff-7a6e746c836f", "troubleshooting_url": null }, "sensitive_values": { @@ -57,8 +57,8 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "3bc8e20f-2024-4014-ac11-806e7e1a1e24", - "id": "7ba714fa-f2b8-4d33-8987-f67466505033", + "agent_id": "b691d6a2-76de-4441-ac90-3260282dc1fb", + "id": "66ce959f-b821-4657-9bdb-6290c3b3a0b9", "instance_id": "example" }, "sensitive_values": {}, @@ -74,7 +74,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "4065206823139127011", + "id": "3867175311980978156", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfplan.json b/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfplan.json index 9fad4b322a02d..2d63b29fac5e4 100644 --- a/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfplan.json +++ b/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -326,6 +327,8 @@ ] } ], - "timestamp": "2024-05-22T17:02:59Z", + "timestamp": "2024-05-31T22:25:34Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfstate.json b/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfstate.json index e19a8b484bf6a..dc78ba27d9f46 100644 --- a/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfstate.json +++ b/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "d8d2ed23-193d-4784-9ce5-7bc0d879bb14", + "id": "d3eece5c-3d36-4e77-a67c-284d6a665004", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "0555adfc-e969-4fd2-8cfd-47560bd1b5a3", + "token": "793d9e17-fe59-4e70-83ee-76397b81a5bd", "troubleshooting_url": null }, "sensitive_values": { @@ -58,13 +58,13 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "d8d2ed23-193d-4784-9ce5-7bc0d879bb14", + "agent_id": "d3eece5c-3d36-4e77-a67c-284d6a665004", "command": null, "display_name": "app1", "external": false, "healthcheck": [], "icon": null, - "id": "11fa3ff2-d6ba-41ca-b1df-6c98d395c0b8", + "id": "02a5c323-badd-4a9d-bb5e-6926b8c3f317", "name": null, "order": null, "relative_path": null, @@ -89,13 +89,13 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "d8d2ed23-193d-4784-9ce5-7bc0d879bb14", + "agent_id": "d3eece5c-3d36-4e77-a67c-284d6a665004", "command": null, "display_name": "app2", "external": false, "healthcheck": [], "icon": null, - "id": "cd1a2e37-adbc-49f0-bd99-033c62a1533e", + "id": "3f9b0fb0-fc06-49ed-b869-27b570b86b47", "name": null, "order": null, "relative_path": null, @@ -119,7 +119,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "4490911212417021152", + "id": "6739553050203442390", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfplan.json b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfplan.json index 7f44aa45ca7d9..8a27774498541 100644 --- a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfplan.json +++ b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -61,7 +62,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -91,7 +93,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -121,7 +124,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -460,6 +464,8 @@ ] } }, - "timestamp": "2024-05-22T17:03:01Z", + "timestamp": "2024-05-31T22:25:36Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfstate.json b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfstate.json index 0bbd45fa5a3df..023f6ab52f0fc 100644 --- a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfstate.json +++ b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "0ffc6582-b017-404e-b83f-48e4a5ab38bc", + "id": "2cd8a28d-b73c-4801-8748-5681512b99ed", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "b7f0a913-ecb1-4c80-8559-fbcb435d53d0", + "token": "68c874c4-2f0d-4dff-9fd7-67209e9a08c7", "troubleshooting_url": null }, "sensitive_values": { @@ -71,7 +71,7 @@ } ], "env": null, - "id": "1780ae95-844c-4d5c-94fb-6ccfe4a7656d", + "id": "2e773a6e-0e57-428d-bdf8-414c2aaa55fc", "init_script": "", "login_before_ready": true, "metadata": [], @@ -83,7 +83,7 @@ "startup_script": null, "startup_script_behavior": "non-blocking", "startup_script_timeout": 30, - "token": "695f8765-3d3d-4da0-9a5a-bb7b1f568bde", + "token": "98944f07-1265-4329-8fd3-c92aac95855c", "troubleshooting_url": null }, "sensitive_values": { @@ -116,7 +116,7 @@ } ], "env": null, - "id": "333b7856-24ac-46be-9ae3-e4981b25481d", + "id": "9568f00b-0bd8-4982-a502-7b37562b1fa3", "init_script": "", "login_before_ready": true, "metadata": [], @@ -128,7 +128,7 @@ "startup_script": null, "startup_script_behavior": "blocking", "startup_script_timeout": 300, - "token": "50ddfb93-264f-4f64-8c8d-db7d8d37c0a1", + "token": "8bf8789b-9efc-4517-aa30-89b99c46dd75", "troubleshooting_url": "https://coder.com/troubleshoot" }, "sensitive_values": { @@ -161,7 +161,7 @@ } ], "env": null, - "id": "90736626-71c9-4b76-bdfc-f6ce9b3dda05", + "id": "403e5299-2f3e-499c-b90a-2fa6fc9e44e6", "init_script": "", "login_before_ready": false, "metadata": [], @@ -173,7 +173,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "8c4ae7b9-12b7-4a9c-a55a-a98cfb049103", + "token": "a10e5bfb-9756-4210-a112-877f2cfbdc0a", "troubleshooting_url": null }, "sensitive_values": { @@ -192,7 +192,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "6980014108785645805", + "id": "2053669122262711043", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfplan.json b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfplan.json index eee1d09317ba1..4a07ac904a675 100644 --- a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfplan.json +++ b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -445,6 +446,8 @@ ] } ], - "timestamp": "2024-05-22T17:03:03Z", + "timestamp": "2024-05-31T22:25:38Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfstate.json b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfstate.json index 3ed04ae6ecab0..e5a64a6928388 100644 --- a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfstate.json +++ b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "c950352c-7c4a-41cc-9049-ad07ded85c47", + "id": "26bc229a-d911-4d91-8b18-c59a2f2939f4", "init_script": "", "login_before_ready": true, "metadata": [], @@ -38,7 +38,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "143c3974-49f5-4898-815b-c4044283ebc8", + "token": "3be506a9-b085-4bd8-a6e9-ac1769aedac5", "troubleshooting_url": null }, "sensitive_values": { @@ -57,13 +57,13 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "c950352c-7c4a-41cc-9049-ad07ded85c47", + "agent_id": "26bc229a-d911-4d91-8b18-c59a2f2939f4", "command": null, "display_name": null, "external": false, "healthcheck": [], "icon": null, - "id": "23135384-0e9f-4efc-b74c-d3e5e878ed67", + "id": "cbfb480c-49f0-41dc-a5e5-fa8ab21514e7", "name": null, "order": null, "relative_path": null, @@ -87,7 +87,7 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "c950352c-7c4a-41cc-9049-ad07ded85c47", + "agent_id": "26bc229a-d911-4d91-8b18-c59a2f2939f4", "command": null, "display_name": null, "external": false, @@ -99,7 +99,7 @@ } ], "icon": null, - "id": "01e73639-0fd1-4bcb-bd88-d22eb8244627", + "id": "6cc74cc4-edd4-482a-be9c-46243008081d", "name": null, "order": null, "relative_path": null, @@ -125,13 +125,13 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "c950352c-7c4a-41cc-9049-ad07ded85c47", + "agent_id": "26bc229a-d911-4d91-8b18-c59a2f2939f4", "command": null, "display_name": null, "external": false, "healthcheck": [], "icon": null, - "id": "058c9054-9714-4a5f-9fde-8a451ab58620", + "id": "7b2131ed-3850-439e-8942-6c83fe02ce0c", "name": null, "order": null, "relative_path": null, @@ -155,7 +155,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "9051436019409847411", + "id": "6270198559972381862", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/resource-metadata-duplicate/resource-metadata-duplicate.tfplan.json b/provisioner/terraform/testdata/resource-metadata-duplicate/resource-metadata-duplicate.tfplan.json index 6084ae4435990..70379dc90d732 100644 --- a/provisioner/terraform/testdata/resource-metadata-duplicate/resource-metadata-duplicate.tfplan.json +++ b/provisioner/terraform/testdata/resource-metadata-duplicate/resource-metadata-duplicate.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -42,7 +42,8 @@ "display_apps": [], "metadata": [ {} - ] + ], + "token": true } }, { @@ -431,6 +432,8 @@ ] } ], - "timestamp": "2024-05-22T17:03:06Z", + "timestamp": "2024-05-31T22:25:42Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/resource-metadata-duplicate/resource-metadata-duplicate.tfstate.json b/provisioner/terraform/testdata/resource-metadata-duplicate/resource-metadata-duplicate.tfstate.json index e617f565156ab..264edcf513f81 100644 --- a/provisioner/terraform/testdata/resource-metadata-duplicate/resource-metadata-duplicate.tfstate.json +++ b/provisioner/terraform/testdata/resource-metadata-duplicate/resource-metadata-duplicate.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "8352a117-1250-44ef-bba2-0abdb2a77665", + "id": "15b21cea-46cb-4e70-b648-56dceff97236", "init_script": "", "login_before_ready": true, "metadata": [ @@ -47,7 +47,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "b46fd197-3be4-42f8-9c47-5a9e71a76ef6", + "token": "3308a570-7944-4238-aca8-fbc3644d7548", "troubleshooting_url": null }, "sensitive_values": { @@ -71,7 +71,7 @@ "daily_cost": 29, "hide": true, "icon": "/icon/server.svg", - "id": "1f7911d4-5b64-4e20-af9b-b6ee2aff602b", + "id": "28db1106-e6f0-41ff-b707-3100a99cadff", "item": [ { "is_null": false, @@ -86,7 +86,7 @@ "value": "" } ], - "resource_id": "7229373774865666851" + "resource_id": "3221770356529482934" }, "sensitive_values": { "item": [ @@ -110,7 +110,7 @@ "daily_cost": 20, "hide": true, "icon": "/icon/server.svg", - "id": "34fe7a46-2a2f-4628-8946-ef80a7ffdb5e", + "id": "a30b56a6-c122-485a-a128-4210600ad17f", "item": [ { "is_null": false, @@ -119,7 +119,7 @@ "value": "world" } ], - "resource_id": "7229373774865666851" + "resource_id": "3221770356529482934" }, "sensitive_values": { "item": [ @@ -139,7 +139,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "7229373774865666851", + "id": "3221770356529482934", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.json b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.json index a03346a724115..8e06a483749ac 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.json +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -42,7 +42,8 @@ "display_apps": [], "metadata": [ {} - ] + ], + "token": true } }, { @@ -383,6 +384,8 @@ ] } ], - "timestamp": "2024-05-22T17:03:05Z", + "timestamp": "2024-05-31T22:25:40Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.json b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.json index f8abe064ec94b..80cb793a44704 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.json +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -26,7 +26,7 @@ } ], "env": null, - "id": "847150eb-c3b6-497d-9dad-8e62d478cfff", + "id": "5d102462-7646-4aae-bdac-c8b9906fb5b3", "init_script": "", "login_before_ready": true, "metadata": [ @@ -47,7 +47,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "a0c4f2f5-cc40-4731-9028-636033229c9c", + "token": "1d1ccced-ce84-4cbf-a80f-f17a59e948a0", "troubleshooting_url": null }, "sensitive_values": { @@ -71,7 +71,7 @@ "daily_cost": 29, "hide": true, "icon": "/icon/server.svg", - "id": "3feec3a3-6f9e-4cfb-b122-2273e345def0", + "id": "35194a0a-0012-4da3-9e3a-a4d7bdcc9638", "item": [ { "is_null": false, @@ -98,7 +98,7 @@ "value": "squirrel" } ], - "resource_id": "160324296641913729" + "resource_id": "2094194534443319186" }, "sensitive_values": { "item": [ @@ -121,7 +121,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "160324296641913729", + "id": "2094194534443319186", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/rich-parameters-order/rich-parameters-order.tfplan.json b/provisioner/terraform/testdata/rich-parameters-order/rich-parameters-order.tfplan.json index 12a6aaccdd7b7..240c9affe23e0 100644 --- a/provisioner/terraform/testdata/rich-parameters-order/rich-parameters-order.tfplan.json +++ b/provisioner/terraform/testdata/rich-parameters-order/rich-parameters-order.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -118,7 +119,7 @@ ], "prior_state": { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -135,7 +136,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "7fb346d2-b8c2-4f2a-99d1-a8fd54cc479e", + "id": "5f79d935-c5bc-47e4-8152-eed302afc455", "mutable": false, "name": "Example", "option": null, @@ -162,7 +163,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "0581cc2a-9e6d-4f04-93a6-88fcbd0757f0", + "id": "e8af506e-91e7-457a-8e68-f33109f30e6a", "mutable": false, "name": "Sample", "option": null, @@ -268,6 +269,8 @@ ] } }, - "timestamp": "2024-05-22T17:03:11Z", + "timestamp": "2024-05-31T22:25:46Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/rich-parameters-order/rich-parameters-order.tfstate.json b/provisioner/terraform/testdata/rich-parameters-order/rich-parameters-order.tfstate.json index ce08e87bce074..4505699adf299 100644 --- a/provisioner/terraform/testdata/rich-parameters-order/rich-parameters-order.tfstate.json +++ b/provisioner/terraform/testdata/rich-parameters-order/rich-parameters-order.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -17,7 +17,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "5c9f037b-3cc1-4616-b4ba-9e7322856575", + "id": "487e2328-8fa1-472f-a35d-5c017f5a2621", "mutable": false, "name": "Example", "option": null, @@ -44,7 +44,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "71a4bcc8-bbcb-4619-9641-df3bc296f58e", + "id": "c85ec281-458c-4932-a10d-049be7e1b8f8", "mutable": false, "name": "Sample", "option": null, @@ -80,7 +80,7 @@ } ], "env": null, - "id": "327e8ab1-90be-4c87-ac7d-09630ae46827", + "id": "3d98abaf-7a38-450f-9fc9-eaebbebb1f1f", "init_script": "", "login_before_ready": true, "metadata": [], @@ -92,7 +92,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "794a8a86-3bb9-4b3d-bbea-acff8b513964", + "token": "3000e759-60df-4470-8f51-50ea4bc6a1ad", "troubleshooting_url": null }, "sensitive_values": { @@ -111,7 +111,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "3735840255017039964", + "id": "4580074114866058503", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/rich-parameters-validation/rich-parameters-validation.tfplan.json b/provisioner/terraform/testdata/rich-parameters-validation/rich-parameters-validation.tfplan.json index d4f402ce40102..0535ccd50bb59 100644 --- a/provisioner/terraform/testdata/rich-parameters-validation/rich-parameters-validation.tfplan.json +++ b/provisioner/terraform/testdata/rich-parameters-validation/rich-parameters-validation.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -118,7 +119,7 @@ ], "prior_state": { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -135,7 +136,7 @@ "display_name": null, "ephemeral": true, "icon": null, - "id": "1e85f9f5-54c2-4a6b-ba7f-8627386b94b7", + "id": "c2d5292e-1dea-434b-b5cc-dc288c2a512b", "mutable": true, "name": "number_example", "option": null, @@ -162,7 +163,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "9908f4c5-87f5-496c-9479-d0f7d49f0fdf", + "id": "689418c1-935c-40ad-aa9f-37ab4f8d9501", "mutable": false, "name": "number_example_max", "option": null, @@ -201,7 +202,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "3f2d0054-0440-4a00-98f6-befa9475a5f4", + "id": "bc7db79f-d6ef-45a2-9bbf-50710eb1db8c", "mutable": false, "name": "number_example_max_zero", "option": null, @@ -240,7 +241,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "29abca17-5bd3-4ae3-9bd3-1e45301fc509", + "id": "5e88eade-4255-4693-86bf-2c0331ca2a06", "mutable": false, "name": "number_example_min", "option": null, @@ -279,7 +280,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "95630cc0-8040-4126-92bb-967dbf8eb2ed", + "id": "26c34bb9-535d-45d7-bebd-1dcb2300f242", "mutable": false, "name": "number_example_min_max", "option": null, @@ -318,7 +319,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "c256c60a-fdfe-42f1-bbaa-27880816a7bf", + "id": "3b55387f-0117-4d34-b585-14959f4a9267", "mutable": false, "name": "number_example_min_zero", "option": null, @@ -550,6 +551,8 @@ ] } }, - "timestamp": "2024-05-22T17:03:12Z", + "timestamp": "2024-05-31T22:25:48Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/rich-parameters-validation/rich-parameters-validation.tfstate.json b/provisioner/terraform/testdata/rich-parameters-validation/rich-parameters-validation.tfstate.json index a09880e54e903..e8415b0959bfa 100644 --- a/provisioner/terraform/testdata/rich-parameters-validation/rich-parameters-validation.tfstate.json +++ b/provisioner/terraform/testdata/rich-parameters-validation/rich-parameters-validation.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -17,7 +17,7 @@ "display_name": null, "ephemeral": true, "icon": null, - "id": "f7cabe8c-f091-4ced-bc9b-873f54edf61b", + "id": "1f836366-337f-47a9-bc49-f4810b2f1078", "mutable": true, "name": "number_example", "option": null, @@ -44,7 +44,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "13b33312-d49b-4df3-af89-5d6ec840a6e4", + "id": "d58e721b-0134-42b6-b4b9-bb012f43a439", "mutable": false, "name": "number_example_max", "option": null, @@ -83,7 +83,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "d5ff002b-d039-42e6-b638-6bc2e3d54c2b", + "id": "4c3ff771-15ab-4a33-8067-45d5d44a5f7e", "mutable": false, "name": "number_example_max_zero", "option": null, @@ -122,7 +122,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "f382fcba-2634-44e7-ab26-866228d0679a", + "id": "11f8f368-f829-403a-8ad9-3a10df1db0bf", "mutable": false, "name": "number_example_min", "option": null, @@ -161,7 +161,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "7f1c3032-1ed9-4602-80f8-cc84489bafc9", + "id": "9de03421-e747-4084-b808-90464beb8ab4", "mutable": false, "name": "number_example_min_max", "option": null, @@ -200,7 +200,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "c474219f-f1e7-4eca-921a-1ace9a8391ee", + "id": "eb75256a-66d6-45d6-a0f5-331a885742e4", "mutable": false, "name": "number_example_min_zero", "option": null, @@ -248,7 +248,7 @@ } ], "env": null, - "id": "138f6db3-bd8d-4a9a-8e61-abc1fdf3c3af", + "id": "e6810890-032b-4a01-9562-b9a8428dcc97", "init_script": "", "login_before_ready": true, "metadata": [], @@ -260,7 +260,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "1ef5dec0-3339-4e24-b781-0166cc6a9820", + "token": "c162e35d-a066-472c-a469-91d6b116fa6f", "troubleshooting_url": null }, "sensitive_values": { @@ -279,7 +279,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "5975950266738511043", + "id": "8464994280406150541", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfplan.json b/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfplan.json index a881255a41e12..393acb59fe5a2 100644 --- a/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfplan.json +++ b/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.2", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "planned_values": { "root_module": { "resources": [ @@ -31,7 +31,8 @@ }, "sensitive_values": { "display_apps": [], - "metadata": [] + "metadata": [], + "token": true } }, { @@ -118,7 +119,7 @@ ], "prior_state": { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -135,7 +136,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "2be3cd75-c44b-482e-8f78-679067d8e0a4", + "id": "e5891365-ddf0-417c-a5d7-9ae7cdc76754", "mutable": false, "name": "Example", "option": [ @@ -179,7 +180,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "5a2f0407-8f11-4ac8-980d-75f919959f08", + "id": "b95cd221-cdca-4d6e-98d0-e4fb6d90dc32", "mutable": false, "name": "number_example", "option": null, @@ -206,7 +207,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "cf4b28cf-ec3c-4f53-ae27-4733a9f7d71a", + "id": "e1e5bce0-ea22-401d-8253-1b9175077abc", "mutable": false, "name": "number_example_max_zero", "option": null, @@ -245,7 +246,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "70d63380-2020-4377-ae05-cecb12c0d709", + "id": "26a6eaca-c9ae-4130-a734-6c290637b250", "mutable": false, "name": "number_example_min_max", "option": null, @@ -284,7 +285,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "ec5827c2-2511-4f16-bd85-6249517c9e5b", + "id": "ad985f1d-21fe-4ce1-988d-903084016cb4", "mutable": false, "name": "number_example_min_zero", "option": null, @@ -323,7 +324,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "eec8845e-4316-450a-a5b7-eaa9567f469a", + "id": "9465cc3a-703a-4218-8fa4-d16a1631e648", "mutable": false, "name": "Sample", "option": null, @@ -354,7 +355,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "3b860d24-85ac-4540-b309-9321e732dfc4", + "id": "547f8420-0630-4c4d-9507-e2d63640d0d9", "mutable": true, "name": "First parameter from module", "option": null, @@ -381,7 +382,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "b36105e3-9bf1-43c7-a857-078ef1e8f95d", + "id": "5c32dcad-d54a-474f-97f0-fbcc8aaba9bd", "mutable": true, "name": "Second parameter from module", "option": null, @@ -413,7 +414,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "a2bee9f2-8a3c-404c-839b-01b6cd840707", + "id": "2362ba5e-0779-472c-bd3c-22446fd14075", "mutable": true, "name": "First parameter from child module", "option": null, @@ -440,7 +441,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "deb13c45-ed6d-45b6-b6eb-d319143fa8f2", + "id": "0a8f6df4-364f-4d5f-b935-7dee8c568e10", "mutable": true, "name": "Second parameter from child module", "option": null, @@ -793,6 +794,8 @@ } } }, - "timestamp": "2024-05-22T17:03:08Z", + "timestamp": "2024-05-31T22:25:44Z", + "applyable": true, + "complete": true, "errored": false } diff --git a/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfstate.json b/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfstate.json index a82bb9ea1925c..eeec6ba4ea9c9 100644 --- a/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfstate.json +++ b/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.7.5", + "terraform_version": "1.8.4", "values": { "root_module": { "resources": [ @@ -17,7 +17,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "7fa1e2f7-36a4-49cd-b92a-b3fc8732d359", + "id": "9f041124-ccf3-4b7b-9e0d-4d37335a6f98", "mutable": false, "name": "Example", "option": [ @@ -61,7 +61,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "86a60580-7221-4bab-b229-9cb61bdb56a0", + "id": "ab5035e4-8dab-453d-92bc-9b866af26c78", "mutable": false, "name": "number_example", "option": null, @@ -88,7 +88,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "ed6bc6e5-b4ff-48b9-88b0-df5faa74ae66", + "id": "bdf84ab6-1029-4645-a2df-cd897f30c145", "mutable": false, "name": "number_example_max_zero", "option": null, @@ -127,7 +127,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "340b19e1-f651-4321-96b1-7908c2c66914", + "id": "b283766e-7e58-459d-a81f-aa71a95bbc0b", "mutable": false, "name": "number_example_min_max", "option": null, @@ -166,7 +166,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "f19c6763-2e55-40dd-9b49-82e9181e5b1b", + "id": "7a4f8f6d-d81a-4b15-9d5b-6f221f2a6b07", "mutable": false, "name": "number_example_min_zero", "option": null, @@ -205,7 +205,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "02169810-8080-4dc6-a656-5fbda745659e", + "id": "fd12f0d0-87dc-4d88-bcdc-352c11bd2144", "mutable": false, "name": "Sample", "option": null, @@ -241,7 +241,7 @@ } ], "env": null, - "id": "42edc650-ddb6-4ed9-9624-7788d60d1507", + "id": "a20d4cf7-2d49-4ab8-8858-a9e1531e7033", "init_script": "", "login_before_ready": true, "metadata": [], @@ -253,7 +253,7 @@ "startup_script": null, "startup_script_behavior": null, "startup_script_timeout": 300, - "token": "c767a648-e670-4c6b-a28b-8559033e92a7", + "token": "0d8692b3-746f-4f2e-b0cc-7952ee240ba4", "troubleshooting_url": null }, "sensitive_values": { @@ -272,7 +272,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "7506678111935039701", + "id": "9033341587141190203", "triggers": null }, "sensitive_values": {}, @@ -297,7 +297,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "11b1ae03-cf81-4f60-9be1-bd4c0586516d", + "id": "6be6ebff-574c-4ab6-b314-a65f4f20446e", "mutable": true, "name": "First parameter from module", "option": null, @@ -324,7 +324,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "79d87261-bfda-46ee-958d-7d62252101ad", + "id": "d7e3d42e-dc51-47f2-ae5f-1b1bdaa85e25", "mutable": true, "name": "Second parameter from module", "option": null, @@ -356,7 +356,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "30c4c518-116a-4591-a571-886101cfcdfa", + "id": "69f71896-5cc4-44d0-ae7a-b7a5514a07ae", "mutable": true, "name": "First parameter from child module", "option": null, @@ -383,7 +383,7 @@ "display_name": null, "ephemeral": false, "icon": null, - "id": "4c7d9f15-da45-453e-85eb-1d22c9baa54c", + "id": "9a2b177e-8f3c-4d6b-b302-3ba2f0e6c76b", "mutable": true, "name": "Second parameter from child module", "option": null, diff --git a/provisioner/terraform/testdata/version.txt b/provisioner/terraform/testdata/version.txt index 6a126f402d53d..bfa363e76ed71 100644 --- a/provisioner/terraform/testdata/version.txt +++ b/provisioner/terraform/testdata/version.txt @@ -1 +1 @@ -1.7.5 +1.8.4 diff --git a/scripts/Dockerfile.base b/scripts/Dockerfile.base index df6cb4637a366..1099e52e01a48 100644 --- a/scripts/Dockerfile.base +++ b/scripts/Dockerfile.base @@ -26,7 +26,7 @@ RUN apk add --no-cache \ # Terraform was disabled in the edge repo due to a build issue. # https://gitlab.alpinelinux.org/alpine/aports/-/commit/f3e263d94cfac02d594bef83790c280e045eba35 # Using wget for now. Note that busybox unzip doesn't support streaming. -RUN ARCH="$(arch)"; if [ "${ARCH}" == "x86_64" ]; then ARCH="amd64"; elif [ "${ARCH}" == "aarch64" ]; then ARCH="arm64"; fi; wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_${ARCH}.zip" && \ +RUN ARCH="$(arch)"; if [ "${ARCH}" == "x86_64" ]; then ARCH="amd64"; elif [ "${ARCH}" == "aarch64" ]; then ARCH="arm64"; fi; wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.8.4/terraform_1.8.4_linux_${ARCH}.zip" && \ busybox unzip /tmp/terraform.zip -d /usr/local/bin && \ rm -f /tmp/terraform.zip && \ chmod +x /usr/local/bin/terraform && \ From bf98b0dfe41067e8c68abfe8163eb112b5b6c5d7 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 3 Jun 2024 15:48:31 +0200 Subject: [PATCH 002/168] fix: correct swagger description for Insights API (#13442) --- coderd/apidoc/docs.go | 80 ++++++++++++++++++++++++++++++++------ coderd/apidoc/swagger.json | 77 ++++++++++++++++++++++++++++++------ coderd/insights.go | 17 +++++--- docs/api/insights.md | 49 +++++++++++++++-------- 4 files changed, 177 insertions(+), 46 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a284e46d0a0bb..9b73496b9c749 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1158,6 +1158,15 @@ const docTemplate = `{ ], "summary": "Get deployment DAUs", "operationId": "get-deployment-daus", + "parameters": [ + { + "type": "integer", + "description": "Time-zone offset (e.g. -2)", + "name": "tz_offset", + "in": "query", + "required": true + } + ], "responses": { "200": { "description": "OK", @@ -1185,18 +1194,41 @@ const docTemplate = `{ "operationId": "get-insights-about-templates", "parameters": [ { - "type": "integer", + "type": "string", + "format": "date-time", "description": "Start time", - "name": "before", + "name": "start_time", "in": "query", "required": true }, { - "type": "integer", + "type": "string", + "format": "date-time", "description": "End time", - "name": "after", + "name": "end_time", "in": "query", "required": true + }, + { + "enum": [ + "week", + "day" + ], + "type": "string", + "description": "Interval", + "name": "interval", + "in": "query", + "required": true + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Template IDs", + "name": "template_ids", + "in": "query" } ], "responses": { @@ -1226,18 +1258,30 @@ const docTemplate = `{ "operationId": "get-insights-about-user-activity", "parameters": [ { - "type": "integer", + "type": "string", + "format": "date-time", "description": "Start time", - "name": "before", + "name": "start_time", "in": "query", "required": true }, { - "type": "integer", + "type": "string", + "format": "date-time", "description": "End time", - "name": "after", + "name": "end_time", "in": "query", "required": true + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Template IDs", + "name": "template_ids", + "in": "query" } ], "responses": { @@ -1267,18 +1311,30 @@ const docTemplate = `{ "operationId": "get-insights-about-user-latency", "parameters": [ { - "type": "integer", + "type": "string", + "format": "date-time", "description": "Start time", - "name": "before", + "name": "start_time", "in": "query", "required": true }, { - "type": "integer", + "type": "string", + "format": "date-time", "description": "End time", - "name": "after", + "name": "end_time", "in": "query", "required": true + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Template IDs", + "name": "template_ids", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 28212bdaa8342..de2ae4ffc34ac 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -998,6 +998,15 @@ "tags": ["Insights"], "summary": "Get deployment DAUs", "operationId": "get-deployment-daus", + "parameters": [ + { + "type": "integer", + "description": "Time-zone offset (e.g. -2)", + "name": "tz_offset", + "in": "query", + "required": true + } + ], "responses": { "200": { "description": "OK", @@ -1021,18 +1030,38 @@ "operationId": "get-insights-about-templates", "parameters": [ { - "type": "integer", + "type": "string", + "format": "date-time", "description": "Start time", - "name": "before", + "name": "start_time", "in": "query", "required": true }, { - "type": "integer", + "type": "string", + "format": "date-time", "description": "End time", - "name": "after", + "name": "end_time", "in": "query", "required": true + }, + { + "enum": ["week", "day"], + "type": "string", + "description": "Interval", + "name": "interval", + "in": "query", + "required": true + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Template IDs", + "name": "template_ids", + "in": "query" } ], "responses": { @@ -1058,18 +1087,30 @@ "operationId": "get-insights-about-user-activity", "parameters": [ { - "type": "integer", + "type": "string", + "format": "date-time", "description": "Start time", - "name": "before", + "name": "start_time", "in": "query", "required": true }, { - "type": "integer", + "type": "string", + "format": "date-time", "description": "End time", - "name": "after", + "name": "end_time", "in": "query", "required": true + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Template IDs", + "name": "template_ids", + "in": "query" } ], "responses": { @@ -1095,18 +1136,30 @@ "operationId": "get-insights-about-user-latency", "parameters": [ { - "type": "integer", + "type": "string", + "format": "date-time", "description": "Start time", - "name": "before", + "name": "start_time", "in": "query", "required": true }, { - "type": "integer", + "type": "string", + "format": "date-time", "description": "End time", - "name": "after", + "name": "end_time", "in": "query", "required": true + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Template IDs", + "name": "template_ids", + "in": "query" } ], "responses": { diff --git a/coderd/insights.go b/coderd/insights.go index a54e79a525644..7234a88d44fe9 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -30,6 +30,7 @@ const insightsTimeLayout = time.RFC3339 // @Security CoderSessionToken // @Produce json // @Tags Insights +// @Param tz_offset query int true "Time-zone offset (e.g. -2)" // @Success 200 {object} codersdk.DAUsResponse // @Router /insights/daus [get] func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) { @@ -100,8 +101,9 @@ func (api *API) returnDAUsInternal(rw http.ResponseWriter, r *http.Request, temp // @Security CoderSessionToken // @Produce json // @Tags Insights -// @Param before query int true "Start time" -// @Param after query int true "End time" +// @Param start_time query string true "Start time" format(date-time) +// @Param end_time query string true "End time" format(date-time) +// @Param template_ids query []string false "Template IDs" collectionFormat(csv) // @Success 200 {object} codersdk.UserActivityInsightsResponse // @Router /insights/user-activity [get] func (api *API) insightsUserActivity(rw http.ResponseWriter, r *http.Request) { @@ -202,8 +204,9 @@ func (api *API) insightsUserActivity(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Produce json // @Tags Insights -// @Param before query int true "Start time" -// @Param after query int true "End time" +// @Param start_time query string true "Start time" format(date-time) +// @Param end_time query string true "End time" format(date-time) +// @Param template_ids query []string false "Template IDs" collectionFormat(csv) // @Success 200 {object} codersdk.UserLatencyInsightsResponse // @Router /insights/user-latency [get] func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { @@ -294,8 +297,10 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Produce json // @Tags Insights -// @Param before query int true "Start time" -// @Param after query int true "End time" +// @Param start_time query string true "Start time" format(date-time) +// @Param end_time query string true "End time" format(date-time) +// @Param interval query string true "Interval" enums(week,day) +// @Param template_ids query []string false "Template IDs" collectionFormat(csv) // @Success 200 {object} codersdk.TemplateInsightsResponse // @Router /insights/templates [get] func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { diff --git a/docs/api/insights.md b/docs/api/insights.md index 7dae576b847b8..eb1a7679a6708 100644 --- a/docs/api/insights.md +++ b/docs/api/insights.md @@ -6,13 +6,19 @@ ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/insights/daus \ +curl -X GET http://coder-server:8080/api/v2/insights/daus?tz_offset=0 \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` `GET /insights/daus` +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ----- | ------- | -------- | -------------------------- | +| `tz_offset` | query | integer | true | Time-zone offset (e.g. -2) | + ### Example responses > 200 Response @@ -43,7 +49,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/insights/templates?before=0&after=0 \ +curl -X GET http://coder-server:8080/api/v2/insights/templates?start_time=2019-08-24T14%3A15%3A22Z&end_time=2019-08-24T14%3A15%3A22Z&interval=week \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -52,10 +58,19 @@ curl -X GET http://coder-server:8080/api/v2/insights/templates?before=0&after=0 ### Parameters -| Name | In | Type | Required | Description | -| -------- | ----- | ------- | -------- | ----------- | -| `before` | query | integer | true | Start time | -| `after` | query | integer | true | End time | +| Name | In | Type | Required | Description | +| -------------- | ----- | ----------------- | -------- | ------------ | +| `start_time` | query | string(date-time) | true | Start time | +| `end_time` | query | string(date-time) | true | End time | +| `interval` | query | string | true | Interval | +| `template_ids` | query | array[string] | false | Template IDs | + +#### Enumerated Values + +| Parameter | Value | +| ---------- | ------ | +| `interval` | `week` | +| `interval` | `day` | ### Example responses @@ -129,7 +144,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/insights/user-activity?before=0&after=0 \ +curl -X GET http://coder-server:8080/api/v2/insights/user-activity?start_time=2019-08-24T14%3A15%3A22Z&end_time=2019-08-24T14%3A15%3A22Z \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -138,10 +153,11 @@ curl -X GET http://coder-server:8080/api/v2/insights/user-activity?before=0&afte ### Parameters -| Name | In | Type | Required | Description | -| -------- | ----- | ------- | -------- | ----------- | -| `before` | query | integer | true | Start time | -| `after` | query | integer | true | End time | +| Name | In | Type | Required | Description | +| -------------- | ----- | ----------------- | -------- | ------------ | +| `start_time` | query | string(date-time) | true | Start time | +| `end_time` | query | string(date-time) | true | End time | +| `template_ids` | query | array[string] | false | Template IDs | ### Example responses @@ -180,7 +196,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/insights/user-latency?before=0&after=0 \ +curl -X GET http://coder-server:8080/api/v2/insights/user-latency?start_time=2019-08-24T14%3A15%3A22Z&end_time=2019-08-24T14%3A15%3A22Z \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -189,10 +205,11 @@ curl -X GET http://coder-server:8080/api/v2/insights/user-latency?before=0&after ### Parameters -| Name | In | Type | Required | Description | -| -------- | ----- | ------- | -------- | ----------- | -| `before` | query | integer | true | Start time | -| `after` | query | integer | true | End time | +| Name | In | Type | Required | Description | +| -------------- | ----- | ----------------- | -------- | ------------ | +| `start_time` | query | string(date-time) | true | Start time | +| `end_time` | query | string(date-time) | true | End time | +| `template_ids` | query | array[string] | false | Template IDs | ### Example responses From 24ba81930bfbf2c99c29e9787580dadc5af575c9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 3 Jun 2024 09:33:49 -0500 Subject: [PATCH 003/168] chore: return failed refresh errors on external auth as string (was boolean) (#13402) * chore: return failed refresh errors on external auth Failed refreshes should return errors. These errors are captured as validate errors. --- coderd/externalauth.go | 8 ++-- coderd/externalauth/externalauth.go | 38 ++++++++++++----- coderd/externalauth/externalauth_test.go | 42 ++++++++++--------- .../provisionerdserver/provisionerdserver.go | 9 ++-- coderd/templateversions.go | 13 ++---- coderd/workspaceagents.go | 10 ++--- 6 files changed, 68 insertions(+), 52 deletions(-) diff --git a/coderd/externalauth.go b/coderd/externalauth.go index a2d017ed43e0e..8f8514fa17442 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -351,15 +351,17 @@ func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) { if link.OAuthAccessToken != "" { cfg, ok := configs[link.ProviderID] if ok { - newLink, valid, err := cfg.RefreshToken(ctx, api.Database, link) + newLink, err := cfg.RefreshToken(ctx, api.Database, link) meta := db2sdk.ExternalAuthMeta{ - Authenticated: valid, + Authenticated: err == nil, } if err != nil { meta.ValidateError = err.Error() } + linkMeta[link.ProviderID] = meta + // Update the link if it was potentially refreshed. - if err == nil && valid { + if err == nil { links[i] = newLink } } diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 85e53f2e91f33..4852de3e860ce 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -95,9 +95,23 @@ func (c *Config) GenerateTokenExtra(token *oauth2.Token) (pqtype.NullRawMessage, }, nil } +// InvalidTokenError is a case where the "RefreshToken" failed to complete +// as a result of invalid credentials. Error contains the reason of the failure. +type InvalidTokenError string + +func (e InvalidTokenError) Error() string { + return string(e) +} + +func IsInvalidTokenError(err error) bool { + var invalidTokenError InvalidTokenError + return xerrors.As(err, &invalidTokenError) +} + // RefreshToken automatically refreshes the token if expired and permitted. -// It returns the token and a bool indicating if the token is valid. -func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, bool, error) { +// If an error is returned, the token is either invalid, or an error occurred. +// Use 'IsInvalidTokenError(err)' to determine the difference. +func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, error) { // If the token is expired and refresh is disabled, we prompt // the user to authenticate again. if c.NoRefresh && @@ -105,7 +119,7 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu // This is true for github, which has no expiry. !externalAuthLink.OAuthExpiry.IsZero() && externalAuthLink.OAuthExpiry.Before(dbtime.Now()) { - return externalAuthLink, false, nil + return externalAuthLink, InvalidTokenError("token expired, refreshing is disabled") } // This is additional defensive programming. Because TokenSource is an interface, @@ -123,14 +137,16 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu Expiry: externalAuthLink.OAuthExpiry, }).Token() if err != nil { - // Even if the token fails to be obtained, we still return false because - // we aren't trying to surface an error, we're just trying to obtain a valid token. - return externalAuthLink, false, nil + // Even if the token fails to be obtained, do not return the error as an error. + // TokenSource(...).Token() will always return the current token if the token is not expired. + // If it is expired, it will attempt to refresh the token, and if it cannot, it will fail with + // an error. This error is a reason the token is invalid. + return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error())) } extra, err := c.GenerateTokenExtra(token) if err != nil { - return externalAuthLink, false, xerrors.Errorf("generate token extra: %w", err) + return externalAuthLink, xerrors.Errorf("generate token extra: %w", err) } r := retry.New(50*time.Millisecond, 200*time.Millisecond) @@ -140,7 +156,7 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu validate: valid, _, err := c.ValidateToken(ctx, token) if err != nil { - return externalAuthLink, false, xerrors.Errorf("validate external auth token: %w", err) + return externalAuthLink, xerrors.Errorf("validate external auth token: %w", err) } if !valid { // A customer using GitHub in Australia reported that validating immediately @@ -154,7 +170,7 @@ validate: goto validate } // The token is no longer valid! - return externalAuthLink, false, nil + return externalAuthLink, InvalidTokenError("token failed to validate") } if token.AccessToken != externalAuthLink.OAuthAccessToken { @@ -170,11 +186,11 @@ validate: OAuthExtra: extra, }) if err != nil { - return updatedAuthLink, false, xerrors.Errorf("update external auth link: %w", err) + return updatedAuthLink, xerrors.Errorf("update external auth link: %w", err) } externalAuthLink = updatedAuthLink } - return externalAuthLink, true, nil + return externalAuthLink, nil } // ValidateToken ensures the Git token provided is valid! diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index 88f3b7a3b59e9..fbc1cab4b7091 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -59,9 +59,10 @@ func TestRefreshToken(t *testing.T) { // Expire the link link.OAuthExpiry = expired - _, refreshed, err := config.RefreshToken(ctx, nil, link) - require.NoError(t, err) - require.False(t, refreshed) + _, err := config.RefreshToken(ctx, nil, link) + require.Error(t, err) + require.True(t, externalauth.IsInvalidTokenError(err)) + require.Contains(t, err.Error(), "refreshing is disabled") }) // NoRefreshNoExpiry tests that an oauth token without an expiry is always valid. @@ -90,9 +91,8 @@ func TestRefreshToken(t *testing.T) { // Zero time used link.OAuthExpiry = time.Time{} - _, refreshed, err := config.RefreshToken(ctx, nil, link) + _, err := config.RefreshToken(ctx, nil, link) require.NoError(t, err) - require.True(t, refreshed, "token without expiry is always valid") require.True(t, validated, "token should have been validated") }) @@ -105,11 +105,12 @@ func TestRefreshToken(t *testing.T) { }, }, } - _, refreshed, err := config.RefreshToken(context.Background(), nil, database.ExternalAuthLink{ + _, err := config.RefreshToken(context.Background(), nil, database.ExternalAuthLink{ OAuthExpiry: expired, }) - require.NoError(t, err) - require.False(t, refreshed) + require.Error(t, err) + require.True(t, externalauth.IsInvalidTokenError(err)) + require.Contains(t, err.Error(), "failure") }) t.Run("ValidateServerError", func(t *testing.T) { @@ -131,8 +132,12 @@ func TestRefreshToken(t *testing.T) { ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil)) link.OAuthExpiry = expired - _, _, err := config.RefreshToken(ctx, nil, link) + _, err := config.RefreshToken(ctx, nil, link) require.ErrorContains(t, err, staticError) + // Unsure if this should be the correct behavior. It's an invalid token because + // 'ValidateToken()' failed with a runtime error. This was the previous behavior, + // so not going to change it. + require.False(t, externalauth.IsInvalidTokenError(err)) require.True(t, validated, "token should have been attempted to be validated") }) @@ -156,9 +161,9 @@ func TestRefreshToken(t *testing.T) { ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil)) link.OAuthExpiry = expired - _, refreshed, err := config.RefreshToken(ctx, nil, link) - require.NoError(t, err, staticError) - require.False(t, refreshed) + _, err := config.RefreshToken(ctx, nil, link) + require.ErrorContains(t, err, "token failed to validate") + require.True(t, externalauth.IsInvalidTokenError(err)) require.True(t, validated, "token should have been attempted to be validated") }) @@ -191,9 +196,8 @@ func TestRefreshToken(t *testing.T) { // Unlimited lifetime, this is what GitHub returns tokens as link.OAuthExpiry = time.Time{} - _, ok, err := config.RefreshToken(ctx, nil, link) + _, err := config.RefreshToken(ctx, nil, link) require.NoError(t, err) - require.True(t, ok) require.Equal(t, 2, validateCalls, "token should have been attempted to be validated more than once") }) @@ -219,9 +223,8 @@ func TestRefreshToken(t *testing.T) { ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil)) - _, ok, err := config.RefreshToken(ctx, nil, link) + _, err := config.RefreshToken(ctx, nil, link) require.NoError(t, err) - require.True(t, ok) require.Equal(t, 1, validateCalls, "token is validated") }) @@ -253,9 +256,8 @@ func TestRefreshToken(t *testing.T) { // Force a refresh link.OAuthExpiry = expired - updated, ok, err := config.RefreshToken(ctx, db, link) + updated, err := config.RefreshToken(ctx, db, link) require.NoError(t, err) - require.True(t, ok) require.Equal(t, 1, validateCalls, "token is validated") require.Equal(t, 1, refreshCalls, "token is refreshed") require.NotEqualf(t, link.OAuthAccessToken, updated.OAuthAccessToken, "token is updated") @@ -292,9 +294,9 @@ func TestRefreshToken(t *testing.T) { // Force a refresh link.OAuthExpiry = expired - updated, ok, err := config.RefreshToken(ctx, db, link) + updated, err := config.RefreshToken(ctx, db, link) require.NoError(t, err) - require.True(t, ok) + require.True(t, updated.OAuthExtra.Valid) extra := map[string]interface{}{} require.NoError(t, json.Unmarshal(updated.OAuthExtra.RawMessage, &extra)) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 3f5876d644617..8c8a3f64beed1 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -559,16 +559,17 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo continue } - link, valid, err := config.RefreshToken(ctx, s.Database, link) - if err != nil { + refreshed, err := config.RefreshToken(ctx, s.Database, link) + if err != nil && !externalauth.IsInvalidTokenError(err) { return nil, failJob(fmt.Sprintf("refresh external auth link %q: %s", p.ID, err)) } - if !valid { + if err != nil { + // Invalid tokens are skipped continue } externalAuthProviders = append(externalAuthProviders, &sdkproto.ExternalAuthProvider{ Id: p.ID, - AccessToken: link.OAuthAccessToken, + AccessToken: refreshed.OAuthAccessToken, }) } diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 788a01ba353b1..1c9131ef0d17c 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -353,21 +353,16 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ return } - _, updated, err := config.RefreshToken(ctx, api.Database, authLink) - if err != nil { + _, err = config.RefreshToken(ctx, api.Database, authLink) + if err != nil && !externalauth.IsInvalidTokenError(err) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to refresh external auth token.", Detail: err.Error(), }) return } - // If the token couldn't be validated, then we assume the user isn't - // authenticated and return early. - if !updated { - providers = append(providers, provider) - continue - } - provider.Authenticated = true + + provider.Authenticated = err == nil providers = append(providers, provider) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 1821948572e29..753e3aeaa0639 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1912,25 +1912,25 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ return } - externalAuthLink, valid, err := externalAuthConfig.RefreshToken(ctx, api.Database, externalAuthLink) - if err != nil { + refreshedLink, err := externalAuthConfig.RefreshToken(ctx, api.Database, externalAuthLink) + if err != nil && !externalauth.IsInvalidTokenError(err) { handleRetrying(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to refresh external auth token.", Detail: err.Error(), }) return } - if !valid { + if err != nil { // Set the previous token so the retry logic will skip validating the // same token again. This should only be set if the token is invalid and there // was no error. If it is invalid because of an error, then we should recheck. - previousToken = &externalAuthLink + previousToken = &refreshedLink handleRetrying(http.StatusOK, agentsdk.ExternalAuthResponse{ URL: redirectURL.String(), }) return } - resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra) + resp, err := createExternalAuthResponse(externalAuthConfig.Type, refreshedLink.OAuthAccessToken, refreshedLink.OAuthExtra) if err != nil { handleRetrying(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to create external auth response.", From 973cc2b875631c156e5c857b96044b796c0bc86a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 3 Jun 2024 09:34:10 -0500 Subject: [PATCH 004/168] chore: add edit organization role to cli (#13365) Editing custom org roles from hidden org cli command. --- cli/cliui/parameter.go | 5 +- cli/cliui/select.go | 17 +- cli/cliui/select_test.go | 5 +- cli/organizationroles.go | 317 ++++++++++++++++++++++++++-- coderd/util/slice/slice.go | 9 + codersdk/rbacresources_gen.go | 30 +++ enterprise/cli/organization_test.go | 112 ++++++++++ scripts/rbacgen/codersdk.gotmpl | 12 ++ 8 files changed, 484 insertions(+), 23 deletions(-) create mode 100644 enterprise/cli/organization_test.go diff --git a/cli/cliui/parameter.go b/cli/cliui/parameter.go index 897ddec4de4d6..8080ef1a96906 100644 --- a/cli/cliui/parameter.go +++ b/cli/cliui/parameter.go @@ -43,7 +43,10 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te return "", err } - values, err := MultiSelect(inv, options) + values, err := MultiSelect(inv, MultiSelectOptions{ + Options: options, + Defaults: options, + }) if err == nil { v, err := json.Marshal(&values) if err != nil { diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 3ae27ee811e50..a67ad42bcfeda 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -56,6 +56,7 @@ type SelectOptions struct { Options []string // Default will be highlighted first if it's a valid option. Default string + Message string Size int HideSearch bool } @@ -122,6 +123,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { Options: opts.Options, Default: defaultOption, PageSize: opts.Size, + Message: opts.Message, }, &value, survey.WithIcons(func(is *survey.IconSet) { is.Help.Text = "Type to search" if opts.HideSearch { @@ -138,15 +140,22 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { return value, err } -func MultiSelect(inv *serpent.Invocation, items []string) ([]string, error) { +type MultiSelectOptions struct { + Message string + Options []string + Defaults []string +} + +func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) { // Similar hack is applied to Select() if flag.Lookup("test.v") != nil { - return items, nil + return opts.Defaults, nil } prompt := &survey.MultiSelect{ - Options: items, - Default: items, + Options: opts.Options, + Default: opts.Defaults, + Message: opts.Message, } var values []string diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index c399121adb6ec..c0da49714fc40 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -107,7 +107,10 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { var values []string cmd := &serpent.Command{ Handler: func(inv *serpent.Invocation) error { - selectedItems, err := cliui.MultiSelect(inv, items) + selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ + Options: items, + Defaults: items, + }) if err == nil { values = selectedItems } diff --git a/cli/organizationroles.go b/cli/organizationroles.go index 91d1b20f54dd4..8cf557eecf9c3 100644 --- a/cli/organizationroles.go +++ b/cli/organizationroles.go @@ -1,13 +1,17 @@ package cli import ( + "encoding/json" "fmt" + "io" "slices" "strings" + "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -23,6 +27,7 @@ func (r *RootCmd) organizationRoles() *serpent.Command { Hidden: true, Children: []*serpent.Command{ r.showOrganizationRoles(), + r.editOrganizationRole(), }, } return cmd @@ -31,25 +36,19 @@ func (r *RootCmd) organizationRoles() *serpent.Command { func (r *RootCmd) showOrganizationRoles() *serpent.Command { formatter := cliui.NewOutputFormatter( cliui.ChangeFormatterData( - cliui.TableFormat([]assignableRolesTableRow{}, []string{"name", "display_name", "built_in", "site_permissions", "org_permissions", "user_permissions"}), + cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "org_permissions", "user_permissions"}), func(data any) (any, error) { - input, ok := data.([]codersdk.AssignableRoles) + inputs, ok := data.([]codersdk.AssignableRoles) if !ok { return nil, xerrors.Errorf("expected []codersdk.AssignableRoles got %T", data) } - rows := make([]assignableRolesTableRow, 0, len(input)) - for _, role := range input { - rows = append(rows, assignableRolesTableRow{ - Name: role.Name, - DisplayName: role.DisplayName, - SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)), - OrganizationPermissions: fmt.Sprintf("%d organizations", len(role.OrganizationPermissions)), - UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)), - Assignable: role.Assignable, - BuiltIn: role.BuiltIn, - }) + + tableRows := make([]roleTableRow, 0) + for _, input := range inputs { + tableRows = append(tableRows, roleToTableView(input.Role)) } - return rows, nil + + return tableRows, nil }, ), cliui.JSONFormat(), @@ -101,13 +100,297 @@ func (r *RootCmd) showOrganizationRoles() *serpent.Command { return cmd } -type assignableRolesTableRow struct { +func (r *RootCmd) editOrganizationRole() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "org_permissions", "user_permissions"}), + func(data any) (any, error) { + typed, _ := data.(codersdk.Role) + return []roleTableRow{roleToTableView(typed)}, nil + }, + ), + cliui.JSONFormat(), + ) + + var ( + dryRun bool + jsonInput bool + ) + + client := new(codersdk.Client) + cmd := &serpent.Command{ + Use: "edit ", + Short: "Edit an organization custom role", + Long: FormatExamples( + Example{ + Description: "Run with an input.json file", + Command: "coder roles edit --stdin < role.json", + }, + ), + Options: []serpent.Option{ + cliui.SkipPromptOption(), + { + Name: "dry-run", + Description: "Does all the work, but does not submit the final updated role.", + Flag: "dry-run", + Value: serpent.BoolOf(&dryRun), + }, + { + Name: "stdin", + Description: "Reads stdin for the json role definition to upload.", + Flag: "stdin", + Value: serpent.BoolOf(&jsonInput), + }, + }, + Middleware: serpent.Chain( + serpent.RequireRangeArgs(0, 1), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + org, err := CurrentOrganization(r, inv, client) + if err != nil { + return err + } + + var customRole codersdk.Role + if jsonInput { + // JSON Upload mode + bytes, err := io.ReadAll(inv.Stdin) + if err != nil { + return xerrors.Errorf("reading stdin: %w", err) + } + + err = json.Unmarshal(bytes, &customRole) + if err != nil { + return xerrors.Errorf("parsing stdin json: %w", err) + } + + if customRole.Name == "" { + arr := make([]json.RawMessage, 0) + err = json.Unmarshal(bytes, &arr) + if err == nil && len(arr) > 0 { + return xerrors.Errorf("the input appears to be an array, only 1 role can be sent at a time") + } + return xerrors.Errorf("json input does not appear to be a valid role") + } + } else { + if len(inv.Args) == 0 { + return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit \"") + } + + interactiveRole, err := interactiveOrgRoleEdit(inv, org.ID, client) + if err != nil { + return xerrors.Errorf("editing role: %w", err) + } + + customRole = *interactiveRole + + preview := fmt.Sprintf("permissions: %d site, %d org, %d user", + len(customRole.SitePermissions), len(customRole.OrganizationPermissions), len(customRole.UserPermissions)) + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Are you sure you wish to update the role? " + preview, + Default: "yes", + IsConfirm: true, + }) + if err != nil { + return xerrors.Errorf("abort: %w", err) + } + } + + var updated codersdk.Role + if dryRun { + // Do not actually post + updated = customRole + } else { + updated, err = client.PatchOrganizationRole(ctx, org.ID, customRole) + if err != nil { + return fmt.Errorf("patch role: %w", err) + } + } + + output, err := formatter.Format(ctx, updated) + if err != nil { + return xerrors.Errorf("formatting: %w", err) + } + + _, err = fmt.Fprintln(inv.Stdout, output) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, client *codersdk.Client) (*codersdk.Role, error) { + ctx := inv.Context() + roles, err := client.ListOrganizationRoles(ctx, orgID) + if err != nil { + return nil, xerrors.Errorf("listing roles: %w", err) + } + + // Make sure the role actually exists first + var originalRole codersdk.AssignableRoles + for _, r := range roles { + if strings.EqualFold(inv.Args[0], r.Name) { + originalRole = r + break + } + } + + if originalRole.Name == "" { + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: "No organization role exists with that name, do you want to create one?", + Default: "yes", + IsConfirm: true, + }) + if err != nil { + return nil, xerrors.Errorf("abort: %w", err) + } + + originalRole.Role = codersdk.Role{ + Name: inv.Args[0], + OrganizationID: orgID.String(), + } + } + + // Some checks since interactive mode is limited in what it currently sees + if len(originalRole.SitePermissions) > 0 { + return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions") + } + + if len(originalRole.UserPermissions) > 0 { + return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions") + } + + role := &originalRole.Role + allowedResources := []codersdk.RBACResource{ + codersdk.ResourceTemplate, + codersdk.ResourceWorkspace, + codersdk.ResourceUser, + codersdk.ResourceGroup, + } + + const done = "Finish and submit changes" + const abort = "Cancel changes" + + // Now starts the role editing "game". +customRoleLoop: + for { + selected, err := cliui.Select(inv, cliui.SelectOptions{ + Message: "Select which resources to edit permissions", + Options: append(permissionPreviews(role, allowedResources), done, abort), + }) + if err != nil { + return role, xerrors.Errorf("selecting resource: %w", err) + } + switch selected { + case done: + break customRoleLoop + case abort: + return role, xerrors.Errorf("edit role %q aborted", role.Name) + default: + strs := strings.Split(selected, "::") + resource := strings.TrimSpace(strs[0]) + + actions, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ + Message: fmt.Sprintf("Select actions to allow across the whole deployment for resources=%q", resource), + Options: slice.ToStrings(codersdk.RBACResourceActions[codersdk.RBACResource(resource)]), + Defaults: defaultActions(role, resource), + }) + if err != nil { + return role, xerrors.Errorf("selecting actions for resource %q: %w", resource, err) + } + applyOrgResourceActions(role, resource, actions) + // back to resources! + } + } + // This println is required because the prompt ends us on the same line as some text. + _, _ = fmt.Println() + + return role, nil +} + +func applyOrgResourceActions(role *codersdk.Role, resource string, actions []string) { + if role.OrganizationPermissions == nil { + role.OrganizationPermissions = make([]codersdk.Permission, 0) + } + + // Construct new site perms with only new perms for the resource + keep := make([]codersdk.Permission, 0) + for _, perm := range role.OrganizationPermissions { + perm := perm + if string(perm.ResourceType) != resource { + keep = append(keep, perm) + } + } + + // Add new perms + for _, action := range actions { + keep = append(keep, codersdk.Permission{ + Negate: false, + ResourceType: codersdk.RBACResource(resource), + Action: codersdk.RBACAction(action), + }) + } + + role.OrganizationPermissions = keep +} + +func defaultActions(role *codersdk.Role, resource string) []string { + if role.OrganizationPermissions == nil { + role.OrganizationPermissions = []codersdk.Permission{} + } + + defaults := make([]string, 0) + for _, perm := range role.OrganizationPermissions { + if string(perm.ResourceType) == resource { + defaults = append(defaults, string(perm.Action)) + } + } + return defaults +} + +func permissionPreviews(role *codersdk.Role, resources []codersdk.RBACResource) []string { + previews := make([]string, 0, len(resources)) + for _, resource := range resources { + previews = append(previews, permissionPreview(role, resource)) + } + return previews +} + +func permissionPreview(role *codersdk.Role, resource codersdk.RBACResource) string { + if role.OrganizationPermissions == nil { + role.OrganizationPermissions = []codersdk.Permission{} + } + + count := 0 + for _, perm := range role.OrganizationPermissions { + if perm.ResourceType == resource { + count++ + } + } + return fmt.Sprintf("%s :: %d permissions", resource, count) +} + +func roleToTableView(role codersdk.Role) roleTableRow { + return roleTableRow{ + Name: role.Name, + DisplayName: role.DisplayName, + OrganizationID: role.OrganizationID, + SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)), + OrganizationPermissions: fmt.Sprintf("%d permissions", len(role.OrganizationPermissions)), + UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)), + } +} + +type roleTableRow struct { Name string `table:"name,default_sort"` DisplayName string `table:"display_name"` + OrganizationID string `table:"organization_id"` SitePermissions string ` table:"site_permissions"` // map[] -> Permissions OrganizationPermissions string `table:"org_permissions"` UserPermissions string `table:"user_permissions"` - Assignable bool `table:"assignable"` - BuiltIn bool `table:"built_in"` } diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index f06930f373557..9bb1da930ff45 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -4,6 +4,15 @@ import ( "golang.org/x/exp/constraints" ) +// ToStrings works for any type where the base type is a string. +func ToStrings[T ~string](a []T) []string { + tmp := make([]string, 0, len(a)) + for _, v := range a { + tmp = append(tmp, string(v)) + } + return tmp +} + // Omit creates a new slice with the arguments omitted from the list. func Omit[T comparable](a []T, omits ...T) []T { tmp := make([]T, 0, len(a)) diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 9c7d9cc485128..42db5449c29f4 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -48,3 +48,33 @@ const ( ActionWorkspaceStart RBACAction = "start" ActionWorkspaceStop RBACAction = "stop" ) + +// RBACResourceActions is the mapping of resources to which actions are valid for +// said resource type. +var RBACResourceActions = map[RBACResource][]RBACAction{ + ResourceWildcard: []RBACAction{}, + ResourceApiKey: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceAssignOrgRole: []RBACAction{ActionAssign, ActionDelete, ActionRead}, + ResourceAssignRole: []RBACAction{ActionAssign, ActionCreate, ActionDelete, ActionRead}, + ResourceAuditLog: []RBACAction{ActionCreate, ActionRead}, + ResourceDebugInfo: []RBACAction{ActionRead}, + ResourceDeploymentConfig: []RBACAction{ActionRead, ActionUpdate}, + ResourceDeploymentStats: []RBACAction{ActionRead}, + ResourceFile: []RBACAction{ActionCreate, ActionRead}, + ResourceGroup: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceLicense: []RBACAction{ActionCreate, ActionDelete, ActionRead}, + ResourceOauth2App: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOauth2AppCodeToken: []RBACAction{ActionCreate, ActionDelete, ActionRead}, + ResourceOauth2AppSecret: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOrganization: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOrganizationMember: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceProvisionerDaemon: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceReplicas: []RBACAction{ActionRead}, + ResourceSystem: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceTailnetCoordinator: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceTemplate: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights}, + ResourceUser: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, + ResourceWorkspace: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceDormant: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceProxy: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, +} diff --git a/enterprise/cli/organization_test.go b/enterprise/cli/organization_test.go new file mode 100644 index 0000000000000..51571602d05e5 --- /dev/null +++ b/enterprise/cli/organization_test.go @@ -0,0 +1,112 @@ +package cli_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestEditOrganizationRoles(t *testing.T) { + t.Parallel() + + // Unit test uses --stdin and json as the role input. The interactive cli would + // be hard to drive from a unit test. + t.Run("JSON", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "organization", "roles", "edit", "--stdin") + inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{ + "name": "new-role", + "organization_id": "%s", + "display_name": "", + "site_permissions": [], + "organization_permissions": [ + { + "resource_type": "workspace", + "action": "read" + } + ], + "user_permissions": [], + "assignable": false, + "built_in": false + }`, owner.OrganizationID.String())) + //nolint:gocritic // only owners can edit roles + clitest.SetupConfig(t, client, root) + + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, buf.String(), "new-role") + }) + + t.Run("InvalidRole", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "organization", "roles", "edit", "--stdin") + inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{ + "name": "new-role", + "organization_id": "%s", + "display_name": "", + "site_permissions": [ + { + "resource_type": "workspace", + "action": "read" + } + ], + "organization_permissions": [ + { + "resource_type": "workspace", + "action": "read" + } + ], + "user_permissions": [], + "assignable": false, + "built_in": false + }`, owner.OrganizationID.String())) + //nolint:gocritic // only owners can edit roles + clitest.SetupConfig(t, client, root) + + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "not allowed to assign site wide permissions for an organization role") + }) +} diff --git a/scripts/rbacgen/codersdk.gotmpl b/scripts/rbacgen/codersdk.gotmpl index 1492eaf86c2bf..dff4e165b1df5 100644 --- a/scripts/rbacgen/codersdk.gotmpl +++ b/scripts/rbacgen/codersdk.gotmpl @@ -16,3 +16,15 @@ const ( {{ $element.Enum }} RBACAction = "{{ $element.Value }}" {{- end }} ) + +// RBACResourceActions is the mapping of resources to which actions are valid for +// said resource type. +var RBACResourceActions = map[RBACResource][]RBACAction{ + {{- range $element := . }} + Resource{{ pascalCaseName $element.FunctionName }}: []RBACAction{ + {{- range $actionValue, $_ := $element.Actions }} + {{- actionEnum $actionValue -}}, + {{- end -}} + }, + {{- end }} +} From 79fd736387bd7d1d78153536e47cf393cd30bc87 Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Mon, 3 Jun 2024 10:03:46 -0600 Subject: [PATCH 005/168] chore(site): enable React's `StrictMode` (#13399) --- site/.storybook/preview.jsx | 19 ++++++++++-------- site/src/App.tsx | 20 +++++++++++++------ site/src/index.tsx | 20 +++++++------------ .../UserDropdown/UserDropdownContent.tsx | 3 +-- 4 files changed, 33 insertions(+), 29 deletions(-) diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index 082b7d24b2af0..47fbb281fbb36 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -6,6 +6,7 @@ import { import { ThemeProvider as EmotionThemeProvider } from "@emotion/react"; import { DecoratorHelpers } from "@storybook/addon-themes"; import { withRouter } from "storybook-addon-remix-react-router"; +import { StrictMode } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; import { HelmetProvider } from "react-helmet-async"; import themes from "theme"; @@ -29,14 +30,16 @@ export const decorators = [ const selected = themeOverride || selectedTheme || "dark"; return ( - - - - - - - - + + + + + + + + + + ); }, ]; diff --git a/site/src/App.tsx b/site/src/App.tsx index f2dd6988ec273..c85adcea8493d 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -1,6 +1,12 @@ import "./theme/globalFonts"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { type FC, type ReactNode, useEffect, useState } from "react"; +import { + type FC, + type ReactNode, + StrictMode, + useEffect, + useState, +} from "react"; import { HelmetProvider } from "react-helmet-async"; import { QueryClient, QueryClientProvider } from "react-query"; import { RouterProvider } from "react-router-dom"; @@ -74,10 +80,12 @@ export const AppProviders: FC = ({ export const App: FC = () => { return ( - - - - - + + + + + + + ); }; diff --git a/site/src/index.tsx b/site/src/index.tsx index a6d366d5d59c5..8604ff268655d 100644 --- a/site/src/index.tsx +++ b/site/src/index.tsx @@ -1,22 +1,16 @@ import { createRoot } from "react-dom/client"; import { App } from "./App"; -// This is the entry point for the app - where everything start. -// In the future, we'll likely bring in more bootstrapping logic - -// like: https://github.com/coder/m/blob/50898bd4803df7639bd181e484c74ac5d84da474/product/coder/site/pages/_app.tsx#L32 -const main = () => { - console.info(` ▄█▀ ▀█▄ +console.info(` ▄█▀ ▀█▄ ▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█ ▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀ █▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █ ██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀ `); - const element = document.getElementById("root"); - if (element === null) { - throw new Error("root element is null"); - } - const root = createRoot(element); - root.render(); -}; -main(); +const element = document.getElementById("root"); +if (element === null) { + throw new Error("root element is null"); +} +const root = createRoot(element); +root.render(); diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index 8dc0f23d34f73..631b673b15c0e 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -206,9 +206,8 @@ export const UserDropdownContent: FC = ({ - + Date: Mon, 3 Jun 2024 11:18:44 -0600 Subject: [PATCH 006/168] fix: fix build error background color (#13445) --- site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx index ed62c37ee29f6..4122c278edb52 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx @@ -167,7 +167,7 @@ export const WorkspaceBuildPageView: FC = ({ css={{ borderRadius: 0, border: 0, - background: theme.palette.error.dark, + background: theme.roles.error.background, borderBottom: `1px solid ${theme.palette.divider}`, }} > From 8cdd46810722ca7f82065b0bc8a24b2c52ed1fb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 12:29:29 -0500 Subject: [PATCH 007/168] chore: bump github.com/coder/terraform-provider-coder from 0.22.0 to 0.23.0 (#13440) Bumps [github.com/coder/terraform-provider-coder](https://github.com/coder/terraform-provider-coder) from 0.22.0 to 0.23.0. - [Release notes](https://github.com/coder/terraform-provider-coder/releases) - [Changelog](https://github.com/coder/terraform-provider-coder/blob/main/.goreleaser.yml) - [Commits](https://github.com/coder/terraform-provider-coder/compare/v0.22.0...v0.23.0) --- updated-dependencies: - dependency-name: github.com/coder/terraform-provider-coder dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ac0b1fe109f20..facfce573a1db 100644 --- a/go.mod +++ b/go.mod @@ -88,7 +88,7 @@ require ( github.com/coder/flog v1.1.0 github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 github.com/coder/retry v1.5.1 - github.com/coder/terraform-provider-coder v0.22.0 + github.com/coder/terraform-provider-coder v0.23.0 github.com/coder/wgtunnel v0.1.13-0.20240522110300-ade90dfb2da0 github.com/coreos/go-oidc/v3 v3.10.0 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf diff --git a/go.sum b/go.sum index aca275325bcc1..513314f52b8bb 100644 --- a/go.sum +++ b/go.sum @@ -217,8 +217,8 @@ github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuO github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= github.com/coder/tailscale v1.1.1-0.20240530071520-1ac63d3a4ee3 h1:F2QRxrwPJyMPmX5qU7UpwEenhsk9qDqHyvYFxON1RkI= github.com/coder/tailscale v1.1.1-0.20240530071520-1ac63d3a4ee3/go.mod h1:rp6BIJxCp127/hvvDWNkHC9MxAlKvQfoOtBr8s5sCqo= -github.com/coder/terraform-provider-coder v0.22.0 h1:L72WFa9/6sc/nnXENPS8LpWi/2NBV+DRUW0WT//pEaU= -github.com/coder/terraform-provider-coder v0.22.0/go.mod h1:wMun9UZ9HT2CzF6qPPBup1odzBpVUc0/xSFoXgdI3tk= +github.com/coder/terraform-provider-coder v0.23.0 h1:DuNLWxhnGlXyG0g+OCAZRI6xd8+bJjIEnE4F3hYgA4E= +github.com/coder/terraform-provider-coder v0.23.0/go.mod h1:wMun9UZ9HT2CzF6qPPBup1odzBpVUc0/xSFoXgdI3tk= github.com/coder/wgtunnel v0.1.13-0.20240522110300-ade90dfb2da0 h1:C2/eCr+r0a5Auuw3YOiSyLNHkdMtyCZHPFBx7syN4rk= github.com/coder/wgtunnel v0.1.13-0.20240522110300-ade90dfb2da0/go.mod h1:qANbdpqyAGlo2bg+4gQKPj24H1ZWa3bQU2Q5/bV5B3Y= github.com/coder/wireguard-go v0.0.0-20240522052547-769cdd7f7818 h1:bNhUTaKl3q0bFn78bBRq7iIwo72kNTvUD9Ll5TTzDDk= From 9d00a26a907f19869aae995bd0bf2ec67d83de76 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 3 Jun 2024 12:29:50 -0500 Subject: [PATCH 008/168] fix: add missing route for `codersdk.PostLogSource` (#13421) --- coderd/apidoc/docs.go | 54 ++++++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 48 ++++++++++++++++++++++++++++++ coderd/apikey/apikey_test.go | 2 +- coderd/coderd.go | 1 + coderd/workspaceagents.go | 50 +++++++++++++++++++++++++++++++ coderd/workspaceagents_test.go | 42 ++++++++++++++++++++++++++ coderd/workspaceapps/proxy.go | 2 +- codersdk/agentsdk/agentsdk.go | 4 +-- docs/api/agents.md | 52 ++++++++++++++++++++++++++++++++ docs/api/schemas.md | 18 ++++++++++++ 10 files changed, 269 insertions(+), 4 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9b73496b9c749..c5e2a6041526f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5871,6 +5871,45 @@ const docTemplate = `{ } } }, + "/workspaceagents/me/log-source": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Post workspace agent log source", + "operationId": "post-workspace-agent-log-source", + "parameters": [ + { + "description": "Log source request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PostLogSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLogSource" + } + } + } + } + }, "/workspaceagents/me/logs": { "patch": { "security": [ @@ -8112,6 +8151,21 @@ const docTemplate = `{ } } }, + "agentsdk.PostLogSourceRequest": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for the log source.\nIt is scoped to a workspace agent, and can be statically\ndefined inside code to prevent duplicate sources from being\ncreated for the same agent.", + "type": "string" + } + } + }, "agentsdk.PostMetadataRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index de2ae4ffc34ac..66afad1f041f0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5179,6 +5179,39 @@ } } }, + "/workspaceagents/me/log-source": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Post workspace agent log source", + "operationId": "post-workspace-agent-log-source", + "parameters": [ + { + "description": "Log source request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PostLogSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentLogSource" + } + } + } + } + }, "/workspaceagents/me/logs": { "patch": { "security": [ @@ -7184,6 +7217,21 @@ } } }, + "agentsdk.PostLogSourceRequest": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for the log source.\nIt is scoped to a workspace agent, and can be statically\ndefined inside code to prevent duplicate sources from being\ncreated for the same agent.", + "type": "string" + } + } + }, "agentsdk.PostMetadataRequest": { "type": "object", "properties": { diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index 734a187219bf5..41f64fe0d866f 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -128,7 +128,7 @@ func TestGenerate(t *testing.T) { // Assert that the hashed secret is correct. hashed := sha256.Sum256([]byte(keytokens[1])) - assert.ElementsMatch(t, hashed, key.HashedSecret[:]) + assert.ElementsMatch(t, hashed, key.HashedSecret) assert.Equal(t, tc.params.UserID, key.UserID) assert.WithinDuration(t, dbtime.Now(), key.CreatedAt, time.Second*5) diff --git a/coderd/coderd.go b/coderd/coderd.go index 25763530db702..98b1686171c07 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1021,6 +1021,7 @@ func New(options *Options) *API { r.Post("/report-lifecycle", api.workspaceAgentReportLifecycle) r.Post("/metadata", api.workspaceAgentPostMetadata) r.Post("/metadata/{key}", api.workspaceAgentPostMetadataDeprecated) + r.Post("/log-source", api.workspaceAgentPostLogSource) }) r.Route("/{workspaceagent}", func(r chi.Router) { r.Use( diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 753e3aeaa0639..c45fae8726480 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1084,6 +1084,56 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R } } +// @Summary Post workspace agent log source +// @ID post-workspace-agent-log-source +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Agents +// @Param request body agentsdk.PostLogSourceRequest true "Log source request" +// @Success 200 {object} codersdk.WorkspaceAgentLogSource +// @Router /workspaceagents/me/log-source [post] +func (api *API) workspaceAgentPostLogSource(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var req agentsdk.PostLogSourceRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + workspaceAgent := httpmw.WorkspaceAgent(r) + + sources, err := api.Database.InsertWorkspaceAgentLogSources(ctx, database.InsertWorkspaceAgentLogSourcesParams{ + WorkspaceAgentID: workspaceAgent.ID, + CreatedAt: dbtime.Now(), + ID: []uuid.UUID{req.ID}, + DisplayName: []string{req.DisplayName}, + Icon: []string{req.Icon}, + }) + if err != nil { + if database.IsUniqueViolation(err, "workspace_agent_log_sources_pkey") { + httpapi.Write(ctx, rw, http.StatusCreated, codersdk.WorkspaceAgentLogSource{ + WorkspaceAgentID: workspaceAgent.ID, + CreatedAt: dbtime.Now(), + ID: req.ID, + DisplayName: req.DisplayName, + Icon: req.Icon, + }) + return + } + httpapi.InternalServerError(rw, err) + return + } + + if len(sources) != 1 { + httpapi.InternalServerError(rw, xerrors.Errorf("database should've returned 1 row, got %d", len(sources))) + return + } + + apiSource := convertLogSources(sources)[0] + + httpapi.Write(ctx, rw, http.StatusCreated, apiSource) +} + // convertProvisionedApps converts applications that are in the middle of provisioning process. // It means that they may not have an agent or workspace assigned (dry-run job). func convertProvisionedApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index e99b6a297c103..7052d59144e1b 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -921,6 +921,48 @@ func TestWorkspaceAgentAppHealth(t *testing.T) { require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, manifest.Apps[1].Health) } +func TestWorkspaceAgentPostLogSource(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitShort) + + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(r.AgentToken) + + req := agentsdk.PostLogSourceRequest{ + ID: uuid.New(), + DisplayName: "colin logs", + Icon: "/emojis/1f42e.png", + } + + res, err := agentClient.PostLogSource(ctx, req) + require.NoError(t, err) + assert.Equal(t, req.ID, res.ID) + assert.Equal(t, req.DisplayName, res.DisplayName) + assert.Equal(t, req.Icon, res.Icon) + assert.NotZero(t, res.WorkspaceAgentID) + assert.NotZero(t, res.CreatedAt) + + // should be idempotent + res, err = agentClient.PostLogSource(ctx, req) + require.NoError(t, err) + assert.Equal(t, req.ID, res.ID) + assert.Equal(t, req.DisplayName, res.DisplayName) + assert.Equal(t, req.Icon, res.Icon) + assert.NotZero(t, res.WorkspaceAgentID) + assert.NotZero(t, res.CreatedAt) + }) +} + // TestWorkspaceAgentReportStats tests the legacy (agent API v1) report stats endpoint. func TestWorkspaceAgentReportStats(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 7bf470a3cc416..69f1aadca49b2 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -573,7 +573,7 @@ func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appT } // This strips the session token from a workspace app request. - cookieHeaders := r.Header.Values("Cookie")[:] + cookieHeaders := r.Header.Values("Cookie") r.Header.Del("Cookie") for _, cookieHeader := range cookieHeaders { r.Header.Add("Cookie", httpapi.StripCoderCookies(cookieHeader)) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 5dcccca09e350..f3a09c5357711 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -533,7 +533,7 @@ func (c *Client) PatchLogs(ctx context.Context, req PatchLogs) error { return nil } -type PostLogSource struct { +type PostLogSourceRequest struct { // ID is a unique identifier for the log source. // It is scoped to a workspace agent, and can be statically // defined inside code to prevent duplicate sources from being @@ -543,7 +543,7 @@ type PostLogSource struct { Icon string `json:"icon"` } -func (c *Client) PostLogSource(ctx context.Context, req PostLogSource) (codersdk.WorkspaceAgentLogSource, error) { +func (c *Client) PostLogSource(ctx context.Context, req PostLogSourceRequest) (codersdk.WorkspaceAgentLogSource, error) { res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/log-source", req) if err != nil { return codersdk.WorkspaceAgentLogSource{}, err diff --git a/docs/api/agents.md b/docs/api/agents.md index 0d73ca9262c11..13e5c38590d5c 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -341,6 +341,58 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitsshkey \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Post workspace agent log source + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/workspaceagents/me/log-source \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /workspaceagents/me/log-source` + +> Body parameter + +```json +{ + "display_name": "string", + "icon": "string", + "id": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------------------------------------------------------------------------ | -------- | ------------------ | +| `body` | body | [agentsdk.PostLogSourceRequest](schemas.md#agentsdkpostlogsourcerequest) | true | Log source request | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "workspace_agent_id": "7ad2e618-fea7-4c1a-b70a-f501566a72f1" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceAgentLogSource](schemas.md#codersdkworkspaceagentlogsource) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Patch workspace agent logs ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 82804508b0e96..7770b091878bd 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -403,6 +403,24 @@ | `changed_at` | string | false | | | | `state` | [codersdk.WorkspaceAgentLifecycle](#codersdkworkspaceagentlifecycle) | false | | | +## agentsdk.PostLogSourceRequest + +```json +{ + "display_name": "string", + "icon": "string", + "id": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `display_name` | string | false | | | +| `icon` | string | false | | | +| `id` | string | false | | ID is a unique identifier for the log source. It is scoped to a workspace agent, and can be statically defined inside code to prevent duplicate sources from being created for the same agent. | + ## agentsdk.PostMetadataRequest ```json From 0b019cad7788f179b513f9353e2077e255b8ae34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 12:30:12 -0500 Subject: [PATCH 009/168] chore: bump google.golang.org/api from 0.181.0 to 0.182.0 (#13439) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.181.0 to 0.182.0. - [Release notes](https://github.com/googleapis/google-api-go-client/releases) - [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md) - [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.181.0...v0.182.0) --- updated-dependencies: - dependency-name: google.golang.org/api dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index facfce573a1db..2261c73e55e1b 100644 --- a/go.mod +++ b/go.mod @@ -183,7 +183,7 @@ require ( golang.org/x/text v0.15.0 golang.org/x/tools v0.21.0 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 - google.golang.org/api v0.181.0 + google.golang.org/api v0.182.0 google.golang.org/grpc v1.64.0 google.golang.org/protobuf v1.34.1 gopkg.in/DataDog/dd-trace-go.v1 v1.64.0 @@ -205,7 +205,7 @@ require ( ) require ( - cloud.google.com/go/auth v0.4.1 // indirect + cloud.google.com/go/auth v0.4.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect github.com/DataDog/go-libddwaf/v2 v2.4.2 // indirect github.com/alecthomas/chroma/v2 v2.13.0 // indirect @@ -412,8 +412,8 @@ require ( golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240506185236-b8a5c65736ae // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect gopkg.in/yaml.v2 v2.4.0 // indirect howett.net/plist v1.0.0 // indirect inet.af/peercred v0.0.0-20210906144145-0893ea02156a // indirect diff --git a/go.sum b/go.sum index 513314f52b8bb..f6be769a4315e 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 h1:KHblWIE/KHOwQ6lEbMZt6YpcGve2FEZ1sDtrW1Am5UI= cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go/auth v0.4.1 h1:Z7YNIhlWRtrnKlZke7z3GMqzvuYzdc2z98F9D1NV5Hg= -cloud.google.com/go/auth v0.4.1/go.mod h1:QVBuVEKpCn4Zp58hzRGvL0tjRGU0YqdRTdCHM1IHnro= +cloud.google.com/go/auth v0.4.2 h1:sb0eyLkhRtpq5jA+a8KWw0W70YcdVca7KJ8TM0AFYDg= +cloud.google.com/go/auth v0.4.2/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc= cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= @@ -1154,8 +1154,8 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvY golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -google.golang.org/api v0.181.0 h1:rPdjwnWgiPPOJx3IcSAQ2III5aX5tCer6wMpa/xmZi4= -google.golang.org/api v0.181.0/go.mod h1:MnQ+M0CFsfUwA5beZ+g/vCBCPXvtmZwRz2qzZk8ih1k= +google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE= +google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= @@ -1166,10 +1166,10 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda h1:wu/KJm9KJwpfHWhkkZGohVC6KRrc1oJNr4jwtQMOQXw= google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda/go.mod h1:g2LLCvCeCSir/JJSWosk19BR4NVxGqHUC6rxIRsd7Aw= -google.golang.org/genproto/googleapis/api v0.0.0-20240506185236-b8a5c65736ae h1:AH34z6WAGVNkllnKs5raNq3yRq93VnjBG6rpfub/jYk= -google.golang.org/genproto/googleapis/api v0.0.0-20240506185236-b8a5c65736ae/go.mod h1:FfiGhwUm6CJviekPrc0oJ+7h29e+DmWU6UtjX0ZvI7Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 h1:mxSlqyb8ZAHsYDCfiXN1EDdNTdvjUJSLY+OnAUtYNYA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8/go.mod h1:I7Y+G38R2bu5j1aLzfFmQfTcU/WnFuqDwLZAbvKTKpM= +google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 h1:W5Xj/70xIA4x60O/IFyXivR5MGqblAb8R3w26pnD6No= +google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8/go.mod h1:vPrPUTsDCYxXWjP7clS81mZ6/803D8K4iM9Ma27VKas= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= From 27f26910b6350cd97a25564822857371cb81a9bd Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 3 Jun 2024 13:16:51 -0500 Subject: [PATCH 010/168] chore: external auth validate response "Forbidden" should return invalid, not an error (#13446) * chore: add unit test to delete workspace from suspended user * chore: account for forbidden as well as unauthorized response codes --- coderd/coderdtest/oidctest/idp.go | 19 +++++++- coderd/externalauth/externalauth.go | 2 +- coderd/externalauth_test.go | 12 ++--- coderd/workspacebuilds_test.go | 74 +++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index c0b95619d46b7..844c4df1d2664 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -1255,7 +1255,9 @@ type ExternalAuthConfigOptions struct { // ValidatePayload is the payload that is used when the user calls the // equivalent of "userinfo" for oauth2. This is not standardized, so is // different for each provider type. - ValidatePayload func(email string) interface{} + // + // The int,error payload can control the response if set. + ValidatePayload func(email string) (interface{}, int, error) // routes is more advanced usage. This allows the caller to // completely customize the response. It captures all routes under the /external-auth-validate/* @@ -1292,7 +1294,20 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu case "/user", "/", "": var payload interface{} = "OK" if custom.ValidatePayload != nil { - payload = custom.ValidatePayload(email) + var err error + var code int + payload, code, err = custom.ValidatePayload(email) + if code == 0 && err == nil { + code = http.StatusOK + } + if code == 0 && err != nil { + code = http.StatusUnauthorized + } + if err != nil { + http.Error(rw, fmt.Sprintf("failed validation via custom method: %s", err.Error()), code) + return + } + rw.WriteHeader(code) } _ = json.NewEncoder(rw).Encode(payload) default: diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 4852de3e860ce..b626a5e28fb1f 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -218,7 +218,7 @@ func (c *Config) ValidateToken(ctx context.Context, link *oauth2.Token) (bool, * return false, nil, err } defer res.Body.Close() - if res.StatusCode == http.StatusUnauthorized { + if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { // The token is no longer valid! return false, nil, nil } diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go index db40ccf38a554..916a88460d53c 100644 --- a/coderd/externalauth_test.go +++ b/coderd/externalauth_test.go @@ -79,11 +79,11 @@ func TestExternalAuthByID(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{ ExternalAuthConfigs: []*externalauth.Config{ fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{ - ValidatePayload: func(_ string) interface{} { + ValidatePayload: func(_ string) (interface{}, int, error) { return github.User{ Login: github.String("kyle"), AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"), - } + }, 0, nil }, }, func(cfg *externalauth.Config) { cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() @@ -108,11 +108,11 @@ func TestExternalAuthByID(t *testing.T) { // routes includes a route for /install that returns a list of installations routes := (&oidctest.ExternalAuthConfigOptions{ - ValidatePayload: func(_ string) interface{} { + ValidatePayload: func(_ string) (interface{}, int, error) { return github.User{ Login: github.String("kyle"), AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"), - } + }, 0, nil }, }).AddRoute("/installs", func(_ string, rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, struct { @@ -556,7 +556,7 @@ func TestExternalAuthCallback(t *testing.T) { // If the validation URL gives a non-OK status code, this // should be treated as an internal server error. srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) + w.WriteHeader(http.StatusBadRequest) w.Write([]byte("Something went wrong!")) }) _, err = agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{ @@ -565,7 +565,7 @@ func TestExternalAuthCallback(t *testing.T) { var apiError *codersdk.Error require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusInternalServerError, apiError.StatusCode()) - require.Equal(t, "validate external auth token: status 403: body: Something went wrong!", apiError.Detail) + require.Equal(t, "validate external auth token: status 400: body: Something went wrong!", apiError.Detail) }) t.Run("ExpiredNoRefresh", func(t *testing.T) { diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index f8560ff911925..eb76239b84658 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -20,9 +20,11 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" @@ -711,6 +713,78 @@ func TestWorkspaceBuildStatus(t *testing.T) { require.EqualValues(t, codersdk.WorkspaceStatusDeleted, workspace.LatestBuild.Status) } +func TestWorkspaceDeleteSuspendedUser(t *testing.T) { + t.Parallel() + const providerID = "fake-github" + fake := oidctest.NewFakeIDP(t, oidctest.WithServing()) + + validateCalls := 0 + userSuspended := false + owner := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{ + fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{ + ValidatePayload: func(email string) (interface{}, int, error) { + validateCalls++ + if userSuspended { + // Simulate the user being suspended from the IDP too. + return "", http.StatusForbidden, fmt.Errorf("user is suspended") + } + return "OK", 0, nil + }, + }), + }, + }) + + first := coderdtest.CreateFirstUser(t, owner) + + // New user that we will suspend when we try to delete the workspace. + client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleTemplateAdmin()) + fake.ExternalLogin(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Error: "", + Resources: nil, + Parameters: nil, + ExternalAuthProviders: []*proto.ExternalAuthProviderResource{ + { + Id: providerID, + Optional: false, + }, + }, + }, + }, + }}, + }) + + validateCalls = 0 // Reset + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + require.Equal(t, 1, validateCalls) // Ensure the external link is working + + // Suspend the user + ctx := testutil.Context(t, testutil.WaitLong) + _, err := owner.UpdateUserStatus(ctx, user.ID.String(), codersdk.UserStatusSuspended) + require.NoError(t, err, "suspend user") + + // Now delete the workspace build + userSuspended = true + build, err := owner.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionDelete, + }) + require.NoError(t, err) + build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, owner, build.ID) + require.Equal(t, 2, validateCalls) + require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status) +} + func TestWorkspaceBuildDebugMode(t *testing.T) { t.Parallel() From 43ef00401c67117a35efb4848d663ddb7c0e5cf9 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 3 Jun 2024 14:33:37 -0500 Subject: [PATCH 011/168] chore: linting fixes (#13450) --- Makefile | 4 +-- cli/organizationroles.go | 2 +- cli/server.go | 2 +- cli/templatepush.go | 5 ++-- cli/templateversionarchive.go | 2 +- codersdk/rbacresources_gen.go | 50 ++++++++++++++++----------------- scripts/apitypings/main.go | 2 +- scripts/rbacgen/codersdk.gotmpl | 2 +- 8 files changed, 34 insertions(+), 35 deletions(-) diff --git a/Makefile b/Makefile index 47cdea7cb653a..ca54d51842c0b 100644 --- a/Makefile +++ b/Makefile @@ -615,10 +615,10 @@ site/src/theme/icons.json: $(wildcard scripts/gensite/*) $(wildcard site/static/ 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/rbacgen/main.go coderd/rbac/object.go +coderd/rbac/object_gen.go: scripts/rbacgen/rbacobject.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go go run scripts/rbacgen/main.go rbac > coderd/rbac/object_gen.go -codersdk/rbacresources_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go +codersdk/rbacresources_gen.go: scripts/rbacgen/codersdk.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go go run scripts/rbacgen/main.go codersdk > codersdk/rbacresources_gen.go docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics diff --git a/cli/organizationroles.go b/cli/organizationroles.go index 8cf557eecf9c3..d1279656666fa 100644 --- a/cli/organizationroles.go +++ b/cli/organizationroles.go @@ -205,7 +205,7 @@ func (r *RootCmd) editOrganizationRole() *serpent.Command { } else { updated, err = client.PatchOrganizationRole(ctx, org.ID, customRole) if err != nil { - return fmt.Errorf("patch role: %w", err) + return xerrors.Errorf("patch role: %w", err) } } diff --git a/cli/server.go b/cli/server.go index 3706b2ee1bc92..409056641a771 100644 --- a/cli/server.go +++ b/cli/server.go @@ -798,7 +798,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. if vals.Telemetry.Enable { gitAuth := make([]telemetry.GitAuth, 0) // TODO: - var gitAuthConfigs []codersdk.ExternalAuthConfig + gitAuthConfigs := make([]codersdk.ExternalAuthConfig, 0) for _, cfg := range gitAuthConfigs { gitAuth = append(gitAuth, telemetry.GitAuth{ Type: cfg.Type, diff --git a/cli/templatepush.go b/cli/templatepush.go index e360aca9f77a7..e4d776dbaa201 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -407,9 +407,8 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat if errors.As(err, &jobErr) && !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) { return nil, err } - if err != nil { - return nil, err - } + + return nil, err } version, err = client.TemplateVersion(inv.Context(), version.ID) if err != nil { diff --git a/cli/templateversionarchive.go b/cli/templateversionarchive.go index b63cf2e2441d7..f9ae87e330be0 100644 --- a/cli/templateversionarchive.go +++ b/cli/templateversionarchive.go @@ -166,7 +166,7 @@ func (r *RootCmd) archiveTemplateVersions() *serpent.Command { inv.Stdout, fmt.Sprintf("Archived %d versions from "+pretty.Sprint(cliui.DefaultStyles.Keyword, template.Name)+" at "+cliui.Timestamp(time.Now()), len(resp.ArchivedIDs)), ) - if ok, _ := inv.ParsedFlags().GetBool("verbose"); err == nil && ok { + if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok { data, err := json.Marshal(resp) if err != nil { return xerrors.Errorf("marshal verbose response: %w", err) diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 42db5449c29f4..2c524e356553e 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -52,29 +52,29 @@ const ( // RBACResourceActions is the mapping of resources to which actions are valid for // said resource type. var RBACResourceActions = map[RBACResource][]RBACAction{ - ResourceWildcard: []RBACAction{}, - ResourceApiKey: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceAssignOrgRole: []RBACAction{ActionAssign, ActionDelete, ActionRead}, - ResourceAssignRole: []RBACAction{ActionAssign, ActionCreate, ActionDelete, ActionRead}, - ResourceAuditLog: []RBACAction{ActionCreate, ActionRead}, - ResourceDebugInfo: []RBACAction{ActionRead}, - ResourceDeploymentConfig: []RBACAction{ActionRead, ActionUpdate}, - ResourceDeploymentStats: []RBACAction{ActionRead}, - ResourceFile: []RBACAction{ActionCreate, ActionRead}, - ResourceGroup: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceLicense: []RBACAction{ActionCreate, ActionDelete, ActionRead}, - ResourceOauth2App: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOauth2AppCodeToken: []RBACAction{ActionCreate, ActionDelete, ActionRead}, - ResourceOauth2AppSecret: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOrganization: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOrganizationMember: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceProvisionerDaemon: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceReplicas: []RBACAction{ActionRead}, - ResourceSystem: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceTailnetCoordinator: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceTemplate: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights}, - ResourceUser: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, - ResourceWorkspace: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, - ResourceWorkspaceDormant: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, - ResourceWorkspaceProxy: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceWildcard: {}, + ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceAssignOrgRole: {ActionAssign, ActionDelete, ActionRead}, + ResourceAssignRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead}, + ResourceAuditLog: {ActionCreate, ActionRead}, + ResourceDebugInfo: {ActionRead}, + ResourceDeploymentConfig: {ActionRead, ActionUpdate}, + ResourceDeploymentStats: {ActionRead}, + ResourceFile: {ActionCreate, ActionRead}, + ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, + ResourceOauth2App: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOauth2AppCodeToken: {ActionCreate, ActionDelete, ActionRead}, + ResourceOauth2AppSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceReplicas: {ActionRead}, + ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights}, + ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, + ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceProxy: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, } diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index 0b4571a6af0a5..98bfbc47eaa25 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -600,7 +600,7 @@ func (g *Generator) buildStruct(obj types.Object, st *types.Struct) (string, err // inferred. typescriptTag, err := tags.Get("typescript") if err == nil { - if err == nil && typescriptTag.Name == "-" { + if typescriptTag.Name == "-" { // Completely ignore this field. continue } else if typescriptTag.Name != "" { diff --git a/scripts/rbacgen/codersdk.gotmpl b/scripts/rbacgen/codersdk.gotmpl index dff4e165b1df5..935d8c4f556c9 100644 --- a/scripts/rbacgen/codersdk.gotmpl +++ b/scripts/rbacgen/codersdk.gotmpl @@ -21,7 +21,7 @@ const ( // said resource type. var RBACResourceActions = map[RBACResource][]RBACAction{ {{- range $element := . }} - Resource{{ pascalCaseName $element.FunctionName }}: []RBACAction{ + Resource{{ pascalCaseName $element.FunctionName }}: { {{- range $actionValue, $_ := $element.Actions }} {{- actionEnum $actionValue -}}, {{- end -}} From e4ac691468498da516a19270d5406ae97204f3c8 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 3 Jun 2024 14:46:56 -0500 Subject: [PATCH 012/168] chore: fix `(*coderdtest.WorkspaceAgentWaiter).Wait()` flake (#13451) --- coderd/coderdtest/coderdtest.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 6153f1a68abcb..b379cedd1834a 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -1018,7 +1018,7 @@ func (w WorkspaceAgentWaiter) Wait() []codersdk.WorkspaceResource { require.Eventually(w.t, func() bool { var err error workspace, err := w.client.Workspace(ctx, w.workspaceID) - if !assert.NoError(w.t, err) { + if err != nil { return false } if workspace.LatestBuild.Job.CompletedAt == nil { From 2806752c7d858963d156971ac17669e80f398e9d Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Mon, 3 Jun 2024 13:50:59 -0600 Subject: [PATCH 013/168] chore: add light mode snapshot to chromatic for `WorkspaceBuildPageView` (#13449) --- .../pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx index 43341b8f6f923..dc6704b124512 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { chromatic } from "testHelpers/chromatic"; import { MockFailedWorkspaceBuild, MockWorkspaceBuild, @@ -14,6 +15,7 @@ const defaultBuilds = Array.from({ length: 15 }, (_, i) => ({ const meta: Meta = { title: "pages/WorkspaceBuildPage", + parameters: { chromatic }, component: WorkspaceBuildPageView, args: { build: MockWorkspaceBuild, From 40390ecc304b6754e4e8d51581f52f4131e94a2b Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 3 Jun 2024 15:38:59 -0500 Subject: [PATCH 014/168] chore: fix `TestServer/Prometheus/DBMetricsDisabled` test flake (#13453) See: https://github.com/coder/coder/actions/runs/9352137263/job/25739550487#step:5:368 --- cli/server_test.go | 75 +++++++++++-------- coderd/prometheusmetrics/prometheusmetrics.go | 4 +- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/cli/server_test.go b/cli/server_test.go index 3ca57cf0ce162..b163713cff303 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -967,26 +967,32 @@ func TestServer(t *testing.T) { assert.NoError(t, err) // nolint:bodyclose res, err = http.DefaultClient.Do(req) - return err == nil - }, testutil.WaitShort, testutil.IntervalFast) - defer res.Body.Close() - - scanner := bufio.NewScanner(res.Body) - hasActiveUsers := false - for scanner.Scan() { - // This metric is manually registered to be tracked in the server. That's - // why we test it's tracked here. - if strings.HasPrefix(scanner.Text(), "coderd_api_active_users_duration_hour") { - hasActiveUsers = true - continue + if err != nil { + return false } - if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") { - t.Fatal("db metrics should not be tracked when --prometheus-collect-db-metrics is not enabled") + defer res.Body.Close() + + scanner := bufio.NewScanner(res.Body) + hasActiveUsers := false + for scanner.Scan() { + // This metric is manually registered to be tracked in the server. That's + // why we test it's tracked here. + if strings.HasPrefix(scanner.Text(), "coderd_api_active_users_duration_hour") { + hasActiveUsers = true + continue + } + if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") { + t.Fatal("db metrics should not be tracked when --prometheus-collect-db-metrics is not enabled") + } + t.Logf("scanned %s", scanner.Text()) } - t.Logf("scanned %s", scanner.Text()) - } - require.NoError(t, scanner.Err()) - require.True(t, hasActiveUsers) + if scanner.Err() != nil { + t.Logf("scanner err: %s", scanner.Err().Error()) + return false + } + + return hasActiveUsers + }, testutil.WaitShort, testutil.IntervalFast, "didn't find coderd_api_active_users_duration_hour in time") }) t.Run("DBMetricsEnabled", func(t *testing.T) { @@ -1017,20 +1023,25 @@ func TestServer(t *testing.T) { assert.NoError(t, err) // nolint:bodyclose res, err = http.DefaultClient.Do(req) - return err == nil - }, testutil.WaitShort, testutil.IntervalFast) - defer res.Body.Close() - - scanner := bufio.NewScanner(res.Body) - hasDBMetrics := false - for scanner.Scan() { - if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") { - hasDBMetrics = true + if err != nil { + return false } - t.Logf("scanned %s", scanner.Text()) - } - require.NoError(t, scanner.Err()) - require.True(t, hasDBMetrics) + defer res.Body.Close() + + scanner := bufio.NewScanner(res.Body) + hasDBMetrics := false + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") { + hasDBMetrics = true + } + t.Logf("scanned %s", scanner.Text()) + } + if scanner.Err() != nil { + t.Logf("scanner err: %s", scanner.Err().Error()) + return false + } + return hasDBMetrics + }, testutil.WaitShort, testutil.IntervalFast, "didn't find coderd_db_query_latencies_seconds in time") }) }) t.Run("GitHubOAuth", func(t *testing.T) { @@ -1347,7 +1358,7 @@ func TestServer(t *testing.T) { } return lastStat.Size() > 0 }, - testutil.WaitShort, + dur, //nolint:gocritic testutil.IntervalFast, "file at %s should exist, last stat: %+v", fiName, lastStat, diff --git a/coderd/prometheusmetrics/prometheusmetrics.go b/coderd/prometheusmetrics/prometheusmetrics.go index fcc6958f39e84..b9a54633a5b13 100644 --- a/coderd/prometheusmetrics/prometheusmetrics.go +++ b/coderd/prometheusmetrics/prometheusmetrics.go @@ -120,9 +120,9 @@ func Workspaces(ctx context.Context, logger slog.Logger, registerer prometheus.R if errors.Is(err, sql.ErrNoRows) { // clear all series if there are no database entries workspaceLatestBuildTotals.Reset() + } else { + logger.Warn(ctx, "failed to load latest workspace builds", slog.Error(err)) } - - logger.Warn(ctx, "failed to load latest workspace builds", slog.Error(err)) return } jobIDs := make([]uuid.UUID, 0, len(builds)) From c7233eccecc220ef55fefef00df930de7f676300 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Tue, 4 Jun 2024 00:03:34 +0300 Subject: [PATCH 015/168] chore(dogfood): bump module versions (#13455) --- dogfood/main.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dogfood/main.tf b/dogfood/main.tf index a2c1528ecaffa..3695ab51b7374 100644 --- a/dogfood/main.tf +++ b/dogfood/main.tf @@ -106,7 +106,7 @@ module "slackme" { module "dotfiles" { source = "registry.coder.com/modules/dotfiles/coder" - version = "1.0.14" + version = "1.0.15" agent_id = coder_agent.dev.id } @@ -126,7 +126,7 @@ module "personalize" { module "code-server" { source = "registry.coder.com/modules/code-server/coder" - version = "1.0.14" + version = "1.0.15" agent_id = coder_agent.dev.id folder = local.repo_dir auto_install_extensions = true @@ -151,7 +151,7 @@ module "filebrowser" { module "coder-login" { source = "registry.coder.com/modules/coder-login/coder" - version = "1.0.2" + version = "1.0.15" agent_id = coder_agent.dev.id } From 78b8264a90eccd55ad92afdfa356620d82bd1358 Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Mon, 3 Jun 2024 15:05:49 -0600 Subject: [PATCH 016/168] feat(site): add deployment menu to navbar (#13401) --- .../dashboard/Navbar/DeploymentDropdown.tsx | 151 ++++++++++++++++++ .../modules/dashboard/Navbar/Navbar.test.tsx | 7 +- .../dashboard/Navbar/NavbarView.stories.tsx | 5 + .../dashboard/Navbar/NavbarView.test.tsx | 13 +- .../modules/dashboard/Navbar/NavbarView.tsx | 68 ++------ .../UserDropdown/UserDropdownContent.tsx | 110 ++++++------- site/src/modules/navigation.ts | 7 + site/src/pages/UsersPage/UsersLayout.tsx | 2 +- 8 files changed, 251 insertions(+), 112 deletions(-) create mode 100644 site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx create mode 100644 site/src/modules/navigation.ts diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx new file mode 100644 index 0000000000000..23f0355ad3e9a --- /dev/null +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -0,0 +1,151 @@ +import { css, type Interpolation, type Theme, useTheme } from "@emotion/react"; +import Button from "@mui/material/Button"; +import MenuItem from "@mui/material/MenuItem"; +import type { FC } from "react"; +import { NavLink } from "react-router-dom"; +import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; +import { + Popover, + PopoverContent, + PopoverTrigger, + usePopover, +} from "components/Popover/Popover"; +import { USERS_LINK } from "modules/navigation"; + +interface DeploymentDropdownProps { + canViewAuditLog: boolean; + canViewDeployment: boolean; + canViewAllUsers: boolean; + canViewHealth: boolean; +} + +export const DeploymentDropdown: FC = ({ + canViewAuditLog, + canViewDeployment, + canViewAllUsers, + canViewHealth, +}) => { + const theme = useTheme(); + + if ( + !canViewAuditLog && + !canViewDeployment && + !canViewAllUsers && + !canViewHealth + ) { + return null; + } + + return ( + + + + + + + + + + ); +}; + +const DeploymentDropdownContent: FC = ({ + canViewAuditLog, + canViewDeployment, + canViewAllUsers, + canViewHealth, +}) => { + const popover = usePopover(); + + const onPopoverClose = () => popover.setIsOpen(false); + + return ( + + ); +}; + +const styles = { + menuItem: (theme) => css` + text-decoration: none; + color: inherit; + gap: 20px; + padding: 8px 20px; + font-size: 14px; + + &:hover { + background-color: ${theme.palette.action.hover}; + transition: background-color 0.3s ease; + } + `, + menuItemIcon: (theme) => ({ + color: theme.palette.text.secondary, + width: 20, + height: 20, + }), +} satisfies Record>; diff --git a/site/src/modules/dashboard/Navbar/Navbar.test.tsx b/site/src/modules/dashboard/Navbar/Navbar.test.tsx index 681af84728851..93953530c2357 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.test.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.test.tsx @@ -1,4 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { App } from "App"; import { @@ -21,6 +22,8 @@ describe("Navbar", () => { }), ); render(); + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); await waitFor( () => { const link = screen.getByText(Language.audit); @@ -34,6 +37,8 @@ describe("Navbar", () => { // by default, user is an Admin with permission to see the audit log, // but is unlicensed so not entitled to see the audit log render(); + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); await waitFor( () => { const link = screen.queryByText(Language.audit); @@ -59,7 +64,7 @@ describe("Navbar", () => { render(); await waitFor( () => { - const link = screen.queryByText(Language.audit); + const link = screen.queryByText("Deployment"); expect(link).toBe(null); }, { timeout: 2000 }, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 2490234bd36e1..146a66b9372e3 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -10,6 +10,10 @@ const meta: Meta = { component: NavbarView, args: { user: MockUser, + canViewAuditLog: true, + canViewDeployment: true, + canViewAllUsers: true, + canViewHealth: true, }, decorators: [withDashboardProvider], }; @@ -25,6 +29,7 @@ export const ForMember: Story = { canViewAuditLog: false, canViewDeployment: false, canViewAllUsers: false, + canViewHealth: false, }, }; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx index c881fa300000d..a6541ea688486 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx @@ -1,4 +1,5 @@ import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ProxyContextValue } from "contexts/ProxyContext"; import { MockPrimaryWorkspaceProxy, MockUser } from "testHelpers/entities"; import { renderWithAuth } from "testHelpers/renderHelpers"; @@ -65,6 +66,8 @@ describe("NavbarView", () => { canViewHealth />, ); + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); const userLink = await screen.findByText(navLanguage.users); expect((userLink as HTMLAnchorElement).href).toContain("/users"); }); @@ -81,6 +84,8 @@ describe("NavbarView", () => { canViewHealth />, ); + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); const auditLink = await screen.findByText(navLanguage.audit); expect((auditLink as HTMLAnchorElement).href).toContain("/audit"); }); @@ -97,8 +102,12 @@ describe("NavbarView", () => { canViewHealth />, ); - const auditLink = await screen.findByText(navLanguage.deployment); - expect((auditLink as HTMLAnchorElement).href).toContain( + const deploymentMenu = await screen.findByText("Deployment"); + await userEvent.click(deploymentMenu); + const deploymentSettingsLink = await screen.findByText( + navLanguage.deployment, + ); + expect((deploymentSettingsLink as HTMLAnchorElement).href).toContain( "/deployment/general", ); }); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 440bdd44f249d..376273c8d75ee 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -9,7 +9,7 @@ import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Skeleton from "@mui/material/Skeleton"; import { visuallyHidden } from "@mui/utils"; -import { type FC, type ReactNode, useRef, useState } from "react"; +import { type FC, useRef, useState } from "react"; import { NavLink, useLocation, useNavigate } from "react-router-dom"; import type * as TypesGen from "api/typesGenerated"; import { Abbr } from "components/Abbr/Abbr"; @@ -20,12 +20,9 @@ import { Latency } from "components/Latency/Latency"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import type { ProxyContextValue } from "contexts/ProxyContext"; import { BUTTON_SM_HEIGHT, navHeight } from "theme/constants"; +import { DeploymentDropdown } from "./DeploymentDropdown"; import { UserDropdown } from "./UserDropdown/UserDropdown"; -export const USERS_LINK = `/users?filter=${encodeURIComponent( - "status:active", -)}`; - export interface NavbarViewProps { logo_url?: string; user?: TypesGen.User; @@ -43,26 +40,15 @@ export const Language = { workspaces: "Workspaces", templates: "Templates", users: "Users", - audit: "Audit", - deployment: "Deployment", + audit: "Auditing", + deployment: "Settings", }; interface NavItemsProps { - children?: ReactNode; className?: string; - canViewAuditLog: boolean; - canViewDeployment: boolean; - canViewAllUsers: boolean; - canViewHealth: boolean; } -const NavItems: FC = ({ - className, - canViewAuditLog, - canViewDeployment, - canViewAllUsers, - canViewHealth, -}) => { +const NavItems: FC = ({ className }) => { const location = useLocation(); const theme = useTheme(); @@ -83,26 +69,6 @@ const NavItems: FC = ({ {Language.templates} - {canViewAllUsers && ( - - {Language.users} - - )} - {canViewAuditLog && ( - - {Language.audit} - - )} - {canViewDeployment && ( - - {Language.deployment} - - )} - {canViewHealth && ( - - Health - - )} ); }; @@ -157,12 +123,7 @@ export const NavbarView: FC = ({ )} - + @@ -174,18 +135,20 @@ export const NavbarView: FC = ({ )} - +
{proxyContextValue && ( )} + + + {user && ( = ({ proxyContextValue }) => { size="small" endIcon={} css={{ - borderRadius: "999px", "& .MuiSvgIcon-root": { fontSize: 14 }, }} > diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index 631b673b15c0e..c0ad5111ea9ae 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -27,61 +27,6 @@ export const Language = { copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`, }; -const styles = { - info: (theme) => [ - theme.typography.body2 as CSSObject, - { - padding: 20, - }, - ], - userName: { - fontWeight: 600, - }, - userEmail: (theme) => ({ - color: theme.palette.text.secondary, - width: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - }), - link: { - textDecoration: "none", - color: "inherit", - }, - menuItem: (theme) => css` - gap: 20px; - padding: 8px 20px; - - &:hover { - background-color: ${theme.palette.action.hover}; - transition: background-color 0.3s ease; - } - `, - menuItemIcon: (theme) => ({ - color: theme.palette.text.secondary, - width: 20, - height: 20, - }), - menuItemText: { - fontSize: 14, - }, - footerText: (theme) => css` - font-size: 12px; - text-decoration: none; - color: ${theme.palette.text.secondary}; - display: flex; - align-items: center; - gap: 4px; - - & svg { - width: 12px; - height: 12px; - } - `, - buildInfo: (theme) => ({ - color: theme.palette.text.primary, - }), -} satisfies Record>; - export interface UserDropdownContentProps { user: TypesGen.User; organizations?: TypesGen.Organization[]; @@ -268,3 +213,58 @@ const includeBuildInfo = ( )}`, ); }; + +const styles = { + info: (theme) => [ + theme.typography.body2 as CSSObject, + { + padding: 20, + }, + ], + userName: { + fontWeight: 600, + }, + userEmail: (theme) => ({ + color: theme.palette.text.secondary, + width: "100%", + textOverflow: "ellipsis", + overflow: "hidden", + }), + link: { + textDecoration: "none", + color: "inherit", + }, + menuItem: (theme) => css` + gap: 20px; + padding: 8px 20px; + + &:hover { + background-color: ${theme.palette.action.hover}; + transition: background-color 0.3s ease; + } + `, + menuItemIcon: (theme) => ({ + color: theme.palette.text.secondary, + width: 20, + height: 20, + }), + menuItemText: { + fontSize: 14, + }, + footerText: (theme) => css` + font-size: 12px; + text-decoration: none; + color: ${theme.palette.text.secondary}; + display: flex; + align-items: center; + gap: 4px; + + & svg { + width: 12px; + height: 12px; + } + `, + buildInfo: (theme) => ({ + color: theme.palette.text.primary, + }), +} satisfies Record>; diff --git a/site/src/modules/navigation.ts b/site/src/modules/navigation.ts new file mode 100644 index 0000000000000..74217a4ceaaac --- /dev/null +++ b/site/src/modules/navigation.ts @@ -0,0 +1,7 @@ +/** + * @fileoverview TODO: centralize navigation code here! URL constants, URL formatting, all of it + */ + +export const USERS_LINK = `/users?filter=${encodeURIComponent( + "status:active", +)}`; diff --git a/site/src/pages/UsersPage/UsersLayout.tsx b/site/src/pages/UsersPage/UsersLayout.tsx index bb85cae1b03b8..8b3dc7858c41e 100644 --- a/site/src/pages/UsersPage/UsersLayout.tsx +++ b/site/src/pages/UsersPage/UsersLayout.tsx @@ -13,8 +13,8 @@ import { Margins } from "components/Margins/Margins"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { USERS_LINK } from "modules/dashboard/Navbar/NavbarView"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { USERS_LINK } from "modules/navigation"; export const UsersLayout: FC = () => { const { permissions } = useAuthenticated(); From a51076a4cdd3818c5697a902db78c1f0ad288ac0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 4 Jun 2024 00:29:24 +0300 Subject: [PATCH 017/168] chore(scripts): fix unbound variable in tag_version.sh (#13428) --- scripts/release/tag_version.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release/tag_version.sh b/scripts/release/tag_version.sh index 16a2011016047..2bf3e88646cc2 100755 --- a/scripts/release/tag_version.sh +++ b/scripts/release/tag_version.sh @@ -86,8 +86,8 @@ fi # shellcheck source=scripts/release/check_commit_metadata.sh source "$SCRIPT_DIR/check_commit_metadata.sh" "$old_version" "$ref" +prev_increment=$increment if ((COMMIT_METADATA_BREAKING == 1)); then - prev_increment=$increment if [[ $increment == patch ]]; then increment=minor fi From e527bc624208f73c5afb1d89479e84ecff24bc1e Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Tue, 4 Jun 2024 11:21:01 +0300 Subject: [PATCH 018/168] chore(dogfood): replace deprecated `coder_workspace.owner_oidc_access_token` and add `order` to agent `metadata` (#13456) --- dogfood/main.tf | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/dogfood/main.tf b/dogfood/main.tf index 3695ab51b7374..9c6076aff3d9e 100644 --- a/dogfood/main.tf +++ b/dogfood/main.tf @@ -160,7 +160,7 @@ resource "coder_agent" "dev" { os = "linux" dir = local.repo_dir env = { - OIDC_TOKEN : data.coder_workspace.me.owner_oidc_access_token, + OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token, } startup_script_behavior = "blocking" @@ -169,7 +169,8 @@ resource "coder_agent" "dev" { # if you don't want to display any information. metadata { display_name = "CPU Usage" - key = "0_cpu_usage" + key = "cpu_usage" + order = 0 script = "coder stat cpu" interval = 10 timeout = 1 @@ -177,7 +178,8 @@ resource "coder_agent" "dev" { metadata { display_name = "RAM Usage" - key = "1_ram_usage" + key = "ram_usage" + order = 1 script = "coder stat mem" interval = 10 timeout = 1 @@ -185,7 +187,8 @@ resource "coder_agent" "dev" { metadata { display_name = "CPU Usage (Host)" - key = "2_cpu_usage_host" + key = "cpu_usage_host" + order = 2 script = "coder stat cpu --host" interval = 10 timeout = 1 @@ -193,7 +196,8 @@ resource "coder_agent" "dev" { metadata { display_name = "RAM Usage (Host)" - key = "3_ram_usage_host" + key = "ram_usage_host" + order = 3 script = "coder stat mem --host" interval = 10 timeout = 1 @@ -201,7 +205,8 @@ resource "coder_agent" "dev" { metadata { display_name = "Swap Usage (Host)" - key = "4_swap_usage_host" + key = "swap_usage_host" + order = 4 script = <&1 | awk ' $0 ~ "Word of the Day: [A-z]+" { print $5; exit }' From cd32c4269963b13e97a967d36f08e7d309684ba0 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 4 Jun 2024 13:59:54 +0200 Subject: [PATCH 019/168] fix(cli): inherit provisioner tags from last template version (#13462) --- cli/templatepush.go | 10 +++ cli/templatepush_test.go | 129 ++++++++++++++++++++++++++++++++ coderd/coderd.go | 6 +- coderd/coderdtest/coderdtest.go | 9 ++- 4 files changed, 151 insertions(+), 3 deletions(-) diff --git a/cli/templatepush.go b/cli/templatepush.go index e4d776dbaa201..b4ff8e50eb5ed 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -100,6 +100,16 @@ func (r *RootCmd) templatePush() *serpent.Command { return err } + // If user hasn't provided new provisioner tags, inherit ones from the active template version. + if len(tags) == 0 && template.ActiveVersionID != uuid.Nil { + templateVersion, err := client.TemplateVersion(inv.Context(), template.ActiveVersionID) + if err != nil { + return err + } + tags = templateVersion.Job.Tags + inv.Logger.Info(inv.Context(), "reusing existing provisioner tags", "tags", tags) + } + userVariableValues, err := ParseUserVariableValues( varsFiles, variablesFile, diff --git a/cli/templatepush_test.go b/cli/templatepush_test.go index 13c9fbc1f35c4..4e9c8613961e5 100644 --- a/cli/templatepush_test.go +++ b/cli/templatepush_test.go @@ -403,6 +403,135 @@ func TestTemplatePush(t *testing.T) { assert.NotEqual(t, template.ActiveVersionID, templateVersions[1].ID) }) + t.Run("ProvisionerTags", func(t *testing.T) { + t.Parallel() + + t.Run("ChangeTags", func(t *testing.T) { + t.Parallel() + + // Start the first provisioner + client, provisionerDocker, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ProvisionerDaemonTags: map[string]string{ + "docker": "true", + }, + }) + defer provisionerDocker.Close() + + // Start the second provisioner + provisionerFoobar := coderdtest.NewTaggedProvisionerDaemon(t, api, "provisioner-foobar", map[string]string{ + "foobar": "foobaz", + }) + defer provisionerFoobar.Close() + + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + // Create the template with initial tagged template version. + templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.ProvisionerTags = map[string]string{ + "docker": "true", + } + }) + templateVersion = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID) + + // Push new template version without provisioner tags. CLI should reuse tags from the previous version. + source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + }) + inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", template.Name, + "--provisioner-tag", "foobar=foobaz") + clitest.SetupConfig(t, templateAdmin, root) + pty := ptytest.New(t).Attach(inv) + + execDone := make(chan error) + go func() { + execDone <- inv.Run() + }() + + matches := []struct { + match string + write string + }{ + {match: "Upload", write: "yes"}, + } + for _, m := range matches { + pty.ExpectMatch(m.match) + pty.WriteLine(m.write) + } + + require.NoError(t, <-execDone) + + // Verify template version tags + template, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + + templateVersion, err = client.TemplateVersion(context.Background(), template.ActiveVersionID) + require.NoError(t, err) + require.EqualValues(t, map[string]string{"foobar": "foobaz", "owner": "", "scope": "organization"}, templateVersion.Job.Tags) + }) + + t.Run("DoNotChangeTags", func(t *testing.T) { + t.Parallel() + + // Start the tagged provisioner + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ProvisionerDaemonTags: map[string]string{ + "docker": "true", + }, + }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + // Create the template with initial tagged template version. + templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.ProvisionerTags = map[string]string{ + "docker": "true", + } + }) + templateVersion = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID) + + // Push new template version without provisioner tags. CLI should reuse tags from the previous version. + source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + }) + inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", template.Name) + clitest.SetupConfig(t, templateAdmin, root) + pty := ptytest.New(t).Attach(inv) + + execDone := make(chan error) + go func() { + execDone <- inv.Run() + }() + + matches := []struct { + match string + write string + }{ + {match: "Upload", write: "yes"}, + } + for _, m := range matches { + pty.ExpectMatch(m.match) + pty.WriteLine(m.write) + } + + require.NoError(t, <-execDone) + + // Verify template version tags + template, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + + templateVersion, err = client.TemplateVersion(context.Background(), template.ActiveVersionID) + require.NoError(t, err) + require.EqualValues(t, map[string]string{"docker": "true", "owner": "", "scope": "organization"}, templateVersion.Job.Tags) + }) + }) + t.Run("Variables", func(t *testing.T) { t.Parallel() diff --git a/coderd/coderd.go b/coderd/coderd.go index 98b1686171c07..60bfe9813c559 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1373,6 +1373,10 @@ func compressHandler(h http.Handler) http.Handler { // CreateInMemoryProvisionerDaemon is an in-memory connection to a provisionerd. // Useful when starting coderd and provisionerd in the same process. func (api *API) CreateInMemoryProvisionerDaemon(dialCtx context.Context, name string, provisionerTypes []codersdk.ProvisionerType) (client proto.DRPCProvisionerDaemonClient, err error) { + return api.CreateInMemoryTaggedProvisionerDaemon(dialCtx, name, provisionerTypes, nil) +} + +func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, name string, provisionerTypes []codersdk.ProvisionerType, provisionerTags map[string]string) (client proto.DRPCProvisionerDaemonClient, err error) { tracer := api.TracerProvider.Tracer(tracing.TracerName) clientSession, serverSession := drpc.MemTransportPipe() defer func() { @@ -1400,7 +1404,7 @@ func (api *API) CreateInMemoryProvisionerDaemon(dialCtx context.Context, name st OrganizationID: defaultOrg.ID, CreatedAt: dbtime.Now(), Provisioners: dbTypes, - Tags: provisionersdk.MutateTags(uuid.Nil, nil), + Tags: provisionersdk.MutateTags(uuid.Nil, provisionerTags), LastSeenAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, Version: buildinfo.Version(), APIVersion: proto.CurrentVersion.String(), diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index b379cedd1834a..7110cc79471fb 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -125,6 +125,7 @@ type Options struct { // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool + ProvisionerDaemonTags map[string]string MetricsCacheRefreshInterval time.Duration AgentStatsRefreshInterval time.Duration DeploymentValues *codersdk.DeploymentValues @@ -512,7 +513,7 @@ func NewWithAPI(t testing.TB, options *Options) (*codersdk.Client, io.Closer, *c setHandler(coderAPI.RootHandler) var provisionerCloser io.Closer = nopcloser{} if options.IncludeProvisionerDaemon { - provisionerCloser = NewProvisionerDaemon(t, coderAPI) + provisionerCloser = NewTaggedProvisionerDaemon(t, coderAPI, "test", options.ProvisionerDaemonTags) } client := codersdk.New(serverURL) t.Cleanup(func() { @@ -552,6 +553,10 @@ func (c *provisionerdCloser) Close() error { // well with coderd testing. It registers the "echo" provisioner for // quick testing. func NewProvisionerDaemon(t testing.TB, coderAPI *coderd.API) io.Closer { + return NewTaggedProvisionerDaemon(t, coderAPI, "test", nil) +} + +func NewTaggedProvisionerDaemon(t testing.TB, coderAPI *coderd.API, name string, provisionerTags map[string]string) io.Closer { t.Helper() // t.Cleanup runs in last added, first called order. t.TempDir() will delete @@ -578,7 +583,7 @@ func NewProvisionerDaemon(t testing.TB, coderAPI *coderd.API) io.Closer { }() daemon := provisionerd.New(func(dialCtx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) { - return coderAPI.CreateInMemoryProvisionerDaemon(dialCtx, "test", []codersdk.ProvisionerType{codersdk.ProvisionerTypeEcho}) + return coderAPI.CreateInMemoryTaggedProvisionerDaemon(dialCtx, name, []codersdk.ProvisionerType{codersdk.ProvisionerTypeEcho}, provisionerTags) }, &provisionerd.Options{ Logger: coderAPI.Logger.Named("provisionerd").Leveled(slog.LevelDebug), UpdateInterval: 250 * time.Millisecond, From 168d2d6ba0dc5f8f1c5449c841c85894595300fe Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 4 Jun 2024 14:17:17 +0100 Subject: [PATCH 020/168] chore(coderd): add update user profile test for members (#13463) --- coderd/users_test.go | 45 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/coderd/users_test.go b/coderd/users_test.go index 01cac4d1c8251..80c7062c914ed 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -692,7 +692,7 @@ func TestUpdateUserProfile(t *testing.T) { require.Equal(t, http.StatusConflict, apiErr.StatusCode()) }) - t.Run("UpdateUser", func(t *testing.T) { + t.Run("UpdateSelf", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) @@ -704,15 +704,48 @@ func TestUpdateUserProfile(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, _ = client.User(ctx, codersdk.Me) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + userProfile, err := client.UpdateUserProfile(ctx, codersdk.Me, codersdk.UpdateUserProfileRequest{ - Username: "newusername", - Name: "Mr User", + Username: me.Username + "1", + Name: me.Name + "1", }) + numLogs++ // add an audit log for user update + require.NoError(t, err) - require.Equal(t, userProfile.Username, "newusername") - require.Equal(t, userProfile.Name, "Mr User") + require.Equal(t, me.Username+"1", userProfile.Username) + require.Equal(t, me.Name+"1", userProfile.Name) + + require.Len(t, auditor.AuditLogs(), numLogs) + require.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[numLogs-1].Action) + }) + + t.Run("UpdateSelfAsMember", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + numLogs := len(auditor.AuditLogs()) + + firstUser := coderdtest.CreateFirstUser(t, client) + numLogs++ // add an audit log for login + + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + numLogs++ // add an audit log for user creation + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + userProfile, err := memberClient.UpdateUserProfile(ctx, codersdk.Me, codersdk.UpdateUserProfileRequest{ + Username: memberUser.Username + "1", + Name: memberUser.Name + "1", + }) numLogs++ // add an audit log for user update + numLogs++ // add an audit log for API key creation + + require.NoError(t, err) + require.Equal(t, memberUser.Username+"1", userProfile.Username) + require.Equal(t, memberUser.Name+"1", userProfile.Name) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[numLogs-1].Action) From e3206612e10ec6b0941547f0cdb3c90fa1a2a32e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 4 Jun 2024 09:27:44 -0500 Subject: [PATCH 021/168] chore: implement typed database for custom permissions (breaks existing custom roles) (#13457) * chore: typed database custom permissions * add migration to fix any custom roles out there --- cli/organizationroles.go | 6 +- coderd/database/db2sdk/db2sdk.go | 62 ++++---- coderd/database/dbauthz/customroles_test.go | 145 +++++++++--------- coderd/database/dbauthz/dbauthz.go | 9 +- coderd/database/dbauthz/dbauthz_test.go | 46 +++--- coderd/database/dbgen/dbgen.go | 6 +- .../000214_org_custom_role_array.down.sql | 1 + .../000214_org_custom_role_array.up.sql | 4 + coderd/database/models.go | 14 +- coderd/database/queries.sql.go | 12 +- coderd/database/sqlc.yaml | 9 ++ coderd/database/types.go | 30 ++++ coderd/rbac/rolestore/rolestore.go | 80 +++------- coderd/roles.go | 25 +-- coderd/roles_test.go | 31 ++-- codersdk/roles.go | 2 +- enterprise/coderd/roles.go | 40 ++--- 17 files changed, 256 insertions(+), 266 deletions(-) create mode 100644 coderd/database/migrations/000214_org_custom_role_array.down.sql create mode 100644 coderd/database/migrations/000214_org_custom_role_array.up.sql diff --git a/cli/organizationroles.go b/cli/organizationroles.go index d1279656666fa..75cf048198b30 100644 --- a/cli/organizationroles.go +++ b/cli/organizationroles.go @@ -36,7 +36,7 @@ func (r *RootCmd) organizationRoles() *serpent.Command { func (r *RootCmd) showOrganizationRoles() *serpent.Command { formatter := cliui.NewOutputFormatter( cliui.ChangeFormatterData( - cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "org_permissions", "user_permissions"}), + cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "organization_permissions", "user_permissions"}), func(data any) (any, error) { inputs, ok := data.([]codersdk.AssignableRoles) if !ok { @@ -103,7 +103,7 @@ func (r *RootCmd) showOrganizationRoles() *serpent.Command { func (r *RootCmd) editOrganizationRole() *serpent.Command { formatter := cliui.NewOutputFormatter( cliui.ChangeFormatterData( - cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "org_permissions", "user_permissions"}), + cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "organization_permissions", "user_permissions"}), func(data any) (any, error) { typed, _ := data.(codersdk.Role) return []roleTableRow{roleToTableView(typed)}, nil @@ -391,6 +391,6 @@ type roleTableRow struct { OrganizationID string `table:"organization_id"` SitePermissions string ` table:"site_permissions"` // map[] -> Permissions - OrganizationPermissions string `table:"org_permissions"` + OrganizationPermissions string `table:"organization_permissions"` UserPermissions string `table:"user_permissions"` } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 2fe9ac9af7a3d..402752805bf7b 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -18,7 +18,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/parameter" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -526,54 +525,51 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner return result } -func Role(role rbac.Role) codersdk.Role { +func RBACRole(role rbac.Role) codersdk.Role { roleName, orgIDStr, err := rbac.RoleSplit(role.Name) if err != nil { roleName = role.Name } + orgPerms := role.Org[orgIDStr] return codersdk.Role{ - Name: roleName, - OrganizationID: orgIDStr, - DisplayName: role.DisplayName, - SitePermissions: List(role.Site, Permission), - // This is not perfect. If there are organization permissions in another - // organization, they will be omitted. This should not be allowed, so - // should never happen. - OrganizationPermissions: List(role.Org[orgIDStr], Permission), - UserPermissions: List(role.User, Permission), + Name: roleName, + OrganizationID: orgIDStr, + DisplayName: role.DisplayName, + SitePermissions: List(role.Site, RBACPermission), + OrganizationPermissions: List(orgPerms, RBACPermission), + UserPermissions: List(role.User, RBACPermission), } } -func Permission(permission rbac.Permission) codersdk.Permission { - return codersdk.Permission{ - Negate: permission.Negate, - ResourceType: codersdk.RBACResource(permission.ResourceType), - Action: codersdk.RBACAction(permission.Action), +func Role(role database.CustomRole) codersdk.Role { + orgID := "" + if role.OrganizationID.UUID != uuid.Nil { + orgID = role.OrganizationID.UUID.String() } -} -func RoleToRBAC(role codersdk.Role) rbac.Role { - orgPerms := map[string][]rbac.Permission{} - if role.OrganizationID != "" { - orgPerms = map[string][]rbac.Permission{ - role.OrganizationID: List(role.OrganizationPermissions, PermissionToRBAC), - } + return codersdk.Role{ + Name: role.Name, + OrganizationID: orgID, + DisplayName: role.DisplayName, + SitePermissions: List(role.SitePermissions, Permission), + OrganizationPermissions: List(role.OrgPermissions, Permission), + UserPermissions: List(role.UserPermissions, Permission), } +} - return rbac.Role{ - Name: rbac.RoleName(role.Name, role.OrganizationID), - DisplayName: role.DisplayName, - Site: List(role.SitePermissions, PermissionToRBAC), - Org: orgPerms, - User: List(role.UserPermissions, PermissionToRBAC), +func Permission(permission database.CustomRolePermission) codersdk.Permission { + return codersdk.Permission{ + Negate: permission.Negate, + ResourceType: codersdk.RBACResource(permission.ResourceType), + Action: codersdk.RBACAction(permission.Action), } } -func PermissionToRBAC(permission codersdk.Permission) rbac.Permission { - return rbac.Permission{ +func RBACPermission(permission rbac.Permission) codersdk.Permission { + return codersdk.Permission{ Negate: permission.Negate, - ResourceType: string(permission.ResourceType), - Action: policy.Action(permission.Action), + ResourceType: codersdk.RBACResource(permission.ResourceType), + Action: codersdk.RBACAction(permission.Action), } } diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index aaa2c7a34bbf3..ddcdca084f7f8 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -1,7 +1,6 @@ package dbauthz_test import ( - "encoding/json" "testing" "github.com/google/uuid" @@ -11,10 +10,12 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -60,17 +61,21 @@ func TestUpsertCustomRoles(t *testing.T) { return all } - orgID := uuid.New() + orgID := uuid.NullUUID{ + UUID: uuid.New(), + Valid: true, + } testCases := []struct { name string subject rbac.ExpandableRoles // Perms to create on new custom role - site []rbac.Permission - org map[string][]rbac.Permission - user []rbac.Permission - errorContains string + organizationID uuid.NullUUID + site []codersdk.Permission + org []codersdk.Permission + user []codersdk.Permission + errorContains string }{ { // No roles, so no assign role @@ -84,45 +89,31 @@ func TestUpsertCustomRoles(t *testing.T) { subject: merge(canAssignRole), }, { - name: "mixed-scopes", - subject: merge(canAssignRole, rbac.RoleOwner()), - site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, + name: "mixed-scopes", + subject: merge(canAssignRole, rbac.RoleOwner()), + organizationID: orgID, + site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), + org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), - org: map[string][]rbac.Permission{ - uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, - }), - }, errorContains: "cannot assign both org and site permissions", }, - { - name: "multiple-org", - subject: merge(canAssignRole, rbac.RoleOwner()), - org: map[string][]rbac.Permission{ - uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, - }), - uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, - }), - }, - errorContains: "cannot assign permissions to more than 1", - }, { name: "invalid-action", subject: merge(canAssignRole, rbac.RoleOwner()), - site: rbac.Permissions(map[string][]policy.Action{ + site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ // Action does not go with resource - rbac.ResourceWorkspace.Type: {policy.ActionViewInsights}, + codersdk.ResourceWorkspace: {codersdk.ActionViewInsights}, }), errorContains: "invalid action", }, { name: "invalid-resource", subject: merge(canAssignRole, rbac.RoleOwner()), - site: rbac.Permissions(map[string][]policy.Action{ - "foobar": {policy.ActionViewInsights}, + site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + "foobar": {codersdk.ActionViewInsights}, }), errorContains: "invalid resource", }, @@ -130,11 +121,11 @@ func TestUpsertCustomRoles(t *testing.T) { // Not allowing these at this time. name: "negative-permission", subject: merge(canAssignRole, rbac.RoleOwner()), - site: []rbac.Permission{ + site: []codersdk.Permission{ { Negate: true, - ResourceType: rbac.ResourceWorkspace.Type, - Action: policy.ActionRead, + ResourceType: codersdk.ResourceWorkspace, + Action: codersdk.ActionRead, }, }, errorContains: "no negative permissions", @@ -142,8 +133,8 @@ func TestUpsertCustomRoles(t *testing.T) { { name: "wildcard", // not allowed subject: merge(canAssignRole, rbac.RoleOwner()), - site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.WildcardSymbol}, + site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {"*"}, }), errorContains: "no wildcard symbols", }, @@ -151,40 +142,41 @@ func TestUpsertCustomRoles(t *testing.T) { { name: "read-workspace-escalation", subject: merge(canAssignRole), - site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, + site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), errorContains: "not allowed to grant this permission", }, { - name: "read-workspace-outside-org", - subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), - org: map[string][]rbac.Permission{ - // The org admin is for a different org - uuid.NewString(): rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, - }), + name: "read-workspace-outside-org", + organizationID: uuid.NullUUID{ + UUID: uuid.New(), + Valid: true, }, + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID.UUID)), + org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), errorContains: "not allowed to grant this permission", }, { name: "user-escalation", // These roles do not grant user perms - subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), - user: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID.UUID)), + user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), errorContains: "not allowed to grant this permission", }, { name: "template-admin-escalation", subject: merge(canAssignRole, rbac.RoleTemplateAdmin()), - site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, // ok! - rbac.ResourceDeploymentConfig.Type: {policy.ActionUpdate}, // not ok! + site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, // ok! + codersdk.ResourceDeploymentConfig: {codersdk.ActionUpdate}, // not ok! }), - user: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, // ok! + user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, // ok! }), errorContains: "deployment_config", }, @@ -192,36 +184,34 @@ func TestUpsertCustomRoles(t *testing.T) { { name: "read-workspace-template-admin", subject: merge(canAssignRole, rbac.RoleTemplateAdmin()), - site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, + site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), }, { - name: "read-workspace-in-org", - subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), - org: map[string][]rbac.Permission{ - // Org admin of this org, this is ok! - orgID.String(): rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, - }), - }, + name: "read-workspace-in-org", + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID.UUID)), + organizationID: orgID, + org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), }, { name: "user-perms", // This is weird, but is ok subject: merge(canAssignRole, rbac.RoleMember()), - user: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, + user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), }, { name: "site+user-perms", subject: merge(canAssignRole, rbac.RoleMember(), rbac.RoleTemplateAdmin()), - site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, + site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), - user: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, + user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), }, } @@ -244,9 +234,10 @@ func TestUpsertCustomRoles(t *testing.T) { _, err := az.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ Name: "test-role", DisplayName: "", - SitePermissions: must(json.Marshal(tc.site)), - OrgPermissions: must(json.Marshal(tc.org)), - UserPermissions: must(json.Marshal(tc.user)), + OrganizationID: tc.organizationID, + SitePermissions: db2sdk.List(tc.site, convertSDKPerm), + OrgPermissions: db2sdk.List(tc.org, convertSDKPerm), + UserPermissions: db2sdk.List(tc.user, convertSDKPerm), }) if tc.errorContains != "" { require.ErrorContains(t, err, tc.errorContains) @@ -256,3 +247,11 @@ func TestUpsertCustomRoles(t *testing.T) { }) } } + +func convertSDKPerm(perm codersdk.Permission) database.CustomRolePermission { + return database.CustomRolePermission{ + Negate: perm.Negate, + ResourceType: string(perm.ResourceType), + Action: policy.Action(perm.Action), + } +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3a814cfed88d2..a590e272e65fe 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3441,13 +3441,20 @@ func (q *querier) UpsertCustomRole(ctx context.Context, arg database.UpsertCusto return database.CustomRole{}, err } - // There is quite a bit of validation we should do here. First, let's make sure the json data is correct. + if arg.OrganizationID.UUID == uuid.Nil && len(arg.OrgPermissions) > 0 { + return database.CustomRole{}, xerrors.Errorf("organization permissions require specifying an organization id") + } + + // There is quite a bit of validation we should do here. + // The rbac.Role has a 'Valid()' function on it that will do a lot + // of checks. rbacRole, err := rolestore.ConvertDBRole(database.CustomRole{ Name: arg.Name, DisplayName: arg.DisplayName, SitePermissions: arg.SitePermissions, OrgPermissions: arg.OrgPermissions, UserPermissions: arg.UserPermissions, + OrganizationID: arg.OrganizationID, }) if err != nil { return database.CustomRole{}, xerrors.Errorf("invalid args: %w", err) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 9507e1b83c00e..218f73af762ae 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -13,7 +13,9 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" @@ -1202,22 +1204,22 @@ func (s *MethodTestSuite) TestUser() { check.Args(database.UpsertCustomRoleParams{ Name: "test", DisplayName: "Test Name", - SitePermissions: []byte(`[]`), - OrgPermissions: []byte(`{}`), - UserPermissions: []byte(`[]`), + SitePermissions: nil, + OrgPermissions: nil, + UserPermissions: nil, }).Asserts(rbac.ResourceAssignRole, policy.ActionCreate) })) s.Run("SitePermissions/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { check.Args(database.UpsertCustomRoleParams{ Name: "test", DisplayName: "Test Name", - SitePermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights}, - }))), - OrgPermissions: []byte(`{}`), - UserPermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, - }))), + SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights}, + }), convertSDKPerm), + OrgPermissions: nil, + UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), convertSDKPerm), }).Asserts( // First check rbac.ResourceAssignRole, policy.ActionCreate, @@ -1234,17 +1236,19 @@ func (s *MethodTestSuite) TestUser() { s.Run("OrgPermissions/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { orgID := uuid.New() check.Args(database.UpsertCustomRoleParams{ - Name: "test", - DisplayName: "Test Name", - SitePermissions: []byte(`[]`), - OrgPermissions: must(json.Marshal(map[string][]rbac.Permission{ - orgID.String(): rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead}, - }), - })), - UserPermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead}, - }))), + Name: "test", + DisplayName: "Test Name", + OrganizationID: uuid.NullUUID{ + UUID: orgID, + Valid: true, + }, + SitePermissions: nil, + OrgPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead}, + }), convertSDKPerm), + UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), convertSDKPerm), }).Asserts( // First check rbac.ResourceAssignRole, policy.ActionCreate, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index be612abc333f9..4dea6bdb39f75 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -823,9 +823,9 @@ func CustomRole(t testing.TB, db database.Store, seed database.CustomRole) datab Name: takeFirst(seed.Name, strings.ToLower(namesgenerator.GetRandomName(1))), DisplayName: namesgenerator.GetRandomName(1), OrganizationID: seed.OrganizationID, - SitePermissions: takeFirstSlice(seed.SitePermissions, []byte("[]")), - OrgPermissions: takeFirstSlice(seed.SitePermissions, []byte("{}")), - UserPermissions: takeFirstSlice(seed.SitePermissions, []byte("[]")), + SitePermissions: takeFirstSlice(seed.SitePermissions, []database.CustomRolePermission{}), + OrgPermissions: takeFirstSlice(seed.SitePermissions, []database.CustomRolePermission{}), + UserPermissions: takeFirstSlice(seed.SitePermissions, []database.CustomRolePermission{}), }) require.NoError(t, err, "insert custom role") return role diff --git a/coderd/database/migrations/000214_org_custom_role_array.down.sql b/coderd/database/migrations/000214_org_custom_role_array.down.sql new file mode 100644 index 0000000000000..099389eac58ce --- /dev/null +++ b/coderd/database/migrations/000214_org_custom_role_array.down.sql @@ -0,0 +1 @@ +UPDATE custom_roles SET org_permissions = '{}'; diff --git a/coderd/database/migrations/000214_org_custom_role_array.up.sql b/coderd/database/migrations/000214_org_custom_role_array.up.sql new file mode 100644 index 0000000000000..294d2826fe5f3 --- /dev/null +++ b/coderd/database/migrations/000214_org_custom_role_array.up.sql @@ -0,0 +1,4 @@ +-- Previous custom roles are now invalid, as the json changed. Since this is an +-- experimental feature, there is no point in trying to save the perms. +-- This does not elevate any permissions, so it is not a security issue. +UPDATE custom_roles SET org_permissions = '[]'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 42c41c83bd5dc..e5ba9fcea6841 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1783,13 +1783,13 @@ type AuditLog struct { // Custom roles allow dynamic roles expanded at runtime type CustomRole struct { - Name string `db:"name" json:"name"` - DisplayName string `db:"display_name" json:"display_name"` - SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"` - OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"` - UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + SitePermissions CustomRolePermissions `db:"site_permissions" json:"site_permissions"` + OrgPermissions CustomRolePermissions `db:"org_permissions" json:"org_permissions"` + UserPermissions CustomRolePermissions `db:"user_permissions" json:"user_permissions"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` // Roles can optionally be scoped to an organization OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 56fcfaf998e4f..5bc7552117b58 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5696,12 +5696,12 @@ RETURNING name, display_name, site_permissions, org_permissions, user_permission ` type UpsertCustomRoleParams struct { - Name string `db:"name" json:"name"` - DisplayName string `db:"display_name" json:"display_name"` - OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` - SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"` - OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"` - UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` + SitePermissions CustomRolePermissions `db:"site_permissions" json:"site_permissions"` + OrgPermissions CustomRolePermissions `db:"org_permissions" json:"org_permissions"` + UserPermissions CustomRolePermissions `db:"user_permissions" json:"user_permissions"` } func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleParams) (CustomRole, error) { diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 7913a9acf1627..ff8faf5f7704c 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -28,6 +28,15 @@ sql: emit_enum_valid_method: true emit_all_enum_values: true overrides: + - column: "custom_roles.site_permissions" + go_type: + type: "CustomRolePermissions" + - column: "custom_roles.org_permissions" + go_type: + type: "CustomRolePermissions" + - column: "custom_roles.user_permissions" + go_type: + type: "CustomRolePermissions" - column: "provisioner_daemons.tags" go_type: type: "StringMap" diff --git a/coderd/database/types.go b/coderd/database/types.go index 497446b25abfa..5d0490d0c9020 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -112,3 +112,33 @@ func (m *StringMapOfInt) Scan(src interface{}) error { func (m StringMapOfInt) Value() (driver.Value, error) { return json.Marshal(m) } + +type CustomRolePermissions []CustomRolePermission + +func (a *CustomRolePermissions) Scan(src interface{}) error { + switch v := src.(type) { + case string: + return json.Unmarshal([]byte(v), &a) + case []byte: + return json.Unmarshal(v, &a) + } + return xerrors.Errorf("unexpected type %T", src) +} + +func (a CustomRolePermissions) Value() (driver.Value, error) { + return json.Marshal(a) +} + +type CustomRolePermission struct { + Negate bool `json:"negate"` + ResourceType string `json:"resource_type"` + Action policy.Action `json:"action"` +} + +func (a CustomRolePermission) String() string { + str := a.ResourceType + "." + string(a.Action) + if a.Negate { + return "-" + str + } + return str +} diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index e0d199241fc9f..083f03877aa83 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -2,7 +2,6 @@ package rolestore import ( "context" - "encoding/json" "net/http" "github.com/google/uuid" @@ -96,6 +95,20 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, return roles, nil } +func convertPermissions(dbPerms []database.CustomRolePermission) []rbac.Permission { + n := make([]rbac.Permission, 0, len(dbPerms)) + for _, dbPerm := range dbPerms { + n = append(n, rbac.Permission{ + Negate: dbPerm.Negate, + ResourceType: dbPerm.ResourceType, + Action: dbPerm.Action, + }) + } + return n +} + +// ConvertDBRole should not be used by any human facing apis. It is used +// for authz purposes. func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { name := dbRole.Name if dbRole.OrganizationID.Valid { @@ -104,68 +117,21 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { role := rbac.Role{ Name: name, DisplayName: dbRole.DisplayName, - Site: nil, + Site: convertPermissions(dbRole.SitePermissions), Org: nil, - User: nil, - } - - err := json.Unmarshal(dbRole.SitePermissions, &role.Site) - if err != nil { - return role, xerrors.Errorf("unmarshal site permissions: %w", err) - } - - err = json.Unmarshal(dbRole.OrgPermissions, &role.Org) - if err != nil { - return role, xerrors.Errorf("unmarshal org permissions: %w", err) - } - - err = json.Unmarshal(dbRole.UserPermissions, &role.User) - if err != nil { - return role, xerrors.Errorf("unmarshal user permissions: %w", err) - } - - return role, nil -} - -func ConvertRoleToDB(role rbac.Role) (database.CustomRole, error) { - roleName, orgIDStr, err := rbac.RoleSplit(role.Name) - if err != nil { - return database.CustomRole{}, xerrors.Errorf("split role %q: %w", role.Name, err) + User: convertPermissions(dbRole.UserPermissions), } - dbRole := database.CustomRole{ - Name: roleName, - DisplayName: role.DisplayName, + // Org permissions only make sense if an org id is specified. + if len(dbRole.OrgPermissions) > 0 && dbRole.OrganizationID.UUID == uuid.Nil { + return rbac.Role{}, xerrors.Errorf("role has organization perms without an org id specified") } - if orgIDStr != "" { - orgID, err := uuid.Parse(orgIDStr) - if err != nil { - return database.CustomRole{}, xerrors.Errorf("parse org id %q: %w", orgIDStr, err) - } - dbRole.OrganizationID = uuid.NullUUID{ - UUID: orgID, - Valid: true, + if dbRole.OrganizationID.UUID != uuid.Nil { + role.Org = map[string][]rbac.Permission{ + dbRole.OrganizationID.UUID.String(): convertPermissions(dbRole.OrgPermissions), } } - siteData, err := json.Marshal(role.Site) - if err != nil { - return dbRole, xerrors.Errorf("marshal site permissions: %w", err) - } - dbRole.SitePermissions = siteData - - orgData, err := json.Marshal(role.Org) - if err != nil { - return dbRole, xerrors.Errorf("marshal org permissions: %w", err) - } - dbRole.OrgPermissions = orgData - - userData, err := json.Marshal(role.User) - if err != nil { - return dbRole, xerrors.Errorf("marshal user permissions: %w", err) - } - dbRole.UserPermissions = userData - - return dbRole, nil + return role, nil } diff --git a/coderd/roles.go b/coderd/roles.go index e8505baa4d255..94b121940ed45 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -10,7 +10,6 @@ import ( "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac/policy" - "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/coderd/httpapi" @@ -91,15 +90,7 @@ func (api *API) AssignableSiteRoles(rw http.ResponseWriter, r *http.Request) { return } - customRoles := make([]rbac.Role, 0, len(dbCustomRoles)) - for _, customRole := range dbCustomRoles { - rbacRole, err := rolestore.ConvertDBRole(customRole) - if err == nil { - customRoles = append(customRoles, rbacRole) - } - } - - httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, rbac.SiteRoles(), customRoles)) + httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, rbac.SiteRoles(), dbCustomRoles)) } // assignableOrgRoles returns all org wide roles that can be assigned. @@ -133,18 +124,10 @@ func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { return } - customRoles := make([]rbac.Role, 0, len(dbCustomRoles)) - for _, customRole := range dbCustomRoles { - rbacRole, err := rolestore.ConvertDBRole(customRole) - if err == nil { - customRoles = append(customRoles, rbacRole) - } - } - - httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles, customRoles)) + httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles, dbCustomRoles)) } -func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customRoles []rbac.Role) []codersdk.AssignableRoles { +func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customRoles []database.CustomRole) []codersdk.AssignableRoles { assignable := make([]codersdk.AssignableRoles, 0) for _, role := range roles { // The member role is implied, and not assignable. @@ -154,7 +137,7 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customR continue } assignable = append(assignable, codersdk.AssignableRoles{ - Role: db2sdk.Role(role), + Role: db2sdk.RBACRole(role), Assignable: rbac.CanAssignRole(actorRoles, role.Name), BuiltIn: true, }) diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 6d4f4bb6fe789..1b1aa94c6025a 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -6,14 +6,15 @@ import ( "slices" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" - "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -170,21 +171,23 @@ func TestListCustomRoles(t *testing.T) { owner := coderdtest.CreateFirstUser(t, client) const roleName = "random_role" - dbgen.CustomRole(t, db, must(rolestore.ConvertRoleToDB(rbac.Role{ - Name: rbac.RoleName(roleName, owner.OrganizationID.String()), + dbgen.CustomRole(t, db, database.CustomRole{ + Name: roleName, DisplayName: "Random Role", - Site: nil, - Org: map[string][]rbac.Permission{ - owner.OrganizationID.String(): { - { - Negate: false, - ResourceType: rbac.ResourceWorkspace.Type, - Action: policy.ActionRead, - }, + OrganizationID: uuid.NullUUID{ + UUID: owner.OrganizationID, + Valid: true, + }, + SitePermissions: nil, + OrgPermissions: []database.CustomRolePermission{ + { + Negate: false, + ResourceType: rbac.ResourceWorkspace.Type, + Action: policy.ActionRead, }, }, - User: nil, - }))) + UserPermissions: nil, + }) ctx := testutil.Context(t, testutil.WaitShort) roles, err := client.ListOrganizationRoles(ctx, owner.OrganizationID) @@ -199,7 +202,7 @@ func TestListCustomRoles(t *testing.T) { func convertRole(roleName string) codersdk.Role { role, _ := rbac.RoleByName(roleName) - return db2sdk.Role(role) + return db2sdk.RBACRole(role) } func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { diff --git a/codersdk/roles.go b/codersdk/roles.go index 8b119e935a6c6..bfab6b15ae391 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -40,7 +40,7 @@ type Role struct { DisplayName string `json:"display_name" table:"display_name"` SitePermissions []Permission `json:"site_permissions" table:"site_permissions"` // OrganizationPermissions are specific for the organization in the field 'OrganizationID' above. - OrganizationPermissions []Permission `json:"organization_permissions" table:"org_permissions"` + OrganizationPermissions []Permission `json:"organization_permissions" table:"organization_permissions"` UserPermissions []Permission `json:"user_permissions" table:"user_permissions"` } diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 448ec9f855cc0..b3a24a8a7779f 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -10,7 +10,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" - "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" ) @@ -59,27 +59,16 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, return codersdk.Role{}, false } - // Make sure all permissions inputted are valid according to our policy. - rbacRole := db2sdk.RoleToRBAC(role) - args, err := rolestore.ConvertRoleToDB(rbacRole) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid request", - Detail: err.Error(), - }) - return codersdk.Role{}, false - } - inserted, err := db.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ - Name: args.Name, - DisplayName: args.DisplayName, + Name: role.Name, + DisplayName: role.DisplayName, OrganizationID: uuid.NullUUID{ UUID: orgID, Valid: true, }, - SitePermissions: args.SitePermissions, - OrgPermissions: args.OrgPermissions, - UserPermissions: args.UserPermissions, + SitePermissions: db2sdk.List(role.SitePermissions, sdkPermissionToDB), + OrgPermissions: db2sdk.List(role.OrganizationPermissions, sdkPermissionToDB), + UserPermissions: db2sdk.List(role.UserPermissions, sdkPermissionToDB), }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) @@ -93,14 +82,13 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, return codersdk.Role{}, false } - convertedInsert, err := rolestore.ConvertDBRole(inserted) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Permissions were updated, unable to read them back out of the database.", - Detail: err.Error(), - }) - return codersdk.Role{}, false - } + return db2sdk.Role(inserted), true +} - return db2sdk.Role(convertedInsert), true +func sdkPermissionToDB(p codersdk.Permission) database.CustomRolePermission { + return database.CustomRolePermission{ + Negate: p.Negate, + ResourceType: string(p.ResourceType), + Action: policy.Action(p.Action), + } } From 3b7f9534fbce20b8bf887b624e1bc144a341b2be Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 4 Jun 2024 20:10:15 +0300 Subject: [PATCH 022/168] chore(scripts): fix dry run for autoversion in release.sh (#13470) --- scripts/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 66c30a6792821..ec887cd9c3dc1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -374,7 +374,7 @@ You can follow the release progress [here](https://github.com/coder/coder/action create_pr_stash=1 fi maybedryrun "${dry_run}" git checkout -b "${pr_branch}" "${remote}/${branch}" - execrelative go run ./release autoversion --channel "${channel}" "${new_version}" --dry-run + execrelative go run ./release autoversion --channel "${channel}" "${new_version}" --dry-run="${dry_run}" maybedryrun "${dry_run}" git add docs maybedryrun "${dry_run}" git commit -m "${title}" # Return to previous branch. From 8435b70bead5ef596ed5bcd7b47d8f85e343e61b Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 4 Jun 2024 12:22:13 -0500 Subject: [PATCH 023/168] chore: update docs for v2.12 mainline and v2.11 stable (#13469) * chore: update docs for v2.12 mainline and v2.11 stable * remove broken link --- docs/ides/remote-desktops.md | 4 ---- docs/install/kubernetes.md | 4 ++-- docs/install/releases.md | 17 +++++++++-------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/docs/ides/remote-desktops.md b/docs/ides/remote-desktops.md index 51ffe4e264cd6..5f654fb5ea8b6 100644 --- a/docs/ides/remote-desktops.md +++ b/docs/ides/remote-desktops.md @@ -33,10 +33,6 @@ To use RDP with Coder, you'll need to install an [RDP client](https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/remote-desktop-clients) on your local machine, and enable RDP on your workspace. -As a starting point, see the -[gcp-windows-rdp](https://github.com/matifali/coder-templates/tree/main/gcp-windows-rdp) -community template. It builds and provisions a Windows Server workspace on GCP. - Use the following command to forward the RDP port to your local machine: ```console diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 0b6d01a150297..e4847dcfe88e9 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -134,7 +134,7 @@ locally in order to log in and manage templates. helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.11.2 + --version 2.12.0 ``` For the **stable** Coder release: @@ -145,7 +145,7 @@ locally in order to log in and manage templates. helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.10.2 + --version 2.11.2 ``` You can watch Coder start up by running `kubectl get pods -n coder`. Once diff --git a/docs/install/releases.md b/docs/install/releases.md index 22dac07f687e3..8f7ffe370095e 100644 --- a/docs/install/releases.md +++ b/docs/install/releases.md @@ -47,11 +47,12 @@ pages. ## Release schedule -| Release name | Release Date | Status | -| ------------ | ------------------ | ---------------- | -| 2.7.x | January 01, 2024 | Not Supported | -| 2.8.x | Februrary 06, 2024 | Not Supported | -| 2.9.x | March 07, 2024 | Security Support | -| 2.10.x | April 03, 2024 | Stable | -| 2.11.x | May 07, 2024 | Mainline | -| 2.12.x | June 04, 2024 | Not Released | +| Release name | Release Date | Status | +| ------------ | ----------------- | ---------------- | +| 2.7.x | January 01, 2024 | Not Supported | +| 2.8.x | February 06, 2024 | Not Supported | +| 2.9.x | March 07, 2024 | Not Supported | +| 2.10.x | April 03, 2024 | Security Support | +| 2.11.x | May 07, 2024 | Stable | +| 2.12.x | June 04, 2024 | Mainline | +| 2.13.x | July 02, 2024 | Not Released | From 213848e2e391e4888fc18f3f9901d526f372e045 Mon Sep 17 00:00:00 2001 From: Stephen Kirby <58410745+stirby@users.noreply.github.com> Date: Tue, 4 Jun 2024 12:49:13 -0500 Subject: [PATCH 024/168] chore(docs): rename banners and show usage of multiple (#13435) * renamed banners in docs * fmt * Update appearance.md Co-authored-by: Kayla Washburn-Love * fmt --------- Co-authored-by: Kayla Washburn-Love Co-authored-by: Ben --- docs/admin/appearance.md | 13 +++++++------ .../admin/announcement_banner_settings.png | Bin 0 -> 23983 bytes docs/images/admin/multiple-banners.PNG | Bin 0 -> 53873 bytes 3 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 docs/images/admin/announcement_banner_settings.png create mode 100644 docs/images/admin/multiple-banners.PNG diff --git a/docs/admin/appearance.md b/docs/admin/appearance.md index 51710855a80fb..edfd144834254 100644 --- a/docs/admin/appearance.md +++ b/docs/admin/appearance.md @@ -18,16 +18,17 @@ is Coder. Specify a custom URL for your enterprise's logo to be displayed on the sign in page and in the top left corner of the dashboard. The default is the Coder logo. -## Service Banner +## Announcement Banners -![service banner](../images/admin/service-banner-config.png) +![service banner](../images/admin/announcement_banner_settings.png) -A Service Banner lets admins post important messages to all site users. Only -Site Owners may set the service banner. +Announcement Banners let admins post important messages to all site users. Only +Site Owners may set the announcement banners. -Example: Notify users of scheduled maintenance of the Coder deployment. +Example: Use multiple announcement banners for concurrent deployment-wide +updates, such as maintenance or new feature rollout. -![service banner maintenance](../images/admin/service-banner-maintenance.png) +![Multiple announcements](../images/admin/multiple-banners.PNG) Example: Adhere to government network classification requirements and notify users of which network their Coder deployment is on. diff --git a/docs/images/admin/announcement_banner_settings.png b/docs/images/admin/announcement_banner_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..beae02bc693db734af1a4a0aae986f6e979625b4 GIT binary patch literal 23983 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 GdiZanzMA_0 literal 0 HcmV?d00001 diff --git a/docs/images/admin/multiple-banners.PNG b/docs/images/admin/multiple-banners.PNG new file mode 100644 index 0000000000000000000000000000000000000000..07272f91167493a6672d24ad68b8a49553342381 GIT binary patch literal 53873 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 VCPguW9nS`$tf>CHOyTvr{{?~4*#iIo literal 0 HcmV?d00001 From 0ea89a3d41399be7ae6858ff7c113ebe15e9deec Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Tue, 4 Jun 2024 12:18:03 -0600 Subject: [PATCH 025/168] chore: add cleanup callbacks to some `useEffect` calls (#13444) --- site/src/contexts/useProxyLatency.ts | 4 +++ .../TemplateVersionEditorPage.tsx | 26 ++++++++++++++----- .../WorkspacePage/WorkspaceBuildProgress.tsx | 5 +++- site/src/utils/templateVersion.ts | 4 +-- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/site/src/contexts/useProxyLatency.ts b/site/src/contexts/useProxyLatency.ts index df2afc277b44a..e4ccc4ddb3303 100644 --- a/site/src/contexts/useProxyLatency.ts +++ b/site/src/contexts/useProxyLatency.ts @@ -225,6 +225,10 @@ export const useProxyLatency = ( // Local storage cleanup garbageCollectStoredLatencies(proxies, maxStoredLatencies); }); + + return () => { + observer.disconnect(); + }; }, [proxies, latestFetchRequest, maxStoredLatencies]); return { diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index fa9d5e25be527..2c3098aa82c10 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -245,20 +245,32 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { fileTree: undefined, tarFile: undefined, }); + useEffect(() => { + let stale = false; const initializeFileTree = async (file: ArrayBuffer) => { const tarFile = new TarReader(); - await tarFile.readFile(file); - const fileTree = await createTemplateVersionFileTree(tarFile); - setState({ fileTree, tarFile }); + try { + await tarFile.readFile(file); + // Ignore stale updates if this effect has been cancelled. + if (stale) { + return; + } + const fileTree = createTemplateVersionFileTree(tarFile); + setState({ fileTree, tarFile }); + } catch (error) { + console.error(error); + displayError("Error on initializing the editor"); + } }; if (fileQuery.data) { - initializeFileTree(fileQuery.data).catch((reason) => { - console.error(reason); - displayError("Error on initializing the editor"); - }); + void initializeFileTree(fileQuery.data); } + + return () => { + stale = true; + }; }, [fileQuery.data]); return state; diff --git a/site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx b/site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx index b9d844385f5d8..ab68e0911d011 100644 --- a/site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx @@ -97,7 +97,10 @@ export const WorkspaceBuildProgress: FC = ({ setProgressValue(est); setProgressText(text); }; - setTimeout(updateProgress, 5); + const updateTimer = requestAnimationFrame(updateProgress); + return () => { + cancelAnimationFrame(updateTimer); + }; }, [progressValue, job, transitionStats]); // HACK: the codersdk type generator doesn't support null values, but this diff --git a/site/src/utils/templateVersion.ts b/site/src/utils/templateVersion.ts index 5f6028cab7226..419105b4df4f8 100644 --- a/site/src/utils/templateVersion.ts +++ b/site/src/utils/templateVersion.ts @@ -23,9 +23,9 @@ export const getTemplateVersionFiles = async ( return files; }; -export const createTemplateVersionFileTree = async ( +export const createTemplateVersionFileTree = ( tarReader: TarReader, -): Promise => { +): FileTree => { let fileTree: FileTree = {}; for (const file of tarReader.fileInfo) { fileTree = set( From 83ac386533b6b792a123816e2067b3ed844d21a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Jun 2024 23:52:59 +0300 Subject: [PATCH 026/168] chore: bump ejs from 3.1.9 to 3.1.10 in /site (#13447) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index f454cd2ee9a22..1b2dc867b8c0f 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -3677,7 +3677,7 @@ packages: '@types/ejs': 3.1.4 '@yarnpkg/esbuild-plugin-pnp': 3.0.0-rc.15(esbuild@0.18.20) browser-assert: 1.2.1 - ejs: 3.1.9 + ejs: 3.1.10 esbuild: 0.18.20 esbuild-plugin-alias: 0.2.1 express: 4.19.2 @@ -6896,8 +6896,8 @@ packages: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: true - /ejs@3.1.9: - resolution: {integrity: sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==} + /ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} hasBin: true dependencies: From 9a757f8e74ae062a81ad365f95d67f24de77d08a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Jun 2024 02:01:26 +0300 Subject: [PATCH 027/168] chore(scripts): fix release promote stable to set latest tag (#13471) --- scripts/release/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release/main.go b/scripts/release/main.go index 8eaeb20825a92..3eb67c7b96225 100644 --- a/scripts/release/main.go +++ b/scripts/release/main.go @@ -242,6 +242,7 @@ func (r *releaseCommand) promoteVersionToStable(ctx context.Context, inv *serpen updatedBody := removeMainlineBlurb(newStable.GetBody()) updatedBody = addStableSince(time.Now().UTC(), updatedBody) updatedNewStable.Body = github.String(updatedBody) + updatedNewStable.MakeLatest = github.String("true") updatedNewStable.Prerelease = github.Bool(false) updatedNewStable.Draft = github.Bool(false) if !r.dryRun { From a4bba520a2203281074ff6e73fa5511680b4be72 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Wed, 5 Jun 2024 18:31:44 +1000 Subject: [PATCH 028/168] feat(cli): add json output to coder speedtest (#13475) --- cli/cliui/output.go | 7 +- cli/cliui/table.go | 74 ++++++++++++++++------ cli/cliui/table_test.go | 44 ++++++++++--- cli/speedtest.go | 69 ++++++++++++++++---- cli/speedtest_test.go | 45 +++++++++++++ cli/testdata/coder_speedtest_--help.golden | 7 ++ docs/cli/speedtest.md | 18 ++++++ 7 files changed, 226 insertions(+), 38 deletions(-) diff --git a/cli/cliui/output.go b/cli/cliui/output.go index 9f06d0ba5d2cb..d15d18b63fe18 100644 --- a/cli/cliui/output.go +++ b/cli/cliui/output.go @@ -7,6 +7,7 @@ import ( "reflect" "strings" + "github.com/jedib0t/go-pretty/v6/table" "golang.org/x/xerrors" "github.com/coder/serpent" @@ -143,7 +144,11 @@ func (f *tableFormat) AttachOptions(opts *serpent.OptionSet) { // Format implements OutputFormat. func (f *tableFormat) Format(_ context.Context, data any) (string, error) { - return DisplayTable(data, f.sort, f.columns) + headers := make(table.Row, len(f.allColumns)) + for i, header := range f.allColumns { + headers[i] = header + } + return renderTable(data, f.sort, headers, f.columns) } type jsonFormat struct{} diff --git a/cli/cliui/table.go b/cli/cliui/table.go index 9962678be902a..f1fb8075133c8 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -22,6 +22,13 @@ func Table() table.Writer { return tableWriter } +// This type can be supplied as part of a slice to DisplayTable +// or to a `TableFormat` `Format` call to render a separator. +// Leading separators are not supported and trailing separators +// are ignored by the table formatter. +// e.g. `[]any{someRow, TableSeparator, someRow}` +type TableSeparator struct{} + // filterTableColumns returns configurations to hide columns // that are not provided in the array. If the array is empty, // no filtering will occur! @@ -47,8 +54,12 @@ func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig return columnConfigs } -// DisplayTable renders a table as a string. The input argument must be a slice -// of structs. At least one field in the struct must have a `table:""` tag +// DisplayTable renders a table as a string. The input argument can be: +// - a struct slice. +// - an interface slice, where the first element is a struct, +// and all other elements are of the same type, or a TableSeparator. +// +// At least one field in the struct must have a `table:""` tag // containing the name of the column in the outputted table. // // If `sort` is not specified, the field with the `table:"$NAME,default_sort"` @@ -66,11 +77,20 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) v := reflect.Indirect(reflect.ValueOf(out)) if v.Kind() != reflect.Slice { - return "", xerrors.Errorf("DisplayTable called with a non-slice type") + return "", xerrors.New("DisplayTable called with a non-slice type") + } + var tableType reflect.Type + if v.Type().Elem().Kind() == reflect.Interface { + if v.Len() == 0 { + return "", xerrors.New("DisplayTable called with empty interface slice") + } + tableType = reflect.Indirect(reflect.ValueOf(v.Index(0).Interface())).Type() + } else { + tableType = v.Type().Elem() } // Get the list of table column headers. - headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem(), true) + headersRaw, defaultSort, err := typeToTableHeaders(tableType, true) if err != nil { return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err) } @@ -82,9 +102,8 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) } headers := make(table.Row, len(headersRaw)) for i, header := range headersRaw { - headers[i] = header + headers[i] = strings.ReplaceAll(header, "_", " ") } - // Verify that the given sort column and filter columns are valid. if sort != "" || len(filterColumns) != 0 { headersMap := make(map[string]string, len(headersRaw)) @@ -130,6 +149,11 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`)) } } + return renderTable(out, sort, headers, filterColumns) +} + +func renderTable(out any, sort string, headers table.Row, filterColumns []string) (string, error) { + v := reflect.Indirect(reflect.ValueOf(out)) // Setup the table formatter. tw := Table() @@ -143,15 +167,22 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) // Write each struct to the table. for i := 0; i < v.Len(); i++ { + cur := v.Index(i).Interface() + _, ok := cur.(TableSeparator) + if ok { + tw.AppendSeparator() + continue + } // Format the row as a slice. - rowMap, err := valueToTableMap(v.Index(i)) + // ValueToTableMap does what `reflect.Indirect` does + rowMap, err := valueToTableMap(reflect.ValueOf(cur)) if err != nil { return "", xerrors.Errorf("get table row map %v: %w", i, err) } rowSlice := make([]any, len(headers)) - for i, h := range headersRaw { - v, ok := rowMap[h] + for i, h := range headers { + v, ok := rowMap[h.(string)] if !ok { v = nil } @@ -188,25 +219,28 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) // returned. If the table tag is malformed, an error is returned. // // The returned name is transformed from "snake_case" to "normal text". -func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, skipParentName bool, err error) { +func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) { tags, err := structtag.Parse(string(field.Tag)) if err != nil { - return "", false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) + return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) } tag, err := tags.Get("table") if err != nil || tag.Name == "-" { // tags.Get only returns an error if the tag is not found. - return "", false, false, false, nil + return "", false, false, false, false, nil } defaultSortOpt := false + noSortOpt = false recursiveOpt := false skipParentNameOpt := false for _, opt := range tag.Options { switch opt { case "default_sort": defaultSortOpt = true + case "nosort": + noSortOpt = true case "recursive": recursiveOpt = true case "recursive_inline": @@ -216,11 +250,11 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, r recursiveOpt = true skipParentNameOpt = true default: - return "", false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) + return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) } } - return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, skipParentNameOpt, nil + return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil } func isStructOrStructPointer(t reflect.Type) bool { @@ -244,12 +278,16 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string, headers := []string{} defaultSortName := "" + noSortOpt := false for i := 0; i < t.NumField(); i++ { field := t.Field(i) - name, defaultSort, recursive, skip, err := parseTableStructTag(field) + name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field) if err != nil { return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) } + if requireDefault && noSort { + noSortOpt = true + } if name == "" && (recursive && skip) { return nil, "", xerrors.Errorf("a name is required for the field %q. "+ @@ -292,8 +330,8 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string, headers = append(headers, name) } - if defaultSortName == "" && requireDefault { - return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String()) + if defaultSortName == "" && requireDefault && !noSortOpt { + return nil, "", xerrors.Errorf("no field marked as default_sort or nosort in type %q", t.String()) } return headers, defaultSortName, nil @@ -320,7 +358,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { for i := 0; i < val.NumField(); i++ { field := val.Type().Field(i) fieldVal := val.Field(i) - name, _, recursive, skip, err := parseTableStructTag(field) + name, _, _, recursive, skip, err := parseTableStructTag(field) if err != nil { return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err) } diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index bb0b6c658fe45..5772c5cf5869e 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -218,6 +218,42 @@ Alice 25 compareTables(t, expected, out) }) + // This test ensures we can display dynamically typed slices + t.Run("Interfaces", func(t *testing.T) { + t.Parallel() + + in := []any{tableTest1{}} + out, err := cliui.DisplayTable(in, "", nil) + t.Log("rendered table:\n" + out) + require.NoError(t, err) + other := []tableTest1{{}} + expected, err := cliui.DisplayTable(other, "", nil) + require.NoError(t, err) + compareTables(t, expected, out) + }) + + t.Run("WithSeparator", func(t *testing.T) { + t.Parallel() + expected := ` +NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z +------------------------------------------------------------------------------------------------------------------------------------------------------------- +baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z +------------------------------------------------------------------------------------------------------------------------------------------------------------- +foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z + ` + + var inlineIn []any + for _, v := range in { + inlineIn = append(inlineIn, v) + inlineIn = append(inlineIn, cliui.TableSeparator{}) + } + out, err := cliui.DisplayTable(inlineIn, "", nil) + t.Log("rendered table:\n" + out) + require.NoError(t, err) + compareTables(t, expected, out) + }) + // This test ensures that safeties against invalid use of `table` tags // causes errors (even without data). t.Run("Errors", func(t *testing.T) { @@ -255,14 +291,6 @@ Alice 25 _, err := cliui.DisplayTable(in, "", nil) require.Error(t, err) }) - - t.Run("WithData", func(t *testing.T) { - t.Parallel() - - in := []any{tableTest1{}} - _, err := cliui.DisplayTable(in, "", nil) - require.Error(t, err) - }) }) t.Run("NotStruct", func(t *testing.T) { diff --git a/cli/speedtest.go b/cli/speedtest.go index 9f8090ef99731..3e0e74668b3b7 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -6,7 +6,6 @@ import ( "os" "time" - "github.com/jedib0t/go-pretty/v6/table" "golang.org/x/xerrors" tsspeedtest "tailscale.com/net/speedtest" "tailscale.com/wgengine/capture" @@ -19,12 +18,51 @@ import ( "github.com/coder/serpent" ) +type SpeedtestResult struct { + Overall SpeedtestResultInterval `json:"overall"` + Intervals []SpeedtestResultInterval `json:"intervals"` +} + +type SpeedtestResultInterval struct { + StartTimeSeconds float64 `json:"start_time_seconds"` + EndTimeSeconds float64 `json:"end_time_seconds"` + ThroughputMbits float64 `json:"throughput_mbits"` +} + +type speedtestTableItem struct { + Interval string `table:"Interval,nosort"` + Throughput string `table:"Throughput"` +} + func (r *RootCmd) speedtest() *serpent.Command { var ( direct bool duration time.Duration direction string pcapFile string + formatter = cliui.NewOutputFormatter( + cliui.ChangeFormatterData(cliui.TableFormat([]speedtestTableItem{}, []string{"Interval", "Throughput"}), func(data any) (any, error) { + res, ok := data.(SpeedtestResult) + if !ok { + // This should never happen + return "", xerrors.Errorf("expected speedtestResult, got %T", data) + } + tableRows := make([]any, len(res.Intervals)+2) + for i, r := range res.Intervals { + tableRows[i] = speedtestTableItem{ + Interval: fmt.Sprintf("%.2f-%.2f sec", r.StartTimeSeconds, r.EndTimeSeconds), + Throughput: fmt.Sprintf("%.4f Mbits/sec", r.ThroughputMbits), + } + } + tableRows[len(res.Intervals)] = cliui.TableSeparator{} + tableRows[len(res.Intervals)+1] = speedtestTableItem{ + Interval: fmt.Sprintf("%.2f-%.2f sec", res.Overall.StartTimeSeconds, res.Overall.EndTimeSeconds), + Throughput: fmt.Sprintf("%.4f Mbits/sec", res.Overall.ThroughputMbits), + } + return tableRows, nil + }), + cliui.JSONFormat(), + ) ) client := new(codersdk.Client) cmd := &serpent.Command{ @@ -124,24 +162,32 @@ func (r *RootCmd) speedtest() *serpent.Command { default: return xerrors.Errorf("invalid direction: %q", direction) } - cliui.Infof(inv.Stdout, "Starting a %ds %s test...", int(duration.Seconds()), tsDir) + cliui.Infof(inv.Stderr, "Starting a %ds %s test...", int(duration.Seconds()), tsDir) results, err := conn.Speedtest(ctx, tsDir, duration) if err != nil { return err } - tableWriter := cliui.Table() - tableWriter.AppendHeader(table.Row{"Interval", "Throughput"}) + var outputResult SpeedtestResult startTime := results[0].IntervalStart - for _, r := range results { + outputResult.Intervals = make([]SpeedtestResultInterval, len(results)-1) + for i, r := range results { + interval := SpeedtestResultInterval{ + StartTimeSeconds: r.IntervalStart.Sub(startTime).Seconds(), + EndTimeSeconds: r.IntervalEnd.Sub(startTime).Seconds(), + ThroughputMbits: r.MBitsPerSecond(), + } if r.Total { - tableWriter.AppendSeparator() + interval.StartTimeSeconds = 0 + outputResult.Overall = interval + } else { + outputResult.Intervals[i] = interval } - tableWriter.AppendRow(table.Row{ - fmt.Sprintf("%.2f-%.2f sec", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds()), - fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()), - }) } - _, err = fmt.Fprintln(inv.Stdout, tableWriter.Render()) + out, err := formatter.Format(inv.Context(), outputResult) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) return err }, } @@ -173,5 +219,6 @@ func (r *RootCmd) speedtest() *serpent.Command { Value: serpent.StringOf(&pcapFile), }, } + formatter.AttachOptions(&cmd.Options) return cmd } diff --git a/cli/speedtest_test.go b/cli/speedtest_test.go index 9878ff04ab527..281fdcc1488d0 100644 --- a/cli/speedtest_test.go +++ b/cli/speedtest_test.go @@ -1,7 +1,9 @@ package cli_test import ( + "bytes" "context" + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -10,6 +12,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" @@ -56,3 +59,45 @@ func TestSpeedtest(t *testing.T) { }) <-cmdDone } + +func TestSpeedtestJson(t *testing.T) { + t.Parallel() + t.Skip("Potentially flaky test - see https://github.com/coder/coder/issues/6321") + if testing.Short() { + t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!") + } + client, workspace, agentToken := setupWorkspaceForAgent(t) + _ = agenttest.New(t, client.URL, agentToken) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + require.Eventually(t, func() bool { + ws, err := client.Workspace(ctx, workspace.ID) + if !assert.NoError(t, err) { + return false + } + a := ws.LatestBuild.Resources[0].Agents[0] + return a.Status == codersdk.WorkspaceAgentConnected && + a.LifecycleState == codersdk.WorkspaceAgentLifecycleReady + }, testutil.WaitLong, testutil.IntervalFast, "agent is not ready") + + inv, root := clitest.New(t, "speedtest", "--output=json", workspace.Name) + clitest.SetupConfig(t, client, root) + out := bytes.NewBuffer(nil) + inv.Stdout = out + ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + inv.Logger = slogtest.Make(t, nil).Named("speedtest").Leveled(slog.LevelDebug) + cmdDone := tGo(t, func() { + err := inv.WithContext(ctx).Run() + assert.NoError(t, err) + }) + <-cmdDone + + var result cli.SpeedtestResult + require.NoError(t, json.Unmarshal(out.Bytes(), &result)) + require.Len(t, result.Intervals, 5) +} diff --git a/cli/testdata/coder_speedtest_--help.golden b/cli/testdata/coder_speedtest_--help.golden index 60eb4026b1028..538c955fae252 100644 --- a/cli/testdata/coder_speedtest_--help.golden +++ b/cli/testdata/coder_speedtest_--help.golden @@ -6,6 +6,10 @@ USAGE: Run upload and download tests from your machine to a workspace OPTIONS: + -c, --column string-array (default: Interval,Throughput) + Columns to display in table output. Available columns: Interval, + Throughput. + -d, --direct bool Specifies whether to wait for a direct connection before testing speed. @@ -14,6 +18,9 @@ OPTIONS: Specifies whether to run in reverse mode where the client receives and the server sends. + -o, --output string (default: table) + Output format. Available formats: table, json. + --pcap-file string Specifies a file to write a network capture to. diff --git a/docs/cli/speedtest.md b/docs/cli/speedtest.md index e2d3a435fb0ea..ab9d9a4f7e49c 100644 --- a/docs/cli/speedtest.md +++ b/docs/cli/speedtest.md @@ -45,3 +45,21 @@ Specifies the duration to monitor traffic. | Type | string | Specifies a file to write a network capture to. + +### -c, --column + +| | | +| ------- | -------------------------------- | +| Type | string-array | +| Default | Interval,Throughput | + +Columns to display in table output. Available columns: Interval, Throughput. + +### -o, --output + +| | | +| ------- | ------------------- | +| Type | string | +| Default | table | + +Output format. Available formats: table, json. From 42324b386a4850a344502f29e68701867bc82c66 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 5 Jun 2024 13:55:45 +0400 Subject: [PATCH 029/168] chore: add clock pkg for testing time (#13461) Adds a package for testing time/timer/ticker functions. Implementation is limited to `NewTimer` and `NewContextTicker`, but will eventually be expanded to all `time` functions from the standard library as well as `context.WithTimeout()`, `context.WithDeadline()`. Replaces `benbjohnson/clock` for the pubsub watchdog, as a proof of concept. Eventually, as we expand functionality, we will replace most time-related functions with this library for testing. --- clock/clock.go | 25 ++ clock/mock.go | 444 ++++++++++++++++++++++++ clock/real.go | 58 ++++ clock/timer.go | 67 ++++ coderd/database/pubsub/watchdog.go | 38 +- coderd/database/pubsub/watchdog_test.go | 71 ++-- 6 files changed, 658 insertions(+), 45 deletions(-) create mode 100644 clock/clock.go create mode 100644 clock/mock.go create mode 100644 clock/real.go create mode 100644 clock/timer.go diff --git a/clock/clock.go b/clock/clock.go new file mode 100644 index 0000000000000..44fdeb5716463 --- /dev/null +++ b/clock/clock.go @@ -0,0 +1,25 @@ +// Package clock is a library for testing time related code. It exports an interface Clock that +// mimics the standard library time package functions. In production, an implementation that calls +// thru to the standard library is used. In testing, a Mock clock is used to precisely control and +// intercept time functions. +package clock + +import ( + "context" + "time" +) + +type Clock interface { + // TickerFunc is a convenience function that calls f on the interval d until either the given + // context expires or f returns an error. Callers may call Wait() on the returned Waiter to + // wait until this happens and obtain the error. + TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter + // NewTimer creates a new Timer that will send the current time on its channel after at least + // duration d. + NewTimer(d time.Duration, tags ...string) *Timer +} + +// Waiter can be waited on for an error. +type Waiter interface { + Wait(tags ...string) error +} diff --git a/clock/mock.go b/clock/mock.go new file mode 100644 index 0000000000000..b119c53ccf9d6 --- /dev/null +++ b/clock/mock.go @@ -0,0 +1,444 @@ +package clock + +import ( + "context" + "errors" + "slices" + "sync" + "time" +) + +// Mock is the testing implementation of Clock. It tracks a time that monotonically increases +// during a test, triggering any timers or tickers automatically. +type Mock struct { + mu sync.Mutex + + // cur is the current time + cur time.Time + // advancing is true when we are in the process of advancing the clock. We don't support + // multiple goroutines doing this at once. + advancing bool + + all []event + nextTime time.Time + nextEvents []event + traps []*Trap +} + +type event interface { + next() time.Time + fire(t time.Time) +} + +func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionTickerFunc, tags, withDuration(d)) + m.matchCallLocked(c) + defer close(c.complete) + t := &mockTickerFunc{ + ctx: ctx, + d: d, + f: f, + nxt: m.cur.Add(d), + mock: m, + cond: sync.NewCond(&m.mu), + } + m.all = append(m.all, t) + m.recomputeNextLocked() + go t.waitForCtx() + return t +} + +func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { + if d < 0 { + panic("duration must be positive or zero") + } + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionNewTimer, tags, withDuration(d)) + defer close(c.complete) + m.matchCallLocked(c) + ch := make(chan time.Time, 1) + t := &Timer{ + C: ch, + c: ch, + nxt: m.cur.Add(d), + mock: m, + } + m.addTimerLocked(t) + return t +} + +func (m *Mock) addTimerLocked(t *Timer) { + m.all = append(m.all, t) + m.recomputeNextLocked() +} + +func (m *Mock) recomputeNextLocked() { + var best time.Time + var events []event + for _, e := range m.all { + if best.IsZero() || e.next().Before(best) { + best = e.next() + events = []event{e} + continue + } + if e.next().Equal(best) { + events = append(events, e) + continue + } + } + m.nextTime = best + m.nextEvents = events +} + +func (m *Mock) removeTimer(t *Timer) { + m.mu.Lock() + defer m.mu.Unlock() + m.removeTimerLocked(t) +} + +func (m *Mock) removeTimerLocked(t *Timer) { + defer m.recomputeNextLocked() + t.stopped = true + var e event = t + for i := range m.all { + if m.all[i] == e { + m.all = append(m.all[:i], m.all[i+1:]...) + return + } + } +} + +func (m *Mock) removeTickerFuncLocked(ct *mockTickerFunc) { + defer m.recomputeNextLocked() + var e event = ct + for i := range m.all { + if m.all[i] == e { + m.all = append(m.all[:i], m.all[i+1:]...) + return + } + } +} + +func (m *Mock) matchCallLocked(c *Call) { + var traps []*Trap + for _, t := range m.traps { + if t.matches(c) { + traps = append(traps, t) + } + } + if len(traps) == 0 { + return + } + c.releases.Add(len(traps)) + m.mu.Unlock() + for _, t := range traps { + go t.catch(c) + } + c.releases.Wait() + m.mu.Lock() +} + +// Advance moves the clock forward by d, triggering any timers or tickers. Advance will wait for +// tick functions of tickers created using TickerFunc to complete before returning from +// Advance. If multiple timers or tickers trigger simultaneously, they are all run on separate go +// routines. +func (m *Mock) Advance(d time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + m.advanceLocked(d) +} + +func (m *Mock) advanceLocked(d time.Duration) { + if m.advancing { + panic("multiple simultaneous calls to Advance not supported") + } + m.advancing = true + defer func() { + m.advancing = false + }() + + fin := m.cur.Add(d) + for { + // nextTime.IsZero implies no events scheduled + if m.nextTime.IsZero() || m.nextTime.After(fin) { + m.cur = fin + return + } + + if m.nextTime.After(m.cur) { + m.cur = m.nextTime + } + + wg := sync.WaitGroup{} + for i := range m.nextEvents { + e := m.nextEvents[i] + t := m.cur + wg.Add(1) + go func() { + e.fire(t) + wg.Done() + }() + } + // release the lock and let the events resolve. This allows them to call back into the + // Mock to query the time or set new timers. Each event should remove or reschedule + // itself from nextEvents. + m.mu.Unlock() + wg.Wait() + m.mu.Lock() + } +} + +// Set the time to t. If the time is after the current mocked time, then this is equivalent to +// Advance() with the difference. You may only Set the time earlier than the current time before +// starting tickers and timers (e.g. at the start of your test case). +func (m *Mock) Set(t time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + if t.Before(m.cur) { + // past + if !m.nextTime.IsZero() { + panic("Set mock clock to the past after timers/tickers started") + } + m.cur = t + return + } + // future, just advance as normal. + m.advanceLocked(t.Sub(m.cur)) +} + +// Trapper allows the creation of Traps +type Trapper struct { + // mock is the underlying Mock. This is a thin wrapper around Mock so that + // we can have our interface look like mClock.Trap().NewTimer("foo") + mock *Mock +} + +func (t Trapper) NewTimer(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionNewTimer, tags) +} + +func (t Trapper) TimerStop(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTimerStop, tags) +} + +func (t Trapper) TimerReset(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTimerReset, tags) +} + +func (t Trapper) TickerFunc(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTickerFunc, tags) +} + +func (t Trapper) TickerFuncWait(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTickerFuncWait, tags) +} + +func (m *Mock) Trap() Trapper { + return Trapper{m} +} + +func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { + m.mu.Lock() + defer m.mu.Unlock() + tr := &Trap{ + fn: fn, + tags: tags, + mock: m, + calls: make(chan *Call), + done: make(chan struct{}), + } + m.traps = append(m.traps, tr) + return tr +} + +// NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024. +// You may re-set the time earlier than this, but only before timers or tickers +// are created. +func NewMock() *Mock { + cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") + if err != nil { + panic(err) + } + return &Mock{ + cur: cur, + } +} + +var _ Clock = &Mock{} + +type mockTickerFunc struct { + ctx context.Context + d time.Duration + f func() error + nxt time.Time + mock *Mock + + // cond is a condition Locked on the main Mock.mu + cond *sync.Cond + // done is true when the ticker exits + done bool + // err holds the error when the ticker exits + err error +} + +func (m *mockTickerFunc) next() time.Time { + return m.nxt +} + +func (m *mockTickerFunc) fire(t time.Time) { + m.mock.mu.Lock() + defer m.mock.mu.Unlock() + if m.done { + return + } + if !m.nxt.Equal(t) { + panic("mockTickerFunc fired at wrong time") + } + m.nxt = m.nxt.Add(m.d) + m.mock.recomputeNextLocked() + + m.mock.mu.Unlock() + err := m.f() + m.mock.mu.Lock() + if err != nil { + m.exitLocked(err) + } +} + +func (m *mockTickerFunc) exitLocked(err error) { + if m.done { + return + } + m.done = true + m.err = err + m.mock.removeTickerFuncLocked(m) + m.cond.Broadcast() +} + +func (m *mockTickerFunc) waitForCtx() { + <-m.ctx.Done() + m.mock.mu.Lock() + defer m.mock.mu.Unlock() + m.exitLocked(m.ctx.Err()) +} + +func (m *mockTickerFunc) Wait(tags ...string) error { + m.mock.mu.Lock() + defer m.mock.mu.Unlock() + c := newCall(clockFunctionTickerFuncWait, tags) + m.mock.matchCallLocked(c) + defer close(c.complete) + for !m.done { + m.cond.Wait() + } + return m.err +} + +var _ Waiter = &mockTickerFunc{} + +type clockFunction int + +const ( + clockFunctionNewTimer clockFunction = iota + clockFunctionTimerStop + clockFunctionTimerReset + clockFunctionTickerFunc + clockFunctionTickerFuncWait +) + +type callArg func(c *Call) + +type Call struct { + Time time.Time + Duration time.Duration + Tags []string + + fn clockFunction + releases sync.WaitGroup + complete chan struct{} +} + +func (c *Call) Release() { + c.releases.Done() + <-c.complete +} + +// nolint: unused // it will be soon +func withTime(t time.Time) callArg { + return func(c *Call) { + c.Time = t + } +} + +func withDuration(d time.Duration) callArg { + return func(c *Call) { + c.Duration = d + } +} + +func newCall(fn clockFunction, tags []string, args ...callArg) *Call { + c := &Call{ + fn: fn, + Tags: tags, + complete: make(chan struct{}), + } + for _, a := range args { + a(c) + } + return c +} + +type Trap struct { + fn clockFunction + tags []string + mock *Mock + calls chan *Call + done chan struct{} +} + +func (t *Trap) catch(c *Call) { + select { + case t.calls <- c: + case <-t.done: + c.Release() + } +} + +func (t *Trap) matches(c *Call) bool { + if t.fn != c.fn { + return false + } + for _, tag := range t.tags { + if !slices.Contains(c.Tags, tag) { + return false + } + } + return true +} + +func (t *Trap) Close() { + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + for i, tr := range t.mock.traps { + if t == tr { + t.mock.traps = append(t.mock.traps[:i], t.mock.traps[i+1:]...) + } + } + close(t.done) +} + +var ErrTrapClosed = errors.New("trap closed") + +func (t *Trap) Wait(ctx context.Context) (*Call, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-t.done: + return nil, ErrTrapClosed + case c := <-t.calls: + return c, nil + } +} diff --git a/clock/real.go b/clock/real.go new file mode 100644 index 0000000000000..d632cb4943c4d --- /dev/null +++ b/clock/real.go @@ -0,0 +1,58 @@ +package clock + +import ( + "context" + "time" +) + +type realClock struct{} + +func NewReal() Clock { + return realClock{} +} + +func (realClock) TickerFunc(ctx context.Context, d time.Duration, f func() error, _ ...string) Waiter { + ct := &realContextTicker{ + ctx: ctx, + tkr: time.NewTicker(d), + f: f, + err: make(chan error, 1), + } + go ct.run() + return ct +} + +type realContextTicker struct { + ctx context.Context + tkr *time.Ticker + f func() error + err chan error +} + +func (t *realContextTicker) Wait(_ ...string) error { + return <-t.err +} + +func (t *realContextTicker) run() { + defer t.tkr.Stop() + for { + select { + case <-t.ctx.Done(): + t.err <- t.ctx.Err() + return + case <-t.tkr.C: + err := t.f() + if err != nil { + t.err <- err + return + } + } + } +} + +func (realClock) NewTimer(d time.Duration, _ ...string) *Timer { + rt := time.NewTimer(d) + return &Timer{C: rt.C, timer: rt} +} + +var _ Clock = realClock{} diff --git a/clock/timer.go b/clock/timer.go new file mode 100644 index 0000000000000..bf31ab18a6764 --- /dev/null +++ b/clock/timer.go @@ -0,0 +1,67 @@ +package clock + +import "time" + +type Timer struct { + C <-chan time.Time + //nolint: revive + c chan time.Time + timer *time.Timer // realtime impl, if set + nxt time.Time // next tick time + mock *Mock // mock clock, if set + fn func() // AfterFunc function, if set + stopped bool // True if stopped, false if running +} + +func (t *Timer) fire(tt time.Time) { + if !tt.Equal(t.nxt) { + panic("mock timer fired at wrong time") + } + t.mock.removeTimer(t) + t.c <- tt + if t.fn != nil { + t.fn() + } +} + +func (t *Timer) next() time.Time { + return t.nxt +} + +func (t *Timer) Stop(tags ...string) bool { + if t.timer != nil { + return t.timer.Stop() + } + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + c := newCall(clockFunctionTimerStop, tags) + t.mock.matchCallLocked(c) + defer close(c.complete) + result := !t.stopped + t.mock.removeTimerLocked(t) + return result +} + +func (t *Timer) Reset(d time.Duration, tags ...string) bool { + if t.timer != nil { + return t.timer.Reset(d) + } + if d < 0 { + panic("duration must be positive or zero") + } + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + c := newCall(clockFunctionTimerReset, tags, withDuration(d)) + t.mock.matchCallLocked(c) + defer close(c.complete) + result := !t.stopped + t.mock.removeTimerLocked(t) + t.stopped = false + t.nxt = t.mock.cur.Add(d) + select { + case <-t.c: + default: + } + t.mock.addTimerLocked(t) + return result +} diff --git a/coderd/database/pubsub/watchdog.go b/coderd/database/pubsub/watchdog.go index 687129fc5bcc2..df54019bb49b2 100644 --- a/coderd/database/pubsub/watchdog.go +++ b/coderd/database/pubsub/watchdog.go @@ -7,9 +7,8 @@ import ( "sync" "time" - "github.com/benbjohnson/clock" - "cdr.dev/slog" + "github.com/coder/coder/v2/clock" ) const ( @@ -36,7 +35,7 @@ type Watchdog struct { } func NewWatchdog(ctx context.Context, logger slog.Logger, ps Pubsub) *Watchdog { - return NewWatchdogWithClock(ctx, logger, ps, clock.New()) + return NewWatchdogWithClock(ctx, logger, ps, clock.NewReal()) } // NewWatchdogWithClock returns a watchdog with the given clock. Product code should always call NewWatchDog. @@ -79,32 +78,23 @@ func (w *Watchdog) Timeout() <-chan struct{} { func (w *Watchdog) publishLoop() { defer w.wg.Done() - tkr := w.clock.Ticker(periodHeartbeat) - defer tkr.Stop() - // immediate publish after starting the ticker. This helps testing so that we can tell from - // the outside that the ticker is started. - err := w.ps.Publish(EventPubsubWatchdog, []byte{}) - if err != nil { - w.logger.Warn(w.ctx, "failed to publish heartbeat on pubsub watchdog", slog.Error(err)) - } - for { - select { - case <-w.ctx.Done(): - w.logger.Debug(w.ctx, "context done; exiting publishLoop") - return - case <-tkr.C: - err := w.ps.Publish(EventPubsubWatchdog, []byte{}) - if err != nil { - w.logger.Warn(w.ctx, "failed to publish heartbeat on pubsub watchdog", slog.Error(err)) - } + tkr := w.clock.TickerFunc(w.ctx, periodHeartbeat, func() error { + err := w.ps.Publish(EventPubsubWatchdog, []byte{}) + if err != nil { + w.logger.Warn(w.ctx, "failed to publish heartbeat on pubsub watchdog", slog.Error(err)) + } else { + w.logger.Debug(w.ctx, "published heartbeat on pubsub watchdog") } - } + return err + }, "publish") + // ignore the error, since we log before returning the error + _ = tkr.Wait() } func (w *Watchdog) subscribeMonitor() { defer w.wg.Done() - tmr := w.clock.Timer(periodTimeout) - defer tmr.Stop() + tmr := w.clock.NewTimer(periodTimeout) + defer tmr.Stop("subscribe") beats := make(chan struct{}) unsub, err := w.ps.Subscribe(EventPubsubWatchdog, func(context.Context, []byte) { w.logger.Debug(w.ctx, "got heartbeat for pubsub watchdog") diff --git a/coderd/database/pubsub/watchdog_test.go b/coderd/database/pubsub/watchdog_test.go index ddd5a864e2c66..8d695447e91cf 100644 --- a/coderd/database/pubsub/watchdog_test.go +++ b/coderd/database/pubsub/watchdog_test.go @@ -4,36 +4,48 @@ import ( "testing" "time" - "github.com/benbjohnson/clock" "github.com/stretchr/testify/require" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/clock" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/testutil" ) func TestWatchdog_NoTimeout(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, time.Hour) + ctx := testutil.Context(t, testutil.WaitShort) mClock := clock.NewMock() - start := time.Date(2024, 2, 5, 8, 7, 6, 5, time.UTC) - mClock.Set(start) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) fPS := newFakePubsub() + + // trap the ticker and timer.Stop() calls + pubTrap := mClock.Trap().TickerFunc("publish") + defer pubTrap.Close() + subTrap := mClock.Trap().TimerStop("subscribe") + defer subTrap.Close() + uut := pubsub.NewWatchdogWithClock(ctx, logger, fPS, mClock) + // wait for the ticker to be created so that we know it starts from the + // right baseline time. + pc, err := pubTrap.Wait(ctx) + require.NoError(t, err) + pc.Release() + require.Equal(t, 15*time.Second, pc.Duration) + + // we subscribe after starting the timer, so we know the timer also starts + // from the baseline. sub := testutil.RequireRecvCtx(ctx, t, fPS.subs) require.Equal(t, pubsub.EventPubsubWatchdog, sub.event) - p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) - require.Equal(t, pubsub.EventPubsubWatchdog, p) // 5 min / 15 sec = 20, so do 21 ticks for i := 0; i < 21; i++ { - mClock.Add(15 * time.Second) - p = testutil.RequireRecvCtx(ctx, t, fPS.pubs) + mClock.Advance(15 * time.Second) + p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) - mClock.Add(30 * time.Millisecond) // reasonable round-trip + mClock.Advance(30 * time.Millisecond) // reasonable round-trip // forward the beat sub.listener(ctx, []byte{}) // we shouldn't time out @@ -45,7 +57,14 @@ func TestWatchdog_NoTimeout(t *testing.T) { } } - err := uut.Close() + errCh := make(chan error, 1) + go func() { + errCh <- uut.Close() + }() + sc, err := subTrap.Wait(ctx) // timer.Stop() called + require.NoError(t, err) + sc.Release() + err = testutil.RequireRecvCtx(ctx, t, errCh) require.NoError(t, err) } @@ -53,23 +72,33 @@ func TestWatchdog_Timeout(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) mClock := clock.NewMock() - start := time.Date(2024, 2, 5, 8, 7, 6, 5, time.UTC) - mClock.Set(start) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) fPS := newFakePubsub() + + // trap the ticker and timer calls + pubTrap := mClock.Trap().TickerFunc("publish") + defer pubTrap.Close() + uut := pubsub.NewWatchdogWithClock(ctx, logger, fPS, mClock) + // wait for the ticker to be created so that we know it starts from the + // right baseline time. + pc, err := pubTrap.Wait(ctx) + require.NoError(t, err) + pc.Release() + require.Equal(t, 15*time.Second, pc.Duration) + + // we subscribe after starting the timer, so we know the timer also starts + // from the baseline. sub := testutil.RequireRecvCtx(ctx, t, fPS.subs) require.Equal(t, pubsub.EventPubsubWatchdog, sub.event) - p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) - require.Equal(t, pubsub.EventPubsubWatchdog, p) // 5 min / 15 sec = 20, so do 19 ticks without timing out for i := 0; i < 19; i++ { - mClock.Add(15 * time.Second) - p = testutil.RequireRecvCtx(ctx, t, fPS.pubs) + mClock.Advance(15 * time.Second) + p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) - mClock.Add(30 * time.Millisecond) // reasonable round-trip + mClock.Advance(30 * time.Millisecond) // reasonable round-trip // we DO NOT forward the heartbeat // we shouldn't time out select { @@ -79,12 +108,12 @@ func TestWatchdog_Timeout(t *testing.T) { // OK! } } - mClock.Add(15 * time.Second) - p = testutil.RequireRecvCtx(ctx, t, fPS.pubs) + mClock.Advance(15 * time.Second) + p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) testutil.RequireRecvCtx(ctx, t, uut.Timeout()) - err := uut.Close() + err = uut.Close() require.NoError(t, err) } @@ -118,7 +147,7 @@ func (f *fakePubsub) Publish(event string, _ []byte) error { func newFakePubsub() *fakePubsub { return &fakePubsub{ - pubs: make(chan string), + pubs: make(chan string, 1), subs: make(chan subscribe), } } From 9c3fd5dd26d6c2e86fc22dcffe935cb436cf98a1 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 5 Jun 2024 15:37:16 +0400 Subject: [PATCH 030/168] chore: add explicit Wait() to clock.Advance() (#13464) --- clock/mock.go | 83 +++++++++++++++++++------ coderd/database/pubsub/watchdog_test.go | 12 ++-- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/clock/mock.go b/clock/mock.go index b119c53ccf9d6..55e4254ac2d3e 100644 --- a/clock/mock.go +++ b/clock/mock.go @@ -5,6 +5,7 @@ import ( "errors" "slices" "sync" + "testing" "time" ) @@ -141,14 +142,51 @@ func (m *Mock) matchCallLocked(c *Call) { m.mu.Lock() } -// Advance moves the clock forward by d, triggering any timers or tickers. Advance will wait for -// tick functions of tickers created using TickerFunc to complete before returning from -// Advance. If multiple timers or tickers trigger simultaneously, they are all run on separate go -// routines. -func (m *Mock) Advance(d time.Duration) { - m.mu.Lock() - defer m.mu.Unlock() - m.advanceLocked(d) +// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for tick functions of +// tickers created using TickerFunc to complete. If multiple timers or tickers trigger +// simultaneously, they are all run on separate go routines. +type AdvanceWaiter struct { + ch chan struct{} +} + +// Wait for all timers and ticks to complete, or until context expires. +func (w AdvanceWaiter) Wait(ctx context.Context) error { + select { + case <-w.ch: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// MustWait waits for all timers and ticks to complete, and fails the test immediately if the +// context completes first. MustWait must be called from the goroutine running the test or +// benchmark, similar to `t.FailNow()`. +func (w AdvanceWaiter) MustWait(ctx context.Context, t testing.TB) { + select { + case <-w.ch: + return + case <-ctx.Done(): + t.Fatalf("context expired while waiting for clock to advance: %s", ctx.Err()) + } +} + +// Done returns a channel that is closed when all timers and ticks complete. +func (w AdvanceWaiter) Done() <-chan struct{} { + return w.ch +} + +// Advance moves the clock forward by d, triggering any timers or tickers. The returned value can +// be used to wait for all timers and ticks to complete. +func (m *Mock) Advance(d time.Duration) AdvanceWaiter { + w := AdvanceWaiter{ch: make(chan struct{})} + go func() { + defer close(w.ch) + m.mu.Lock() + defer m.mu.Unlock() + m.advanceLocked(d) + }() + return w } func (m *Mock) advanceLocked(d time.Duration) { @@ -194,19 +232,24 @@ func (m *Mock) advanceLocked(d time.Duration) { // Set the time to t. If the time is after the current mocked time, then this is equivalent to // Advance() with the difference. You may only Set the time earlier than the current time before // starting tickers and timers (e.g. at the start of your test case). -func (m *Mock) Set(t time.Time) { - m.mu.Lock() - defer m.mu.Unlock() - if t.Before(m.cur) { - // past - if !m.nextTime.IsZero() { - panic("Set mock clock to the past after timers/tickers started") +func (m *Mock) Set(t time.Time) AdvanceWaiter { + w := AdvanceWaiter{ch: make(chan struct{})} + go func() { + defer close(w.ch) + m.mu.Lock() + defer m.mu.Unlock() + if t.Before(m.cur) { + // past + if !m.nextTime.IsZero() { + panic("Set mock clock to the past after timers/tickers started") + } + m.cur = t + return } - m.cur = t - return - } - // future, just advance as normal. - m.advanceLocked(t.Sub(m.cur)) + // future, just advance as normal. + m.advanceLocked(t.Sub(m.cur)) + }() + return w } // Trapper allows the creation of Traps diff --git a/coderd/database/pubsub/watchdog_test.go b/coderd/database/pubsub/watchdog_test.go index 8d695447e91cf..62d51c8ecaaee 100644 --- a/coderd/database/pubsub/watchdog_test.go +++ b/coderd/database/pubsub/watchdog_test.go @@ -42,10 +42,11 @@ func TestWatchdog_NoTimeout(t *testing.T) { // 5 min / 15 sec = 20, so do 21 ticks for i := 0; i < 21; i++ { - mClock.Advance(15 * time.Second) + mClock.Advance(15*time.Second).MustWait(ctx, t) p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) - mClock.Advance(30 * time.Millisecond) // reasonable round-trip + mClock.Advance(30*time.Millisecond). // reasonable round-trip + MustWait(ctx, t) // forward the beat sub.listener(ctx, []byte{}) // we shouldn't time out @@ -95,10 +96,11 @@ func TestWatchdog_Timeout(t *testing.T) { // 5 min / 15 sec = 20, so do 19 ticks without timing out for i := 0; i < 19; i++ { - mClock.Advance(15 * time.Second) + mClock.Advance(15*time.Second).MustWait(ctx, t) p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) - mClock.Advance(30 * time.Millisecond) // reasonable round-trip + mClock.Advance(30*time.Millisecond). // reasonable round-trip + MustWait(ctx, t) // we DO NOT forward the heartbeat // we shouldn't time out select { @@ -108,7 +110,7 @@ func TestWatchdog_Timeout(t *testing.T) { // OK! } } - mClock.Advance(15 * time.Second) + mClock.Advance(15*time.Second).MustWait(ctx, t) p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) testutil.RequireRecvCtx(ctx, t, uut.Timeout()) From ffcfbb6c55070d6e8878f8e2406957915eff0371 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 5 Jun 2024 15:49:31 +0400 Subject: [PATCH 031/168] chore: add example test case for clock package (#13465) --- clock/example_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 clock/example_test.go diff --git a/clock/example_test.go b/clock/example_test.go new file mode 100644 index 0000000000000..69d6ba4a318ae --- /dev/null +++ b/clock/example_test.go @@ -0,0 +1,84 @@ +package clock_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/coder/coder/v2/clock" +) + +type exampleTickCounter struct { + ctx context.Context + mu sync.Mutex + ticks int + clock clock.Clock +} + +func (c *exampleTickCounter) Ticks() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.ticks +} + +func (c *exampleTickCounter) count() { + _ = c.clock.TickerFunc(c.ctx, time.Hour, func() error { + c.mu.Lock() + defer c.mu.Unlock() + c.ticks++ + return nil + }, "mytag") +} + +func newExampleTickCounter(ctx context.Context, clk clock.Clock) *exampleTickCounter { + tc := &exampleTickCounter{ctx: ctx, clock: clk} + go tc.count() + return tc +} + +// TestExampleTickerFunc demonstrates how to test the use of TickerFunc. +func TestExampleTickerFunc(t *testing.T) { + t.Parallel() + // nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + mClock := clock.NewMock() + + // Because the ticker is started on a goroutine, we can't immediately start + // advancing the clock, or we will race with the start of the ticker. If we + // win that race, the clock gets advanced _before_ the ticker starts, and + // our ticker will not get a tick. + // + // To handle this, we set a trap for the call to TickerFunc(), so that we + // can assert it has been called before advancing the clock. + trap := mClock.Trap().TickerFunc("mytag") + defer trap.Close() + + tc := newExampleTickCounter(ctx, mClock) + + // Here, we wait for our trap to be triggered. + call, err := trap.Wait(ctx) + if err != nil { + t.Fatal("ticker never started") + } + // it's good practice to release calls before any possible t.Fatal() calls + // so that we don't leave dangling goroutines waiting for the call to be + // released. + call.Release() + if call.Duration != time.Hour { + t.Fatal("unexpected duration") + } + + if tks := tc.Ticks(); tks != 0 { + t.Fatalf("expected 0 got %d ticks", tks) + } + + // Now that we know the ticker is started, we can advance the time. + mClock.Advance(time.Hour).MustWait(ctx, t) + + if tks := tc.Ticks(); tks != 1 { + t.Fatalf("expected 1 got %d ticks", tks) + } +} From 775fc3f5e9d2b92d3d713af8d84ab1ca566952f9 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 5 Jun 2024 16:00:02 +0400 Subject: [PATCH 032/168] chore: add Now, Since, AfterFunc to clock; use clock for configmaps test (#13476) * chore: add Now, Since, AfterFunc to clock; use clock for configmaps test * chore: update flake.nix vendor hash Signed-off-by: Spike Curtis --------- Signed-off-by: Spike Curtis --- clock/clock.go | 9 ++++ clock/mock.go | 67 +++++++++++++++++++++++++++-- clock/real.go | 13 ++++++ clock/timer.go | 3 +- flake.nix | 2 +- go.mod | 1 - go.sum | 2 - tailnet/configmaps.go | 4 +- tailnet/configmaps_internal_test.go | 47 ++++++-------------- 9 files changed, 104 insertions(+), 44 deletions(-) diff --git a/clock/clock.go b/clock/clock.go index 44fdeb5716463..516b74e6b117b 100644 --- a/clock/clock.go +++ b/clock/clock.go @@ -17,6 +17,15 @@ type Clock interface { // NewTimer creates a new Timer that will send the current time on its channel after at least // duration d. NewTimer(d time.Duration, tags ...string) *Timer + // AfterFunc waits for the duration to elapse and then calls f in its own goroutine. It returns + // a Timer that can be used to cancel the call using its Stop method. The returned Timer's C + // field is not used and will be nil. + AfterFunc(d time.Duration, f func(), tags ...string) *Timer + + // Now returns the current local time. + Now(tags ...string) time.Time + // Since returns the time elapsed since t. It is shorthand for Clock.Now().Sub(t). + Since(t time.Time, tags ...string) time.Duration } // Waiter can be waited on for an error. diff --git a/clock/mock.go b/clock/mock.go index 55e4254ac2d3e..55c8cdcaa3277 100644 --- a/clock/mock.go +++ b/clock/mock.go @@ -71,6 +71,46 @@ func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { return t } +func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { + if d < 0 { + panic("duration must be positive or zero") + } + m.mu.Lock() + defer m.mu.Unlock() + m.matchCallLocked(&Call{ + fn: clockFunctionAfterFunc, + Duration: d, + Tags: tags, + }) + t := &Timer{ + nxt: m.cur.Add(d), + fn: f, + mock: m, + } + m.addTimerLocked(t) + return t +} + +func (m *Mock) Now(tags ...string) time.Time { + m.mu.Lock() + defer m.mu.Unlock() + m.matchCallLocked(&Call{ + fn: clockFunctionNow, + Tags: tags, + }) + return m.cur +} + +func (m *Mock) Since(t time.Time, tags ...string) time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + m.matchCallLocked(&Call{ + fn: clockFunctionSince, + Tags: tags, + }) + return m.cur.Sub(t) +} + func (m *Mock) addTimerLocked(t *Timer) { m.all = append(m.all, t) m.recomputeNextLocked() @@ -142,9 +182,13 @@ func (m *Mock) matchCallLocked(c *Call) { m.mu.Lock() } -// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for tick functions of -// tickers created using TickerFunc to complete. If multiple timers or tickers trigger -// simultaneously, they are all run on separate go routines. +// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for: +// +// - tick functions of tickers created using NewContextTicker +// - functions passed to AfterFunc +// +// to complete. If multiple timers or tickers trigger simultaneously, they are all run on separate +// go routines. type AdvanceWaiter struct { ch chan struct{} } @@ -191,7 +235,7 @@ func (m *Mock) Advance(d time.Duration) AdvanceWaiter { func (m *Mock) advanceLocked(d time.Duration) { if m.advancing { - panic("multiple simultaneous calls to Advance not supported") + panic("multiple simultaneous calls to Advance/Set not supported") } m.advancing = true defer func() { @@ -263,6 +307,10 @@ func (t Trapper) NewTimer(tags ...string) *Trap { return t.mock.newTrap(clockFunctionNewTimer, tags) } +func (t Trapper) AfterFunc(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionAfterFunc, tags) +} + func (t Trapper) TimerStop(tags ...string) *Trap { return t.mock.newTrap(clockFunctionTimerStop, tags) } @@ -279,6 +327,14 @@ func (t Trapper) TickerFuncWait(tags ...string) *Trap { return t.mock.newTrap(clockFunctionTickerFuncWait, tags) } +func (t Trapper) Now(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionNow, tags) +} + +func (t Trapper) Since(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionSince, tags) +} + func (m *Mock) Trap() Trapper { return Trapper{m} } @@ -386,10 +442,13 @@ type clockFunction int const ( clockFunctionNewTimer clockFunction = iota + clockFunctionAfterFunc clockFunctionTimerStop clockFunctionTimerReset clockFunctionTickerFunc clockFunctionTickerFuncWait + clockFunctionNow + clockFunctionSince ) type callArg func(c *Call) diff --git a/clock/real.go b/clock/real.go index d632cb4943c4d..e31c80616d896 100644 --- a/clock/real.go +++ b/clock/real.go @@ -55,4 +55,17 @@ func (realClock) NewTimer(d time.Duration, _ ...string) *Timer { return &Timer{C: rt.C, timer: rt} } +func (realClock) AfterFunc(d time.Duration, f func(), _ ...string) *Timer { + rt := time.AfterFunc(d, f) + return &Timer{C: rt.C, timer: rt} +} + +func (realClock) Now(_ ...string) time.Time { + return time.Now() +} + +func (realClock) Since(t time.Time, _ ...string) time.Duration { + return time.Since(t) +} + var _ Clock = realClock{} diff --git a/clock/timer.go b/clock/timer.go index bf31ab18a6764..ee1d67485219d 100644 --- a/clock/timer.go +++ b/clock/timer.go @@ -18,9 +18,10 @@ func (t *Timer) fire(tt time.Time) { panic("mock timer fired at wrong time") } t.mock.removeTimer(t) - t.c <- tt if t.fn != nil { t.fn() + } else { + t.c <- tt } } diff --git a/flake.nix b/flake.nix index 8cc4ea27b5f00..2b6f032ade30d 100644 --- a/flake.nix +++ b/flake.nix @@ -97,7 +97,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-PDA+Yd/tYvMTJfw8zyperTYnSBijvECc6XjxqnYgtkw="; + vendorHash = "sha256-BVgjKZeVogPb/kyCtZw/R898TI4YGnu9oWzzxHSVIyY="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; diff --git a/go.mod b/go.mod index 2261c73e55e1b..84d1274adc4ee 100644 --- a/go.mod +++ b/go.mod @@ -198,7 +198,6 @@ require ( require go.uber.org/mock v0.4.0 require ( - github.com/benbjohnson/clock v1.3.5 github.com/coder/serpent v0.7.0 github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 github.com/google/go-github/v61 v61.0.0 diff --git a/go.sum b/go.sum index f6be769a4315e..0f5f7b9bf3b1c 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,6 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= -github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= diff --git a/tailnet/configmaps.go b/tailnet/configmaps.go index 0a784eeab73dc..e6258817afaa7 100644 --- a/tailnet/configmaps.go +++ b/tailnet/configmaps.go @@ -9,7 +9,6 @@ import ( "sync" "time" - "github.com/benbjohnson/clock" "github.com/google/uuid" "go4.org/netipx" "tailscale.com/ipn/ipnstate" @@ -25,6 +24,7 @@ import ( "tailscale.com/wgengine/wgcfg/nmcfg" "cdr.dev/slog" + "github.com/coder/coder/v2/clock" "github.com/coder/coder/v2/tailnet/proto" ) @@ -116,7 +116,7 @@ func newConfigMaps(logger slog.Logger, engine engineConfigurable, nodeID tailcfg }}, }, peers: make(map[uuid.UUID]*peerLifecycle), - clock: clock.New(), + clock: clock.NewReal(), } go c.configLoop() return c diff --git a/tailnet/configmaps_internal_test.go b/tailnet/configmaps_internal_test.go index 49171ecf03030..c658e5fb2f44e 100644 --- a/tailnet/configmaps_internal_test.go +++ b/tailnet/configmaps_internal_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/benbjohnson/clock" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,6 +21,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/clock" "github.com/coder/coder/v2/tailnet/proto" "github.com/coder/coder/v2/testutil" ) @@ -195,9 +195,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake_neverConfigures(t *testing. discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - start := time.Date(2024, time.March, 29, 8, 0, 0, 0, time.UTC) mClock := clock.NewMock() - mClock.Set(start) uut.clock = mClock p1ID := uuid.UUID{1} @@ -241,9 +239,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake_outOfOrder(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - start := time.Date(2024, time.March, 29, 8, 0, 0, 0, time.UTC) mClock := clock.NewMock() - mClock.Set(start) uut.clock = mClock p1ID := uuid.UUID{1} @@ -314,9 +310,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - start := time.Date(2024, time.March, 29, 8, 0, 0, 0, time.UTC) mClock := clock.NewMock() - mClock.Set(start) uut.clock = mClock p1ID := uuid.UUID{1} @@ -387,9 +381,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake_timeout(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - start := time.Date(2024, time.March, 29, 8, 0, 0, 0, time.UTC) mClock := clock.NewMock() - mClock.Set(start) uut.clock = mClock p1ID := uuid.UUID{1} @@ -412,7 +404,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake_timeout(t *testing.T) { } uut.updatePeers(u1) - mClock.Add(5 * time.Second) + mClock.Advance(5*time.Second).MustWait(ctx, t) // it should now send the peer to the netmap @@ -574,9 +566,8 @@ func TestConfigMaps_updatePeers_lost(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - start := time.Date(2024, time.January, 1, 8, 0, 0, 0, time.UTC) mClock := clock.NewMock() - mClock.Set(start) + start := mClock.Now() uut.clock = mClock p1ID := uuid.UUID{1} @@ -600,7 +591,7 @@ func TestConfigMaps_updatePeers_lost(t *testing.T) { require.Len(t, r.wg.Peers, 1) _ = testutil.RequireRecvCtx(ctx, t, s1) - mClock.Add(5 * time.Second) + mClock.Advance(5*time.Second).MustWait(ctx, t) s2 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) @@ -621,7 +612,7 @@ func TestConfigMaps_updatePeers_lost(t *testing.T) { // latest handshake has advanced by a minute, so we don't remove the peer. lh := start.Add(time.Minute) s3 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, lh) - mClock.Add(lostTimeout) + mClock.Advance(lostTimeout).MustWait(ctx, t) _ = testutil.RequireRecvCtx(ctx, t, s3) select { case <-fEng.setNetworkMap: @@ -630,18 +621,10 @@ func TestConfigMaps_updatePeers_lost(t *testing.T) { // OK! } - // Before we update the clock again, we need to be sure the timeout has - // completed running. To do that, we check the new lastHandshake has been set - require.Eventually(t, func() bool { - uut.L.Lock() - defer uut.L.Unlock() - return uut.peers[p1ID].lastHandshake == lh - }, testutil.WaitShort, testutil.IntervalFast) - // Advance the clock again by a minute, which should trigger the reprogrammed // timeout. s4 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, lh) - mClock.Add(time.Minute) + mClock.Advance(time.Minute).MustWait(ctx, t) nm = testutil.RequireRecvCtx(ctx, t, fEng.setNetworkMap) r = testutil.RequireRecvCtx(ctx, t, fEng.reconfig) @@ -667,9 +650,8 @@ func TestConfigMaps_updatePeers_lost_and_found(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - start := time.Date(2024, time.January, 1, 8, 0, 0, 0, time.UTC) mClock := clock.NewMock() - mClock.Set(start) + start := mClock.Now() uut.clock = mClock p1ID := uuid.UUID{1} @@ -693,7 +675,7 @@ func TestConfigMaps_updatePeers_lost_and_found(t *testing.T) { require.Len(t, r.wg.Peers, 1) _ = testutil.RequireRecvCtx(ctx, t, s1) - mClock.Add(5 * time.Second) + mClock.Advance(5*time.Second).MustWait(ctx, t) s2 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) @@ -710,7 +692,7 @@ func TestConfigMaps_updatePeers_lost_and_found(t *testing.T) { // OK! } - mClock.Add(5 * time.Second) + mClock.Advance(5*time.Second).MustWait(ctx, t) s3 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) updates[0].Kind = proto.CoordinateResponse_PeerUpdate_NODE @@ -727,7 +709,7 @@ func TestConfigMaps_updatePeers_lost_and_found(t *testing.T) { // When we advance the clock, nothing happens because the timeout was // canceled - mClock.Add(lostTimeout) + mClock.Advance(lostTimeout).MustWait(ctx, t) select { case <-fEng.setNetworkMap: t.Fatal("should not reprogram") @@ -753,9 +735,8 @@ func TestConfigMaps_setAllPeersLost(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - start := time.Date(2024, time.January, 1, 8, 0, 0, 0, time.UTC) mClock := clock.NewMock() - mClock.Set(start) + start := mClock.Now() uut.clock = mClock p1ID := uuid.UUID{1} @@ -788,7 +769,7 @@ func TestConfigMaps_setAllPeersLost(t *testing.T) { require.Len(t, r.wg.Peers, 2) _ = testutil.RequireRecvCtx(ctx, t, s1) - mClock.Add(5 * time.Second) + mClock.Advance(5*time.Second).MustWait(ctx, t) uut.setAllPeersLost() // No reprogramming yet, since we keep the peer around. @@ -802,7 +783,7 @@ func TestConfigMaps_setAllPeersLost(t *testing.T) { // When we advance the clock, even by a few ms, the timeout for peer 2 pops // because our status only includes a handshake for peer 1 s2 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) - mClock.Add(time.Millisecond * 10) + mClock.Advance(time.Millisecond*10).MustWait(ctx, t) _ = testutil.RequireRecvCtx(ctx, t, s2) nm = testutil.RequireRecvCtx(ctx, t, fEng.setNetworkMap) @@ -812,7 +793,7 @@ func TestConfigMaps_setAllPeersLost(t *testing.T) { // Finally, advance the clock until after the timeout s3 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) - mClock.Add(lostTimeout) + mClock.Advance(lostTimeout).MustWait(ctx, t) _ = testutil.RequireRecvCtx(ctx, t, s3) nm = testutil.RequireRecvCtx(ctx, t, fEng.setNetworkMap) From fade8ba759e4c84c2dabffcbad974b53ca5a0967 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 5 Jun 2024 18:27:56 +0400 Subject: [PATCH 033/168] fix: fix MeasureLatencyRecvTimeout to accept send=0 (#13477) Fixes the flake seen here: https://github.com/coder/coder/runs/25832852690 Linux is not a real time operating system, and so there is no guarantee that subsequent `time.Now()` `time.Since()` calls will return a non-zero time. This assert is mainly there to ensure we don't return `-1`. --- coderd/database/pubsub/pubsub_linux_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/pubsub/pubsub_linux_test.go b/coderd/database/pubsub/pubsub_linux_test.go index 203287eb71637..f208af921b441 100644 --- a/coderd/database/pubsub/pubsub_linux_test.go +++ b/coderd/database/pubsub/pubsub_linux_test.go @@ -351,7 +351,7 @@ func TestMeasureLatency(t *testing.T) { send, recv, err := pubsub.NewLatencyMeasurer(logger).Measure(ctx, ps) require.ErrorContains(t, err, context.Canceled.Error()) - require.Greater(t, send.Nanoseconds(), int64(0)) + require.GreaterOrEqual(t, send.Nanoseconds(), int64(0)) require.EqualValues(t, recv, time.Duration(-1)) }) From 8f62311f006d86ccc8adc4ae59c7f37bce15d7b0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 5 Jun 2024 11:25:02 -0500 Subject: [PATCH 034/168] chore: remove organization_id suffix from org_member roles in database (#13473) Organization member's table is already scoped to an organization. Rolename should avoid having the org_id appended. Wipes all existing organization role assignments, which should not be used anyway. --- cli/server_createadminuser.go | 2 +- cli/server_createadminuser_test.go | 2 +- coderd/apidoc/docs.go | 3 ++ coderd/apidoc/swagger.json | 3 ++ coderd/authorize_test.go | 2 +- coderd/batchstats/batcher_internal_test.go | 2 +- coderd/coderdtest/coderdtest.go | 5 ++- coderd/database/db2sdk/db2sdk.go | 20 ++++++---- coderd/database/dbauthz/customroles_test.go | 6 +-- coderd/database/dbauthz/dbauthz.go | 18 ++++++++- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbmem/dbmem.go | 4 +- coderd/database/dump.sql | 2 +- .../000215_scoped_org_db_roles.down.sql | 1 + .../000215_scoped_org_db_roles.up.sql | 7 ++++ coderd/database/queries.sql.go | 8 ++-- coderd/database/queries/users.sql | 8 ++-- coderd/httpmw/authorize_test.go | 8 ++-- coderd/httpmw/organizationparam_test.go | 2 +- coderd/members.go | 2 +- coderd/organizations.go | 2 +- coderd/rbac/authz_internal_test.go | 40 +++++++++---------- coderd/rbac/authz_test.go | 20 +++++----- coderd/rbac/roles.go | 18 ++++++++- coderd/rbac/roles_internal_test.go | 4 +- coderd/rbac/roles_test.go | 12 +++--- coderd/roles_test.go | 8 ++-- coderd/users_test.go | 12 +++--- codersdk/roles.go | 7 ++-- docs/api/audit.md | 3 +- docs/api/enterprise.md | 7 +++- docs/api/members.md | 3 +- docs/api/schemas.md | 30 +++++++++----- docs/api/users.md | 30 +++++++++----- enterprise/coderd/authorize_test.go | 2 +- enterprise/coderd/provisionerdaemons_test.go | 2 +- enterprise/coderd/templates_test.go | 6 +-- site/src/api/typesGenerated.ts | 3 +- 38 files changed, 200 insertions(+), 118 deletions(-) create mode 100644 coderd/database/migrations/000215_scoped_org_db_roles.down.sql create mode 100644 coderd/database/migrations/000215_scoped_org_db_roles.up.sql diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index 278ecafb0644a..9f8d5ffe3ccf9 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -222,7 +222,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { UserID: newUser.ID, CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), - Roles: []string{rbac.RoleOrgAdmin(org.ID)}, + Roles: []string{rbac.ScopedRoleOrgAdmin(org.ID)}, }) if err != nil { return xerrors.Errorf("insert organization member: %w", err) diff --git a/cli/server_createadminuser_test.go b/cli/server_createadminuser_test.go index 67ce74fd237a3..6510e1332f120 100644 --- a/cli/server_createadminuser_test.go +++ b/cli/server_createadminuser_test.go @@ -71,7 +71,7 @@ func TestServerCreateAdminUser(t *testing.T) { orgIDs2 := make(map[uuid.UUID]struct{}, len(orgMemberships)) for _, membership := range orgMemberships { orgIDs2[membership.OrganizationID] = struct{}{} - assert.Equal(t, []string{rbac.RoleOrgAdmin(membership.OrganizationID)}, membership.Roles, "user is not org admin") + assert.Equal(t, []string{rbac.ScopedRoleOrgAdmin(membership.OrganizationID)}, membership.Roles, "user is not org admin") } require.Equal(t, orgIDs, orgIDs2, "user is not in all orgs") diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c5e2a6041526f..590fc3ed8e6a4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11458,6 +11458,9 @@ const docTemplate = `{ }, "name": { "type": "string" + }, + "organization_id": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 66afad1f041f0..346893693d752 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10341,6 +10341,9 @@ }, "name": { "type": "string" + }, + "organization_id": { + "type": "string" } } }, diff --git a/coderd/authorize_test.go b/coderd/authorize_test.go index 3fcb2f6c8e64f..f720f90c09206 100644 --- a/coderd/authorize_test.go +++ b/coderd/authorize_test.go @@ -27,7 +27,7 @@ func TestCheckPermissions(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) memberUser, err := memberClient.User(ctx, codersdk.Me) require.NoError(t, err) - orgAdminClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID, rbac.RoleOrgAdmin(adminUser.OrganizationID)) + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID, rbac.ScopedRoleOrgAdmin(adminUser.OrganizationID)) orgAdminUser, err := orgAdminClient.User(ctx, codersdk.Me) require.NoError(t, err) diff --git a/coderd/batchstats/batcher_internal_test.go b/coderd/batchstats/batcher_internal_test.go index 8954fa5455fcd..d153ac283b086 100644 --- a/coderd/batchstats/batcher_internal_test.go +++ b/coderd/batchstats/batcher_internal_test.go @@ -177,7 +177,7 @@ func setupDeps(t *testing.T, store database.Store, ps pubsub.Pubsub) deps { _, err := store.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{ OrganizationID: org.ID, UserID: user.ID, - Roles: []string{rbac.RoleOrgMember(org.ID)}, + Roles: []string{rbac.ScopedRoleOrgMember(org.ID)}, }) require.NoError(t, err) tv := dbgen.TemplateVersion(t, store, database.TemplateVersion{ diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 7110cc79471fb..316683a9f1e65 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -663,6 +663,7 @@ func CreateFirstUser(t testing.TB, client *codersdk.Client) codersdk.CreateFirst } // CreateAnotherUser creates and authenticates a new user. +// Roles can include org scoped roles with 'roleName:' func CreateAnotherUser(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, roles ...string) (*codersdk.Client, codersdk.User) { return createAnotherUserRetry(t, client, organizationID, 5, roles) } @@ -680,7 +681,7 @@ func AuthzUserSubject(user codersdk.User, orgID uuid.UUID) rbac.Subject { roles = append(roles, r.Name) } // We assume only 1 org exists - roles = append(roles, rbac.RoleOrgMember(orgID)) + roles = append(roles, rbac.ScopedRoleOrgMember(orgID)) return rbac.Subject{ ID: user.ID.String(), @@ -754,6 +755,8 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI for _, roleName := range roles { roleName := roleName orgID, ok := rbac.IsOrgRole(roleName) + roleName, _, err = rbac.RoleSplit(roleName) + require.NoError(t, err, "split org role name") if ok { orgRoles[orgID] = append(orgRoles[orgID], roleName) } else { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 402752805bf7b..1dbd62b4d6a96 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -204,13 +204,6 @@ func Group(group database.Group, members []database.User) codersdk.Group { } } -func SlimRole(role rbac.Role) codersdk.SlimRole { - return codersdk.SlimRole{ - DisplayName: role.DisplayName, - Name: role.Name, - } -} - func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) { // Use a stable sort, similarly to how we would sort in the query, note that // we don't sort in the query because order varies depending on the table @@ -525,6 +518,19 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner return result } +func SlimRole(role rbac.Role) codersdk.SlimRole { + roleName, orgIDStr, err := rbac.RoleSplit(role.Name) + if err != nil { + roleName = role.Name + } + + return codersdk.SlimRole{ + DisplayName: role.DisplayName, + Name: roleName, + OrganizationID: orgIDStr, + } +} + func RBACRole(role rbac.Role) codersdk.Role { roleName, orgIDStr, err := rbac.RoleSplit(role.Name) if err != nil { diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index ddcdca084f7f8..a5077121c0629 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -153,7 +153,7 @@ func TestUpsertCustomRoles(t *testing.T) { UUID: uuid.New(), Valid: true, }, - subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID.UUID)), + subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)), org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), @@ -162,7 +162,7 @@ func TestUpsertCustomRoles(t *testing.T) { { name: "user-escalation", // These roles do not grant user perms - subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID.UUID)), + subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)), user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), @@ -190,7 +190,7 @@ func TestUpsertCustomRoles(t *testing.T) { }, { name: "read-workspace-in-org", - subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID.UUID)), + subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)), organizationID: orgID, org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ codersdk.ResourceWorkspace: {codersdk.ActionRead}, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a590e272e65fe..73c73176b5953 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2472,7 +2472,7 @@ func (q *querier) InsertOrganization(ctx context.Context, arg database.InsertOrg func (q *querier) InsertOrganizationMember(ctx context.Context, arg database.InsertOrganizationMemberParams) (database.OrganizationMember, error) { // All roles are added roles. Org member is always implied. - addedRoles := append(arg.Roles, rbac.RoleOrgMember(arg.OrganizationID)) + addedRoles := append(arg.Roles, rbac.ScopedRoleOrgMember(arg.OrganizationID)) err := q.canAssignRoles(ctx, &arg.OrganizationID, addedRoles, []string{}) if err != nil { return database.OrganizationMember{}, err @@ -2847,8 +2847,22 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb return database.OrganizationMember{}, err } + // The 'rbac' package expects role names to be scoped. + // Convert the argument roles for validation. + scopedGranted := make([]string, 0, len(arg.GrantedRoles)) + for _, grantedRole := range arg.GrantedRoles { + // This check is a developer safety check. Old code might try to invoke this code path with + // organization id suffixes. Catch this and return a nice error so it can be fixed. + _, foundOrg, _ := rbac.RoleSplit(grantedRole) + if foundOrg != "" { + return database.OrganizationMember{}, xerrors.Errorf("attempt to assign a role %q, remove the ': suffix", grantedRole) + } + + scopedGranted = append(scopedGranted, rbac.RoleName(grantedRole, arg.OrgID.String())) + } + // The org member role is always implied. - impliedTypes := append(arg.GrantedRoles, rbac.RoleOrgMember(arg.OrgID)) + impliedTypes := append(scopedGranted, rbac.ScopedRoleOrgMember(arg.OrgID)) added, removed := rbac.ChangeRoleSet(member.Roles, impliedTypes) err = q.canAssignRoles(ctx, &arg.OrgID, added, removed) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 218f73af762ae..dbfb4e15e0de0 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -636,7 +636,7 @@ func (s *MethodTestSuite) TestOrganization() { check.Args(database.InsertOrganizationMemberParams{ OrganizationID: o.ID, UserID: u.ID, - Roles: []string{rbac.RoleOrgAdmin(o.ID)}, + Roles: []string{rbac.ScopedRoleOrgAdmin(o.ID)}, }).Asserts( rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionAssign, rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate) @@ -664,7 +664,7 @@ func (s *MethodTestSuite) TestOrganization() { mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ OrganizationID: o.ID, UserID: u.ID, - Roles: []string{rbac.RoleOrgAdmin(o.ID)}, + Roles: []string{rbac.ScopedRoleOrgAdmin(o.ID)}, }) out := mem out.Roles = []string{} diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index fe9b56e35ebdb..2bfff39a949a9 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1997,7 +1997,9 @@ func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U for _, mem := range q.organizationMembers { if mem.UserID == userID { - roles = append(roles, mem.Roles...) + for _, orgRole := range mem.Roles { + roles = append(roles, orgRole+":"+mem.OrganizationID.String()) + } roles = append(roles, "organization-member:"+mem.OrganizationID.String()) } } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index fde9c9556ac84..28adc8a36b1f1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -576,7 +576,7 @@ CREATE TABLE organization_members ( organization_id uuid NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - roles text[] DEFAULT '{organization-member}'::text[] NOT NULL + roles text[] DEFAULT '{}'::text[] NOT NULL ); CREATE TABLE organizations ( diff --git a/coderd/database/migrations/000215_scoped_org_db_roles.down.sql b/coderd/database/migrations/000215_scoped_org_db_roles.down.sql new file mode 100644 index 0000000000000..68a43a8fe8c7a --- /dev/null +++ b/coderd/database/migrations/000215_scoped_org_db_roles.down.sql @@ -0,0 +1 @@ +ALTER TABLE ONLY organization_members ALTER COLUMN roles SET DEFAULT '{organization-member}'; diff --git a/coderd/database/migrations/000215_scoped_org_db_roles.up.sql b/coderd/database/migrations/000215_scoped_org_db_roles.up.sql new file mode 100644 index 0000000000000..aecd19b8da668 --- /dev/null +++ b/coderd/database/migrations/000215_scoped_org_db_roles.up.sql @@ -0,0 +1,7 @@ +-- The default was 'organization-member', but we imply that in the +-- 'GetAuthorizationUserRoles' query. +ALTER TABLE ONLY organization_members ALTER COLUMN roles SET DEFAULT '{}'; + +-- No one should be using organization roles yet. If they are, the names in the +-- database are now incorrect. Just remove them all. +UPDATE organization_members SET roles = '{}'; diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5bc7552117b58..677f972e734c3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8432,12 +8432,14 @@ SELECT array_append(users.rbac_roles, 'member'), ( SELECT - array_agg(org_roles) + -- The roles are returned as a flat array, org scoped and site side. + -- Concatenating the organization id scopes the organization roles. + array_agg(org_roles || ':' || organization_members.organization_id::text) FROM organization_members, - -- All org_members get the org-member role for their orgs + -- All org_members get the organization-member role for their orgs unnest( - array_append(roles, 'organization-member:' || organization_members.organization_id::text) + array_append(roles, 'organization-member') ) AS org_roles WHERE user_id = users.id diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 5062b14429427..cd2b3456379fa 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -227,12 +227,14 @@ SELECT array_append(users.rbac_roles, 'member'), ( SELECT - array_agg(org_roles) + -- The roles are returned as a flat array, org scoped and site side. + -- Concatenating the organization id scopes the organization roles. + array_agg(org_roles || ':' || organization_members.organization_id::text) FROM organization_members, - -- All org_members get the org-member role for their orgs + -- All org_members get the organization-member role for their orgs unnest( - array_append(roles, 'organization-member:' || organization_members.organization_id::text) + array_append(roles, 'organization-member') ) AS org_roles WHERE user_id = users.id diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index c67be2ca2bdf7..131040b89b1f4 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -68,7 +68,7 @@ func TestExtractUserRoles(t *testing.T) { Roles: orgRoles, }) require.NoError(t, err) - return user, append(roles, append(orgRoles, rbac.RoleMember(), rbac.RoleOrgMember(org.ID))...), token + return user, append(roles, append(orgRoles, rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID))...), token }, }, { @@ -89,7 +89,8 @@ func TestExtractUserRoles(t *testing.T) { orgRoles := []string{} if i%2 == 0 { - orgRoles = append(orgRoles, rbac.RoleOrgAdmin(organization.ID)) + orgRoles = append(orgRoles, rbac.RoleOrgAdmin()) + roles = append(roles, rbac.ScopedRoleOrgAdmin(organization.ID)) } _, err = db.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, @@ -99,8 +100,7 @@ func TestExtractUserRoles(t *testing.T) { Roles: orgRoles, }) require.NoError(t, err) - roles = append(roles, orgRoles...) - roles = append(roles, rbac.RoleOrgMember(organization.ID)) + roles = append(roles, rbac.ScopedRoleOrgMember(organization.ID)) } return user, roles, token }, diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index 02b7ce1e14ad8..44d63cd664460 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -152,7 +152,7 @@ func TestOrganizationParam(t *testing.T) { _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ OrganizationID: organization.ID, UserID: user.ID, - Roles: []string{rbac.RoleOrgMember(organization.ID)}, + Roles: []string{rbac.ScopedRoleOrgMember(organization.ID)}, }) _, err := db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ ID: user.ID, diff --git a/coderd/members.go b/coderd/members.go index beae302ab3124..36660e5cb968e 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -68,7 +68,7 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz } for _, roleName := range mem.Roles { - rbacRole, _ := rbac.RoleByName(roleName) + rbacRole, _ := rbac.RoleByName(rbac.RoleName(roleName, mem.OrganizationID.String())) convertedMember.Roles = append(convertedMember.Roles, db2sdk.SlimRole(rbacRole)) } return convertedMember diff --git a/coderd/organizations.go b/coderd/organizations.go index 2a43ed2a7011a..b92d80342dfd6 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -94,7 +94,7 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { // come back to determining the default role of the person who // creates the org. Until that happens, all users in an organization // should be just regular members. - rbac.RoleOrgMember(organization.ID), + rbac.ScopedRoleOrgMember(organization.ID), }, }) if err != nil { diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 7b53939a3651b..d3d1ae8d9f765 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -168,7 +168,7 @@ func TestFilter(t *testing.T) { Name: "Admin", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{RoleOrgMember(orgIDs[0]), "auditor", RoleOwner(), RoleMember()}, + Roles: RoleNames{ScopedRoleOrgMember(orgIDs[0]), "auditor", RoleOwner(), RoleMember()}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -177,7 +177,7 @@ func TestFilter(t *testing.T) { Name: "OrgAdmin", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{RoleOrgMember(orgIDs[0]), RoleOrgAdmin(orgIDs[0]), RoleMember()}, + Roles: RoleNames{ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgAdmin(orgIDs[0]), RoleMember()}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -186,7 +186,7 @@ func TestFilter(t *testing.T) { Name: "OrgMember", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{RoleOrgMember(orgIDs[0]), RoleOrgMember(orgIDs[1]), RoleMember()}, + Roles: RoleNames{ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgMember(orgIDs[1]), RoleMember()}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -196,11 +196,11 @@ func TestFilter(t *testing.T) { Actor: Subject{ ID: userIDs[0].String(), Roles: RoleNames{ - RoleOrgMember(orgIDs[0]), RoleOrgAdmin(orgIDs[0]), - RoleOrgMember(orgIDs[1]), RoleOrgAdmin(orgIDs[1]), - RoleOrgMember(orgIDs[2]), RoleOrgAdmin(orgIDs[2]), - RoleOrgMember(orgIDs[4]), - RoleOrgMember(orgIDs[5]), + ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgAdmin(orgIDs[0]), + ScopedRoleOrgMember(orgIDs[1]), ScopedRoleOrgAdmin(orgIDs[1]), + ScopedRoleOrgMember(orgIDs[2]), ScopedRoleOrgAdmin(orgIDs[2]), + ScopedRoleOrgMember(orgIDs[4]), + ScopedRoleOrgMember(orgIDs[5]), RoleMember(), }, }, @@ -221,10 +221,10 @@ func TestFilter(t *testing.T) { Actor: Subject{ ID: userIDs[0].String(), Roles: RoleNames{ - RoleOrgMember(orgIDs[0]), - RoleOrgMember(orgIDs[1]), - RoleOrgMember(orgIDs[2]), - RoleOrgMember(orgIDs[3]), + ScopedRoleOrgMember(orgIDs[0]), + ScopedRoleOrgMember(orgIDs[1]), + ScopedRoleOrgMember(orgIDs[2]), + ScopedRoleOrgMember(orgIDs[3]), RoleMember(), }, }, @@ -235,7 +235,7 @@ func TestFilter(t *testing.T) { Name: "ScopeApplicationConnect", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{RoleOrgMember(orgIDs[0]), "auditor", RoleOwner(), RoleMember()}, + Roles: RoleNames{ScopedRoleOrgMember(orgIDs[0]), "auditor", RoleOwner(), RoleMember()}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -297,7 +297,7 @@ func TestAuthorizeDomain(t *testing.T) { Groups: []string{allUsersGroup}, Roles: Roles{ must(RoleByName(RoleMember())), - must(RoleByName(RoleOrgMember(defOrg))), + must(RoleByName(ScopedRoleOrgMember(defOrg))), }, } @@ -435,7 +435,7 @@ func TestAuthorizeDomain(t *testing.T) { ID: "me", Scope: must(ExpandScope(ScopeAll)), Roles: Roles{ - must(RoleByName(RoleOrgAdmin(defOrg))), + must(RoleByName(ScopedRoleOrgAdmin(defOrg))), must(RoleByName(RoleMember())), }, } @@ -507,7 +507,7 @@ func TestAuthorizeDomain(t *testing.T) { ID: "me", Scope: must(ExpandScope(ScopeApplicationConnect)), Roles: Roles{ - must(RoleByName(RoleOrgMember(defOrg))), + must(RoleByName(ScopedRoleOrgMember(defOrg))), must(RoleByName(RoleMember())), }, } @@ -770,7 +770,7 @@ func TestAuthorizeLevels(t *testing.T) { }, }, }, - must(RoleByName(RoleOrgAdmin(defOrg))), + must(RoleByName(ScopedRoleOrgAdmin(defOrg))), { Name: "user-deny-all", // List out deny permissions explicitly @@ -856,7 +856,7 @@ func TestAuthorizeScope(t *testing.T) { ID: "me", Roles: Roles{ must(RoleByName(RoleMember())), - must(RoleByName(RoleOrgMember(defOrg))), + must(RoleByName(ScopedRoleOrgMember(defOrg))), }, Scope: must(ExpandScope(ScopeApplicationConnect)), } @@ -892,7 +892,7 @@ func TestAuthorizeScope(t *testing.T) { ID: "me", Roles: Roles{ must(RoleByName(RoleMember())), - must(RoleByName(RoleOrgMember(defOrg))), + must(RoleByName(ScopedRoleOrgMember(defOrg))), }, Scope: Scope{ Role: Role{ @@ -981,7 +981,7 @@ func TestAuthorizeScope(t *testing.T) { ID: "me", Roles: Roles{ must(RoleByName(RoleMember())), - must(RoleByName(RoleOrgMember(defOrg))), + must(RoleByName(ScopedRoleOrgMember(defOrg))), }, Scope: Scope{ Role: Role{ diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index 05940856ec583..344d85562a094 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -49,7 +49,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "Admin", Actor: rbac.Subject{ // Give some extra roles that an admin might have - Roles: rbac.RoleNames{rbac.RoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()}, + Roles: rbac.RoleNames{rbac.ScopedRoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()}, ID: user.String(), Scope: rbac.ScopeAll, Groups: noiseGroups, @@ -58,7 +58,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U { Name: "OrgAdmin", Actor: rbac.Subject{ - Roles: rbac.RoleNames{rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]), rbac.RoleMember()}, + Roles: rbac.RoleNames{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), rbac.RoleMember()}, ID: user.String(), Scope: rbac.ScopeAll, Groups: noiseGroups, @@ -68,7 +68,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "OrgMember", Actor: rbac.Subject{ // Member of 2 orgs - Roles: rbac.RoleNames{rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgMember(orgs[1]), rbac.RoleMember()}, + Roles: rbac.RoleNames{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgMember(orgs[1]), rbac.RoleMember()}, ID: user.String(), Scope: rbac.ScopeAll, Groups: noiseGroups, @@ -79,9 +79,9 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Actor: rbac.Subject{ // Admin of many orgs Roles: rbac.RoleNames{ - rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]), - rbac.RoleOrgMember(orgs[1]), rbac.RoleOrgAdmin(orgs[1]), - rbac.RoleOrgMember(orgs[2]), rbac.RoleOrgAdmin(orgs[2]), + rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), + rbac.ScopedRoleOrgMember(orgs[1]), rbac.ScopedRoleOrgAdmin(orgs[1]), + rbac.ScopedRoleOrgMember(orgs[2]), rbac.ScopedRoleOrgAdmin(orgs[2]), rbac.RoleMember(), }, ID: user.String(), @@ -94,9 +94,9 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Actor: rbac.Subject{ // Admin of many orgs Roles: rbac.RoleNames{ - rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]), - rbac.RoleOrgMember(orgs[1]), rbac.RoleOrgAdmin(orgs[1]), - rbac.RoleOrgMember(orgs[2]), rbac.RoleOrgAdmin(orgs[2]), + rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), + rbac.ScopedRoleOrgMember(orgs[1]), rbac.ScopedRoleOrgAdmin(orgs[1]), + rbac.ScopedRoleOrgMember(orgs[2]), rbac.ScopedRoleOrgAdmin(orgs[2]), rbac.RoleMember(), }, ID: user.String(), @@ -108,7 +108,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "AdminWithScope", Actor: rbac.Subject{ // Give some extra roles that an admin might have - Roles: rbac.RoleNames{rbac.RoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()}, + Roles: rbac.RoleNames{rbac.ScopedRoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()}, ID: user.String(), Scope: rbac.ScopeApplicationConnect, Groups: noiseGroups, diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 137d2c0c1258b..fae31150e2053 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -70,11 +70,25 @@ func RoleMember() string { return RoleName(member, "") } -func RoleOrgAdmin(organizationID uuid.UUID) string { +func RoleOrgAdmin() string { + return orgAdmin +} + +func RoleOrgMember() string { + return orgMember +} + +// ScopedRoleOrgAdmin is the org role with the organization ID +// Deprecated This was used before organization scope was included as a +// field in all user facing APIs. Usage of 'ScopedRoleOrgAdmin()' is preferred. +func ScopedRoleOrgAdmin(organizationID uuid.UUID) string { return RoleName(orgAdmin, organizationID.String()) } -func RoleOrgMember(organizationID uuid.UUID) string { +// ScopedRoleOrgMember is the org role with the organization ID +// Deprecated This was used before organization scope was included as a +// field in all user facing APIs. Usage of 'ScopedRoleOrgMember()' is preferred. +func ScopedRoleOrgMember(organizationID uuid.UUID) string { return RoleName(orgMember, organizationID.String()) } diff --git a/coderd/rbac/roles_internal_test.go b/coderd/rbac/roles_internal_test.go index 07126981081d8..70296f7519b97 100644 --- a/coderd/rbac/roles_internal_test.go +++ b/coderd/rbac/roles_internal_test.go @@ -20,7 +20,7 @@ import ( // A possible large improvement would be to implement the ast.Value interface directly. func BenchmarkRBACValueAllocation(b *testing.B) { actor := Subject{ - Roles: RoleNames{RoleOrgMember(uuid.New()), RoleOrgAdmin(uuid.New()), RoleMember()}, + Roles: RoleNames{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}, ID: uuid.NewString(), Scope: ScopeAll, Groups: []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}, @@ -73,7 +73,7 @@ func TestRegoInputValue(t *testing.T) { // Expand all roles and make sure we have a good copy. // This is because these tests modify the roles, and we don't want to // modify the original roles. - roles, err := RoleNames{RoleOrgMember(uuid.New()), RoleOrgAdmin(uuid.New()), RoleMember()}.Expand() + roles, err := RoleNames{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}.Expand() require.NoError(t, err, "failed to expand roles") for i := range roles { // If all cached values are nil, then the role will not use diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index e6680d4d628cc..f2f0d1d3399e2 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -99,13 +99,13 @@ func TestRolePermissions(t *testing.T) { // Subjects to user memberMe := authSubject{Name: "member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleNames{rbac.RoleMember()}}} - orgMemberMe := authSubject{Name: "org_member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOrgMember(orgID)}}} + orgMemberMe := authSubject{Name: "org_member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID)}}} owner := authSubject{Name: "owner", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOwner()}}} - orgAdmin := authSubject{Name: "org_admin", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOrgMember(orgID), rbac.RoleOrgAdmin(orgID)}}} + orgAdmin := authSubject{Name: "org_admin", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAdmin(orgID)}}} - otherOrgMember := authSubject{Name: "org_member_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOrgMember(otherOrg)}}} - otherOrgAdmin := authSubject{Name: "org_admin_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOrgMember(otherOrg), rbac.RoleOrgAdmin(otherOrg)}}} + otherOrgMember := authSubject{Name: "org_member_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.ScopedRoleOrgMember(otherOrg)}}} + otherOrgAdmin := authSubject{Name: "org_admin_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.ScopedRoleOrgMember(otherOrg), rbac.ScopedRoleOrgAdmin(otherOrg)}}} templateAdmin := authSubject{Name: "template-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleTemplateAdmin()}}} userAdmin := authSubject{Name: "user-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleUserAdmin()}}} @@ -636,12 +636,12 @@ func TestIsOrgRole(t *testing.T) { // Org roles { - RoleName: rbac.RoleOrgAdmin(randomUUID), + RoleName: rbac.ScopedRoleOrgAdmin(randomUUID), OrgRole: true, OrgID: randomUUID.String(), }, { - RoleName: rbac.RoleOrgMember(randomUUID), + RoleName: rbac.ScopedRoleOrgMember(randomUUID), OrgRole: true, OrgID: randomUUID.String(), }, diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 1b1aa94c6025a..24845fea3fa3d 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -26,7 +26,7 @@ func TestListRoles(t *testing.T) { // Create owner, member, and org admin owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleOrgAdmin(owner.OrganizationID)) + orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) t.Cleanup(cancel) @@ -64,7 +64,7 @@ func TestListRoles(t *testing.T) { return member.ListOrganizationRoles(ctx, owner.OrganizationID) }, ExpectedRoles: convertRoles(map[string]bool{ - rbac.RoleOrgAdmin(owner.OrganizationID): false, + rbac.ScopedRoleOrgAdmin(owner.OrganizationID): false, }), }, { @@ -93,7 +93,7 @@ func TestListRoles(t *testing.T) { return orgAdmin.ListOrganizationRoles(ctx, owner.OrganizationID) }, ExpectedRoles: convertRoles(map[string]bool{ - rbac.RoleOrgAdmin(owner.OrganizationID): true, + rbac.ScopedRoleOrgAdmin(owner.OrganizationID): true, }), }, { @@ -122,7 +122,7 @@ func TestListRoles(t *testing.T) { return client.ListOrganizationRoles(ctx, owner.OrganizationID) }, ExpectedRoles: convertRoles(map[string]bool{ - rbac.RoleOrgAdmin(owner.OrganizationID): true, + rbac.ScopedRoleOrgAdmin(owner.OrganizationID): true, }), }, } diff --git a/coderd/users_test.go b/coderd/users_test.go index 80c7062c914ed..0fa42c4578c6d 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -962,12 +962,12 @@ func TestGrantSiteRoles(t *testing.T) { admin := coderdtest.New(t, nil) first := coderdtest.CreateFirstUser(t, admin) member, _ := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID) - orgAdmin, _ := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID, rbac.RoleOrgAdmin(first.OrganizationID)) + orgAdmin, _ := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) randOrg, err := admin.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ Name: "random", }) require.NoError(t, err) - _, randOrgUser := coderdtest.CreateAnotherUser(t, admin, randOrg.ID, rbac.RoleOrgAdmin(randOrg.ID)) + _, randOrgUser := coderdtest.CreateAnotherUser(t, admin, randOrg.ID, rbac.ScopedRoleOrgAdmin(randOrg.ID)) userAdmin, _ := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID, rbac.RoleUserAdmin()) const newUser = "newUser" @@ -986,7 +986,7 @@ func TestGrantSiteRoles(t *testing.T) { Name: "OrgRoleInSite", Client: admin, AssignToUser: codersdk.Me, - Roles: []string{rbac.RoleOrgAdmin(first.OrganizationID)}, + Roles: []string{rbac.RoleOrgAdmin()}, Error: true, StatusCode: http.StatusBadRequest, }, @@ -1029,7 +1029,7 @@ func TestGrantSiteRoles(t *testing.T) { Client: orgAdmin, OrgID: randOrg.ID, AssignToUser: randOrgUser.ID.String(), - Roles: []string{rbac.RoleOrgMember(randOrg.ID)}, + Roles: []string{rbac.RoleOrgMember()}, Error: true, StatusCode: http.StatusNotFound, }, @@ -1047,9 +1047,9 @@ func TestGrantSiteRoles(t *testing.T) { Client: orgAdmin, OrgID: first.OrganizationID, AssignToUser: newUser, - Roles: []string{rbac.RoleOrgAdmin(first.OrganizationID)}, + Roles: []string{rbac.RoleOrgAdmin()}, ExpectedRoles: []string{ - rbac.RoleOrgAdmin(first.OrganizationID), + rbac.RoleOrgAdmin(), }, Error: false, }, diff --git a/codersdk/roles.go b/codersdk/roles.go index bfab6b15ae391..6707bb1d6e276 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -14,8 +14,9 @@ import ( // and it would require extra db calls to fetch this information. The UI does // not need it, so most api calls will use this structure that omits information. type SlimRole struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + OrganizationID string `json:"organization_id,omitempty"` } type AssignableRoles struct { @@ -36,7 +37,7 @@ type Permission struct { // Role is a longer form of SlimRole used to edit custom roles. type Role struct { Name string `json:"name" table:"name,default_sort" validate:"username"` - OrganizationID string `json:"organization_id" table:"organization_id" format:"uuid"` + OrganizationID string `json:"organization_id,omitempty" table:"organization_id" format:"uuid"` DisplayName string `json:"display_name" table:"display_name"` SitePermissions []Permission `json:"site_permissions" table:"site_permissions"` // OrganizationPermissions are specific for the organization in the field 'OrganizationID' above. diff --git a/docs/api/audit.md b/docs/api/audit.md index a755ed9412bd5..0c2cf32cd2758 100644 --- a/docs/api/audit.md +++ b/docs/api/audit.md @@ -68,7 +68,8 @@ curl -X GET http://coder-server:8080/api/v2/audit?limit=0 \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 3cf43102e7c77..758489995ccf5 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -1600,7 +1600,8 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -1655,7 +1656,8 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -1690,6 +1692,7 @@ Status Code **200** | `» roles` | array | false | | | | `»» display_name` | string | false | | | | `»» name` | string | false | | | +| `»» organization_id` | string | false | | | | `» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | | `» theme_preference` | string | false | | | | `» username` | string | true | | | diff --git a/docs/api/members.md b/docs/api/members.md index 6364b08ca528e..ce7cc81f1762b 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -289,7 +289,8 @@ curl -X PUT http://coder-server:8080/api/v2/organizations/{organization}/members "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "updated_at": "2019-08-24T14:15:22Z", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 7770b091878bd..bd460b5f8fdbf 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -966,7 +966,8 @@ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -1045,7 +1046,8 @@ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -2972,7 +2974,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -3616,7 +3619,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "updated_at": "2019-08-24T14:15:22Z", @@ -4452,16 +4456,18 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------ | -------- | ------------ | ----------- | -| `display_name` | string | false | | | -| `name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | ------ | -------- | ------------ | ----------- | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | ## codersdk.SupportConfig @@ -5002,7 +5008,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -5607,7 +5614,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", diff --git a/docs/api/users.md b/docs/api/users.md index c9910bf66c1c7..db5f959a7fa76 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -42,7 +42,8 @@ curl -X GET http://coder-server:8080/api/v2/users \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -111,7 +112,8 @@ curl -X POST http://coder-server:8080/api/v2/users \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -381,7 +383,8 @@ curl -X GET http://coder-server:8080/api/v2/users/{user} \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -434,7 +437,8 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -497,7 +501,8 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/appearance \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -1148,7 +1153,8 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/profile \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -1201,7 +1207,8 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/roles \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -1264,7 +1271,8 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/roles \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -1317,7 +1325,8 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", @@ -1370,7 +1379,8 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/suspend \ "roles": [ { "display_name": "string", - "name": "string" + "name": "string", + "organization_id": "string" } ], "status": "active", diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go index 30d890b0beab6..670df18916aaa 100644 --- a/enterprise/coderd/authorize_test.go +++ b/enterprise/coderd/authorize_test.go @@ -35,7 +35,7 @@ func TestCheckACLPermissions(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) memberUser, err := memberClient.User(ctx, codersdk.Me) require.NoError(t, err) - orgAdminClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID, rbac.RoleOrgAdmin(adminUser.OrganizationID)) + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID, rbac.ScopedRoleOrgAdmin(adminUser.OrganizationID)) orgAdminUser, err := orgAdminClient.User(ctx, codersdk.Me) require.NoError(t, err) diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index c62a91593d80f..68558706914a0 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -197,7 +197,7 @@ func TestProvisionerDaemonServe(t *testing.T) { codersdk.FeatureExternalProvisionerDaemons: 1, }, }}) - another, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOrgAdmin(user.OrganizationID)) + another, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID)) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index c7d9a9cfb3830..7440ee743dca2 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -1639,9 +1639,9 @@ func TestTemplateAccess(t *testing.T) { newOrg, err := ownerClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{Name: orgName}) require.NoError(t, err, "failed to create org") - adminCli, adminUsr := coderdtest.CreateAnotherUser(t, ownerClient, newOrg.ID, rbac.RoleOrgAdmin(newOrg.ID)) - groupMemCli, groupMemUsr := coderdtest.CreateAnotherUser(t, ownerClient, newOrg.ID, rbac.RoleOrgMember(newOrg.ID)) - memberCli, memberUsr := coderdtest.CreateAnotherUser(t, ownerClient, newOrg.ID, rbac.RoleOrgMember(newOrg.ID)) + adminCli, adminUsr := coderdtest.CreateAnotherUser(t, ownerClient, newOrg.ID, rbac.ScopedRoleOrgAdmin(newOrg.ID)) + groupMemCli, groupMemUsr := coderdtest.CreateAnotherUser(t, ownerClient, newOrg.ID, rbac.ScopedRoleOrgMember(newOrg.ID)) + memberCli, memberUsr := coderdtest.CreateAnotherUser(t, ownerClient, newOrg.ID, rbac.ScopedRoleOrgMember(newOrg.ID)) // Make group group, err := adminCli.CreateGroup(ctx, newOrg.ID, codersdk.CreateGroupRequest{ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 88e5c7e508f67..b7de0be88ba37 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -977,7 +977,7 @@ export interface Response { // From codersdk/roles.go export interface Role { readonly name: string; - readonly organization_id: string; + readonly organization_id?: string; readonly display_name: string; readonly site_permissions: readonly Permission[]; readonly organization_permissions: readonly Permission[]; @@ -1030,6 +1030,7 @@ export interface SessionLifetime { export interface SlimRole { readonly name: string; readonly display_name: string; + readonly organization_id?: string; } // From codersdk/deployment.go From f1b42a15fa1c10ee122a116cf4eb6b2f37f2f829 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 5 Jun 2024 16:50:52 -0500 Subject: [PATCH 035/168] fix(site): show workspace start button when require active version is enabled (#13482) --- .../pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index ad79ce1be9c95..7e41b7662b20a 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -172,6 +172,10 @@ export const WorkspaceActions: FC = ({ )} + {!canBeUpdated && + workspace.template_require_active_version && + buttonMapping.start} + {isRestarting ? buttonMapping.restarting : actions.map((action) => ( From 7995d7c3d6fa7dffd8ed420c3d0de43907d747db Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 5 Jun 2024 21:52:49 -0500 Subject: [PATCH 036/168] fix: only render tooltip when require_active_version enabled (#13484) --- .../WorkspacePage/WorkspaceActions/WorkspaceActions.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 7e41b7662b20a..beab34de37633 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -244,7 +244,11 @@ function getTooltipText( return ""; } - if (!mustUpdate && canChangeVersions) { + if ( + !mustUpdate && + canChangeVersions && + workspace.template_require_active_version + ) { return "This template requires automatic updates on workspace startup, but template administrators can ignore this policy."; } From 37676c46d574b732ddcc355e8e0cd2d12570df1d Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 5 Jun 2024 22:24:26 -0500 Subject: [PATCH 037/168] chore(scripts): remove remaining `gh_auth` calls from release scripts (#13485) --- scripts/release/check_commit_metadata.sh | 3 --- scripts/release/generate_release_notes.sh | 3 --- scripts/release/publish.sh | 3 --- 3 files changed, 9 deletions(-) diff --git a/scripts/release/check_commit_metadata.sh b/scripts/release/check_commit_metadata.sh index def18071af019..507e8d8d797a7 100755 --- a/scripts/release/check_commit_metadata.sh +++ b/scripts/release/check_commit_metadata.sh @@ -31,9 +31,6 @@ range="${from_ref}..${to_ref}" # Check dependencies. dependencies gh -# Authenticate gh CLI -gh_auth - COMMIT_METADATA_BREAKING=0 declare -a COMMIT_METADATA_COMMITS declare -A COMMIT_METADATA_TITLE COMMIT_METADATA_HUMAN_TITLE COMMIT_METADATA_CATEGORY COMMIT_METADATA_AUTHORS diff --git a/scripts/release/generate_release_notes.sh b/scripts/release/generate_release_notes.sh index b593ccad3cc5b..ae9a7dcfd7d07 100755 --- a/scripts/release/generate_release_notes.sh +++ b/scripts/release/generate_release_notes.sh @@ -57,9 +57,6 @@ done # Check dependencies. dependencies gh sort -# Authticate gh CLI -gh_auth - if [[ -z ${old_version} ]]; then error "No old version specified" fi diff --git a/scripts/release/publish.sh b/scripts/release/publish.sh index 12539b5757afc..68dbf468f40b9 100755 --- a/scripts/release/publish.sh +++ b/scripts/release/publish.sh @@ -71,9 +71,6 @@ done # Check dependencies dependencies gh -# Authenticate gh CLI -gh_auth - # Remove the "v" prefix. version="${version#v}" if [[ "$version" == "" ]]; then From e7435888434f7722f4399ea8f4b68b150805c619 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 5 Jun 2024 22:31:32 -0500 Subject: [PATCH 038/168] docs: bump k8s install version (#13487) --- docs/install/kubernetes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index e4847dcfe88e9..2e2b6bd5da50b 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -134,7 +134,7 @@ locally in order to log in and manage templates. helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.12.0 + --version 2.12.1 ``` For the **stable** Coder release: From 1131772e794c93fa8c03818bf1041be774f701d7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 6 Jun 2024 13:37:08 +0100 Subject: [PATCH 039/168] feat(coderd): set full name from IDP name claim (#13468) * Updates OIDC and GitHub OAuth login to fetch set name from relevant claim fields * Adds CODER_OIDC_NAME_FIELD as configurable source of user name claim * Adds httpapi function to normalize a username such that it will pass validation * Adds firstName / lastName fields to dev OIDC setup --- cli/server.go | 1 + cli/testdata/coder_server_--help.golden | 3 + cli/testdata/server-config.yaml.golden | 3 + coderd/apidoc/docs.go | 3 + coderd/apidoc/swagger.json | 3 + coderd/httpapi/name.go | 11 + coderd/httpapi/name_test.go | 17 +- coderd/userauth.go | 24 +- coderd/userauth_test.go | 244 +++++++++++++++--- codersdk/deployment.go | 11 + docs/api/general.md | 1 + docs/api/schemas.md | 4 + docs/cli/server.md | 11 + .../cli/testdata/coder_server_--help.golden | 3 + scripts/dev-oidc.sh | 3 + site/src/api/typesGenerated.ts | 1 + 16 files changed, 301 insertions(+), 42 deletions(-) diff --git a/cli/server.go b/cli/server.go index 409056641a771..855c5e6c547bf 100644 --- a/cli/server.go +++ b/cli/server.go @@ -169,6 +169,7 @@ func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*co EmailDomain: vals.OIDC.EmailDomain, AllowSignups: vals.OIDC.AllowSignups.Value(), UsernameField: vals.OIDC.UsernameField.String(), + NameField: vals.OIDC.NameField.String(), EmailField: vals.OIDC.EmailField.String(), AuthURLParams: vals.OIDC.AuthURLParams.Value, IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(), diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 6d8f866c11c0b..acd2c62ead445 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -407,6 +407,9 @@ OIDC OPTIONS: --oidc-issuer-url string, $CODER_OIDC_ISSUER_URL Issuer URL to use for Login with OIDC. + --oidc-name-field string, $CODER_OIDC_NAME_FIELD (default: name) + OIDC claim field to use as the name. + --oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*) If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index bf49239bc4e63..9a34d6be56b20 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -306,6 +306,9 @@ oidc: # OIDC claim field to use as the username. # (default: preferred_username, type: string) usernameField: preferred_username + # OIDC claim field to use as the name. + # (default: name, type: string) + nameField: name # OIDC claim field to use as the email. # (default: email, type: string) emailField: email diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 590fc3ed8e6a4..26ddef4177d82 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10551,6 +10551,9 @@ const docTemplate = `{ "issuer_url": { "type": "string" }, + "name_field": { + "type": "string" + }, "scopes": { "type": "array", "items": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 346893693d752..57800f5a38fa9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9482,6 +9482,9 @@ "issuer_url": { "type": "string" }, + "name_field": { + "type": "string" + }, "scopes": { "type": "array", "items": { diff --git a/coderd/httpapi/name.go b/coderd/httpapi/name.go index d8b64a71bdc44..cdf4a9ad48971 100644 --- a/coderd/httpapi/name.go +++ b/coderd/httpapi/name.go @@ -91,3 +91,14 @@ func UserRealNameValid(str string) error { } return nil } + +// NormalizeUserRealName normalizes a user name such that it will pass +// validation by UserRealNameValid. This is done to avoid blocking +// little Bobby Whitespace from using Coder. +func NormalizeRealUsername(str string) string { + s := strings.TrimSpace(str) + if len(s) > 128 { + s = s[:128] + } + return s +} diff --git a/coderd/httpapi/name_test.go b/coderd/httpapi/name_test.go index a6313c54034f5..15da7b3e82a63 100644 --- a/coderd/httpapi/name_test.go +++ b/coderd/httpapi/name_test.go @@ -1,9 +1,11 @@ package httpapi_test import ( + "strings" "testing" "github.com/moby/moby/pkg/namesgenerator" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/httpapi" @@ -217,6 +219,10 @@ func TestUserRealNameValid(t *testing.T) { Name string Valid bool }{ + {"", true}, + {" a", false}, + {"a ", false}, + {" a ", false}, {"1", true}, {"A", true}, {"A1", true}, @@ -229,17 +235,22 @@ func TestUserRealNameValid(t *testing.T) { {"Małgorzata Kalinowska-Iszkowska", true}, {"成龍", true}, {". .", true}, - {"Lord Voldemort ", false}, {" Bellatrix Lestrange", false}, {" ", false}, + {strings.Repeat("a", 128), true}, + {strings.Repeat("a", 129), false}, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() - valid := httpapi.UserRealNameValid(testCase.Name) - require.Equal(t, testCase.Valid, valid == nil) + err := httpapi.UserRealNameValid(testCase.Name) + norm := httpapi.NormalizeRealUsername(testCase.Name) + normErr := httpapi.UserRealNameValid(norm) + assert.NoError(t, normErr) + assert.Equal(t, testCase.Valid, err == nil) + assert.Equal(t, testCase.Valid, norm == testCase.Name, "invalid name should be different after normalization") }) } } diff --git a/coderd/userauth.go b/coderd/userauth.go index 3f341db65bcb1..b41f496814306 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -607,6 +607,9 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { return } + ghName := ghUser.GetName() + normName := httpapi.NormalizeRealUsername(ghName) + // If we have a nil GitHub ID, that is a big problem. That would mean we link // this user and all other users with this bug to the same uuid. // We should instead throw an error. This should never occur in production. @@ -652,6 +655,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { Email: verifiedEmail.GetEmail(), Username: ghUser.GetLogin(), AvatarURL: ghUser.GetAvatarURL(), + Name: normName, DebugContext: OauthDebugContext{}, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { return audit.InitRequest[database.User](rw, params) @@ -701,6 +705,9 @@ type OIDCConfig struct { // EmailField selects the claim field to be used as the created user's // email. EmailField string + // NameField selects the claim field to be used as the created user's + // full / given name. + NameField string // AuthURLParams are additional parameters to be passed to the OIDC provider // when requesting an access token. AuthURLParams map[string]string @@ -952,13 +959,22 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } } + // The 'name' is an optional property in Coder. If not specified, + // it will be left blank. + var name string + nameRaw, ok := mergedClaims[api.OIDCConfig.NameField] + if ok { + name, _ = nameRaw.(string) + name = httpapi.NormalizeRealUsername(name) + } + var picture string pictureRaw, ok := mergedClaims["picture"] if ok { picture, _ = pictureRaw.(string) } - ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username)) + ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name)) usingGroups, groups, groupErr := api.oidcGroups(ctx, mergedClaims) if groupErr != nil { groupErr.Write(rw, r) @@ -996,6 +1012,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { AllowSignups: api.OIDCConfig.AllowSignups, Email: email, Username: username, + Name: name, AvatarURL: picture, UsingRoles: api.OIDCConfig.RoleSyncEnabled(), Roles: roles, @@ -1222,6 +1239,7 @@ type oauthLoginParams struct { AllowSignups bool Email string Username string + Name string AvatarURL string // Is UsingGroups is true, then the user will be assigned // to the Groups provided. @@ -1544,6 +1562,10 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C user.AvatarURL = params.AvatarURL needsUpdate = true } + if user.Name != params.Name { + user.Name = params.Name + needsUpdate = true + } // If the upstream email or username has changed we should mirror // that in Coder. Many enterprises use a user's email/username as diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index f1adbfe869610..1c647f3cca281 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -16,6 +16,7 @@ import ( "github.com/google/go-github/v43/github" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -213,6 +214,7 @@ func TestUserOAuth2Github(t *testing.T) { return &github.User{ ID: github.Int64(100), Login: github.String("kyle"), + Name: github.String("Kylium Carbonate"), }, nil }, TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) { @@ -272,7 +274,9 @@ func TestUserOAuth2Github(t *testing.T) { }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ - ID: github.Int64(100), + ID: github.Int64(100), + Login: github.String("testuser"), + Name: github.String("The Right Honorable Sir Test McUser"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -305,7 +309,9 @@ func TestUserOAuth2Github(t *testing.T) { }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ - ID: github.Int64(100), + ID: github.Int64(100), + Login: github.String("testuser"), + Name: github.String("The Right Honorable Sir Test McUser"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -346,9 +352,10 @@ func TestUserOAuth2Github(t *testing.T) { }, AuthenticatedUser: func(ctx context.Context, _ *http.Client) (*github.User, error) { return &github.User{ - Login: github.String("kyle"), - ID: i64ptr(1234), AvatarURL: github.String("/hello-world"), + ID: i64ptr(1234), + Login: github.String("kyle"), + Name: github.String("Kylium Carbonate"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -372,6 +379,60 @@ func TestUserOAuth2Github(t *testing.T) { require.NoError(t, err) require.Equal(t, "kyle@coder.com", user.Email) require.Equal(t, "kyle", user.Username) + require.Equal(t, "Kylium Carbonate", user.Name) + require.Equal(t, "/hello-world", user.AvatarURL) + + require.Len(t, auditor.AuditLogs(), numLogs) + require.NotEqual(t, auditor.AuditLogs()[numLogs-1].UserID, uuid.Nil) + require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) + }) + t.Run("SignupWeirdName", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{ + Auditor: auditor, + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &testutil.OAuth2Config{}, + AllowOrganizations: []string{"coder"}, + AllowSignups: true, + ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) { + return []*github.Membership{{ + State: &stateActive, + Organization: &github.Organization{ + Login: github.String("coder"), + }, + }}, nil + }, + AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) { + return &github.User{ + AvatarURL: github.String("/hello-world"), + ID: i64ptr(1234), + Login: github.String("kyle"), + Name: github.String(" " + strings.Repeat("a", 129) + " "), + }, nil + }, + ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) { + return []*github.UserEmail{{ + Email: github.String("kyle@coder.com"), + Verified: github.Bool(true), + Primary: github.Bool(true), + }}, nil + }, + }, + }) + numLogs := len(auditor.AuditLogs()) + + resp := oauth2Callback(t, client) + numLogs++ // add an audit log for login + + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + client.SetSessionToken(authCookieValue(resp.Cookies())) + user, err := client.User(context.Background(), "me") + require.NoError(t, err) + require.Equal(t, "kyle@coder.com", user.Email) + require.Equal(t, "kyle", user.Username) + require.Equal(t, strings.Repeat("a", 128), user.Name) require.Equal(t, "/hello-world", user.AvatarURL) require.Len(t, auditor.AuditLogs(), numLogs) @@ -401,8 +462,10 @@ func TestUserOAuth2Github(t *testing.T) { }, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { return &github.User{ - ID: github.Int64(100), - Login: github.String("kyle"), + AvatarURL: github.String("/hello-world"), + ID: github.Int64(100), + Login: github.String("kyle"), + Name: github.String("Kylium Carbonate"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -419,10 +482,19 @@ func TestUserOAuth2Github(t *testing.T) { resp := oauth2Callback(t, client) numLogs++ // add an audit log for login + client.SetSessionToken(authCookieValue(resp.Cookies())) + user, err := client.User(context.Background(), "me") + require.NoError(t, err) + require.Equal(t, "kyle@coder.com", user.Email) + require.Equal(t, "kyle", user.Username) + require.Equal(t, "Kylium Carbonate", user.Name) + require.Equal(t, "/hello-world", user.AvatarURL) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) + // nolint: dupl t.Run("SignupAllowedTeamInFirstOrganization", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() @@ -456,6 +528,7 @@ func TestUserOAuth2Github(t *testing.T) { return &github.User{ ID: github.Int64(100), Login: github.String("mathias"), + Name: github.String("Mathias Mathias"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -472,10 +545,18 @@ func TestUserOAuth2Github(t *testing.T) { resp := oauth2Callback(t, client) numLogs++ // add an audit log for login + client.SetSessionToken(authCookieValue(resp.Cookies())) + user, err := client.User(context.Background(), "me") + require.NoError(t, err) + require.Equal(t, "mathias@coder.com", user.Email) + require.Equal(t, "mathias", user.Username) + require.Equal(t, "Mathias Mathias", user.Name) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) + // nolint: dupl t.Run("SignupAllowedTeamInSecondOrganization", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() @@ -509,6 +590,7 @@ func TestUserOAuth2Github(t *testing.T) { return &github.User{ ID: github.Int64(100), Login: github.String("mathias"), + Name: github.String("Mathias Mathias"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -525,6 +607,13 @@ func TestUserOAuth2Github(t *testing.T) { resp := oauth2Callback(t, client) numLogs++ // add an audit log for login + client.SetSessionToken(authCookieValue(resp.Cookies())) + user, err := client.User(context.Background(), "me") + require.NoError(t, err) + require.Equal(t, "mathias@coder.com", user.Email) + require.Equal(t, "mathias", user.Username) + require.Equal(t, "Mathias Mathias", user.Name) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) @@ -548,6 +637,7 @@ func TestUserOAuth2Github(t *testing.T) { return &github.User{ ID: github.Int64(100), Login: github.String("mathias"), + Name: github.String("Mathias Mathias"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -564,6 +654,13 @@ func TestUserOAuth2Github(t *testing.T) { resp := oauth2Callback(t, client) numLogs++ // add an audit log for login + client.SetSessionToken(authCookieValue(resp.Cookies())) + user, err := client.User(context.Background(), "me") + require.NoError(t, err) + require.Equal(t, "mathias@coder.com", user.Email) + require.Equal(t, "mathias", user.Username) + require.Equal(t, "Mathias Mathias", user.Name) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) @@ -591,6 +688,7 @@ func TestUserOAuth2Github(t *testing.T) { return &github.User{ ID: github.Int64(100), Login: github.String("kyle"), + Name: github.String("Kylium Carbonate"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -652,6 +750,7 @@ func TestUserOAuth2Github(t *testing.T) { return &github.User{ Login: github.String("alice"), ID: github.Int64(ghID), + Name: github.String("Alice Liddell"), }, nil }, ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { @@ -739,8 +838,7 @@ func TestUserOIDC(t *testing.T) { UserInfoClaims jwt.MapClaims AllowSignups bool EmailDomain []string - Username string - AvatarURL string + AssertUser func(t testing.TB, u codersdk.User) StatusCode int IgnoreEmailVerified bool IgnoreUserInfo bool @@ -752,7 +850,9 @@ func TestUserOIDC(t *testing.T) { }, AllowSignups: true, StatusCode: http.StatusOK, - Username: "kyle", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "kyle", u.Username) + }, }, { Name: "EmailNotVerified", @@ -778,9 +878,11 @@ func TestUserOIDC(t *testing.T) { "email": "kyle@kwc.io", "email_verified": false, }, - AllowSignups: true, - StatusCode: http.StatusOK, - Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusOK, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, u.Username, "kyle") + }, IgnoreEmailVerified: true, }, { @@ -802,6 +904,9 @@ func TestUserOIDC(t *testing.T) { "email_verified": true, }, AllowSignups: true, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, u.Username, "kyle") + }, EmailDomain: []string{ "kwc.io", }, @@ -839,7 +944,9 @@ func TestUserOIDC(t *testing.T) { "email": "kyle@kwc.io", "email_verified": true, }, - Username: "kyle", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "kyle", u.Username) + }, AllowSignups: true, StatusCode: http.StatusOK, }, @@ -850,10 +957,56 @@ func TestUserOIDC(t *testing.T) { "email_verified": true, "preferred_username": "hotdog", }, - Username: "hotdog", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "hotdog", u.Username) + }, + AllowSignups: true, + StatusCode: http.StatusOK, + }, + { + Name: "FullNameFromClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "name": "Hot Dog", + }, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "Hot Dog", u.Name) + }, AllowSignups: true, StatusCode: http.StatusOK, }, + { + Name: "InvalidFullNameFromClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + // Full names must be less or equal to than 128 characters in length. + // However, we should not fail to log someone in if their name is too long. + // Just truncate it. + "name": strings.Repeat("a", 129), + }, + AllowSignups: true, + StatusCode: http.StatusOK, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, strings.Repeat("a", 128), u.Name) + }, + }, + { + Name: "FullNameWhitespace", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + // Full names must not have leading or trailing whitespace, but this is a + // daft reason to fail a login. + "name": " Bobby Whitespace ", + }, + AllowSignups: true, + StatusCode: http.StatusOK, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "Bobby Whitespace", u.Name) + }, + }, { // Services like Okta return the email as the username: // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present @@ -861,9 +1014,12 @@ func TestUserOIDC(t *testing.T) { IDTokenClaims: jwt.MapClaims{ "email": "kyle@kwc.io", "email_verified": true, + "name": "Kylium Carbonate", "preferred_username": "kyle@kwc.io", }, - Username: "kyle", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "kyle", u.Username) + }, AllowSignups: true, StatusCode: http.StatusOK, }, @@ -873,7 +1029,10 @@ func TestUserOIDC(t *testing.T) { IDTokenClaims: jwt.MapClaims{ "preferred_username": "kyle@kwc.io", }, - Username: "kyle", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "kyle", u.Username) + assert.Empty(t, u.Name) + }, AllowSignups: true, StatusCode: http.StatusOK, }, @@ -885,9 +1044,11 @@ func TestUserOIDC(t *testing.T) { "preferred_username": "kyle", "picture": "/example.png", }, - Username: "kyle", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "/example.png", u.AvatarURL) + assert.Equal(t, "kyle", u.Username) + }, AllowSignups: true, - AvatarURL: "/example.png", StatusCode: http.StatusOK, }, { @@ -899,10 +1060,14 @@ func TestUserOIDC(t *testing.T) { UserInfoClaims: jwt.MapClaims{ "preferred_username": "potato", "picture": "/example.png", + "name": "Kylium Carbonate", + }, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "/example.png", u.AvatarURL) + assert.Equal(t, "Kylium Carbonate", u.Name) + assert.Equal(t, "potato", u.Username) }, - Username: "potato", AllowSignups: true, - AvatarURL: "/example.png", StatusCode: http.StatusOK, }, { @@ -925,7 +1090,9 @@ func TestUserOIDC(t *testing.T) { "email_verified": true, "preferred_username": "user", }, - Username: "user", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "user", u.Username) + }, AllowSignups: true, IgnoreEmailVerified: false, StatusCode: http.StatusOK, @@ -948,13 +1115,18 @@ func TestUserOIDC(t *testing.T) { IDTokenClaims: jwt.MapClaims{ "email": "user@internal.domain", "email_verified": true, + "name": "User McName", "preferred_username": "user", }, UserInfoClaims: jwt.MapClaims{ "email": "user.mcname@external.domain", + "name": "Mr. User McName", "preferred_username": "Mr. User McName", }, - Username: "user", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "user", u.Username) + assert.Equal(t, "User McName", u.Name) + }, IgnoreUserInfo: true, AllowSignups: true, StatusCode: http.StatusOK, @@ -965,7 +1137,9 @@ func TestUserOIDC(t *testing.T) { "email": "user@domain.tld", "email_verified": true, }, 65536), - Username: "user", + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "user", u.Username) + }, AllowSignups: true, StatusCode: http.StatusOK, }, @@ -976,9 +1150,11 @@ func TestUserOIDC(t *testing.T) { "email_verified": true, }, UserInfoClaims: inflateClaims(t, jwt.MapClaims{}, 65536), - Username: "user", - AllowSignups: true, - StatusCode: http.StatusOK, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "user", u.Username) + }, + AllowSignups: true, + StatusCode: http.StatusOK, }, } { tc := tc @@ -996,6 +1172,7 @@ func TestUserOIDC(t *testing.T) { cfg.EmailDomain = tc.EmailDomain cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified cfg.IgnoreUserInfo = tc.IgnoreUserInfo + cfg.NameField = "name" }) auditor := audit.NewMock() @@ -1013,22 +1190,13 @@ func TestUserOIDC(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - if tc.Username != "" { - user, err := client.User(ctx, "me") - require.NoError(t, err) - require.Equal(t, tc.Username, user.Username) - - require.Len(t, auditor.AuditLogs(), numLogs) - require.NotEqual(t, auditor.AuditLogs()[numLogs-1].UserID, uuid.Nil) - require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) - } - - if tc.AvatarURL != "" { + if tc.AssertUser != nil { user, err := client.User(ctx, "me") require.NoError(t, err) - require.Equal(t, tc.AvatarURL, user.AvatarURL) + tc.AssertUser(t, user) require.Len(t, auditor.AuditLogs(), numLogs) + require.NotEqual(t, uuid.Nil, auditor.AuditLogs()[numLogs-1].UserID) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) } }) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index c89a78668637d..21d33ebc81dc0 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -333,6 +333,7 @@ type OIDCConfig struct { Scopes serpent.StringArray `json:"scopes" typescript:",notnull"` IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"` UsernameField serpent.String `json:"username_field" typescript:",notnull"` + NameField serpent.String `json:"name_field" typescript:",notnull"` EmailField serpent.String `json:"email_field" typescript:",notnull"` AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"` @@ -1192,6 +1193,16 @@ when required by your organization's security policy.`, Group: &deploymentGroupOIDC, YAML: "usernameField", }, + { + Name: "OIDC Name Field", + Description: "OIDC claim field to use as the name.", + Flag: "oidc-name-field", + Env: "CODER_OIDC_NAME_FIELD", + Default: "name", + Value: &c.OIDC.NameField, + Group: &deploymentGroupOIDC, + YAML: "nameField", + }, { Name: "OIDC Email Field", Description: "OIDC claim field to use as the email.", diff --git a/docs/api/general.md b/docs/api/general.md index 52313409cb02c..84424331cf488 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -294,6 +294,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "ignore_email_verified": true, "ignore_user_info": true, "issuer_url": "string", + "name_field": "string", "scopes": ["string"], "sign_in_text": "string", "signups_disabled_text": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index bd460b5f8fdbf..65b6e0fb47106 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2096,6 +2096,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "ignore_email_verified": true, "ignore_user_info": true, "issuer_url": "string", + "name_field": "string", "scopes": ["string"], "sign_in_text": "string", "signups_disabled_text": "string", @@ -2469,6 +2470,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "ignore_email_verified": true, "ignore_user_info": true, "issuer_url": "string", + "name_field": "string", "scopes": ["string"], "sign_in_text": "string", "signups_disabled_text": "string", @@ -3549,6 +3551,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "ignore_email_verified": true, "ignore_user_info": true, "issuer_url": "string", + "name_field": "string", "scopes": ["string"], "sign_in_text": "string", "signups_disabled_text": "string", @@ -3580,6 +3583,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `ignore_email_verified` | boolean | false | | | | `ignore_user_info` | boolean | false | | | | `issuer_url` | string | false | | | +| `name_field` | string | false | | | | `scopes` | array of string | false | | | | `sign_in_text` | string | false | | | | `signups_disabled_text` | string | false | | | diff --git a/docs/cli/server.md b/docs/cli/server.md index a7c32c2d78420..ea3672a1cb2d7 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -514,6 +514,17 @@ Ignore the email_verified claim from the upstream provider. OIDC claim field to use as the username. +### --oidc-name-field + +| | | +| ----------- | ----------------------------------- | +| Type | string | +| Environment | $CODER_OIDC_NAME_FIELD | +| YAML | oidc.nameField | +| Default | name | + +OIDC claim field to use as the name. + ### --oidc-email-field | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 4d4576d6d57cc..2c094e84913f0 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -408,6 +408,9 @@ OIDC OPTIONS: --oidc-issuer-url string, $CODER_OIDC_ISSUER_URL Issuer URL to use for Login with OIDC. + --oidc-name-field string, $CODER_OIDC_NAME_FIELD (default: name) + OIDC claim field to use as the name. + --oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*) If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is diff --git a/scripts/dev-oidc.sh b/scripts/dev-oidc.sh index 017c7f07c646d..6a6d6e08ac705 100755 --- a/scripts/dev-oidc.sh +++ b/scripts/dev-oidc.sh @@ -10,6 +10,7 @@ set -euo pipefail KEYCLOAK_VERSION="${KEYCLOAK_VERSION:-22.0}" +# NOTE: the trailing space in "lastName" is intentional. cat </tmp/example-realm.json { "realm": "coder", @@ -23,6 +24,8 @@ cat </tmp/example-realm.json { "username": "oidcuser", "email": "oidcuser@coder.com", + "firstName": "OIDC", + "lastName": "user ", "emailVerified": true, "enabled": true, "credentials": [ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b7de0be88ba37..86b6cf1e830f4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -756,6 +756,7 @@ export interface OIDCConfig { readonly scopes: string[]; readonly ignore_email_verified: boolean; readonly username_field: string; + readonly name_field: string; readonly email_field: string; readonly auth_url_params: Record; readonly ignore_user_info: boolean; From 44a70a5bc27c55beec71dc8dc9c9815ba8ac5ca9 Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Thu, 6 Jun 2024 10:59:59 -0600 Subject: [PATCH 040/168] feat: edit org display names and descriptions (#13474) --- coderd/apidoc/docs.go | 23 +++- coderd/apidoc/swagger.json | 29 +++- coderd/database/dbgen/dbgen.go | 1 + coderd/database/dbmem/dbmem.go | 15 +- coderd/database/dump.sql | 3 +- .../000216_organization_display_name.down.sql | 2 + .../000216_organization_display_name.up.sql | 10 ++ coderd/database/models.go | 1 + coderd/database/queries.sql.go | 47 +++++-- coderd/database/queries/organizations.sql | 8 +- coderd/httpapi/httpapi.go | 16 ++- coderd/httpapi/name.go | 4 +- coderd/httpapi/name_test.go | 2 +- coderd/organizations.go | 54 ++++++-- coderd/organizations_test.go | 129 ++++++++++++++++-- coderd/workspacebuilds_test.go | 2 +- codersdk/organizations.go | 21 ++- docs/api/organizations.md | 10 ++ docs/api/schemas.md | 38 ++++-- docs/api/users.md | 22 +-- site/src/api/typesGenerated.ts | 8 +- site/src/testHelpers/entities.ts | 4 +- 22 files changed, 359 insertions(+), 90 deletions(-) create mode 100644 coderd/database/migrations/000216_organization_display_name.down.sql create mode 100644 coderd/database/migrations/000216_organization_display_name.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 26ddef4177d82..8905c8a09cba5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8973,6 +8973,13 @@ const docTemplate = `{ "name" ], "properties": { + "description": { + "type": "string" + }, + "display_name": { + "description": "DisplayName will default to the same value as ` + "`" + `Name` + "`" + ` if not provided.", + "type": "string" + }, "name": { "type": "string" } @@ -10587,6 +10594,7 @@ const docTemplate = `{ "type": "object", "required": [ "created_at", + "display_name", "id", "is_default", "name", @@ -10597,6 +10605,12 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" @@ -12305,10 +12319,13 @@ const docTemplate = `{ }, "codersdk.UpdateOrganizationRequest": { "type": "object", - "required": [ - "name" - ], "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, "name": { "type": "string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 57800f5a38fa9..3ab826d3920da 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7995,6 +7995,13 @@ "type": "object", "required": ["name"], "properties": { + "description": { + "type": "string" + }, + "display_name": { + "description": "DisplayName will default to the same value as `Name` if not provided.", + "type": "string" + }, "name": { "type": "string" } @@ -9516,12 +9523,25 @@ }, "codersdk.Organization": { "type": "object", - "required": ["created_at", "id", "is_default", "name", "updated_at"], + "required": [ + "created_at", + "display_name", + "id", + "is_default", + "name", + "updated_at" + ], "properties": { "created_at": { "type": "string", "format": "date-time" }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" @@ -11145,8 +11165,13 @@ }, "codersdk.UpdateOrganizationRequest": { "type": "object", - "required": ["name"], "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, "name": { "type": "string" } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 4dea6bdb39f75..61c44d0779307 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -336,6 +336,7 @@ func Organization(t testing.TB, db database.Store, orig database.Organization) d org, err := db.InsertOrganization(genCtx, database.InsertOrganizationParams{ ID: takeFirst(orig.ID, uuid.New()), Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), + DisplayName: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), Description: takeFirst(orig.Description, namesgenerator.GetRandomName(1)), CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 2bfff39a949a9..8c2773ce80ac0 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -86,6 +86,7 @@ func New() database.Store { defaultOrg, err := q.InsertOrganization(context.Background(), database.InsertOrganizationParams{ ID: uuid.New(), Name: "first-organization", + DisplayName: "first-organization", Description: "Builtin default organization.", CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), @@ -6179,11 +6180,13 @@ func (q *FakeQuerier) InsertOrganization(_ context.Context, arg database.InsertO defer q.mutex.Unlock() organization := database.Organization{ - ID: arg.ID, - Name: arg.Name, - CreatedAt: arg.CreatedAt, - UpdatedAt: arg.UpdatedAt, - IsDefault: len(q.organizations) == 0, + ID: arg.ID, + Name: arg.Name, + DisplayName: arg.DisplayName, + Description: arg.Description, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + IsDefault: len(q.organizations) == 0, } q.organizations = append(q.organizations, organization) return organization, nil @@ -7324,6 +7327,8 @@ func (q *FakeQuerier) UpdateOrganization(_ context.Context, arg database.UpdateO for i, org := range q.organizations { if org.ID == arg.ID { org.Name = arg.Name + org.DisplayName = arg.DisplayName + org.Description = arg.Description q.organizations[i] = org return org, nil } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 28adc8a36b1f1..7baa59f745446 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -585,7 +585,8 @@ CREATE TABLE organizations ( description text NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - is_default boolean DEFAULT false NOT NULL + is_default boolean DEFAULT false NOT NULL, + display_name text NOT NULL ); CREATE TABLE parameter_schemas ( diff --git a/coderd/database/migrations/000216_organization_display_name.down.sql b/coderd/database/migrations/000216_organization_display_name.down.sql new file mode 100644 index 0000000000000..4dea440465b11 --- /dev/null +++ b/coderd/database/migrations/000216_organization_display_name.down.sql @@ -0,0 +1,2 @@ +alter table organizations + drop column display_name; diff --git a/coderd/database/migrations/000216_organization_display_name.up.sql b/coderd/database/migrations/000216_organization_display_name.up.sql new file mode 100644 index 0000000000000..26245f03fc525 --- /dev/null +++ b/coderd/database/migrations/000216_organization_display_name.up.sql @@ -0,0 +1,10 @@ +-- This default is just a temporary thing to avoid null errors when first creating the column. +alter table organizations + add column display_name text not null default ''; + +update organizations + set display_name = name; + +-- We can remove the default now that everything has been copied. +alter table organizations + alter column display_name drop default; diff --git a/coderd/database/models.go b/coderd/database/models.go index e5ba9fcea6841..aa69054abc2aa 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1927,6 +1927,7 @@ type Organization struct { CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` IsDefault bool `db:"is_default" json:"is_default"` + DisplayName string `db:"display_name" json:"display_name"` } type OrganizationMember struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 677f972e734c3..d0c344e676d80 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3949,7 +3949,7 @@ func (q *sqlQuerier) DeleteOrganization(ctx context.Context, id uuid.UUID) error const getDefaultOrganization = `-- name: GetDefaultOrganization :one SELECT - id, name, description, created_at, updated_at, is_default + id, name, description, created_at, updated_at, is_default, display_name FROM organizations WHERE @@ -3968,13 +3968,14 @@ func (q *sqlQuerier) GetDefaultOrganization(ctx context.Context) (Organization, &i.CreatedAt, &i.UpdatedAt, &i.IsDefault, + &i.DisplayName, ) return i, err } const getOrganizationByID = `-- name: GetOrganizationByID :one SELECT - id, name, description, created_at, updated_at, is_default + id, name, description, created_at, updated_at, is_default, display_name FROM organizations WHERE @@ -3991,13 +3992,14 @@ func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Org &i.CreatedAt, &i.UpdatedAt, &i.IsDefault, + &i.DisplayName, ) return i, err } const getOrganizationByName = `-- name: GetOrganizationByName :one SELECT - id, name, description, created_at, updated_at, is_default + id, name, description, created_at, updated_at, is_default, display_name FROM organizations WHERE @@ -4016,13 +4018,14 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, name string) (Or &i.CreatedAt, &i.UpdatedAt, &i.IsDefault, + &i.DisplayName, ) return i, err } const getOrganizations = `-- name: GetOrganizations :many SELECT - id, name, description, created_at, updated_at, is_default + id, name, description, created_at, updated_at, is_default, display_name FROM organizations ` @@ -4043,6 +4046,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context) ([]Organization, erro &i.CreatedAt, &i.UpdatedAt, &i.IsDefault, + &i.DisplayName, ); err != nil { return nil, err } @@ -4059,7 +4063,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context) ([]Organization, erro const getOrganizationsByUserID = `-- name: GetOrganizationsByUserID :many SELECT - id, name, description, created_at, updated_at, is_default + id, name, description, created_at, updated_at, is_default, display_name FROM organizations WHERE @@ -4089,6 +4093,7 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.U &i.CreatedAt, &i.UpdatedAt, &i.IsDefault, + &i.DisplayName, ); err != nil { return nil, err } @@ -4105,15 +4110,16 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.U const insertOrganization = `-- name: InsertOrganization :one INSERT INTO - organizations (id, "name", description, created_at, updated_at, is_default) + organizations (id, "name", display_name, description, created_at, updated_at, is_default) VALUES -- If no organizations exist, and this is the first, make it the default. - ($1, $2, $3, $4, $5, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default + ($1, $2, $3, $4, $5, $6, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name ` type InsertOrganizationParams struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` Description string `db:"description" json:"description"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` @@ -4123,6 +4129,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat row := q.db.QueryRowContext(ctx, insertOrganization, arg.ID, arg.Name, + arg.DisplayName, arg.Description, arg.CreatedAt, arg.UpdatedAt, @@ -4135,6 +4142,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat &i.CreatedAt, &i.UpdatedAt, &i.IsDefault, + &i.DisplayName, ) return i, err } @@ -4144,20 +4152,30 @@ UPDATE organizations SET updated_at = $1, - name = $2 + name = $2, + display_name = $3, + description = $4 WHERE - id = $3 -RETURNING id, name, description, created_at, updated_at, is_default + id = $5 +RETURNING id, name, description, created_at, updated_at, is_default, display_name ` type UpdateOrganizationParams struct { - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Name string `db:"name" json:"name"` - ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + Description string `db:"description" json:"description"` + ID uuid.UUID `db:"id" json:"id"` } func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error) { - row := q.db.QueryRowContext(ctx, updateOrganization, arg.UpdatedAt, arg.Name, arg.ID) + row := q.db.QueryRowContext(ctx, updateOrganization, + arg.UpdatedAt, + arg.Name, + arg.DisplayName, + arg.Description, + arg.ID, + ) var i Organization err := row.Scan( &i.ID, @@ -4166,6 +4184,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat &i.CreatedAt, &i.UpdatedAt, &i.IsDefault, + &i.DisplayName, ) return i, err } diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index 9d5cec1324fe6..dbefb9f8ad711 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -49,17 +49,19 @@ WHERE -- name: InsertOrganization :one INSERT INTO - organizations (id, "name", description, created_at, updated_at, is_default) + organizations (id, "name", display_name, description, created_at, updated_at, is_default) VALUES -- If no organizations exist, and this is the first, make it the default. - ($1, $2, $3, $4, $5, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *; + (@id, @name, @display_name, @description, @created_at, @updated_at, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *; -- name: UpdateOrganization :one UPDATE organizations SET updated_at = @updated_at, - name = @name + name = @name, + display_name = @display_name, + description = @description WHERE id = @id RETURNING *; diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index fb5e4361ec32c..e8229cf5477c1 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -46,25 +46,27 @@ func init() { valid := NameValid(str) return valid == nil } - for _, tag := range []string{"username", "template_name", "workspace_name", "oauth2_app_name"} { + for _, tag := range []string{"username", "organization_name", "template_name", "workspace_name", "oauth2_app_name"} { err := Validate.RegisterValidation(tag, nameValidator) if err != nil { panic(err) } } - templateDisplayNameValidator := func(fl validator.FieldLevel) bool { + displayNameValidator := func(fl validator.FieldLevel) bool { f := fl.Field().Interface() str, ok := f.(string) if !ok { return false } - valid := TemplateDisplayNameValid(str) + valid := DisplayNameValid(str) return valid == nil } - err := Validate.RegisterValidation("template_display_name", templateDisplayNameValidator) - if err != nil { - panic(err) + for _, displayNameTag := range []string{"organization_display_name", "template_display_name"} { + err := Validate.RegisterValidation(displayNameTag, displayNameValidator) + if err != nil { + panic(err) + } } templateVersionNameValidator := func(fl validator.FieldLevel) bool { @@ -76,7 +78,7 @@ func init() { valid := TemplateVersionNameValid(str) return valid == nil } - err = Validate.RegisterValidation("template_version_name", templateVersionNameValidator) + err := Validate.RegisterValidation("template_version_name", templateVersionNameValidator) if err != nil { panic(err) } diff --git a/coderd/httpapi/name.go b/coderd/httpapi/name.go index cdf4a9ad48971..9431f574e5565 100644 --- a/coderd/httpapi/name.go +++ b/coderd/httpapi/name.go @@ -65,8 +65,8 @@ func TemplateVersionNameValid(str string) error { return nil } -// TemplateDisplayNameValid returns whether the input string is a valid template display name. -func TemplateDisplayNameValid(str string) error { +// DisplayNameValid returns whether the input string is a valid template display name. +func DisplayNameValid(str string) error { if len(str) == 0 { return nil // empty display_name is correct } diff --git a/coderd/httpapi/name_test.go b/coderd/httpapi/name_test.go index 15da7b3e82a63..f0c83ea2bdb0c 100644 --- a/coderd/httpapi/name_test.go +++ b/coderd/httpapi/name_test.go @@ -117,7 +117,7 @@ func TestTemplateDisplayNameValid(t *testing.T) { testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() - valid := httpapi.TemplateDisplayNameValid(testCase.Name) + valid := httpapi.DisplayNameValid(testCase.Name) require.Equal(t, testCase.Valid, valid == nil) }) } diff --git a/coderd/organizations.go b/coderd/organizations.go index b92d80342dfd6..6ae3358e9a2f2 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -74,12 +74,17 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { var organization database.Organization err = api.Database.InTx(func(tx database.Store) error { + if req.DisplayName == "" { + req.DisplayName = req.Name + } + organization, err = tx.InsertOrganization(ctx, database.InsertOrganizationParams{ ID: uuid.New(), Name: req.Name, + DisplayName: req.DisplayName, + Description: req.Description, CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), - Description: "", }) if err != nil { return xerrors.Errorf("create organization: %w", err) @@ -146,11 +151,38 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) { return } - organization, err := api.Database.UpdateOrganization(ctx, database.UpdateOrganizationParams{ - ID: organization.ID, - UpdatedAt: dbtime.Now(), - Name: req.Name, + err := database.ReadModifyUpdate(api.Database, func(tx database.Store) error { + var err error + organization, err = tx.GetOrganizationByID(ctx, organization.ID) + if err != nil { + return err + } + + updateOrgParams := database.UpdateOrganizationParams{ + UpdatedAt: dbtime.Now(), + ID: organization.ID, + Name: organization.Name, + DisplayName: organization.DisplayName, + Description: organization.Description, + } + + if req.Name != "" { + updateOrgParams.Name = req.Name + } + if req.DisplayName != "" { + updateOrgParams.DisplayName = req.DisplayName + } + if req.Description != "" { + updateOrgParams.Description = req.Description + } + + organization, err = tx.UpdateOrganization(ctx, updateOrgParams) + if err != nil { + return err + } + return nil }) + if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return @@ -212,10 +244,12 @@ func (api *API) deleteOrganization(rw http.ResponseWriter, r *http.Request) { // convertOrganization consumes the database representation and outputs an API friendly representation. func convertOrganization(organization database.Organization) codersdk.Organization { return codersdk.Organization{ - ID: organization.ID, - Name: organization.Name, - CreatedAt: organization.CreatedAt, - UpdatedAt: organization.UpdatedAt, - IsDefault: organization.IsDefault, + ID: organization.ID, + Name: organization.Name, + DisplayName: organization.DisplayName, + Description: organization.Description, + CreatedAt: organization.CreatedAt, + UpdatedAt: organization.UpdatedAt, + IsDefault: organization.IsDefault, } } diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index 8ce39c5593d90..20fb7243faa5b 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -20,7 +20,8 @@ func TestMultiOrgFetch(t *testing.T) { makeOrgs := []string{"foo", "bar", "baz"} for _, name := range makeOrgs { _, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: name, + Name: name, + DisplayName: name, }) require.NoError(t, err) } @@ -45,7 +46,8 @@ func TestOrganizationsByUser(t *testing.T) { // Make an extra org, and it should not be defaulted. notDefault, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "another", + Name: "another", + DisplayName: "Another", }) require.NoError(t, err) require.False(t, notDefault.IsDefault, "only 1 default org allowed") @@ -73,7 +75,8 @@ func TestOrganizationByUserAndName(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) org, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "another", + Name: "another", + DisplayName: "Another", }) require.NoError(t, err) _, err = other.OrganizationByUserAndName(ctx, codersdk.Me, org.Name) @@ -106,23 +109,58 @@ func TestPostOrganizationsByUser(t *testing.T) { org, err := client.Organization(ctx, user.OrganizationID) require.NoError(t, err) _, err = client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: org.Name, + Name: org.Name, + DisplayName: org.DisplayName, }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusConflict, apiErr.StatusCode()) }) - t.Run("Create", func(t *testing.T) { + t.Run("InvalidName", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) _, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "A name which is definitely not url safe", + DisplayName: "New", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("Create", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "new", + DisplayName: "New", + Description: "A new organization to love and cherish forever.", + }) + require.NoError(t, err) + require.Equal(t, "new", o.Name) + require.Equal(t, "New", o.DisplayName) + require.Equal(t, "A new organization to love and cherish forever.", o.Description) + }) + + t.Run("CreateWithoutExplicitDisplayName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ Name: "new", }) require.NoError(t, err) + require.Equal(t, "new", o.Name) + require.Equal(t, "new", o.DisplayName) // should match the given `Name` }) } @@ -137,7 +175,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { originalOrg, err := client.Organization(ctx, user.OrganizationID) require.NoError(t, err) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "something-unique", + Name: "something-unique", + DisplayName: "Something Unique", }) require.NoError(t, err) @@ -156,7 +195,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "something-unique", + Name: "something-unique", + DisplayName: "Something Unique", }) require.NoError(t, err) @@ -168,6 +208,26 @@ func TestPatchOrganizationsByUser(t *testing.T) { require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) }) + t.Run("InvalidName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "something-unique", + DisplayName: "Something Unique", + }) + require.NoError(t, err) + + _, err = client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{ + Name: "something unique but not url safe", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + t.Run("UpdateById", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -175,7 +235,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", + Name: "new", + DisplayName: "New", }) require.NoError(t, err) @@ -193,7 +254,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", + Name: "new", + DisplayName: "New", }) require.NoError(t, err) @@ -202,6 +264,49 @@ func TestPatchOrganizationsByUser(t *testing.T) { }) require.NoError(t, err) require.Equal(t, "new-new", o.Name) + require.Equal(t, "New", o.DisplayName) // didn't change + }) + + t.Run("UpdateDisplayName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "new", + DisplayName: "New", + }) + require.NoError(t, err) + + o, err = client.UpdateOrganization(ctx, o.Name, codersdk.UpdateOrganizationRequest{ + DisplayName: "The Newest One", + }) + require.NoError(t, err) + require.Equal(t, "new", o.Name) // didn't change + require.Equal(t, "The Newest One", o.DisplayName) + }) + + t.Run("UpdateDescription", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "new", + DisplayName: "New", + }) + require.NoError(t, err) + + o, err = client.UpdateOrganization(ctx, o.Name, codersdk.UpdateOrganizationRequest{ + Description: "wow, this organization description is so updated!", + }) + + require.NoError(t, err) + require.Equal(t, "new", o.Name) // didn't change + require.Equal(t, "New", o.DisplayName) // didn't change + require.Equal(t, "wow, this organization description is so updated!", o.Description) }) } @@ -229,7 +334,8 @@ func TestDeleteOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "doomed", + Name: "doomed", + DisplayName: "Doomed", }) require.NoError(t, err) @@ -244,7 +350,8 @@ func TestDeleteOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "doomed", + Name: "doomed", + DisplayName: "Doomed", }) require.NoError(t, err) diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index eb76239b84658..5d99e56820aa1 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -728,7 +728,7 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) { validateCalls++ if userSuspended { // Simulate the user being suspended from the IDP too. - return "", http.StatusForbidden, fmt.Errorf("user is suspended") + return "", http.StatusForbidden, xerrors.New("user is suspended") } return "OK", 0, nil }, diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 646eae71d2475..b9ff98d1a3917 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -40,11 +40,13 @@ func ProvisionerTypeValid[T ProvisionerType | string](pt T) error { // Organization is the JSON representation of a Coder organization. type Organization struct { - ID uuid.UUID `table:"id" json:"id" validate:"required" format:"uuid"` - Name string `table:"name,default_sort" json:"name" validate:"required"` - CreatedAt time.Time `table:"created_at" json:"created_at" validate:"required" format:"date-time"` - UpdatedAt time.Time `table:"updated_at" json:"updated_at" validate:"required" format:"date-time"` - IsDefault bool `table:"default" json:"is_default" validate:"required"` + ID uuid.UUID `table:"id" json:"id" validate:"required" format:"uuid"` + Name string `table:"name,default_sort" json:"name" validate:"required,username"` + DisplayName string `table:"display_name" json:"display_name" validate:"required"` + Description string `table:"description" json:"description"` + CreatedAt time.Time `table:"created_at" json:"created_at" validate:"required" format:"date-time"` + UpdatedAt time.Time `table:"updated_at" json:"updated_at" validate:"required" format:"date-time"` + IsDefault bool `table:"default" json:"is_default" validate:"required"` } type OrganizationMember struct { @@ -56,11 +58,16 @@ type OrganizationMember struct { } type CreateOrganizationRequest struct { - Name string `json:"name" validate:"required,username"` + Name string `json:"name" validate:"required,organization_name"` + // DisplayName will default to the same value as `Name` if not provided. + DisplayName string `json:"display_name" validate:"omitempty,organization_display_name"` + Description string `json:"description,omitempty"` } type UpdateOrganizationRequest struct { - Name string `json:"name" validate:"required,username"` + Name string `json:"name,omitempty" validate:"omitempty,organization_name"` + DisplayName string `json:"display_name,omitempty" validate:"omitempty,organization_display_name"` + Description string `json:"description,omitempty"` } // CreateTemplateVersionRequest enables callers to create a new Template Version. diff --git a/docs/api/organizations.md b/docs/api/organizations.md index c6f4514eb9bad..820d4be64d281 100644 --- a/docs/api/organizations.md +++ b/docs/api/organizations.md @@ -105,6 +105,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations \ ```json { + "description": "string", + "display_name": "string", "name": "string" } ``` @@ -122,6 +124,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations \ ```json { "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", @@ -163,6 +167,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization} \ ```json { "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", @@ -240,6 +246,8 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization} \ ```json { + "description": "string", + "display_name": "string", "name": "string" } ``` @@ -258,6 +266,8 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization} \ ```json { "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 65b6e0fb47106..24af8aece05e6 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1416,15 +1416,19 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "description": "string", + "display_name": "string", "name": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------ | ------ | -------- | ------------ | ----------- | -| `name` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ---------------------------------------------------------------------- | +| `description` | string | false | | | +| `display_name` | string | false | | Display name will default to the same value as `Name` if not provided. | +| `name` | string | true | | | ## codersdk.CreateTemplateRequest @@ -3597,6 +3601,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", @@ -3606,13 +3612,15 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------ | ------- | -------- | ------------ | ----------- | -| `created_at` | string | true | | | -| `id` | string | true | | | -| `is_default` | boolean | true | | | -| `name` | string | true | | | -| `updated_at` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| -------------- | ------- | -------- | ------------ | ----------- | +| `created_at` | string | true | | | +| `description` | string | false | | | +| `display_name` | string | true | | | +| `id` | string | true | | | +| `is_default` | boolean | true | | | +| `name` | string | true | | | +| `updated_at` | string | true | | | ## codersdk.OrganizationMember @@ -5378,15 +5386,19 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { + "description": "string", + "display_name": "string", "name": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------ | ------ | -------- | ------------ | ----------- | -| `name` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `description` | string | false | | | +| `display_name` | string | false | | | +| `name` | string | false | | | ## codersdk.UpdateRoles diff --git a/docs/api/users.md b/docs/api/users.md index db5f959a7fa76..2a40ba1e8577b 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -998,6 +998,8 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations \ [ { "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", @@ -1016,14 +1018,16 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| -------------- | ----------------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | true | | | -| `» id` | string(uuid) | true | | | -| `» is_default` | boolean | true | | | -| `» name` | string | true | | | -| `» updated_at` | string(date-time) | true | | | +| Name | Type | Required | Restrictions | Description | +| ---------------- | ----------------- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | true | | | +| `» description` | string | false | | | +| `» display_name` | string | true | | | +| `» id` | string(uuid) | true | | | +| `» is_default` | boolean | true | | | +| `» name` | string | true | | | +| `» updated_at` | string(date-time) | true | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -1054,6 +1058,8 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations/{organiza ```json { "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 86b6cf1e830f4..fae41504f1c34 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -226,6 +226,8 @@ export interface CreateGroupRequest { // From codersdk/organizations.go export interface CreateOrganizationRequest { readonly name: string; + readonly display_name: string; + readonly description?: string; } // From codersdk/organizations.go @@ -777,6 +779,8 @@ export interface OIDCConfig { export interface Organization { readonly id: string; readonly name: string; + readonly display_name: string; + readonly description: string; readonly created_at: string; readonly updated_at: string; readonly is_default: boolean; @@ -1323,7 +1327,9 @@ export interface UpdateCheckResponse { // From codersdk/organizations.go export interface UpdateOrganizationRequest { - readonly name: string; + readonly name?: string; + readonly display_name?: string; + readonly description?: string; } // From codersdk/users.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1e2cf21e23383..424c1b7ef331d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -13,7 +13,9 @@ import type { TemplateVersionFiles } from "utils/templateVersion"; export const MockOrganization: TypesGen.Organization = { id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0", - name: "Test Organization", + name: "test-organization", + display_name: "Test Organization", + description: "", created_at: "", updated_at: "", is_default: true, From a8a81a61cdc11856bd2ea9ff588e8e7d255f85aa Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 6 Jun 2024 14:51:52 -0300 Subject: [PATCH 041/168] fix(site): fix tooltip in start button group (#13497) --- .../WorkspaceActions/Buttons.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index caa034d77c29f..ecfde9e97aa9b 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -63,7 +63,21 @@ export const StartButton: FC = ({ disabled, tooltipText, }) => { - const buttonContent = ( + let mainButton = ( + } + onClick={() => handleAction()} + disabled={disabled || loading} + > + {loading ? <>Starting… : "Start"} + + ); + + if (tooltipText) { + mainButton = {mainButton}; + } + + return ( = ({ }} disabled={disabled} > - } - onClick={() => handleAction()} - disabled={disabled || loading} - > - {loading ? <>Starting… : "Start"} - + {mainButton} = ({ /> ); - - return tooltipText ? ( - {buttonContent} - ) : ( - buttonContent - ); }; export const StopButton: FC = ({ From 4dfa901990855b4cfd719ab6b390c712f26ff38e Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 6 Jun 2024 15:17:43 -0300 Subject: [PATCH 042/168] refactor(site): hide select helper when only one proxy exists (#13496) --- .../modules/dashboard/Navbar/NavbarView.tsx | 249 +---------------- .../dashboard/Navbar/ProxyMenu.stories.tsx | 81 ++++++ .../modules/dashboard/Navbar/ProxyMenu.tsx | 252 ++++++++++++++++++ 3 files changed, 337 insertions(+), 245 deletions(-) create mode 100644 site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx create mode 100644 site/src/modules/dashboard/Navbar/ProxyMenu.tsx diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 376273c8d75ee..06e847ef76a3a 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -1,26 +1,16 @@ import { css, type Interpolation, type Theme, useTheme } from "@emotion/react"; -import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; import MenuIcon from "@mui/icons-material/Menu"; -import Button from "@mui/material/Button"; -import Divider from "@mui/material/Divider"; import Drawer from "@mui/material/Drawer"; import IconButton from "@mui/material/IconButton"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import Skeleton from "@mui/material/Skeleton"; -import { visuallyHidden } from "@mui/utils"; -import { type FC, useRef, useState } from "react"; -import { NavLink, useLocation, useNavigate } from "react-router-dom"; +import { type FC, useState } from "react"; +import { NavLink, useLocation } from "react-router-dom"; import type * as TypesGen from "api/typesGenerated"; -import { Abbr } from "components/Abbr/Abbr"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; -import { displayError } from "components/GlobalSnackbar/utils"; import { CoderIcon } from "components/Icons/CoderIcon"; -import { Latency } from "components/Latency/Latency"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; import type { ProxyContextValue } from "contexts/ProxyContext"; -import { BUTTON_SM_HEIGHT, navHeight } from "theme/constants"; +import { navHeight } from "theme/constants"; import { DeploymentDropdown } from "./DeploymentDropdown"; +import { ProxyMenu } from "./ProxyMenu"; import { UserDropdown } from "./UserDropdown/UserDropdown"; export interface NavbarViewProps { @@ -163,237 +153,6 @@ export const NavbarView: FC = ({ ); }; -interface ProxyMenuProps { - proxyContextValue: ProxyContextValue; -} - -const ProxyMenu: FC = ({ proxyContextValue }) => { - const theme = useTheme(); - const buttonRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const [refetchDate, setRefetchDate] = useState(); - const selectedProxy = proxyContextValue.proxy.proxy; - const refreshLatencies = proxyContextValue.refetchProxyLatencies; - const closeMenu = () => setIsOpen(false); - const navigate = useNavigate(); - const latencies = proxyContextValue.proxyLatencies; - const isLoadingLatencies = Object.keys(latencies).length === 0; - const isLoading = proxyContextValue.isLoading || isLoadingLatencies; - const { permissions } = useAuthenticated(); - - const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => { - if (!refetchDate) { - // Only show loading if the user manually requested a refetch - return false; - } - - // Only show a loading spinner if: - // - A latency exists. This means the latency was fetched at some point, so - // the loader *should* be resolved. - // - The proxy is healthy. If it is not, the loader might never resolve. - // - The latency reported is older than the refetch date. This means the - // latency is stale and we should show a loading spinner until the new - // latency is fetched. - const latency = latencies[proxy.id]; - return proxy.healthy && latency !== undefined && latency.at < refetchDate; - }; - - // This endpoint returns a 404 when not using enterprise. - // If we don't return null, then it looks like this is - // loading forever! - if (proxyContextValue.error) { - return null; - } - - if (isLoading) { - return ( - - ); - } - - return ( - <> - - - -
-

- Select a region nearest to you -

- -

- Workspace proxies improve terminal and web app connections to - workspaces. This does not apply to{" "} - - CLI - {" "} - connections. A region must be manually selected, otherwise the - default primary region will be used. -

-
- - - - {proxyContextValue.proxies && - [...proxyContextValue.proxies] - .sort((a, b) => { - const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity; - const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity; - return latencyA - latencyB; - }) - .map((proxy) => ( - { - if (!proxy.healthy) { - displayError("Please select a healthy workspace proxy."); - closeMenu(); - return; - } - - proxyContextValue.setProxy(proxy); - closeMenu(); - }} - > -
-
- -
- - {proxy.display_name} - - -
-
- ))} - - - - {Boolean(permissions.editWorkspaceProxies) && ( - { - navigate("/deployment/workspace-proxies"); - }} - > - Proxy settings - - )} - - { - // Stop the menu from closing - e.stopPropagation(); - // Refresh the latencies. - const refetchDate = refreshLatencies(); - setRefetchDate(refetchDate); - }} - > - Refresh Latencies - -
- - ); -}; - const styles = { desktopNavItems: (theme) => css` display: none; diff --git a/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx b/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx new file mode 100644 index 0000000000000..185677b62d8df --- /dev/null +++ b/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn, userEvent, within } from "@storybook/test"; +import { getAuthorizationKey } from "api/queries/authCheck"; +import { AuthProvider } from "contexts/auth/AuthProvider"; +import { permissionsToCheck } from "contexts/auth/permissions"; +import { getPreferredProxy } from "contexts/ProxyContext"; +import { + MockAuthMethodsAll, + MockPermissions, + MockProxyLatencies, + MockUser, + MockWorkspaceProxies, +} from "testHelpers/entities"; +import { ProxyMenu } from "./ProxyMenu"; + +const defaultProxyContextValue = { + proxyLatencies: MockProxyLatencies, + proxy: getPreferredProxy(MockWorkspaceProxies, undefined), + proxies: MockWorkspaceProxies, + isLoading: false, + isFetched: true, + setProxy: fn(), + clearProxy: fn(), + refetchProxyLatencies: () => new Date(), +}; + +const meta: Meta = { + title: "modules/dashboard/ProxyMenu", + component: ProxyMenu, + args: { + proxyContextValue: defaultProxyContextValue, + }, + decorators: [ + (Story) => ( + + + + ), + (Story) => ( +
+ +
+ ), + ], + parameters: { + queries: [ + { key: ["me"], data: MockUser }, + { key: ["authMethods"], data: MockAuthMethodsAll }, + { key: ["hasFirstUser"], data: true }, + { + key: getAuthorizationKey({ checks: permissionsToCheck }), + data: MockPermissions, + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Closed: Story = {}; + +export const Opened: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + }, +}; + +export const SingleProxy: Story = { + args: { + proxyContextValue: { + ...defaultProxyContextValue, + proxies: [MockWorkspaceProxies[0]], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + }, +}; diff --git a/site/src/modules/dashboard/Navbar/ProxyMenu.tsx b/site/src/modules/dashboard/Navbar/ProxyMenu.tsx new file mode 100644 index 0000000000000..61fb88fd190a0 --- /dev/null +++ b/site/src/modules/dashboard/Navbar/ProxyMenu.tsx @@ -0,0 +1,252 @@ +import { useTheme } from "@emotion/react"; +import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; +import Button from "@mui/material/Button"; +import Divider from "@mui/material/Divider"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import Skeleton from "@mui/material/Skeleton"; +import { visuallyHidden } from "@mui/utils"; +import { type FC, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import type * as TypesGen from "api/typesGenerated"; +import { Abbr } from "components/Abbr/Abbr"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Latency } from "components/Latency/Latency"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import type { ProxyContextValue } from "contexts/ProxyContext"; +import { BUTTON_SM_HEIGHT } from "theme/constants"; + +interface ProxyMenuProps { + proxyContextValue: ProxyContextValue; +} + +export const ProxyMenu: FC = ({ proxyContextValue }) => { + const theme = useTheme(); + const buttonRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [refetchDate, setRefetchDate] = useState(); + const selectedProxy = proxyContextValue.proxy.proxy; + const refreshLatencies = proxyContextValue.refetchProxyLatencies; + const closeMenu = () => setIsOpen(false); + const navigate = useNavigate(); + const latencies = proxyContextValue.proxyLatencies; + const isLoadingLatencies = Object.keys(latencies).length === 0; + const isLoading = proxyContextValue.isLoading || isLoadingLatencies; + const { permissions } = useAuthenticated(); + + const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => { + if (!refetchDate) { + // Only show loading if the user manually requested a refetch + return false; + } + + // Only show a loading spinner if: + // - A latency exists. This means the latency was fetched at some point, so + // the loader *should* be resolved. + // - The proxy is healthy. If it is not, the loader might never resolve. + // - The latency reported is older than the refetch date. This means the + // latency is stale and we should show a loading spinner until the new + // latency is fetched. + const latency = latencies[proxy.id]; + return proxy.healthy && latency !== undefined && latency.at < refetchDate; + }; + + // This endpoint returns a 404 when not using enterprise. + // If we don't return null, then it looks like this is + // loading forever! + if (proxyContextValue.error) { + return null; + } + + if (isLoading) { + return ( + + ); + } + + return ( + <> + + + + {proxyContextValue.proxies && proxyContextValue.proxies.length > 1 && ( + <> +
+

+ Select a region nearest to you +

+ +

+ Workspace proxies improve terminal and web app connections to + workspaces. This does not apply to{" "} + + CLI + {" "} + connections. A region must be manually selected, otherwise the + default primary region will be used. +

+
+ + + + )} + + {proxyContextValue.proxies && + [...proxyContextValue.proxies] + .sort((a, b) => { + const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity; + const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity; + return latencyA - latencyB; + }) + .map((proxy) => ( + { + if (!proxy.healthy) { + displayError("Please select a healthy workspace proxy."); + closeMenu(); + return; + } + + proxyContextValue.setProxy(proxy); + closeMenu(); + }} + > +
+
+ +
+ + {proxy.display_name} + + +
+
+ ))} + + + + {Boolean(permissions.editWorkspaceProxies) && ( + { + navigate("/deployment/workspace-proxies"); + }} + > + Proxy settings + + )} + + { + // Stop the menu from closing + e.stopPropagation(); + // Refresh the latencies. + const refetchDate = refreshLatencies(); + setRefetchDate(refetchDate); + }} + > + Refresh Latencies + +
+ + ); +}; From 1adc19b41f7d32705a6c7eddfd3adb6170174d4d Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 6 Jun 2024 15:32:51 -0300 Subject: [PATCH 043/168] fix(site): allow user to update their name (#13493) --- .../AccountPage/AccountForm.stories.tsx | 7 +++++++ .../AccountPage/AccountForm.test.tsx | 4 ---- .../UserSettingsPage/AccountPage/AccountForm.tsx | 12 +++--------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.stories.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.stories.tsx index d0e2e425b9a63..2babd4fa5b818 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.stories.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.stories.tsx @@ -26,6 +26,7 @@ export const Loading: Story = { isLoading: true, }, }; + export const WithError: Story = { args: { updateProfileError: mockApiError({ @@ -42,3 +43,9 @@ export const WithError: Story = { }, }, }; + +export const Editable: Story = { + args: { + editable: true, + }, +}; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx index 253498dc3ef37..649260c347d1e 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx @@ -63,10 +63,6 @@ describe("AccountForm", () => { // Then const el = await screen.findByLabelText("Username"); expect(el).toBeDisabled(); - const btn = await screen.findByRole("button", { - name: /Update account/i, - }); - expect(btn).toBeDisabled(); }); }); }); diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx index 84cdd04b39144..d713fbf35bfd8 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx @@ -21,6 +21,7 @@ export const Language = { const validationSchema = Yup.object({ username: nameValidator(Language.usernameLabel), + name: Yup.string(), }); export interface AccountFormProps { @@ -75,24 +76,17 @@ export const AccountForm: FC = ({ /> { e.target.value = e.target.value.trim(); form.handleChange(e); }} - aria-disabled={!editable} - disabled={!editable} - fullWidth label={Language.nameLabel} helperText='The human-readable name is optional and can be accessed in a template via the "data.coder_workspace_owner.me.full_name" property.' />
- + {Language.updateSettings}
From e2b330fcbac8e44909cf35c46d0a98c93b211635 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 6 Jun 2024 15:36:37 -0500 Subject: [PATCH 044/168] chore: change sql parameter for custom roles to be a `(name,org_id)` tuple (#13480) * chore: sql parameter to custom roles to be a (name,org) tuple CustomRole lookup takes (name,org_id) tuples as the search criteria. --- coderd/database/dbauthz/customroles_test.go | 16 +- coderd/database/dbmem/dbmem.go | 15 +- coderd/database/dbtestutil/postgres.go | 1 + coderd/database/dump.sql | 5 + ...000216_custom_role_pair_parameter.down.sql | 1 + .../000216_custom_role_pair_parameter.up.sql | 1 + coderd/database/querier_test.go | 230 ++++++++++++++++++ coderd/database/queries.sql.go | 22 +- coderd/database/queries/roles.sql | 17 +- coderd/database/sqlc.yaml | 4 + coderd/database/types.go | 28 +++ coderd/rbac/rolestore/rolestore.go | 25 +- 12 files changed, 339 insertions(+), 26 deletions(-) create mode 100644 coderd/database/migrations/000216_custom_role_pair_parameter.down.sql create mode 100644 coderd/database/migrations/000216_custom_role_pair_parameter.up.sql diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index a5077121c0629..b98af8fd23889 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -38,7 +38,7 @@ func TestUpsertCustomRoles(t *testing.T) { Name: "can-assign", DisplayName: "", Site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceAssignRole.Type: {policy.ActionCreate}, + rbac.ResourceAssignRole.Type: {policy.ActionRead, policy.ActionCreate}, }), } @@ -243,6 +243,20 @@ func TestUpsertCustomRoles(t *testing.T) { require.ErrorContains(t, err, tc.errorContains) } else { require.NoError(t, err) + + // Verify we can fetch the role + roles, err := az.CustomRoles(ctx, database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{ + { + Name: "test-role", + OrganizationID: tc.organizationID.UUID, + }, + }, + ExcludeOrgRoles: false, + OrganizationID: uuid.UUID{}, + }) + require.NoError(t, err) + require.Len(t, roles, 1) } }) } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8c2773ce80ac0..3f9ef73048e6b 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1187,12 +1187,17 @@ func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesPar for _, role := range q.data.customRoles { role := role if len(arg.LookupRoles) > 0 { - if !slices.ContainsFunc(arg.LookupRoles, func(s string) bool { - roleName := rbac.RoleName(role.Name, "") - if role.OrganizationID.UUID != uuid.Nil { - roleName = rbac.RoleName(role.Name, role.OrganizationID.UUID.String()) + if !slices.ContainsFunc(arg.LookupRoles, func(pair database.NameOrganizationPair) bool { + if pair.Name != role.Name { + return false } - return strings.EqualFold(s, roleName) + + if role.OrganizationID.Valid { + // Expect org match + return role.OrganizationID.UUID == pair.OrganizationID + } + // Expect no org + return pair.OrganizationID == uuid.Nil }) { continue } diff --git a/coderd/database/dbtestutil/postgres.go b/coderd/database/dbtestutil/postgres.go index 33e0350821099..3a559778b6968 100644 --- a/coderd/database/dbtestutil/postgres.go +++ b/coderd/database/dbtestutil/postgres.go @@ -28,6 +28,7 @@ func Open() (string, func(), error) { if err != nil { return "", nil, xerrors.Errorf("connect to ci postgres: %w", err) } + defer db.Close() dbName, err := cryptorand.StringCharset(cryptorand.Lower, 10) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7baa59f745446..c6faa00c65fc5 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -73,6 +73,11 @@ CREATE TYPE login_type AS ENUM ( COMMENT ON TYPE login_type IS 'Specifies the method of authentication. "none" is a special case in which no authentication method is allowed.'; +CREATE TYPE name_organization_pair AS ( + name text, + organization_id uuid +); + CREATE TYPE parameter_destination_scheme AS ENUM ( 'none', 'environment_variable', diff --git a/coderd/database/migrations/000216_custom_role_pair_parameter.down.sql b/coderd/database/migrations/000216_custom_role_pair_parameter.down.sql new file mode 100644 index 0000000000000..7322a09ee26b8 --- /dev/null +++ b/coderd/database/migrations/000216_custom_role_pair_parameter.down.sql @@ -0,0 +1 @@ +DROP TYPE name_organization_pair; diff --git a/coderd/database/migrations/000216_custom_role_pair_parameter.up.sql b/coderd/database/migrations/000216_custom_role_pair_parameter.up.sql new file mode 100644 index 0000000000000..b131054fc8dfb --- /dev/null +++ b/coderd/database/migrations/000216_custom_role_pair_parameter.up.sql @@ -0,0 +1 @@ +CREATE TYPE name_organization_pair AS (name text, organization_id uuid); diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c3e1f2e46b3db..0d523c25290e2 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "sort" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/migrations" @@ -514,6 +516,234 @@ func TestDefaultOrg(t *testing.T) { require.True(t, all[0].IsDefault, "first org should always be default") } +// TestReadCustomRoles tests the input params returns the correct set of roles. +func TestReadCustomRoles(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.SkipNow() + } + + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + + db := database.New(sqlDB) + ctx := testutil.Context(t, testutil.WaitLong) + + // Make a few site roles, and a few org roles + orgIDs := make([]uuid.UUID, 3) + for i := range orgIDs { + orgIDs[i] = uuid.New() + } + + allRoles := make([]database.CustomRole, 0) + siteRoles := make([]database.CustomRole, 0) + orgRoles := make([]database.CustomRole, 0) + for i := 0; i < 15; i++ { + orgID := uuid.NullUUID{ + UUID: orgIDs[i%len(orgIDs)], + Valid: true, + } + if i%4 == 0 { + // Some should be site wide + orgID = uuid.NullUUID{} + } + + role, err := db.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ + Name: fmt.Sprintf("role-%d", i), + OrganizationID: orgID, + }) + require.NoError(t, err) + allRoles = append(allRoles, role) + if orgID.Valid { + orgRoles = append(orgRoles, role) + } else { + siteRoles = append(siteRoles, role) + } + } + + // normalizedRoleName allows for the simple ElementsMatch to work properly. + normalizedRoleName := func(role database.CustomRole) string { + return role.Name + ":" + role.OrganizationID.UUID.String() + } + + roleToLookup := func(role database.CustomRole) database.NameOrganizationPair { + return database.NameOrganizationPair{ + Name: role.Name, + OrganizationID: role.OrganizationID.UUID, + } + } + + testCases := []struct { + Name string + Params database.CustomRolesParams + Match func(role database.CustomRole) bool + }{ + { + Name: "NilRoles", + Params: database.CustomRolesParams{ + LookupRoles: nil, + ExcludeOrgRoles: false, + OrganizationID: uuid.UUID{}, + }, + Match: func(role database.CustomRole) bool { + return true + }, + }, + { + // Empty params should return all roles + Name: "Empty", + Params: database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{}, + ExcludeOrgRoles: false, + OrganizationID: uuid.UUID{}, + }, + Match: func(role database.CustomRole) bool { + return true + }, + }, + { + Name: "Organization", + Params: database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{}, + ExcludeOrgRoles: false, + OrganizationID: orgIDs[1], + }, + Match: func(role database.CustomRole) bool { + return role.OrganizationID.UUID == orgIDs[1] + }, + }, + { + Name: "SpecificOrgRole", + Params: database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{ + { + Name: orgRoles[0].Name, + OrganizationID: orgRoles[0].OrganizationID.UUID, + }, + }, + }, + Match: func(role database.CustomRole) bool { + return role.Name == orgRoles[0].Name && role.OrganizationID.UUID == orgRoles[0].OrganizationID.UUID + }, + }, + { + Name: "SpecificSiteRole", + Params: database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{ + { + Name: siteRoles[0].Name, + OrganizationID: siteRoles[0].OrganizationID.UUID, + }, + }, + }, + Match: func(role database.CustomRole) bool { + return role.Name == siteRoles[0].Name && role.OrganizationID.UUID == siteRoles[0].OrganizationID.UUID + }, + }, + { + Name: "FewSpecificRoles", + Params: database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{ + { + Name: orgRoles[0].Name, + OrganizationID: orgRoles[0].OrganizationID.UUID, + }, + { + Name: orgRoles[1].Name, + OrganizationID: orgRoles[1].OrganizationID.UUID, + }, + { + Name: siteRoles[0].Name, + OrganizationID: siteRoles[0].OrganizationID.UUID, + }, + }, + }, + Match: func(role database.CustomRole) bool { + return (role.Name == orgRoles[0].Name && role.OrganizationID.UUID == orgRoles[0].OrganizationID.UUID) || + (role.Name == orgRoles[1].Name && role.OrganizationID.UUID == orgRoles[1].OrganizationID.UUID) || + (role.Name == siteRoles[0].Name && role.OrganizationID.UUID == siteRoles[0].OrganizationID.UUID) + }, + }, + { + Name: "AllRolesByLookup", + Params: database.CustomRolesParams{ + LookupRoles: db2sdk.List(allRoles, roleToLookup), + }, + Match: func(role database.CustomRole) bool { + return true + }, + }, + { + Name: "NotExists", + Params: database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{ + { + Name: "not-exists", + OrganizationID: uuid.New(), + }, + { + Name: "not-exists", + OrganizationID: uuid.Nil, + }, + }, + }, + Match: func(role database.CustomRole) bool { + return false + }, + }, + { + Name: "Mixed", + Params: database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{ + { + Name: "not-exists", + OrganizationID: uuid.New(), + }, + { + Name: "not-exists", + OrganizationID: uuid.Nil, + }, + { + Name: orgRoles[0].Name, + OrganizationID: orgRoles[0].OrganizationID.UUID, + }, + { + Name: siteRoles[0].Name, + }, + }, + }, + Match: func(role database.CustomRole) bool { + return (role.Name == orgRoles[0].Name && role.OrganizationID.UUID == orgRoles[0].OrganizationID.UUID) || + (role.Name == siteRoles[0].Name && role.OrganizationID.UUID == siteRoles[0].OrganizationID.UUID) + }, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + found, err := db.CustomRoles(ctx, tc.Params) + require.NoError(t, err) + filtered := make([]database.CustomRole, 0) + for _, role := range allRoles { + if tc.Match(role) { + filtered = append(filtered, role) + } + } + + a := db2sdk.List(filtered, normalizedRoleName) + b := db2sdk.List(found, normalizedRoleName) + require.Equal(t, a, b) + }) + } +} + type tvArgs struct { Status database.ProvisionerJobStatus // CreateWorkspace is true if we should create a workspace for the template version diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d0c344e676d80..5dff8c05e05a7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5623,20 +5623,20 @@ FROM custom_roles WHERE true - -- Lookup roles filter expects the role names to be in the rbac package - -- format. Eg: name[:] - AND CASE WHEN array_length($1 :: text[], 1) > 0 THEN - -- Case insensitive lookup with org_id appended (if non-null). - -- This will return just the name if org_id is null. It'll append - -- the org_id if not null - concat(name, NULLIF(concat(':', organization_id), ':')) ILIKE ANY($1 :: text []) + -- @lookup_roles will filter for exact (role_name, org_id) pairs + -- To do this manually in SQL, you can construct an array and cast it: + -- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[]) + AND CASE WHEN array_length($1 :: name_organization_pair[], 1) > 0 THEN + -- Using 'coalesce' to avoid troubles with null literals being an empty string. + (name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY ($1::name_organization_pair[]) ELSE true END - -- Org scoping filter, to only fetch site wide roles + -- This allows fetching all roles, or just site wide roles AND CASE WHEN $2 :: boolean THEN organization_id IS null ELSE true END + -- Allows fetching all roles to a particular organization AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN organization_id = $3 ELSE true @@ -5644,9 +5644,9 @@ WHERE ` type CustomRolesParams struct { - LookupRoles []string `db:"lookup_roles" json:"lookup_roles"` - ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + LookupRoles []NameOrganizationPair `db:"lookup_roles" json:"lookup_roles"` + ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) { diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index dd8816d40eecc..ec5566a3d0dbb 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -5,26 +5,27 @@ FROM custom_roles WHERE true - -- Lookup roles filter expects the role names to be in the rbac package - -- format. Eg: name[:] - AND CASE WHEN array_length(@lookup_roles :: text[], 1) > 0 THEN - -- Case insensitive lookup with org_id appended (if non-null). - -- This will return just the name if org_id is null. It'll append - -- the org_id if not null - concat(name, NULLIF(concat(':', organization_id), ':')) ILIKE ANY(@lookup_roles :: text []) + -- @lookup_roles will filter for exact (role_name, org_id) pairs + -- To do this manually in SQL, you can construct an array and cast it: + -- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[]) + AND CASE WHEN array_length(@lookup_roles :: name_organization_pair[], 1) > 0 THEN + -- Using 'coalesce' to avoid troubles with null literals being an empty string. + (name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY (@lookup_roles::name_organization_pair[]) ELSE true END - -- Org scoping filter, to only fetch site wide roles + -- This allows fetching all roles, or just site wide roles AND CASE WHEN @exclude_org_roles :: boolean THEN organization_id IS null ELSE true END + -- Allows fetching all roles to a particular organization AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN organization_id = @organization_id ELSE true END ; + -- name: UpsertCustomRole :one INSERT INTO custom_roles ( diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index ff8faf5f7704c..67d7e52b06b6d 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -28,6 +28,10 @@ sql: emit_enum_valid_method: true emit_all_enum_values: true overrides: + # Used in 'CustomRoles' query to filter by (name,organization_id) + - db_type: "name_organization_pair" + go_type: + type: "NameOrganizationPair" - column: "custom_roles.site_permissions" go_type: type: "CustomRolePermissions" diff --git a/coderd/database/types.go b/coderd/database/types.go index 5d0490d0c9020..fd7a2fed82300 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -3,6 +3,7 @@ package database import ( "database/sql/driver" "encoding/json" + "fmt" "time" "github.com/google/uuid" @@ -142,3 +143,30 @@ func (a CustomRolePermission) String() string { } return str } + +// NameOrganizationPair is used as a lookup tuple for custom role rows. +type NameOrganizationPair struct { + Name string `db:"name" json:"name"` + // OrganizationID if unset will assume a null column value + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +func (*NameOrganizationPair) Scan(_ interface{}) error { + return xerrors.Errorf("this should never happen, type 'NameOrganizationPair' should only be used as a parameter") +} + +// Value returns the tuple **literal** +// To get the literal value to return, you can use the expression syntax in a psql +// shell. +// +// SELECT ('customrole'::text,'ece79dac-926e-44ca-9790-2ff7c5eb6e0c'::uuid); +// To see 'null' option. Using the nil uuid as null to avoid empty string literals for null. +// SELECT ('customrole',00000000-0000-0000-0000-000000000000); +// +// This value is usually used as an array, NameOrganizationPair[]. You can see +// what that literal is as well, with proper quoting. +// +// SELECT ARRAY[('customrole'::text,'ece79dac-926e-44ca-9790-2ff7c5eb6e0c'::uuid)]; +func (a NameOrganizationPair) Value() (driver.Value, error) { + return fmt.Sprintf(`(%s,%s)`, a.Name, a.OrganizationID.String()), nil +} diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 083f03877aa83..80cbd1165073b 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -69,11 +69,34 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, } if len(lookup) > 0 { + // The set of roles coming in are formatted as 'rolename[:]'. + // In the database, org roles are scoped with an organization column. + lookupArgs := make([]database.NameOrganizationPair, 0, len(lookup)) + for _, name := range lookup { + roleName, orgID, err := rbac.RoleSplit(name) + if err != nil { + continue + } + + parsedOrgID := uuid.Nil // Default to no org ID + if orgID != "" { + parsedOrgID, err = uuid.Parse(orgID) + if err != nil { + continue + } + } + + lookupArgs = append(lookupArgs, database.NameOrganizationPair{ + Name: roleName, + OrganizationID: parsedOrgID, + }) + } + // If some roles are missing from the database, they are omitted from // the expansion. These roles are no-ops. Should we raise some kind of // warning when this happens? dbroles, err := db.CustomRoles(ctx, database.CustomRolesParams{ - LookupRoles: lookup, + LookupRoles: lookupArgs, ExcludeOrgRoles: false, OrganizationID: uuid.Nil, }) From 7c3b8b62246277bae131a2c60f5b28e74617aff7 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 6 Jun 2024 16:13:00 -0500 Subject: [PATCH 045/168] chore: duplicate migration file fix, 000216 (#13498) --- ...ameter.down.sql => 000217_custom_role_pair_parameter.down.sql} | 0 ..._parameter.up.sql => 000217_custom_role_pair_parameter.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000216_custom_role_pair_parameter.down.sql => 000217_custom_role_pair_parameter.down.sql} (100%) rename coderd/database/migrations/{000216_custom_role_pair_parameter.up.sql => 000217_custom_role_pair_parameter.up.sql} (100%) diff --git a/coderd/database/migrations/000216_custom_role_pair_parameter.down.sql b/coderd/database/migrations/000217_custom_role_pair_parameter.down.sql similarity index 100% rename from coderd/database/migrations/000216_custom_role_pair_parameter.down.sql rename to coderd/database/migrations/000217_custom_role_pair_parameter.down.sql diff --git a/coderd/database/migrations/000216_custom_role_pair_parameter.up.sql b/coderd/database/migrations/000217_custom_role_pair_parameter.up.sql similarity index 100% rename from coderd/database/migrations/000216_custom_role_pair_parameter.up.sql rename to coderd/database/migrations/000217_custom_role_pair_parameter.up.sql From 48ecee10250fdb37efffd10e8a36afcfc5a6378a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 7 Jun 2024 10:01:54 +0100 Subject: [PATCH 046/168] chore(cli): address cli netcheck test flake (#13492) * netcheck: removes check for healthy node report in test * coderd/healthcheck/derphealth: do not override parent context deadline --- cli/netcheck_test.go | 4 +-- coderd/healthcheck/derphealth/derp.go | 8 +++-- coderd/healthcheck/derphealth/derp_test.go | 40 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/cli/netcheck_test.go b/cli/netcheck_test.go index 45166861db04f..16b72beb2fd89 100644 --- a/cli/netcheck_test.go +++ b/cli/netcheck_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" @@ -30,7 +29,8 @@ func TestNetcheck(t *testing.T) { var report healthsdk.DERPHealthReport require.NoError(t, json.Unmarshal(b, &report)) - assert.True(t, report.Healthy) + // We do not assert that the report is healthy, just that + // it has the expected number of reports per region. require.Len(t, report.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region for _, v := range report.Regions { require.Len(t, v.NodeReports, len(v.Region.Nodes)) diff --git a/coderd/healthcheck/derphealth/derp.go b/coderd/healthcheck/derphealth/derp.go index 65d905f16917e..f74db243cbc18 100644 --- a/coderd/healthcheck/derphealth/derp.go +++ b/coderd/healthcheck/derphealth/derp.go @@ -236,8 +236,12 @@ func (r *NodeReport) derpURL() *url.URL { } func (r *NodeReport) Run(ctx context.Context) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() + // If there already is a deadline set on the context, do not override it. + if _, ok := ctx.Deadline(); !ok { + dCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + ctx = dCtx + } r.Severity = health.SeverityOK r.ClientLogs = [][]string{} diff --git a/coderd/healthcheck/derphealth/derp_test.go b/coderd/healthcheck/derphealth/derp_test.go index 90e5db63c9763..c009ea982d620 100644 --- a/coderd/healthcheck/derphealth/derp_test.go +++ b/coderd/healthcheck/derphealth/derp_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -84,6 +85,45 @@ func TestDERP(t *testing.T) { } }) + t.Run("TimeoutCtx", func(t *testing.T) { + t.Parallel() + + derpSrv := derp.NewServer(key.NewNode(), func(format string, args ...any) { t.Logf(format, args...) }) + defer derpSrv.Close() + srv := httptest.NewServer(derphttp.Handler(derpSrv)) + defer srv.Close() + + var ( + // nolint:gocritic // testing a deadline exceeded + ctx, cancel = context.WithTimeout(context.Background(), time.Nanosecond) + report = derphealth.Report{} + derpURL, _ = url.Parse(srv.URL) + opts = &derphealth.ReportOptions{ + DERPMap: &tailcfg.DERPMap{Regions: map[int]*tailcfg.DERPRegion{ + 1: { + EmbeddedRelay: true, + RegionID: 999, + Nodes: []*tailcfg.DERPNode{{ + Name: "1a", + RegionID: 999, + HostName: derpURL.Host, + IPv4: derpURL.Host, + STUNPort: -1, + InsecureForTests: true, + ForceHTTP: true, + }}, + }, + }}, + } + ) + cancel() + + report.Run(ctx, opts) + + assert.False(t, report.Healthy) + assert.Nil(t, report.Error) + }) + t.Run("HealthyWithNodeDegraded", func(t *testing.T) { t.Parallel() From 056a697eff210c7d2779e54d43a9a9c608531c84 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 7 Jun 2024 10:03:05 -0300 Subject: [PATCH 047/168] feat(site): add download logs option (#13466) --- .github/workflows/ci.yaml | 2 +- site/package.json | 3 + site/pnpm-lock.yaml | 50 +++++- site/src/api/api.ts | 3 +- site/src/api/queries/util.ts | 4 +- site/src/api/queries/workspaces.ts | 30 ++++ .../DropdownArrow/DropdownArrow.tsx | 6 +- .../modules/resources/AgentLogs/AgentLogs.tsx | 67 +------ .../resources/AgentLogs/useAgentLogs.test.tsx | 143 +++++++++++++++ .../resources/AgentLogs/useAgentLogs.ts | 75 ++++++++ .../modules/resources/AgentRow.stories.tsx | 38 ++-- site/src/modules/resources/AgentRow.test.tsx | 3 +- site/src/modules/resources/AgentRow.tsx | 76 ++++---- .../DownloadAgentLogsButton.stories.tsx | 58 ++++++ .../resources/DownloadAgentLogsButton.tsx | 63 +++++++ .../WorkspaceBuildPageView.tsx | 27 ++- .../DownloadLogsDialog.stories.tsx | 78 ++++++++ .../WorkspaceActions/DownloadLogsDialog.tsx | 166 ++++++++++++++++++ .../WorkspaceActions.stories.tsx | 40 +++++ .../WorkspaceActions/WorkspaceActions.tsx | 18 +- 20 files changed, 801 insertions(+), 149 deletions(-) create mode 100644 site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx create mode 100644 site/src/modules/resources/AgentLogs/useAgentLogs.ts create mode 100644 site/src/modules/resources/DownloadAgentLogsButton.stories.tsx create mode 100644 site/src/modules/resources/DownloadAgentLogsButton.tsx create mode 100644 site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx create mode 100644 site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e092cef28ab02..7d1e9837c8682 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -922,7 +922,7 @@ jobs: uses: actions/dependency-review-action@v4.3.2 with: allow-licenses: Apache-2.0, BSD-2-Clause, BSD-3-Clause, CC0-1.0, ISC, MIT, MIT-0, MPL-2.0 - allow-dependencies-licenses: "pkg:golang/github.com/coder/wgtunnel@0.1.13-0.20240522110300-ade90dfb2da0" + allow-dependencies-licenses: "pkg:golang/github.com/coder/wgtunnel@0.1.13-0.20240522110300-ade90dfb2da0, pkg:npm/pako@1.0.11" license-check: true vulnerability-check: false - name: "Report" diff --git a/site/package.json b/site/package.json index 2aa4c6b047c0b..3640558a824f1 100644 --- a/site/package.json +++ b/site/package.json @@ -45,6 +45,7 @@ "@mui/system": "5.14.0", "@mui/utils": "5.14.11", "@tanstack/react-query-devtools": "4.35.3", + "@types/file-saver": "2.0.7", "ansi-to-html": "0.7.2", "axios": "1.6.0", "canvas": "2.11.0", @@ -58,8 +59,10 @@ "date-fns": "2.30.0", "dayjs": "1.11.4", "emoji-mart": "5.4.0", + "file-saver": "2.0.5", "formik": "2.4.1", "front-matter": "4.0.2", + "jszip": "3.10.1", "lodash": "4.17.21", "monaco-editor": "0.44.0", "pretty-bytes": "6.1.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 1b2dc867b8c0f..288e31f3515b9 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -54,6 +54,9 @@ dependencies: '@tanstack/react-query-devtools': specifier: 4.35.3 version: 4.35.3(@tanstack/react-query@4.35.3)(react-dom@18.2.0)(react@18.2.0) + '@types/file-saver': + specifier: 2.0.7 + version: 2.0.7 ansi-to-html: specifier: 0.7.2 version: 0.7.2 @@ -93,12 +96,18 @@ dependencies: emoji-mart: specifier: 5.4.0 version: 5.4.0 + file-saver: + specifier: 2.0.5 + version: 2.0.5 formik: specifier: 2.4.1 version: 2.4.1(react@18.2.0) front-matter: specifier: 4.0.2 version: 4.0.2 + jszip: + specifier: 3.10.1 + version: 3.10.1 lodash: specifier: 4.17.21 version: 4.17.21 @@ -4781,6 +4790,10 @@ packages: '@types/serve-static': 1.15.2 dev: true + /@types/file-saver@2.0.7: + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + dev: false + /@types/find-cache-dir@3.2.1: resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==} dev: true @@ -6444,7 +6457,6 @@ packages: /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - dev: true /cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} @@ -7682,6 +7694,10 @@ packages: flat-cache: 3.1.1 dev: true + /file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + dev: false + /file-system-cache@2.3.0: resolution: {integrity: sha512-l4DMNdsIPsVnKrgEXbJwDJsA5mB8rGwHYERMgqQx/xAUtChPJMre1bXBzDEqqVbWv9AIbFezXMxeEkZDSrXUOQ==} dependencies: @@ -8334,6 +8350,10 @@ packages: engines: {node: '>= 4'} dev: true + /immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + dev: false + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -8696,7 +8716,6 @@ packages: /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - dev: true /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -9479,6 +9498,15 @@ packages: object.values: 1.1.7 dev: true + /jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -9527,6 +9555,12 @@ packages: type-check: 0.4.0 dev: true + /lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + dependencies: + immediate: 3.0.6 + dev: false + /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -10573,6 +10607,10 @@ packages: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} dev: true + /pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + dev: false + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -10814,7 +10852,6 @@ packages: /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - dev: true /process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} @@ -11310,7 +11347,6 @@ packages: safe-buffer: 5.1.2 string_decoder: 1.1.1 util-deprecate: 1.0.2 - dev: true /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} @@ -11625,7 +11661,6 @@ packages: /safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - dev: true /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -11726,6 +11761,10 @@ packages: engines: {node: '>=6.9'} dev: false + /setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + dev: false + /setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} dev: true @@ -12064,7 +12103,6 @@ packages: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} dependencies: safe-buffer: 5.1.2 - dev: true /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7e8829201dc3a..2a1057ef04b4a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1142,10 +1142,9 @@ class ApiMethods { getWorkspaceBuildLogs = async ( buildId: string, - before: Date, ): Promise => { const response = await this.axios.get( - `/api/v2/workspacebuilds/${buildId}/logs?before=${before.getTime()}`, + `/api/v2/workspacebuilds/${buildId}/logs`, ); return response.data; diff --git a/site/src/api/queries/util.ts b/site/src/api/queries/util.ts index 6043b984fab93..fe1b55b68e58d 100644 --- a/site/src/api/queries/util.ts +++ b/site/src/api/queries/util.ts @@ -1,7 +1,7 @@ import type { UseQueryOptions, QueryKey } from "react-query"; import type { MetadataState, MetadataValue } from "hooks/useEmbeddedMetadata"; -const disabledFetchOptions = { +export const disabledRefetchOptions = { cacheTime: Infinity, staleTime: Infinity, refetchOnMount: false, @@ -62,7 +62,7 @@ export function cachedQuery< // Make sure the disabled options are always serialized last, so that no // one using this function can accidentally override the values - ...(metadata.available ? disabledFetchOptions : {}), + ...(metadata.available ? disabledRefetchOptions : {}), }; return newOptions as FormattedQueryOptionsResult< diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 95df3b7f592f6..809c8a4c2862f 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -14,6 +14,7 @@ import type { WorkspacesRequest, WorkspacesResponse, } from "api/typesGenerated"; +import { disabledRefetchOptions } from "./util"; import { workspaceBuildsKey } from "./workspaceBuilds"; export const workspaceByOwnerAndNameKey = (owner: string, name: string) => [ @@ -283,3 +284,32 @@ export const toggleFavorite = ( }, }; }; + +export const buildLogsKey = (workspaceId: string) => [ + "workspaces", + workspaceId, + "logs", +]; + +export const buildLogs = (workspace: Workspace) => { + return { + queryKey: buildLogsKey(workspace.id), + queryFn: () => API.getWorkspaceBuildLogs(workspace.latest_build.id), + }; +}; + +export const agentLogsKey = (workspaceId: string, agentId: string) => [ + "workspaces", + workspaceId, + "agents", + agentId, + "logs", +]; + +export const agentLogs = (workspaceId: string, agentId: string) => { + return { + queryKey: agentLogsKey(workspaceId, agentId), + queryFn: () => API.getWorkspaceAgentLogs(agentId), + ...disabledRefetchOptions, + }; +}; diff --git a/site/src/components/DropdownArrow/DropdownArrow.tsx b/site/src/components/DropdownArrow/DropdownArrow.tsx index e0d79d6b12305..dc26a8d2da3f6 100644 --- a/site/src/components/DropdownArrow/DropdownArrow.tsx +++ b/site/src/components/DropdownArrow/DropdownArrow.tsx @@ -26,11 +26,11 @@ export const DropdownArrow: FC = ({ }; const styles = { - base: (theme) => ({ - color: theme.palette.primary.contrastText, + base: { + color: "currentcolor", width: 16, height: 16, - }), + }, withMargin: { marginLeft: 8, diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.tsx index 407e3c12fe9b5..518d4315bb7e3 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogs.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogs.tsx @@ -1,14 +1,7 @@ import type { Interpolation, Theme } from "@emotion/react"; import Tooltip from "@mui/material/Tooltip"; -import { - type ComponentProps, - forwardRef, - useEffect, - useMemo, - useState, -} from "react"; +import { type ComponentProps, forwardRef, useMemo } from "react"; import { FixedSizeList as List } from "react-window"; -import { watchWorkspaceAgentLogs } from "api/api"; import type { WorkspaceAgentLogSource } from "api/typesGenerated"; import { AGENT_LOG_LINE_HEIGHT, @@ -179,64 +172,6 @@ export const AgentLogs = forwardRef( }, ); -export const useAgentLogs = ( - agentId: string, - options?: { enabled?: boolean; initialData?: LineWithID[] }, -) => { - const initialData = options?.initialData; - const enabled = options?.enabled === undefined ? true : options.enabled; - const [logs, setLogs] = useState(initialData); - - useEffect(() => { - if (!enabled) { - setLogs([]); - return; - } - - const socket = watchWorkspaceAgentLogs(agentId, { - // Get all logs - after: 0, - onMessage: (logs) => { - // Prevent new logs getting added when a connection is not open - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - setLogs((previousLogs) => { - const newLogs: LineWithID[] = logs.map((log) => ({ - id: log.id, - level: log.level || "info", - output: log.output, - time: log.created_at, - sourceId: log.source_id, - })); - - if (!previousLogs) { - return newLogs; - } - - return [...previousLogs, ...newLogs]; - }); - }, - onError: (error) => { - // For some reason Firefox and Safari throw an error when a websocket - // connection is close in the middle of a message and because of that we - // can't safely show to the users an error message since most of the - // time they are just internal stuff. This does not happen to Chrome at - // all and I tried to find better way to "soft close" a WS connection on - // those browsers without success. - console.error(error); - }, - }); - - return () => { - socket.close(); - }; - }, [agentId, enabled]); - - return logs; -}; - // These colors were picked at random. Feel free // to add more, adjust, or change! Users will not // depend on these colors. diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx b/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx new file mode 100644 index 0000000000000..5323a8bf57f26 --- /dev/null +++ b/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx @@ -0,0 +1,143 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import WS from "jest-websocket-mock"; +import { type QueryClient, QueryClientProvider } from "react-query"; +import { API } from "api/api"; +import * as APIModule from "api/api"; +import { agentLogsKey } from "api/queries/workspaces"; +import type { WorkspaceAgentLog } from "api/typesGenerated"; +import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"; +import { createTestQueryClient } from "testHelpers/renderHelpers"; +import { type UseAgentLogsOptions, useAgentLogs } from "./useAgentLogs"; + +afterEach(() => { + WS.clean(); +}); + +describe("useAgentLogs", () => { + it("should not fetch logs if disabled", async () => { + const queryClient = createTestQueryClient(); + const fetchSpy = jest.spyOn(API, "getWorkspaceAgentLogs"); + const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs"); + renderUseAgentLogs(queryClient, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "ready", + enabled: false, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(wsSpy).not.toHaveBeenCalled(); + }); + + it("should return existing logs without network calls", async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData( + agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id), + generateLogs(5), + ); + const fetchSpy = jest.spyOn(API, "getWorkspaceAgentLogs"); + const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs"); + const { result } = renderUseAgentLogs(queryClient, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "ready", + }); + await waitFor(() => { + expect(result.current).toHaveLength(5); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(wsSpy).not.toHaveBeenCalled(); + }); + + it("should fetch logs when empty and should not connect to WebSocket when not starting", async () => { + const queryClient = createTestQueryClient(); + const fetchSpy = jest + .spyOn(API, "getWorkspaceAgentLogs") + .mockResolvedValueOnce(generateLogs(5)); + const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs"); + const { result } = renderUseAgentLogs(queryClient, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "ready", + }); + await waitFor(() => { + expect(result.current).toHaveLength(5); + }); + expect(fetchSpy).toHaveBeenCalledWith(MockWorkspaceAgent.id); + expect(wsSpy).not.toHaveBeenCalled(); + }); + + it("should fetch logs and connect to websocket when agent is starting", async () => { + const queryClient = createTestQueryClient(); + const logs = generateLogs(5); + const fetchSpy = jest + .spyOn(API, "getWorkspaceAgentLogs") + .mockResolvedValueOnce(logs); + const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs"); + new WS( + `ws://localhost/api/v2/workspaceagents/${ + MockWorkspaceAgent.id + }/logs?follow&after=${logs[logs.length - 1].id}`, + ); + const { result } = renderUseAgentLogs(queryClient, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "starting", + }); + await waitFor(() => { + expect(result.current).toHaveLength(5); + }); + expect(fetchSpy).toHaveBeenCalledWith(MockWorkspaceAgent.id); + expect(wsSpy).toHaveBeenCalledWith(MockWorkspaceAgent.id, { + after: logs[logs.length - 1].id, + onMessage: expect.any(Function), + onError: expect.any(Function), + }); + }); + + it("update logs from websocket messages", async () => { + const queryClient = createTestQueryClient(); + const logs = generateLogs(5); + jest.spyOn(API, "getWorkspaceAgentLogs").mockResolvedValueOnce(logs); + const server = new WS( + `ws://localhost/api/v2/workspaceagents/${ + MockWorkspaceAgent.id + }/logs?follow&after=${logs[logs.length - 1].id}`, + ); + const { result } = renderUseAgentLogs(queryClient, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "starting", + }); + await waitFor(() => { + expect(result.current).toHaveLength(5); + }); + await server.connected; + act(() => { + server.send(JSON.stringify(generateLogs(3))); + }); + await waitFor(() => { + expect(result.current).toHaveLength(8); + }); + }); +}); + +function renderUseAgentLogs( + queryClient: QueryClient, + options: UseAgentLogsOptions, +) { + return renderHook(() => useAgentLogs(options), { + wrapper: ({ children }) => ( + {children} + ), + }); +} + +function generateLogs(count: number): WorkspaceAgentLog[] { + return Array.from({ length: count }, (_, i) => ({ + id: i, + created_at: new Date().toISOString(), + level: "info", + output: `Log ${i}`, + source_id: "", + })); +} diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.ts b/site/src/modules/resources/AgentLogs/useAgentLogs.ts new file mode 100644 index 0000000000000..e5d797a14e9c2 --- /dev/null +++ b/site/src/modules/resources/AgentLogs/useAgentLogs.ts @@ -0,0 +1,75 @@ +import { useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "react-query"; +import { watchWorkspaceAgentLogs } from "api/api"; +import { agentLogs } from "api/queries/workspaces"; +import type { + WorkspaceAgentLifecycle, + WorkspaceAgentLog, +} from "api/typesGenerated"; +import { useEffectEvent } from "hooks/hookPolyfills"; + +export type UseAgentLogsOptions = Readonly<{ + workspaceId: string; + agentId: string; + agentLifeCycleState: WorkspaceAgentLifecycle; + enabled?: boolean; +}>; + +export function useAgentLogs( + options: UseAgentLogsOptions, +): readonly WorkspaceAgentLog[] | undefined { + const { workspaceId, agentId, agentLifeCycleState, enabled = true } = options; + const queryClient = useQueryClient(); + const queryOptions = agentLogs(workspaceId, agentId); + const query = useQuery({ + ...queryOptions, + enabled, + }); + const logs = query.data; + + const lastQueriedLogId = useRef(0); + useEffect(() => { + if (logs && lastQueriedLogId.current === 0) { + lastQueriedLogId.current = logs[logs.length - 1].id; + } + }, [logs]); + + const addLogs = useEffectEvent((newLogs: WorkspaceAgentLog[]) => { + queryClient.setQueryData( + queryOptions.queryKey, + (oldData: WorkspaceAgentLog[] = []) => [...oldData, ...newLogs], + ); + }); + + useEffect(() => { + if (agentLifeCycleState !== "starting" || !query.isFetched) { + return; + } + + const socket = watchWorkspaceAgentLogs(agentId, { + after: lastQueriedLogId.current, + onMessage: (newLogs) => { + // Prevent new logs getting added when a connection is not open + if (socket.readyState !== WebSocket.OPEN) { + return; + } + addLogs(newLogs); + }, + onError: (error) => { + // For some reason Firefox and Safari throw an error when a websocket + // connection is close in the middle of a message and because of that we + // can't safely show to the users an error message since most of the + // time they are just internal stuff. This does not happen to Chrome at + // all and I tried to find better way to "soft close" a WS connection on + // those browsers without success. + console.error(error); + }, + }); + + return () => { + socket.close(); + }; + }, [addLogs, agentId, agentLifeCycleState, query.isFetched]); + + return logs; +} diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index c4c4149b7279c..9bcaa088b7ade 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -2,8 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; import { chromatic } from "testHelpers/chromatic"; import * as M from "testHelpers/entities"; -import { withDashboardProvider } from "testHelpers/storybook"; -import type { LineWithID } from "./AgentLogs/AgentLogLine"; +import { withDashboardProvider, withWebSocket } from "testHelpers/storybook"; import { AgentRow } from "./AgentRow"; const defaultAgentMetadata = [ @@ -69,7 +68,7 @@ const defaultAgentMetadata = [ }, ]; -const storybookLogs: LineWithID[] = [ +const logs = [ "\x1b[91mCloning Git repository...", "\x1b[2;37;41mStarting Docker Daemon...", "\x1b[1;95mAdding some 🧙magic🧙...", @@ -79,28 +78,17 @@ const storybookLogs: LineWithID[] = [ id: index, level: "info", output: line, - time: "", - sourceId: M.MockWorkspaceAgentLogSource.id, + source_id: M.MockWorkspaceAgentLogSource.id, + created_at: new Date().toISOString(), })); const meta: Meta = { title: "components/AgentRow", - parameters: { - chromatic, - queries: [ - { - key: ["portForward", M.MockWorkspaceAgent.id], - data: M.MockListeningPortsResponse, - }, - ], - }, - component: AgentRow, args: { - storybookLogs, agent: { ...M.MockWorkspaceAgent, - logs_length: storybookLogs.length, + logs_length: logs.length, }, workspace: M.MockWorkspace, showApps: true, @@ -130,7 +118,23 @@ const meta: Meta = { ), withDashboardProvider, + withWebSocket, ], + parameters: { + chromatic, + queries: [ + { + key: ["portForward", M.MockWorkspaceAgent.id], + data: M.MockListeningPortsResponse, + }, + ], + webSocket: [ + { + event: "message", + data: JSON.stringify(logs), + }, + ], + }, }; export default meta; diff --git a/site/src/modules/resources/AgentRow.test.tsx b/site/src/modules/resources/AgentRow.test.tsx index 4ae3f1536b659..0ad222fc09740 100644 --- a/site/src/modules/resources/AgentRow.test.tsx +++ b/site/src/modules/resources/AgentRow.test.tsx @@ -8,7 +8,8 @@ import { renderWithAuth, waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers"; -import { AgentRow, type AgentRowProps } from "./AgentRow"; +import type { AgentRowProps } from "./AgentRow"; +import { AgentRow } from "./AgentRow"; import { DisplayAppNameMap } from "./AppLink/AppLink"; jest.mock("modules/resources/AgentMetadata", () => { diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 145b5add7d25b..7b6395cad3297 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -1,5 +1,7 @@ import type { Interpolation, Theme } from "@emotion/react"; +import Button from "@mui/material/Button"; import Collapse from "@mui/material/Collapse"; +import Divider from "@mui/material/Divider"; import Skeleton from "@mui/material/Skeleton"; import { type FC, @@ -24,15 +26,14 @@ import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import { Stack } from "components/Stack/Stack"; import { useProxy } from "contexts/ProxyContext"; import { AgentLatency } from "./AgentLatency"; -import { - AGENT_LOG_LINE_HEIGHT, - type LineWithID, -} from "./AgentLogs/AgentLogLine"; -import { AgentLogs, useAgentLogs } from "./AgentLogs/AgentLogs"; +import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine"; +import { AgentLogs } from "./AgentLogs/AgentLogs"; +import { useAgentLogs } from "./AgentLogs/useAgentLogs"; import { AgentMetadata } from "./AgentMetadata"; import { AgentStatus } from "./AgentStatus"; import { AgentVersion } from "./AgentVersion"; import { AppLink } from "./AppLink/AppLink"; +import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton"; import { PortForwardButton } from "./PortForwardButton"; import { SSHButton } from "./SSHButton/SSHButton"; import { TerminalLink } from "./TerminalLink/TerminalLink"; @@ -51,7 +52,6 @@ export interface AgentRowProps { serverAPIVersion: string; onUpdateAgent: () => void; template: Template; - storybookLogs?: LineWithID[]; storybookAgentMetadata?: WorkspaceAgentMetadata[]; } @@ -68,7 +68,6 @@ export const AgentRow: FC = ({ onUpdateAgent, storybookAgentMetadata, sshPrefix, - storybookLogs, }) => { // XRay integration const xrayScanQuery = useQuery( @@ -92,9 +91,11 @@ export const AgentRow: FC = ({ ["starting", "start_timeout"].includes(agent.lifecycle_state) && hasStartupFeatures, ); - const agentLogs = useAgentLogs(agent.id, { + const agentLogs = useAgentLogs({ + workspaceId: workspace.id, + agentId: agent.id, + agentLifeCycleState: agent.lifecycle_state, enabled: showLogs, - initialData: process.env.STORYBOOK ? storybookLogs || [] : undefined, }); const logListRef = useRef(null); const logListDivRef = useRef(null); @@ -107,8 +108,8 @@ export const AgentRow: FC = ({ id: -1, level: "error", output: "Startup logs exceeded the max size of 1MB!", - time: new Date().toISOString(), - sourceId: "", + created_at: new Date().toISOString(), + source_id: "", }); } return logs; @@ -289,20 +290,31 @@ export const AgentRow: FC = ({ width={width} css={styles.startupLogs} onScroll={handleLogScroll} - logs={startupLogs} + logs={startupLogs.map((l) => ({ + id: l.id, + level: l.level, + output: l.output, + sourceId: l.source_id, + time: l.created_at, + }))} sources={agent.log_sources} /> )} - + + + + + )} @@ -475,32 +487,6 @@ const styles = { }, }), - logsPanelButton: (theme) => ({ - textAlign: "left", - background: "transparent", - border: 0, - fontFamily: "inherit", - padding: "16px 32px", - color: theme.palette.text.secondary, - cursor: "pointer", - display: "flex", - alignItems: "center", - gap: 8, - whiteSpace: "nowrap", - width: "100%", - borderBottomLeftRadius: 8, - borderBottomRightRadius: 8, - - "&:hover": { - color: theme.palette.text.primary, - backgroundColor: theme.experimental.l2.hover.background, - }, - - "& svg": { - color: "inherit", - }, - }), - buttonSkeleton: { borderRadius: 4, }, diff --git a/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx new file mode 100644 index 0000000000000..712a950decf1a --- /dev/null +++ b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { waitFor, within, userEvent, expect, fn } from "@storybook/test"; +import { agentLogsKey } from "api/queries/workspaces"; +import type { WorkspaceAgentLog } from "api/typesGenerated"; +import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"; +import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton"; + +const meta: Meta = { + title: "modules/resources/DownloadAgentLogsButton", + component: DownloadAgentLogsButton, + args: { + workspaceId: MockWorkspace.id, + agent: MockWorkspaceAgent, + }, + parameters: { + queries: [ + { + key: agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id), + data: generateLogs(5), + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const ClickOnDownload: Story = { + args: { + download: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Download logs" }), + ); + await waitFor(() => + expect(args.download).toHaveBeenCalledWith( + expect.anything(), + `${MockWorkspaceAgent.name}-logs.txt`, + ), + ); + const blob: Blob = (args.download as jest.Mock).mock.calls[0][0]; + await expect(blob.type).toEqual("text/plain"); + }, +}; + +function generateLogs(count: number): WorkspaceAgentLog[] { + return Array.from({ length: count }, (_, i) => ({ + id: i, + output: `log line ${i}`, + created_at: new Date().toISOString(), + level: "info", + source_id: "", + })); +} diff --git a/site/src/modules/resources/DownloadAgentLogsButton.tsx b/site/src/modules/resources/DownloadAgentLogsButton.tsx new file mode 100644 index 0000000000000..d127069d895b2 --- /dev/null +++ b/site/src/modules/resources/DownloadAgentLogsButton.tsx @@ -0,0 +1,63 @@ +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; +import Button from "@mui/material/Button"; +import { saveAs } from "file-saver"; +import { useState, type FC } from "react"; +import { useQueryClient } from "react-query"; +import { agentLogs } from "api/queries/workspaces"; +import type { WorkspaceAgent, WorkspaceAgentLog } from "api/typesGenerated"; +import { displayError } from "components/GlobalSnackbar/utils"; + +type DownloadAgentLogsButtonProps = { + workspaceId: string; + agent: Pick; + download?: (file: Blob, filename: string) => void; +}; + +export const DownloadAgentLogsButton: FC = ({ + workspaceId, + agent, + download = saveAs, +}) => { + const queryClient = useQueryClient(); + const isConnected = agent.status === "connected"; + const [isDownloading, setIsDownloading] = useState(false); + + const fetchLogs = async () => { + const queryOpts = agentLogs(workspaceId, agent.id); + let logs = queryClient.getQueryData( + queryOpts.queryKey, + ); + if (!logs) { + logs = await queryClient.fetchQuery(queryOpts); + } + return logs; + }; + + return ( + + ); +}; diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx index 4122c278edb52..9a1f4c5b753ea 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx @@ -19,7 +19,8 @@ import { Stats, StatsItem } from "components/Stats/Stats"; import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { DashboardFullPage } from "modules/dashboard/DashboardLayout"; -import { AgentLogs, useAgentLogs } from "modules/resources/AgentLogs/AgentLogs"; +import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs"; +import { useAgentLogs } from "modules/resources/AgentLogs/useAgentLogs"; import { WorkspaceBuildData, WorkspaceBuildDataSkeleton, @@ -193,7 +194,10 @@ export const WorkspaceBuildPageView: FC = ({ {tabState.value === "build" ? ( ) : ( - + )}
@@ -222,8 +226,15 @@ const BuildLogsContent: FC<{ logs?: ProvisionerJobLog[] }> = ({ logs }) => { ); }; -const AgentLogsContent: FC<{ agent: WorkspaceAgent }> = ({ agent }) => { - const logs = useAgentLogs(agent.id); +const AgentLogsContent: FC<{ workspaceId: string; agent: WorkspaceAgent }> = ({ + agent, + workspaceId, +}) => { + const logs = useAgentLogs({ + workspaceId, + agentId: agent.id, + agentLifeCycleState: agent.lifecycle_state, + }); if (!logs) { return ; @@ -232,7 +243,13 @@ const AgentLogsContent: FC<{ agent: WorkspaceAgent }> = ({ agent }) => { return ( ({ + id: l.id, + output: l.output, + time: l.created_at, + level: l.level, + sourceId: l.source_id, + }))} height={560} width="100%" /> diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx new file mode 100644 index 0000000000000..ddeaf6fb46634 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, fn, waitFor, within } from "@storybook/test"; +import { agentLogsKey, buildLogsKey } from "api/queries/workspaces"; +import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"; +import { DownloadLogsDialog } from "./DownloadLogsDialog"; + +const meta: Meta = { + title: "pages/WorkspacePage/DownloadLogsDialog", + component: DownloadLogsDialog, + args: { + open: true, + workspace: MockWorkspace, + onClose: fn(), + }, + parameters: { + queries: [ + { + key: buildLogsKey(MockWorkspace.id), + data: generateLogs(200), + }, + { + key: agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id), + data: generateLogs(400), + }, + ], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Ready: Story = {}; + +export const Loading: Story = { + parameters: { + queries: [ + { + key: buildLogsKey(MockWorkspace.id), + data: undefined, + }, + { + key: agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id), + data: undefined, + }, + ], + }, +}; + +export const DownloadLogs: Story = { + args: { + download: fn(), + }, + play: async ({ args }) => { + const screen = within(document.body); + await userEvent.click(screen.getByRole("button", { name: "Download" })); + await waitFor(() => + expect(args.download).toHaveBeenCalledWith( + expect.anything(), + `${MockWorkspace.name}-logs.zip`, + ), + ); + const blob: Blob = (args.download as jest.Mock).mock.calls[0][0]; + await expect(blob.type).toEqual("application/zip"); + }, +}; + +function generateLogs(count: number) { + return Array.from({ length: count }, (_, i) => ({ + output: `log ${i + 1}`, + })); +} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx new file mode 100644 index 0000000000000..aefd4d7d6b9e1 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx @@ -0,0 +1,166 @@ +import { useTheme, type Interpolation, type Theme } from "@emotion/react"; +import Skeleton from "@mui/material/Skeleton"; +import { saveAs } from "file-saver"; +import JSZip from "jszip"; +import { useMemo, useState, type FC } from "react"; +import { useQueries, useQuery } from "react-query"; +import { agentLogs, buildLogs } from "api/queries/workspaces"; +import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; +import { + ConfirmDialog, + type ConfirmDialogProps, +} from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Stack } from "components/Stack/Stack"; + +type DownloadLogsDialogProps = Pick< + ConfirmDialogProps, + "onConfirm" | "onClose" | "open" +> & { + workspace: Workspace; + download?: (zip: Blob, filename: string) => void; +}; + +type DownloadableFile = { + name: string; + blob: Blob | undefined; +}; + +export const DownloadLogsDialog: FC = ({ + workspace, + download = saveAs, + ...dialogProps +}) => { + const theme = useTheme(); + const agents = selectAgents(workspace); + const agentLogResults = useQueries({ + queries: agents.map((a) => ({ + ...agentLogs(workspace.id, a.id), + enabled: dialogProps.open, + })), + }); + const buildLogsQuery = useQuery({ + ...buildLogs(workspace), + enabled: dialogProps.open, + }); + const downloadableFiles: DownloadableFile[] = useMemo(() => { + const files: DownloadableFile[] = [ + { + name: `${workspace.name}-build-logs.txt`, + blob: buildLogsQuery.data + ? new Blob([buildLogsQuery.data.map((l) => l.output).join("\n")], { + type: "text/plain", + }) + : undefined, + }, + ]; + + agents.forEach((a, i) => { + const name = `${a.name}-logs.txt`; + const logs = agentLogResults[i].data; + const txt = logs?.map((l) => l.output).join("\n"); + let blob: Blob | undefined; + if (txt) { + blob = new Blob([txt], { type: "text/plain" }); + } + files.push({ name, blob }); + }); + + return files; + }, [agentLogResults, agents, buildLogsQuery.data, workspace.name]); + const isLoadingFiles = downloadableFiles.some((f) => f.blob === undefined); + const [isDownloading, setIsDownloading] = useState(false); + + return ( + { + try { + setIsDownloading(true); + const zip = new JSZip(); + downloadableFiles.forEach((f) => { + if (f.blob) { + zip.file(f.name, f.blob); + } + }); + const content = await zip.generateAsync({ type: "blob" }); + download(content, `${workspace.name}-logs.zip`); + dialogProps.onClose(); + setTimeout(() => { + setIsDownloading(false); + }, theme.transitions.duration.leavingScreen); + } catch (error) { + setIsDownloading(false); + displayError("Error downloading workspace logs"); + console.error(error); + } + }} + description={ + +

+ Downloading logs will create a zip file containing all logs from all + jobs in this workspace. This may take a while. +

+
    + {downloadableFiles.map((f) => ( +
  • + {f.name} + + {f.blob ? ( + humanBlobSize(f.blob.size) + ) : ( + + )} + +
  • + ))} +
+
+ } + /> + ); +}; + +function humanBlobSize(size: number) { + const units = ["B", "KB", "MB", "GB", "TB"]; + let i = 0; + while (size > 1024 && i < units.length) { + size /= 1024; + i++; + } + return `${size.toFixed(2)} ${units[i]}`; +} + +function selectAgents(workspace: Workspace): WorkspaceAgent[] { + return workspace.latest_build.resources + .flatMap((r) => r.agents) + .filter((a) => a !== undefined) as WorkspaceAgent[]; +} + +const styles = { + list: { + listStyle: "none", + padding: 0, + margin: 0, + display: "flex", + flexDirection: "column", + gap: 8, + }, + listItem: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, + listItemPrimary: (theme) => ({ + fontWeight: 500, + color: theme.palette.text.primary, + }), + listItemSecondary: { + fontSize: 14, + }, +} satisfies Record>; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx index ce03863b69c55..3e663dafba1a9 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx @@ -1,4 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within, expect } from "@storybook/test"; +import { buildLogsKey, agentLogsKey } from "api/queries/workspaces"; import * as Mocks from "testHelpers/entities"; import { WorkspaceActions } from "./WorkspaceActions"; @@ -8,6 +10,13 @@ const meta: Meta = { args: { isUpdating: false, }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], }; export default meta; @@ -140,3 +149,34 @@ export const CancelHiddenForUser: Story = { isOwner: false, }, }; + +export const OpenDownloadLogs: Story = { + args: { + workspace: Mocks.MockWorkspace, + }, + parameters: { + queries: [ + { + key: buildLogsKey(Mocks.MockWorkspace.id), + data: generateLogs(200), + }, + { + key: agentLogsKey(Mocks.MockWorkspace.id, Mocks.MockWorkspaceAgent.id), + data: generateLogs(400), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + await userEvent.click(canvas.getByText("Download logs", { exact: false })); + const screen = within(document.body); + await expect(screen.getByTestId("dialog")).toBeInTheDocument(); + }, +}; + +function generateLogs(count: number) { + return Array.from({ length: count }, (_, i) => ({ + output: `log ${i + 1}`, + })); +} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index beab34de37633..6bef6f864d961 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -1,10 +1,11 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined"; +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; import DuplicateIcon from "@mui/icons-material/FileCopyOutlined"; import HistoryIcon from "@mui/icons-material/HistoryOutlined"; import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined"; import SettingsIcon from "@mui/icons-material/SettingsOutlined"; import Divider from "@mui/material/Divider"; -import { type FC, type ReactNode, Fragment } from "react"; +import { type FC, type ReactNode, Fragment, useState } from "react"; import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; import { TopbarIconButton } from "components/FullPageLayout/Topbar"; import { @@ -28,6 +29,7 @@ import { } from "./Buttons"; import { type ActionType, abilitiesByWorkspaceStatus } from "./constants"; import { DebugButton } from "./DebugButton"; +import { DownloadLogsDialog } from "./DownloadLogsDialog"; import { RetryButton } from "./RetryButton"; export interface WorkspaceActionsProps { @@ -75,6 +77,8 @@ export const WorkspaceActions: FC = ({ const { duplicateWorkspace, isDuplicationReady } = useWorkspaceDuplication(workspace); + const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false); + const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus( workspace, canDebug, @@ -219,6 +223,11 @@ export const WorkspaceActions: FC = ({ Duplicate… + setIsDownloadDialogOpen(true)}> + + Download logs… + + = ({ + + setIsDownloadDialogOpen(false)} + onConfirm={() => {}} + /> ); }; From 0d651433016dcde8591de6b4a6f2fb19d6f966e6 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 7 Jun 2024 14:11:57 -0500 Subject: [PATCH 048/168] chore: implement audit log for custom role edits (#13494) * chore: implement audit log for custom role edits --- coderd/apidoc/docs.go | 6 ++- coderd/apidoc/swagger.json | 6 ++- coderd/audit/diff.go | 3 +- coderd/audit/request.go | 8 ++++ coderd/coderdtest/coderdtest.go | 2 + coderd/database/dbauthz/customroles_test.go | 2 +- coderd/database/dbmem/dbmem.go | 1 + coderd/database/dump.sql | 10 ++++- .../000218_org_custom_role_audit.down.sql | 2 + .../000218_org_custom_role_audit.up.sql | 8 ++++ coderd/database/models.go | 7 +++- coderd/database/queries.sql.go | 6 ++- coderd/roles.go | 6 +-- codersdk/audit.go | 3 ++ docs/admin/audit-logs.md | 1 + docs/api/schemas.md | 1 + enterprise/audit/diff.go | 12 ++++++ enterprise/audit/table.go | 12 ++++++ enterprise/coderd/coderd.go | 2 +- enterprise/coderd/roles.go | 38 ++++++++++++++++++- site/src/api/typesGenerated.ts | 2 + 21 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 coderd/database/migrations/000218_org_custom_role_audit.down.sql create mode 100644 coderd/database/migrations/000218_org_custom_role_audit.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8905c8a09cba5..5fe1e929d83bd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11330,7 +11330,8 @@ const docTemplate = `{ "workspace_proxy", "organization", "oauth2_provider_app", - "oauth2_provider_app_secret" + "oauth2_provider_app_secret", + "custom_role" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -11347,7 +11348,8 @@ const docTemplate = `{ "ResourceTypeWorkspaceProxy", "ResourceTypeOrganization", "ResourceTypeOAuth2ProviderApp", - "ResourceTypeOAuth2ProviderAppSecret" + "ResourceTypeOAuth2ProviderAppSecret", + "ResourceTypeCustomRole" ] }, "codersdk.Response": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3ab826d3920da..18eb052c3fd64 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10219,7 +10219,8 @@ "workspace_proxy", "organization", "oauth2_provider_app", - "oauth2_provider_app_secret" + "oauth2_provider_app_secret", + "custom_role" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -10236,7 +10237,8 @@ "ResourceTypeWorkspaceProxy", "ResourceTypeOrganization", "ResourceTypeOAuth2ProviderApp", - "ResourceTypeOAuth2ProviderAppSecret" + "ResourceTypeOAuth2ProviderAppSecret", + "ResourceTypeCustomRole" ] }, "codersdk.Response": { diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index a6835014d4fe2..dd5205c0afb42 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -21,7 +21,8 @@ type Auditable interface { database.AuditOAuthConvertState | database.HealthSettings | database.OAuth2ProviderApp | - database.OAuth2ProviderAppSecret + database.OAuth2ProviderAppSecret | + database.CustomRole } // Map is a map of changed fields in an audited resource. It maps field names to diff --git a/coderd/audit/request.go b/coderd/audit/request.go index e6d9d01fbfd27..20eb8185af53e 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -103,6 +103,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.OAuth2ProviderAppSecret: return typed.DisplaySecret + case database.CustomRole: + return typed.Name default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -140,6 +142,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.OAuth2ProviderAppSecret: return typed.ID + case database.CustomRole: + return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } @@ -175,6 +179,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeOauth2ProviderApp case database.OAuth2ProviderAppSecret: return database.ResourceTypeOauth2ProviderAppSecret + case database.CustomRole: + return database.ResourceTypeCustomRole default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -211,6 +217,8 @@ func ResourceRequiresOrgID[T Auditable]() bool { return false case database.OAuth2ProviderAppSecret: return false + case database.CustomRole: + return true default: panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 316683a9f1e65..3ebca07686d0e 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -758,6 +758,8 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI roleName, _, err = rbac.RoleSplit(roleName) require.NoError(t, err, "split org role name") if ok { + roleName, _, err = rbac.RoleSplit(roleName) + require.NoError(t, err, "split rolename") orgRoles[orgID] = append(orgRoles[orgID], roleName) } else { siteRoles = append(siteRoles, roleName) diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index b98af8fd23889..814ba88a1b18c 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -244,7 +244,7 @@ func TestUpsertCustomRoles(t *testing.T) { } else { require.NoError(t, err) - // Verify we can fetch the role + // Verify the role is fetched with the lookup filter. roles, err := az.CustomRoles(ctx, database.CustomRolesParams{ LookupRoles: []database.NameOrganizationPair{ { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 3f9ef73048e6b..147eb8eca6a05 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8415,6 +8415,7 @@ func (q *FakeQuerier) UpsertCustomRole(_ context.Context, arg database.UpsertCus } role := database.CustomRole{ + ID: uuid.New(), Name: arg.Name, DisplayName: arg.DisplayName, OrganizationID: arg.OrganizationID, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c6faa00c65fc5..83eea6e3583a6 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -147,7 +147,8 @@ CREATE TYPE resource_type AS ENUM ( 'convert_login', 'health_settings', 'oauth2_provider_app', - 'oauth2_provider_app_secret' + 'oauth2_provider_app_secret', + 'custom_role' ); CREATE TYPE startup_script_behavior AS ENUM ( @@ -417,13 +418,16 @@ CREATE TABLE custom_roles ( user_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - organization_id uuid + organization_id uuid, + id uuid DEFAULT gen_random_uuid() NOT NULL ); COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; COMMENT ON COLUMN custom_roles.organization_id IS 'Roles can optionally be scoped to an organization'; +COMMENT ON COLUMN custom_roles.id IS 'Custom roles ID is used purely for auditing purposes. Name is a better unique identifier.'; + CREATE TABLE dbcrypt_keys ( number integer NOT NULL, active_key_digest text, @@ -1642,6 +1646,8 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id); CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); +CREATE INDEX idx_custom_roles_id ON custom_roles USING btree (id); + CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); CREATE INDEX idx_organization_member_organization_id_uuid ON organization_members USING btree (organization_id); diff --git a/coderd/database/migrations/000218_org_custom_role_audit.down.sql b/coderd/database/migrations/000218_org_custom_role_audit.down.sql new file mode 100644 index 0000000000000..5ad6106f2fc26 --- /dev/null +++ b/coderd/database/migrations/000218_org_custom_role_audit.down.sql @@ -0,0 +1,2 @@ +DROP INDEX idx_custom_roles_id; +ALTER TABLE custom_roles DROP COLUMN id; diff --git a/coderd/database/migrations/000218_org_custom_role_audit.up.sql b/coderd/database/migrations/000218_org_custom_role_audit.up.sql new file mode 100644 index 0000000000000..a780f34960907 --- /dev/null +++ b/coderd/database/migrations/000218_org_custom_role_audit.up.sql @@ -0,0 +1,8 @@ +-- (name) is the primary key, this column is almost exclusively for auditing. +-- Audit logs require a uuid as the unique identifier for a resource. +ALTER TABLE custom_roles ADD COLUMN id uuid DEFAULT gen_random_uuid() NOT NULL; +COMMENT ON COLUMN custom_roles.id IS 'Custom roles ID is used purely for auditing purposes. Name is a better unique identifier.'; + +-- Ensure unique uuids. +CREATE INDEX idx_custom_roles_id ON custom_roles (id); +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'custom_role'; diff --git a/coderd/database/models.go b/coderd/database/models.go index aa69054abc2aa..8a558f5beeb0b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1222,6 +1222,7 @@ const ( ResourceTypeHealthSettings ResourceType = "health_settings" ResourceTypeOauth2ProviderApp ResourceType = "oauth2_provider_app" ResourceTypeOauth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" + ResourceTypeCustomRole ResourceType = "custom_role" ) func (e *ResourceType) Scan(src interface{}) error { @@ -1275,7 +1276,8 @@ func (e ResourceType) Valid() bool { ResourceTypeConvertLogin, ResourceTypeHealthSettings, ResourceTypeOauth2ProviderApp, - ResourceTypeOauth2ProviderAppSecret: + ResourceTypeOauth2ProviderAppSecret, + ResourceTypeCustomRole: return true } return false @@ -1298,6 +1300,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeHealthSettings, ResourceTypeOauth2ProviderApp, ResourceTypeOauth2ProviderAppSecret, + ResourceTypeCustomRole, } } @@ -1792,6 +1795,8 @@ type CustomRole struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` // Roles can optionally be scoped to an organization OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` + // Custom roles ID is used purely for auditing purposes. Name is a better unique identifier. + ID uuid.UUID `db:"id" json:"id"` } // A table used to store the keys used to encrypt the database. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5dff8c05e05a7..823cf2cc45796 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5618,7 +5618,7 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) const customRoles = `-- name: CustomRoles :many SELECT - name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id + name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id FROM custom_roles WHERE @@ -5667,6 +5667,7 @@ func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([] &i.CreatedAt, &i.UpdatedAt, &i.OrganizationID, + &i.ID, ); err != nil { return nil, err } @@ -5711,7 +5712,7 @@ ON CONFLICT (name) org_permissions = $5, user_permissions = $6, updated_at = now() -RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id +RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id ` type UpsertCustomRoleParams struct { @@ -5742,6 +5743,7 @@ func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleP &i.CreatedAt, &i.UpdatedAt, &i.OrganizationID, + &i.ID, ) return i, err } diff --git a/coderd/roles.go b/coderd/roles.go index 94b121940ed45..1e7f1b1473b9a 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -20,12 +20,12 @@ import ( // roles. Ideally only included in the enterprise package, but the routes are // intermixed with AGPL endpoints. type CustomRoleHandler interface { - PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) + PatchOrganizationRole(ctx context.Context, rw http.ResponseWriter, r *http.Request, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) } type agplCustomRoleHandler struct{} -func (agplCustomRoleHandler) PatchOrganizationRole(ctx context.Context, _ database.Store, rw http.ResponseWriter, _ uuid.UUID, _ codersdk.Role) (codersdk.Role, bool) { +func (agplCustomRoleHandler) PatchOrganizationRole(ctx context.Context, rw http.ResponseWriter, _ *http.Request, _ uuid.UUID, _ codersdk.Role) (codersdk.Role, bool) { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ Message: "Creating and updating custom roles is an Enterprise feature. Contact sales!", }) @@ -54,7 +54,7 @@ func (api *API) patchOrgRoles(rw http.ResponseWriter, r *http.Request) { return } - updated, ok := handler.PatchOrganizationRole(ctx, api.Database, rw, organization.ID, req) + updated, ok := handler.PatchOrganizationRole(ctx, rw, r, organization.ID, req) if !ok { return } diff --git a/codersdk/audit.go b/codersdk/audit.go index 553bd9cc2dbea..837ef729e4a58 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -30,6 +30,7 @@ const ( ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app" // nolint:gosec // This is not a secret. ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" + ResourceTypeCustomRole ResourceType = "custom_role" ) func (r ResourceType) FriendlyString() string { @@ -66,6 +67,8 @@ func (r ResourceType) FriendlyString() string { return "oauth2 app" case ResourceTypeOAuth2ProviderAppSecret: return "oauth2 app secret" + case ResourceTypeCustomRole: + return "custom role" default: return "unknown" } diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index fada57f32065f..34c4e8c9a8dc3 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -13,6 +13,7 @@ We track the following resources: | APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| | AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| CustomRole
|
FieldTracked
created_atfalse
display_nametrue
idfalse
nametrue
org_permissionstrue
organization_idtrue
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | HealthSettings
|
FieldTracked
dismissed_healthcheckstrue
idfalse
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 24af8aece05e6..55070fb629864 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4323,6 +4323,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `organization` | | `oauth2_provider_app` | | `oauth2_provider_app_secret` | +| `custom_role` | ## codersdk.Response diff --git a/enterprise/audit/diff.go b/enterprise/audit/diff.go index 59780d2918418..007f475f6f5eb 100644 --- a/enterprise/audit/diff.go +++ b/enterprise/audit/diff.go @@ -144,6 +144,18 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) { return leftInt64Ptr, rightInt64Ptr, true case database.TemplateACL: return fmt.Sprintf("%+v", left), fmt.Sprintf("%+v", right), true + case database.CustomRolePermissions: + // String representation is much easier to visually inspect + leftArr := make([]string, 0) + rightArr := make([]string, 0) + for _, p := range typedLeft { + leftArr = append(leftArr, p.String()) + } + for _, p := range right.(database.CustomRolePermissions) { + rightArr = append(rightArr, p.String()) + } + + return leftArr, rightArr, true default: return left, right, false } diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index f26b4921aaace..e2788959e3275 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -50,6 +50,18 @@ type Table map[string]map[string]Action var AuditableResources = auditMap(auditableResourcesTypes) var auditableResourcesTypes = map[any]map[string]Action{ + &database.CustomRole{}: { + "name": ActionTrack, + "display_name": ActionTrack, + "site_permissions": ActionTrack, + "org_permissions": ActionTrack, + "user_permissions": ActionTrack, + "organization_id": ActionTrack, + + "id": ActionIgnore, + "created_at": ActionIgnore, + "updated_at": ActionIgnore, + }, &database.GitSSHKey{}: { "user_id": ActionTrack, "created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 574d2c12dd2de..26fdab6ec1bfb 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -746,7 +746,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { } if initial, changed, enabled := featureChanged(codersdk.FeatureCustomRoles); shouldUpdate(initial, changed, enabled) { - var handler coderd.CustomRoleHandler = &enterpriseCustomRoleHandler{Enabled: enabled} + var handler coderd.CustomRoleHandler = &enterpriseCustomRoleHandler{API: api, Enabled: enabled} api.AGPL.CustomRoleHandler.Store(&handler) } diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index b3a24a8a7779f..3a162a1b5ea80 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" @@ -15,10 +16,11 @@ import ( ) type enterpriseCustomRoleHandler struct { + API *API Enabled bool } -func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) { +func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, rw http.ResponseWriter, r *http.Request, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) { if !h.Enabled { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ Message: "Custom roles are not enabled", @@ -26,6 +28,19 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, return codersdk.Role{}, false } + var ( + db = h.API.Database + auditor = h.API.AGPL.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.CustomRole](rw, &audit.RequestParams{ + Audit: *auditor, + Log: h.API.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: orgID, + }) + ) + defer commitAudit() + if err := httpapi.NameValid(role.Name); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid role name", @@ -59,6 +74,26 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, return codersdk.Role{}, false } + originalRoles, err := db.CustomRoles(ctx, database.CustomRolesParams{ + LookupRoles: []database.NameOrganizationPair{ + { + Name: role.Name, + OrganizationID: orgID, + }, + }, + ExcludeOrgRoles: false, + OrganizationID: orgID, + }) + // If it is a 404 (not found) error, ignore it. + if err != nil && !httpapi.Is404Error(err) { + httpapi.InternalServerError(rw, err) + return codersdk.Role{}, false + } + if len(originalRoles) == 1 { + // For auditing changes to a role. + aReq.Old = originalRoles[0] + } + inserted, err := db.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ Name: role.Name, DisplayName: role.DisplayName, @@ -81,6 +116,7 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, }) return codersdk.Role{}, false } + aReq.New = inserted return db2sdk.Role(inserted), true } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fae41504f1c34..a53717e3e0229 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2181,6 +2181,7 @@ export const RBACResources: RBACResource[] = [ export type ResourceType = | "api_key" | "convert_login" + | "custom_role" | "git_ssh_key" | "group" | "health_settings" @@ -2197,6 +2198,7 @@ export type ResourceType = export const ResourceTypes: ResourceType[] = [ "api_key", "convert_login", + "custom_role", "git_ssh_key", "group", "health_settings", From 7c081dcd6feb6abe76c3f739b0725829e7d05260 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 10 Jun 2024 07:27:11 -0400 Subject: [PATCH 049/168] fix: replace invalid utf-8 sequences in agent logs (#13436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: replace invalid utf-8 sequences in agent logs Fixes #13433. * fix: replace invalid UTF-8 with ❌, add regression Signed-off-by: Spike Curtis --------- Signed-off-by: Spike Curtis Co-authored-by: Spike Curtis --- codersdk/agentsdk/convert.go | 2 +- codersdk/agentsdk/logs_internal_test.go | 45 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/codersdk/agentsdk/convert.go b/codersdk/agentsdk/convert.go index adfabd1510768..e60685d029507 100644 --- a/codersdk/agentsdk/convert.go +++ b/codersdk/agentsdk/convert.go @@ -348,7 +348,7 @@ func ProtoFromLog(log Log) (*proto.Log, error) { } return &proto.Log{ CreatedAt: timestamppb.New(log.CreatedAt), - Output: log.Output, + Output: strings.ToValidUTF8(log.Output, "❌"), Level: proto.Log_Level(lvl), }, nil } diff --git a/codersdk/agentsdk/logs_internal_test.go b/codersdk/agentsdk/logs_internal_test.go index d942689d31465..da2f0dd86dd38 100644 --- a/codersdk/agentsdk/logs_internal_test.go +++ b/codersdk/agentsdk/logs_internal_test.go @@ -231,6 +231,51 @@ func TestLogSender_SkipHugeLog(t *testing.T) { require.ErrorIs(t, err, context.Canceled) } +func TestLogSender_InvalidUTF8(t *testing.T) { + t.Parallel() + testCtx := testutil.Context(t, testutil.WaitShort) + ctx, cancel := context.WithCancel(testCtx) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + fDest := newFakeLogDest() + uut := NewLogSender(logger) + + t0 := dbtime.Now() + ls1 := uuid.UUID{0x11} + + uut.Enqueue(ls1, + Log{ + CreatedAt: t0, + Output: "test log 0, src 1\xc3\x28", + Level: codersdk.LogLevelInfo, + }, + Log{ + CreatedAt: t0, + Output: "test log 1, src 1", + Level: codersdk.LogLevelInfo, + }) + + loopErr := make(chan error, 1) + go func() { + err := uut.SendLoop(ctx, fDest) + loopErr <- err + }() + + req := testutil.RequireRecvCtx(ctx, t, fDest.reqs) + require.NotNil(t, req) + require.Len(t, req.Logs, 2, "it should sanitize invalid UTF-8, but still send") + // the 0xc3, 0x28 is an invalid 2-byte sequence in UTF-8. The sanitizer replaces 0xc3 with ❌, and then + // interprets 0x28 as a 1-byte sequence "(" + require.Equal(t, "test log 0, src 1❌(", req.Logs[0].GetOutput()) + require.Equal(t, proto.Log_INFO, req.Logs[0].GetLevel()) + require.Equal(t, "test log 1, src 1", req.Logs[1].GetOutput()) + require.Equal(t, proto.Log_INFO, req.Logs[1].GetLevel()) + testutil.RequireSendCtx(ctx, t, fDest.resps, &proto.BatchCreateLogsResponse{}) + + cancel() + err := testutil.RequireRecvCtx(testCtx, t, loopErr) + require.ErrorIs(t, err, context.Canceled) +} + func TestLogSender_Batch(t *testing.T) { t.Parallel() testCtx := testutil.Context(t, testutil.WaitShort) From 8326a3a6759c4355e04093893aef7a66db6e4069 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 10 Jun 2024 15:27:24 +0400 Subject: [PATCH 050/168] chore: change mock clock to allow Advance() within timer/tick functions (#13500) --- clock/clock.go | 2 + clock/example_test.go | 69 +++++- clock/mock.go | 226 ++++++++++++-------- clock/real.go | 4 + clock/timer.go | 16 +- coderd/database/pubsub/watchdog_test.go | 26 ++- enterprise/tailnet/pgcoord.go | 80 +++---- enterprise/tailnet/pgcoord_internal_test.go | 132 +++++++++--- enterprise/tailnet/pgcoord_test.go | 45 ++-- tailnet/configmaps_internal_test.go | 41 ++-- 10 files changed, 424 insertions(+), 217 deletions(-) diff --git a/clock/clock.go b/clock/clock.go index 516b74e6b117b..5f3b0de105911 100644 --- a/clock/clock.go +++ b/clock/clock.go @@ -26,6 +26,8 @@ type Clock interface { Now(tags ...string) time.Time // Since returns the time elapsed since t. It is shorthand for Clock.Now().Sub(t). Since(t time.Time, tags ...string) time.Duration + // Until returns the duration until t. It is shorthand for t.Sub(Clock.Now()). + Until(t time.Time, tags ...string) time.Duration } // Waiter can be waited on for an error. diff --git a/clock/example_test.go b/clock/example_test.go index 69d6ba4a318ae..de72312d7d036 100644 --- a/clock/example_test.go +++ b/clock/example_test.go @@ -44,7 +44,7 @@ func TestExampleTickerFunc(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - mClock := clock.NewMock() + mClock := clock.NewMock(t) // Because the ticker is started on a goroutine, we can't immediately start // advancing the clock, or we will race with the start of the ticker. If we @@ -76,9 +76,74 @@ func TestExampleTickerFunc(t *testing.T) { } // Now that we know the ticker is started, we can advance the time. - mClock.Advance(time.Hour).MustWait(ctx, t) + mClock.Advance(time.Hour).MustWait(ctx) if tks := tc.Ticks(); tks != 1 { t.Fatalf("expected 1 got %d ticks", tks) } } + +type exampleLatencyMeasurer struct { + mu sync.Mutex + lastLatency time.Duration +} + +func newExampleLatencyMeasurer(ctx context.Context, clk clock.Clock) *exampleLatencyMeasurer { + m := &exampleLatencyMeasurer{} + clk.TickerFunc(ctx, 10*time.Second, func() error { + start := clk.Now() + // m.doSomething() + latency := clk.Since(start) + m.mu.Lock() + defer m.mu.Unlock() + m.lastLatency = latency + return nil + }) + return m +} + +func (m *exampleLatencyMeasurer) LastLatency() time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + return m.lastLatency +} + +func TestExampleLatencyMeasurer(t *testing.T) { + t.Parallel() + + // nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + mClock := clock.NewMock(t) + trap := mClock.Trap().Since() + defer trap.Close() + + lm := newExampleLatencyMeasurer(ctx, mClock) + + w := mClock.Advance(10 * time.Second) // triggers first tick + c := trap.MustWait(ctx) // call to Since() + mClock.Advance(33 * time.Millisecond) + c.Release() + w.MustWait(ctx) + + if l := lm.LastLatency(); l != 33*time.Millisecond { + t.Fatalf("expected 33ms got %s", l.String()) + } + + // Next tick is in 10s - 33ms, but if we don't want to calculate, we can use: + d, w2 := mClock.AdvanceNext() + c = trap.MustWait(ctx) + mClock.Advance(17 * time.Millisecond) + c.Release() + w2.MustWait(ctx) + + expectedD := 10*time.Second - 33*time.Millisecond + if d != expectedD { + t.Fatalf("expected %s got %s", expectedD.String(), d.String()) + } + + if l := lm.LastLatency(); l != 17*time.Millisecond { + t.Fatalf("expected 17ms got %s", l.String()) + } +} diff --git a/clock/mock.go b/clock/mock.go index 55c8cdcaa3277..6e66206c1614d 100644 --- a/clock/mock.go +++ b/clock/mock.go @@ -3,6 +3,7 @@ package clock import ( "context" "errors" + "fmt" "slices" "sync" "testing" @@ -12,13 +13,11 @@ import ( // Mock is the testing implementation of Clock. It tracks a time that monotonically increases // during a test, triggering any timers or tickers automatically. type Mock struct { + tb testing.TB mu sync.Mutex // cur is the current time cur time.Time - // advancing is true when we are in the process of advancing the clock. We don't support - // multiple goroutines doing this at once. - advancing bool all []event nextTime time.Time @@ -77,11 +76,9 @@ func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { } m.mu.Lock() defer m.mu.Unlock() - m.matchCallLocked(&Call{ - fn: clockFunctionAfterFunc, - Duration: d, - Tags: tags, - }) + c := newCall(clockFunctionAfterFunc, tags, withDuration(d)) + defer close(c.complete) + m.matchCallLocked(c) t := &Timer{ nxt: m.cur.Add(d), fn: f, @@ -94,23 +91,30 @@ func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { func (m *Mock) Now(tags ...string) time.Time { m.mu.Lock() defer m.mu.Unlock() - m.matchCallLocked(&Call{ - fn: clockFunctionNow, - Tags: tags, - }) + c := newCall(clockFunctionNow, tags) + defer close(c.complete) + m.matchCallLocked(c) return m.cur } func (m *Mock) Since(t time.Time, tags ...string) time.Duration { m.mu.Lock() defer m.mu.Unlock() - m.matchCallLocked(&Call{ - fn: clockFunctionSince, - Tags: tags, - }) + c := newCall(clockFunctionSince, tags, withTime(t)) + defer close(c.complete) + m.matchCallLocked(c) return m.cur.Sub(t) } +func (m *Mock) Until(t time.Time, tags ...string) time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionUntil, tags, withTime(t)) + defer close(c.complete) + m.matchCallLocked(c) + return t.Sub(m.cur) +} + func (m *Mock) addTimerLocked(t *Timer) { m.all = append(m.all, t) m.recomputeNextLocked() @@ -182,14 +186,15 @@ func (m *Mock) matchCallLocked(c *Call) { m.mu.Lock() } -// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for: -// -// - tick functions of tickers created using NewContextTicker -// - functions passed to AfterFunc +// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for ticks and timers +// to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the +// functions to return. For other ticks & timers, it just waits for the tick to be delivered to +// the channel. // -// to complete. If multiple timers or tickers trigger simultaneously, they are all run on separate +// If multiple timers or tickers trigger simultaneously, they are all run on separate // go routines. type AdvanceWaiter struct { + tb testing.TB ch chan struct{} } @@ -206,12 +211,13 @@ func (w AdvanceWaiter) Wait(ctx context.Context) error { // MustWait waits for all timers and ticks to complete, and fails the test immediately if the // context completes first. MustWait must be called from the goroutine running the test or // benchmark, similar to `t.FailNow()`. -func (w AdvanceWaiter) MustWait(ctx context.Context, t testing.TB) { +func (w AdvanceWaiter) MustWait(ctx context.Context) { + w.tb.Helper() select { case <-w.ch: return case <-ctx.Done(): - t.Fatalf("context expired while waiting for clock to advance: %s", ctx.Err()) + w.tb.Fatalf("context expired while waiting for clock to advance: %s", ctx.Err()) } } @@ -221,81 +227,112 @@ func (w AdvanceWaiter) Done() <-chan struct{} { } // Advance moves the clock forward by d, triggering any timers or tickers. The returned value can -// be used to wait for all timers and ticks to complete. +// be used to wait for all timers and ticks to complete. Advance sets the clock forward before +// returning, and can only advance up to the next timer or tick event. It will fail the test if you +// attempt to advance beyond. +// +// If you need to advance exactly to the next event, and don't know or don't wish to calculate it, +// consider AdvanceNext(). func (m *Mock) Advance(d time.Duration) AdvanceWaiter { - w := AdvanceWaiter{ch: make(chan struct{})} - go func() { - defer close(w.ch) - m.mu.Lock() - defer m.mu.Unlock() - m.advanceLocked(d) - }() - return w -} - -func (m *Mock) advanceLocked(d time.Duration) { - if m.advancing { - panic("multiple simultaneous calls to Advance/Set not supported") - } - m.advancing = true - defer func() { - m.advancing = false - }() - + m.tb.Helper() + w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + m.mu.Lock() fin := m.cur.Add(d) - for { - // nextTime.IsZero implies no events scheduled - if m.nextTime.IsZero() || m.nextTime.After(fin) { - m.cur = fin - return - } - - if m.nextTime.After(m.cur) { - m.cur = m.nextTime - } - - wg := sync.WaitGroup{} - for i := range m.nextEvents { - e := m.nextEvents[i] - t := m.cur - wg.Add(1) - go func() { - e.fire(t) - wg.Done() - }() - } - // release the lock and let the events resolve. This allows them to call back into the - // Mock to query the time or set new timers. Each event should remove or reschedule - // itself from nextEvents. + // nextTime.IsZero implies no events scheduled. + if m.nextTime.IsZero() || fin.Before(m.nextTime) { + m.cur = fin + m.mu.Unlock() + close(w.ch) + return w + } + if fin.After(m.nextTime) { + m.tb.Errorf(fmt.Sprintf("cannot advance %s which is beyond next timer/ticker event in %s", + d.String(), m.nextTime.Sub(m.cur))) m.mu.Unlock() - wg.Wait() - m.mu.Lock() + close(w.ch) + return w } + + m.cur = m.nextTime + go m.advanceLocked(w) + return w +} + +func (m *Mock) advanceLocked(w AdvanceWaiter) { + defer close(w.ch) + wg := sync.WaitGroup{} + for i := range m.nextEvents { + e := m.nextEvents[i] + t := m.cur + wg.Add(1) + go func() { + e.fire(t) + wg.Done() + }() + } + // release the lock and let the events resolve. This allows them to call back into the + // Mock to query the time or set new timers. Each event should remove or reschedule + // itself from nextEvents. + m.mu.Unlock() + wg.Wait() } // Set the time to t. If the time is after the current mocked time, then this is equivalent to // Advance() with the difference. You may only Set the time earlier than the current time before // starting tickers and timers (e.g. at the start of your test case). func (m *Mock) Set(t time.Time) AdvanceWaiter { - w := AdvanceWaiter{ch: make(chan struct{})} - go func() { + m.tb.Helper() + w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + m.mu.Lock() + if t.Before(m.cur) { defer close(w.ch) - m.mu.Lock() defer m.mu.Unlock() - if t.Before(m.cur) { - // past - if !m.nextTime.IsZero() { - panic("Set mock clock to the past after timers/tickers started") - } - m.cur = t - return + // past + if !m.nextTime.IsZero() { + m.tb.Error("Set mock clock to the past after timers/tickers started") } - // future, just advance as normal. - m.advanceLocked(t.Sub(m.cur)) - }() + m.cur = t + return w + } + // future + // nextTime.IsZero implies no events scheduled. + if m.nextTime.IsZero() || t.Before(m.nextTime) { + defer close(w.ch) + defer m.mu.Unlock() + m.cur = t + return w + } + if t.After(m.nextTime) { + defer close(w.ch) + defer m.mu.Unlock() + m.tb.Errorf("cannot Set time to %s which is beyond next timer/ticker event at %s", + t.String(), m.nextTime) + return w + } + + m.cur = m.nextTime + go m.advanceLocked(w) return w } +// AdvanceNext advances the clock to the next timer or tick event. It fails the test if there are +// none scheduled. It returns the duration the clock was advanced and a waiter that can be used to +// wait for the timer/tick event(s) to finish. +func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { + m.mu.Lock() + m.tb.Helper() + w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + if m.nextTime.IsZero() { + defer close(w.ch) + defer m.mu.Unlock() + m.tb.Error("cannot AdvanceNext because there are no timers or tickers running") + } + d := m.nextTime.Sub(m.cur) + m.cur = m.nextTime + go m.advanceLocked(w) + return d, w +} + // Trapper allows the creation of Traps type Trapper struct { // mock is the underlying Mock. This is a thin wrapper around Mock so that @@ -335,6 +372,10 @@ func (t Trapper) Since(tags ...string) *Trap { return t.mock.newTrap(clockFunctionSince, tags) } +func (t Trapper) Until(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionUntil, tags) +} + func (m *Mock) Trap() Trapper { return Trapper{m} } @@ -356,12 +397,13 @@ func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { // NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024. // You may re-set the time earlier than this, but only before timers or tickers // are created. -func NewMock() *Mock { +func NewMock(tb testing.TB) *Mock { cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") if err != nil { panic(err) } return &Mock{ + tb: tb, cur: cur, } } @@ -387,15 +429,12 @@ func (m *mockTickerFunc) next() time.Time { return m.nxt } -func (m *mockTickerFunc) fire(t time.Time) { +func (m *mockTickerFunc) fire(_ time.Time) { m.mock.mu.Lock() defer m.mock.mu.Unlock() if m.done { return } - if !m.nxt.Equal(t) { - panic("mockTickerFunc fired at wrong time") - } m.nxt = m.nxt.Add(m.d) m.mock.recomputeNextLocked() @@ -449,6 +488,7 @@ const ( clockFunctionTickerFuncWait clockFunctionNow clockFunctionSince + clockFunctionUntil ) type callArg func(c *Call) @@ -468,7 +508,6 @@ func (c *Call) Release() { <-c.complete } -// nolint: unused // it will be soon func withTime(t time.Time) callArg { return func(c *Call) { c.Time = t @@ -544,3 +583,14 @@ func (t *Trap) Wait(ctx context.Context) (*Call, error) { return c, nil } } + +// MustWait calls Wait() and then if there is an error, immediately fails the +// test via tb.Fatalf() +func (t *Trap) MustWait(ctx context.Context) *Call { + t.mock.tb.Helper() + c, err := t.Wait(ctx) + if err != nil { + t.mock.tb.Fatalf("context expired while waiting for trap: %s", err.Error()) + } + return c +} diff --git a/clock/real.go b/clock/real.go index e31c80616d896..41019571e6aea 100644 --- a/clock/real.go +++ b/clock/real.go @@ -68,4 +68,8 @@ func (realClock) Since(t time.Time, _ ...string) time.Duration { return time.Since(t) } +func (realClock) Until(t time.Time, _ ...string) time.Duration { + return time.Until(t) +} + var _ Clock = realClock{} diff --git a/clock/timer.go b/clock/timer.go index ee1d67485219d..b2175c953f0d5 100644 --- a/clock/timer.go +++ b/clock/timer.go @@ -14,9 +14,6 @@ type Timer struct { } func (t *Timer) fire(tt time.Time) { - if !tt.Equal(t.nxt) { - panic("mock timer fired at wrong time") - } t.mock.removeTimer(t) if t.fn != nil { t.fn() @@ -56,13 +53,20 @@ func (t *Timer) Reset(d time.Duration, tags ...string) bool { t.mock.matchCallLocked(c) defer close(c.complete) result := !t.stopped - t.mock.removeTimerLocked(t) - t.stopped = false - t.nxt = t.mock.cur.Add(d) select { case <-t.c: default: } + if d == 0 { + // zero duration timer means we should immediately re-fire it, rather + // than remove and re-add it. + t.stopped = false + go t.fire(t.mock.cur) + return result + } + t.mock.removeTimerLocked(t) + t.stopped = false + t.nxt = t.mock.cur.Add(d) t.mock.addTimerLocked(t) return result } diff --git a/coderd/database/pubsub/watchdog_test.go b/coderd/database/pubsub/watchdog_test.go index 62d51c8ecaaee..942f9eeb849c4 100644 --- a/coderd/database/pubsub/watchdog_test.go +++ b/coderd/database/pubsub/watchdog_test.go @@ -16,7 +16,7 @@ import ( func TestWatchdog_NoTimeout(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) - mClock := clock.NewMock() + mClock := clock.NewMock(t) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) fPS := newFakePubsub() @@ -42,11 +42,13 @@ func TestWatchdog_NoTimeout(t *testing.T) { // 5 min / 15 sec = 20, so do 21 ticks for i := 0; i < 21; i++ { - mClock.Advance(15*time.Second).MustWait(ctx, t) + d, w := mClock.AdvanceNext() + w.MustWait(ctx) + require.LessOrEqual(t, d, 15*time.Second) p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) - mClock.Advance(30*time.Millisecond). // reasonable round-trip - MustWait(ctx, t) + mClock.Advance(30 * time.Millisecond). // reasonable round-trip + MustWait(ctx) // forward the beat sub.listener(ctx, []byte{}) // we shouldn't time out @@ -72,11 +74,11 @@ func TestWatchdog_NoTimeout(t *testing.T) { func TestWatchdog_Timeout(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) - mClock := clock.NewMock() + mClock := clock.NewMock(t) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) fPS := newFakePubsub() - // trap the ticker and timer calls + // trap the ticker calls pubTrap := mClock.Trap().TickerFunc("publish") defer pubTrap.Close() @@ -96,11 +98,13 @@ func TestWatchdog_Timeout(t *testing.T) { // 5 min / 15 sec = 20, so do 19 ticks without timing out for i := 0; i < 19; i++ { - mClock.Advance(15*time.Second).MustWait(ctx, t) + d, w := mClock.AdvanceNext() + w.MustWait(ctx) + require.LessOrEqual(t, d, 15*time.Second) p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) - mClock.Advance(30*time.Millisecond). // reasonable round-trip - MustWait(ctx, t) + mClock.Advance(30 * time.Millisecond). // reasonable round-trip + MustWait(ctx) // we DO NOT forward the heartbeat // we shouldn't time out select { @@ -110,7 +114,9 @@ func TestWatchdog_Timeout(t *testing.T) { // OK! } } - mClock.Advance(15*time.Second).MustWait(ctx, t) + d, w := mClock.AdvanceNext() + w.MustWait(ctx) + require.LessOrEqual(t, d, 15*time.Second) p := testutil.RequireRecvCtx(ctx, t, fPS.pubs) require.Equal(t, pubsub.EventPubsubWatchdog, p) testutil.RequireRecvCtx(ctx, t, uut.Timeout()) diff --git a/enterprise/tailnet/pgcoord.go b/enterprise/tailnet/pgcoord.go index 857cdafe94e79..104a649d87839 100644 --- a/enterprise/tailnet/pgcoord.go +++ b/enterprise/tailnet/pgcoord.go @@ -15,6 +15,7 @@ import ( gProto "google.golang.org/protobuf/proto" "cdr.dev/slog" + "github.com/coder/coder/v2/clock" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/pubsub" @@ -115,11 +116,16 @@ var pgCoordSubject = rbac.Subject{ // NewPGCoord creates a high-availability coordinator that stores state in the PostgreSQL database and // receives notifications of updates via the pubsub. func NewPGCoord(ctx context.Context, logger slog.Logger, ps pubsub.Pubsub, store database.Store) (agpl.Coordinator, error) { - return newPGCoordInternal(ctx, logger, ps, store) + return newPGCoordInternal(ctx, logger, ps, store, clock.NewReal()) +} + +// NewTestPGCoord is only used in testing to pass a clock.Clock in. +func NewTestPGCoord(ctx context.Context, logger slog.Logger, ps pubsub.Pubsub, store database.Store, clk clock.Clock) (agpl.Coordinator, error) { + return newPGCoordInternal(ctx, logger, ps, store, clk) } func newPGCoordInternal( - ctx context.Context, logger slog.Logger, ps pubsub.Pubsub, store database.Store, + ctx context.Context, logger slog.Logger, ps pubsub.Pubsub, store database.Store, clk clock.Clock, ) ( *pgCoord, error, ) { @@ -157,7 +163,7 @@ func newPGCoordInternal( handshaker: newHandshaker(ctx, logger, id, ps, rfhCh, fHB), handshakerCh: rfhCh, id: id, - querier: newQuerier(querierCtx, logger, id, ps, store, id, cCh, ccCh, numQuerierWorkers, fHB), + querier: newQuerier(querierCtx, logger, id, ps, store, id, cCh, ccCh, numQuerierWorkers, fHB, clk), closed: make(chan struct{}), } go func() { @@ -817,6 +823,7 @@ func newQuerier(ctx context.Context, closeConnections chan *connIO, numWorkers int, firstHeartbeat chan struct{}, + clk clock.Clock, ) *querier { updates := make(chan hbUpdate) q := &querier{ @@ -828,7 +835,7 @@ func newQuerier(ctx context.Context, newConnections: newConnections, closeConnections: closeConnections, workQ: newWorkQ[querierWorkKey](ctx), - heartbeats: newHeartbeats(ctx, logger, ps, store, self, updates, firstHeartbeat), + heartbeats: newHeartbeats(ctx, logger, ps, store, self, updates, firstHeartbeat, clk), mappers: make(map[mKey]*mapper), updates: updates, healthy: true, // assume we start healthy @@ -1462,12 +1469,12 @@ type heartbeats struct { lock sync.RWMutex coordinators map[uuid.UUID]time.Time - timer *time.Timer + timer *clock.Timer wg sync.WaitGroup - // overwritten in tests, but otherwise constant - cleanupPeriod time.Duration + // for testing + clock clock.Clock } func newHeartbeats( @@ -1475,6 +1482,7 @@ func newHeartbeats( ps pubsub.Pubsub, store database.Store, self uuid.UUID, update chan<- hbUpdate, firstHeartbeat chan<- struct{}, + clk clock.Clock, ) *heartbeats { h := &heartbeats{ ctx: ctx, @@ -1485,7 +1493,7 @@ func newHeartbeats( update: update, firstHeartbeat: firstHeartbeat, coordinators: make(map[uuid.UUID]time.Time), - cleanupPeriod: cleanupPeriod, + clock: clk, } h.wg.Add(3) go h.subscribe() @@ -1576,11 +1584,11 @@ func (h *heartbeats) recvBeat(id uuid.UUID) { _ = agpl.SendCtx(h.ctx, h.update, hbUpdate{filter: filterUpdateUpdated}) }() } - h.coordinators[id] = time.Now() + h.coordinators[id] = h.clock.Now("heartbeats", "recvBeat") if h.timer == nil { // this can only happen for the very first beat - h.timer = time.AfterFunc(MissedHeartbeats*HeartbeatPeriod, h.checkExpiry) + h.timer = h.clock.AfterFunc(MissedHeartbeats*HeartbeatPeriod, h.checkExpiry, "heartbeats", "recvBeat") h.logger.Debug(h.ctx, "set initial heartbeat timeout") return } @@ -1594,24 +1602,30 @@ func (h *heartbeats) resetExpiryTimerWithLock() { oldestTime = t } } - d := time.Until(oldestTime.Add(MissedHeartbeats * HeartbeatPeriod)) + d := h.clock.Until( + oldestTime.Add(MissedHeartbeats*HeartbeatPeriod), + "heartbeats", "resetExpiryTimerWithLock", + ) + if len(h.coordinators) == 0 { + return + } h.logger.Debug(h.ctx, "computed oldest heartbeat", slog.F("oldest", oldestTime), slog.F("time_to_expiry", d)) - // only reschedule if it's in the future. - if d > 0 { - h.timer.Reset(d) + if d < 0 { + d = 0 } + h.timer.Reset(d) } func (h *heartbeats) checkExpiry() { h.logger.Debug(h.ctx, "checking heartbeat expiry") h.lock.Lock() defer h.lock.Unlock() - now := time.Now() + now := h.clock.Now() expired := false for id, t := range h.coordinators { lastHB := now.Sub(t) h.logger.Debug(h.ctx, "last heartbeat from coordinator", slog.F("other_coordinator_id", id), slog.F("last_heartbeat", lastHB)) - if lastHB > MissedHeartbeats*HeartbeatPeriod { + if lastHB >= MissedHeartbeats*HeartbeatPeriod { expired = true delete(h.coordinators, id) h.logger.Info(h.ctx, "coordinator failed heartbeat check", slog.F("other_coordinator_id", id), slog.F("last_heartbeat", lastHB)) @@ -1633,17 +1647,12 @@ func (h *heartbeats) sendBeats() { h.sendBeat() close(h.firstHeartbeat) // signal binder it can start writing defer h.sendDelete() - tkr := time.NewTicker(HeartbeatPeriod) - defer tkr.Stop() - for { - select { - case <-h.ctx.Done(): - h.logger.Debug(h.ctx, "ending heartbeats", slog.Error(h.ctx.Err())) - return - case <-tkr.C: - h.sendBeat() - } - } + tkr := h.clock.TickerFunc(h.ctx, HeartbeatPeriod, func() error { + h.sendBeat() + return nil + }, "heartbeats", "sendBeats") + err := tkr.Wait() + h.logger.Debug(h.ctx, "ending heartbeats", slog.Error(err)) } func (h *heartbeats) sendBeat() { @@ -1682,17 +1691,12 @@ func (h *heartbeats) sendDelete() { func (h *heartbeats) cleanupLoop() { defer h.wg.Done() h.cleanup() - tkr := time.NewTicker(h.cleanupPeriod) - defer tkr.Stop() - for { - select { - case <-h.ctx.Done(): - h.logger.Debug(h.ctx, "ending cleanupLoop", slog.Error(h.ctx.Err())) - return - case <-tkr.C: - h.cleanup() - } - } + tkr := h.clock.TickerFunc(h.ctx, cleanupPeriod, func() error { + h.cleanup() + return nil + }, "heartbeats", "cleanupLoop") + err := tkr.Wait() + h.logger.Debug(h.ctx, "ending cleanupLoop", slog.Error(err)) } // cleanup issues a DB command to clean out any old expired coordinators or lost peer state. The diff --git a/enterprise/tailnet/pgcoord_internal_test.go b/enterprise/tailnet/pgcoord_internal_test.go index 4607e6fb2ab2f..5117131c05956 100644 --- a/enterprise/tailnet/pgcoord_internal_test.go +++ b/enterprise/tailnet/pgcoord_internal_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/coder/coder/v2/clock" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,8 +36,7 @@ import ( // make update-golden-files var UpdateGoldenFiles = flag.Bool("update", false, "update .golden files") -// TestHeartbeats_Cleanup is internal so that we can overwrite the cleanup period and not wait an hour for the timed -// cleanup. +// TestHeartbeats_Cleanup tests the cleanup loop func TestHeartbeats_Cleanup(t *testing.T) { t.Parallel() @@ -46,38 +47,82 @@ func TestHeartbeats_Cleanup(t *testing.T) { defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - waitForCleanup := make(chan struct{}) - mStore.EXPECT().CleanTailnetCoordinators(gomock.Any()).MinTimes(2).DoAndReturn(func(_ context.Context) error { - <-waitForCleanup - return nil - }) - mStore.EXPECT().CleanTailnetLostPeers(gomock.Any()).MinTimes(2).DoAndReturn(func(_ context.Context) error { - <-waitForCleanup - return nil - }) - mStore.EXPECT().CleanTailnetTunnels(gomock.Any()).MinTimes(2).DoAndReturn(func(_ context.Context) error { - <-waitForCleanup - return nil - }) + mStore.EXPECT().CleanTailnetCoordinators(gomock.Any()).Times(2).Return(nil) + mStore.EXPECT().CleanTailnetLostPeers(gomock.Any()).Times(2).Return(nil) + mStore.EXPECT().CleanTailnetTunnels(gomock.Any()).Times(2).Return(nil) + + mClock := clock.NewMock(t) + trap := mClock.Trap().TickerFunc("heartbeats", "cleanupLoop") + defer trap.Close() uut := &heartbeats{ - ctx: ctx, - logger: logger, - store: mStore, - cleanupPeriod: time.Millisecond, + ctx: ctx, + logger: logger, + store: mStore, + clock: mClock, } uut.wg.Add(1) go uut.cleanupLoop() - for i := 0; i < 6; i++ { - select { - case <-ctx.Done(): - t.Fatal("timeout") - case waitForCleanup <- struct{}{}: - // ok - } + call, err := trap.Wait(ctx) + require.NoError(t, err) + call.Release() + require.Equal(t, cleanupPeriod, call.Duration) + mClock.Advance(cleanupPeriod).MustWait(ctx) +} + +// TestHeartbeats_recvBeat_resetSkew is a regression test for a bug where heartbeats from two +// coordinators slightly skewed from one another could result in one coordinator failing to get +// expired +func TestHeartbeats_recvBeat_resetSkew(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + mClock := clock.NewMock(t) + trap := mClock.Trap().Until("heartbeats", "resetExpiryTimerWithLock") + defer trap.Close() + + uut := heartbeats{ + ctx: ctx, + logger: logger, + clock: mClock, + self: uuid.UUID{1}, + update: make(chan hbUpdate, 4), + coordinators: make(map[uuid.UUID]time.Time), } - close(waitForCleanup) + + coord2 := uuid.UUID{2} + coord3 := uuid.UUID{3} + + uut.listen(ctx, []byte(coord2.String()), nil) + + // coord 3 heartbeat comes very soon after + mClock.Advance(time.Millisecond).MustWait(ctx) + go uut.listen(ctx, []byte(coord3.String()), nil) + trap.MustWait(ctx).Release() + + // both coordinators are present + uut.lock.RLock() + require.Contains(t, uut.coordinators, coord2) + require.Contains(t, uut.coordinators, coord3) + uut.lock.RUnlock() + + // no more heartbeats arrive, and coord2 expires + w := mClock.Advance(MissedHeartbeats*HeartbeatPeriod - time.Millisecond) + // however, several ms pass between expiring 2 and computing the time until 3 expires + c := trap.MustWait(ctx) + mClock.Advance(2 * time.Millisecond).MustWait(ctx) // 3 has now expired _in the past_ + c.Release() + w.MustWait(ctx) + + // expired in the past means we immediately reschedule checkExpiry, so we get another call + trap.MustWait(ctx).Release() + + uut.lock.RLock() + require.NotContains(t, uut.coordinators, coord2) + require.NotContains(t, uut.coordinators, coord3) + uut.lock.RUnlock() } func TestHeartbeats_LostCoordinator_MarkLost(t *testing.T) { @@ -85,25 +130,26 @@ func TestHeartbeats_LostCoordinator_MarkLost(t *testing.T) { ctrl := gomock.NewController(t) mStore := dbmock.NewMockStore(ctrl) + mClock := clock.NewMock(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) uut := &heartbeats{ - ctx: ctx, - logger: logger, - store: mStore, - cleanupPeriod: time.Millisecond, + ctx: ctx, + logger: logger, + store: mStore, coordinators: map[uuid.UUID]time.Time{ - uuid.New(): time.Now(), + uuid.New(): mClock.Now(), }, + clock: mClock, } mpngs := []mapping{{ peer: uuid.New(), coordinator: uuid.New(), - updatedAt: time.Now(), + updatedAt: mClock.Now(), node: &proto.Node{}, kind: proto.CoordinateResponse_PeerUpdate_NODE, }} @@ -342,11 +388,14 @@ func TestPGCoordinatorUnhealthy(t *testing.T) { ctrl := gomock.NewController(t) mStore := dbmock.NewMockStore(ctrl) ps := pubsub.NewInMemory() + mClock := clock.NewMock(t) + tfTrap := mClock.Trap().TickerFunc("heartbeats", "sendBeats") + defer tfTrap.Close() // after 3 failed heartbeats, the coordinator is unhealthy mStore.EXPECT(). UpsertTailnetCoordinator(gomock.Any(), gomock.Any()). - MinTimes(3). + Times(3). Return(database.TailnetCoordinator{}, xerrors.New("badness")) mStore.EXPECT(). DeleteCoordinator(gomock.Any(), gomock.Any()). @@ -360,9 +409,22 @@ func TestPGCoordinatorUnhealthy(t *testing.T) { mStore.EXPECT().CleanTailnetLostPeers(gomock.Any()).AnyTimes().Return(nil) mStore.EXPECT().CleanTailnetTunnels(gomock.Any()).AnyTimes().Return(nil) - coordinator, err := newPGCoordInternal(ctx, logger, ps, mStore) + coordinator, err := newPGCoordInternal(ctx, logger, ps, mStore, mClock) + require.NoError(t, err) + + expectedPeriod := HeartbeatPeriod + tfCall, err := tfTrap.Wait(ctx) require.NoError(t, err) + tfCall.Release() + require.Equal(t, expectedPeriod, tfCall.Duration) + + // Now that the ticker has started, we can advance 2 more beats to get to 3 + // failed heartbeats + mClock.Advance(HeartbeatPeriod).MustWait(ctx) + mClock.Advance(HeartbeatPeriod).MustWait(ctx) + // The querier is informed async about being unhealthy, so we need to wait + // until it is. require.Eventually(t, func() bool { return !coordinator.querier.isHealthy() }, testutil.WaitShort, testutil.IntervalFast) diff --git a/enterprise/tailnet/pgcoord_test.go b/enterprise/tailnet/pgcoord_test.go index 9c363ee700570..c02774adb7245 100644 --- a/enterprise/tailnet/pgcoord_test.go +++ b/enterprise/tailnet/pgcoord_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/coder/coder/v2/clock" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -337,7 +339,13 @@ func TestPGCoordinatorSingle_MissedHeartbeats(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - coordinator, err := tailnet.NewPGCoord(ctx, logger, ps, store) + mClock := clock.NewMock(t) + nowTrap := mClock.Trap().Now("heartbeats", "recvBeat") + defer nowTrap.Close() + afTrap := mClock.Trap().AfterFunc("heartbeats", "recvBeat") + defer afTrap.Close() + + coordinator, err := tailnet.NewTestPGCoord(ctx, logger, ps, store, mClock) require.NoError(t, err) defer coordinator.Close() @@ -360,21 +368,11 @@ func TestPGCoordinatorSingle_MissedHeartbeats(t *testing.T) { store: store, id: uuid.New(), } - // heatbeat until canceled - ctx2, cancel2 := context.WithCancel(ctx) - go func() { - t := time.NewTicker(tailnet.HeartbeatPeriod) - defer t.Stop() - for { - select { - case <-ctx2.Done(): - return - case <-t.C: - fCoord2.heartbeat() - } - } - }() + fCoord2.heartbeat() + nowTrap.MustWait(ctx).Release() + afTrap.MustWait(ctx).Release() // heartbeat timeout started + fCoord2.agentNode(agent.id, &agpl.Node{PreferredDERP: 12}) assertEventuallyHasDERPs(ctx, t, client, 12) @@ -384,22 +382,31 @@ func TestPGCoordinatorSingle_MissedHeartbeats(t *testing.T) { store: store, id: uuid.New(), } - start := time.Now() fCoord3.heartbeat() + nowTrap.MustWait(ctx).Release() fCoord3.agentNode(agent.id, &agpl.Node{PreferredDERP: 13}) assertEventuallyHasDERPs(ctx, t, client, 13) + // fCoord2 sends in a second heartbeat, one period later (on time) + fCoord2.heartbeat() + c := nowTrap.MustWait(ctx) + mClock.Advance(tailnet.HeartbeatPeriod).MustWait(ctx) + c.Release() + // when the fCoord3 misses enough heartbeats, the real coordinator should send an update with the // node from fCoord2 for the agent. + mClock.Advance(tailnet.HeartbeatPeriod).MustWait(ctx) + mClock.Advance(tailnet.HeartbeatPeriod).MustWait(ctx) assertEventuallyHasDERPs(ctx, t, client, 12) - assert.Greater(t, time.Since(start), tailnet.HeartbeatPeriod*tailnet.MissedHeartbeats) - // stop fCoord2 heartbeats, which should cause us to revert to the original agent mapping - cancel2() + // one more heartbeat period will result in fCoord2 being expired, which should cause us to + // revert to the original agent mapping + mClock.Advance(tailnet.HeartbeatPeriod).MustWait(ctx) assertEventuallyHasDERPs(ctx, t, client, 10) // send fCoord3 heartbeat, which should trigger us to consider that mapping valid again. fCoord3.heartbeat() + nowTrap.MustWait(ctx).Release() assertEventuallyHasDERPs(ctx, t, client, 13) err = agent.close() diff --git a/tailnet/configmaps_internal_test.go b/tailnet/configmaps_internal_test.go index c658e5fb2f44e..83b15387a9a43 100644 --- a/tailnet/configmaps_internal_test.go +++ b/tailnet/configmaps_internal_test.go @@ -195,7 +195,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake_neverConfigures(t *testing. discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - mClock := clock.NewMock() + mClock := clock.NewMock(t) uut.clock = mClock p1ID := uuid.UUID{1} @@ -239,7 +239,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake_outOfOrder(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - mClock := clock.NewMock() + mClock := clock.NewMock(t) uut.clock = mClock p1ID := uuid.UUID{1} @@ -310,7 +310,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - mClock := clock.NewMock() + mClock := clock.NewMock(t) uut.clock = mClock p1ID := uuid.UUID{1} @@ -381,7 +381,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake_timeout(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - mClock := clock.NewMock() + mClock := clock.NewMock(t) uut.clock = mClock p1ID := uuid.UUID{1} @@ -404,7 +404,7 @@ func TestConfigMaps_updatePeers_new_waitForHandshake_timeout(t *testing.T) { } uut.updatePeers(u1) - mClock.Advance(5*time.Second).MustWait(ctx, t) + mClock.Advance(5 * time.Second).MustWait(ctx) // it should now send the peer to the netmap @@ -566,7 +566,7 @@ func TestConfigMaps_updatePeers_lost(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - mClock := clock.NewMock() + mClock := clock.NewMock(t) start := mClock.Now() uut.clock = mClock @@ -591,7 +591,7 @@ func TestConfigMaps_updatePeers_lost(t *testing.T) { require.Len(t, r.wg.Peers, 1) _ = testutil.RequireRecvCtx(ctx, t, s1) - mClock.Advance(5*time.Second).MustWait(ctx, t) + mClock.Advance(5 * time.Second).MustWait(ctx) s2 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) @@ -612,7 +612,8 @@ func TestConfigMaps_updatePeers_lost(t *testing.T) { // latest handshake has advanced by a minute, so we don't remove the peer. lh := start.Add(time.Minute) s3 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, lh) - mClock.Advance(lostTimeout).MustWait(ctx, t) + // 5 seconds have already elapsed from above + mClock.Advance(lostTimeout - 5*time.Second).MustWait(ctx) _ = testutil.RequireRecvCtx(ctx, t, s3) select { case <-fEng.setNetworkMap: @@ -624,7 +625,7 @@ func TestConfigMaps_updatePeers_lost(t *testing.T) { // Advance the clock again by a minute, which should trigger the reprogrammed // timeout. s4 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, lh) - mClock.Advance(time.Minute).MustWait(ctx, t) + mClock.Advance(time.Minute).MustWait(ctx) nm = testutil.RequireRecvCtx(ctx, t, fEng.setNetworkMap) r = testutil.RequireRecvCtx(ctx, t, fEng.reconfig) @@ -650,7 +651,7 @@ func TestConfigMaps_updatePeers_lost_and_found(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - mClock := clock.NewMock() + mClock := clock.NewMock(t) start := mClock.Now() uut.clock = mClock @@ -675,7 +676,7 @@ func TestConfigMaps_updatePeers_lost_and_found(t *testing.T) { require.Len(t, r.wg.Peers, 1) _ = testutil.RequireRecvCtx(ctx, t, s1) - mClock.Advance(5*time.Second).MustWait(ctx, t) + mClock.Advance(5 * time.Second).MustWait(ctx) s2 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) @@ -692,7 +693,7 @@ func TestConfigMaps_updatePeers_lost_and_found(t *testing.T) { // OK! } - mClock.Advance(5*time.Second).MustWait(ctx, t) + mClock.Advance(5 * time.Second).MustWait(ctx) s3 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) updates[0].Kind = proto.CoordinateResponse_PeerUpdate_NODE @@ -709,7 +710,7 @@ func TestConfigMaps_updatePeers_lost_and_found(t *testing.T) { // When we advance the clock, nothing happens because the timeout was // canceled - mClock.Advance(lostTimeout).MustWait(ctx, t) + mClock.Advance(lostTimeout).MustWait(ctx) select { case <-fEng.setNetworkMap: t.Fatal("should not reprogram") @@ -735,7 +736,7 @@ func TestConfigMaps_setAllPeersLost(t *testing.T) { discoKey := key.NewDisco() uut := newConfigMaps(logger, fEng, nodeID, nodePrivateKey, discoKey.Public()) defer uut.close() - mClock := clock.NewMock() + mClock := clock.NewMock(t) start := mClock.Now() uut.clock = mClock @@ -769,7 +770,7 @@ func TestConfigMaps_setAllPeersLost(t *testing.T) { require.Len(t, r.wg.Peers, 2) _ = testutil.RequireRecvCtx(ctx, t, s1) - mClock.Advance(5*time.Second).MustWait(ctx, t) + mClock.Advance(5 * time.Second).MustWait(ctx) uut.setAllPeersLost() // No reprogramming yet, since we keep the peer around. @@ -780,10 +781,12 @@ func TestConfigMaps_setAllPeersLost(t *testing.T) { // OK! } - // When we advance the clock, even by a few ms, the timeout for peer 2 pops - // because our status only includes a handshake for peer 1 + // When we advance the clock, even by a millisecond, the timeout for peer 2 + // pops because our status only includes a handshake for peer 1 s2 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) - mClock.Advance(time.Millisecond*10).MustWait(ctx, t) + d, w := mClock.AdvanceNext() + w.MustWait(ctx) + require.LessOrEqual(t, d, time.Millisecond) _ = testutil.RequireRecvCtx(ctx, t, s2) nm = testutil.RequireRecvCtx(ctx, t, fEng.setNetworkMap) @@ -793,7 +796,7 @@ func TestConfigMaps_setAllPeersLost(t *testing.T) { // Finally, advance the clock until after the timeout s3 := expectStatusWithHandshake(ctx, t, fEng, p1Node.Key, start) - mClock.Advance(lostTimeout).MustWait(ctx, t) + mClock.Advance(lostTimeout - d - 5*time.Second).MustWait(ctx) _ = testutil.RequireRecvCtx(ctx, t, s3) nm = testutil.RequireRecvCtx(ctx, t, fEng.setNetworkMap) From e96652ebbcdd7554977594286b32015115c3f5b6 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 10 Jun 2024 14:12:23 +0200 Subject: [PATCH 051/168] feat: block file transfers for security (#13501) --- agent/agent.go | 4 ++ agent/agent_test.go | 93 ++++++++++++++++++++++++++ agent/agentssh/agentssh.go | 53 +++++++++++++++ cli/agent.go | 11 +++ cli/testdata/coder_agent_--help.golden | 3 + 5 files changed, 164 insertions(+) diff --git a/agent/agent.go b/agent/agent.go index c7a785f8d5da1..5512f04db28ea 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -91,6 +91,7 @@ type Options struct { ModifiedProcesses chan []*agentproc.Process // ProcessManagementTick is used for testing process priority management. ProcessManagementTick <-chan time.Time + BlockFileTransfer bool } type Client interface { @@ -184,6 +185,7 @@ func New(options Options) Agent { modifiedProcs: options.ModifiedProcesses, processManagementTick: options.ProcessManagementTick, logSender: agentsdk.NewLogSender(options.Logger), + blockFileTransfer: options.BlockFileTransfer, prometheusRegistry: prometheusRegistry, metrics: newAgentMetrics(prometheusRegistry), @@ -239,6 +241,7 @@ type agent struct { sessionToken atomic.Pointer[string] sshServer *agentssh.Server sshMaxTimeout time.Duration + blockFileTransfer bool lifecycleUpdate chan struct{} lifecycleReported chan codersdk.WorkspaceAgentLifecycle @@ -277,6 +280,7 @@ func (a *agent) init() { AnnouncementBanners: func() *[]codersdk.BannerConfig { return a.announcementBanners.Load() }, UpdateEnv: a.updateCommandEnv, WorkingDirectory: func() string { return a.manifest.Load().Directory }, + BlockFileTransfer: a.blockFileTransfer, }) if err != nil { panic(err) diff --git a/agent/agent_test.go b/agent/agent_test.go index a008a60a2362e..4b0712bcf93c6 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -970,6 +970,99 @@ func TestAgent_SCP(t *testing.T) { require.NoError(t, err) } +func TestAgent_FileTransferBlocked(t *testing.T) { + t.Parallel() + + assertFileTransferBlocked := func(t *testing.T, errorMessage string) { + // NOTE: Checking content of the error message is flaky. Most likely there is a race condition, which results + // in stopping the client in different phases, and returning different errors: + // - client read the full error message: File transfer has been disabled. + // - client's stream was terminated before reading the error message: EOF + // - client just read the error code (Windows): Process exited with status 65 + isErr := strings.Contains(errorMessage, agentssh.BlockedFileTransferErrorMessage) || + strings.Contains(errorMessage, "EOF") || + strings.Contains(errorMessage, "Process exited with status 65") + require.True(t, isErr, fmt.Sprintf("Message: "+errorMessage)) + } + + t.Run("SFTP", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + //nolint:dogsled + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { + o.BlockFileTransfer = true + }) + sshClient, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshClient.Close() + _, err = sftp.NewClient(sshClient) + require.Error(t, err) + assertFileTransferBlocked(t, err.Error()) + }) + + t.Run("SCP with go-scp package", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + //nolint:dogsled + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { + o.BlockFileTransfer = true + }) + sshClient, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshClient.Close() + scpClient, err := scp.NewClientBySSH(sshClient) + require.NoError(t, err) + defer scpClient.Close() + tempFile := filepath.Join(t.TempDir(), "scp") + err = scpClient.CopyFile(context.Background(), strings.NewReader("hello world"), tempFile, "0755") + require.Error(t, err) + assertFileTransferBlocked(t, err.Error()) + }) + + t.Run("Forbidden commands", func(t *testing.T) { + t.Parallel() + + for _, c := range agentssh.BlockedFileTransferCommands { + t.Run(c, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + //nolint:dogsled + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { + o.BlockFileTransfer = true + }) + sshClient, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshClient.Close() + + session, err := sshClient.NewSession() + require.NoError(t, err) + defer session.Close() + + stdout, err := session.StdoutPipe() + require.NoError(t, err) + + //nolint:govet // we don't need `c := c` in Go 1.22 + err = session.Start(c) + require.NoError(t, err) + defer session.Close() + + msg, err := io.ReadAll(stdout) + require.NoError(t, err) + assertFileTransferBlocked(t, string(msg)) + }) + } + }) +} + func TestAgent_EnvironmentVariables(t *testing.T) { t.Parallel() key := "EXAMPLE" diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 54e5a3f41223e..5903220975b8c 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -52,8 +52,16 @@ const ( // 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." ) +// 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 @@ -74,6 +82,8 @@ type Config struct { // X11SocketDir is the directory where X11 sockets are created. Default is // /tmp/.X11-unix. X11SocketDir string + // BlockFileTransfer restricts use of file transfer applications. + BlockFileTransfer bool } type Server struct { @@ -272,6 +282,18 @@ func (s *Server) sessionHandler(session ssh.Session) { extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=:%d.0", x11.ScreenNumber)) } + 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: \n + errorMessage := fmt.Sprintf("\x02%s\n", BlockedFileTransferErrorMessage) + _, _ = session.Write([]byte(errorMessage)) + } + _ = session.Exit(BlockedFileTransferErrorCode) + return + } + switch ss := session.Subsystem(); ss { case "": case "sftp": @@ -322,6 +344,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, extraEnv []string) (retErr error) { ctx := session.Context() env := append(session.Environ(), extraEnv...) diff --git a/cli/agent.go b/cli/agent.go index 1f91f1c98bb8d..5465aeedd9302 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -27,6 +27,7 @@ import ( "cdr.dev/slog/sloggers/slogstackdriver" "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agentproc" + "github.com/coder/coder/v2/agent/agentssh" "github.com/coder/coder/v2/agent/reaper" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/codersdk" @@ -48,6 +49,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { slogHumanPath string slogJSONPath string slogStackdriverPath string + blockFileTransfer bool ) cmd := &serpent.Command{ Use: "agent", @@ -314,6 +316,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { // Intentionally set this to nil. It's mainly used // for testing. ModifiedProcesses: nil, + + BlockFileTransfer: blockFileTransfer, }) promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger) @@ -417,6 +421,13 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { Default: "", Value: serpent.StringOf(&slogStackdriverPath), }, + { + Flag: "block-file-transfer", + Default: "false", + Env: "CODER_AGENT_BLOCK_FILE_TRANSFER", + Description: fmt.Sprintf("Block file transfer using known applications: %s.", strings.Join(agentssh.BlockedFileTransferCommands, ",")), + Value: serpent.BoolOf(&blockFileTransfer), + }, } return cmd diff --git a/cli/testdata/coder_agent_--help.golden b/cli/testdata/coder_agent_--help.golden index 372395c4ba5fe..d6982fda18e7c 100644 --- a/cli/testdata/coder_agent_--help.golden +++ b/cli/testdata/coder_agent_--help.golden @@ -18,6 +18,9 @@ OPTIONS: --auth string, $CODER_AGENT_AUTH (default: token) Specify the authentication type to use for the agent. + --block-file-transfer bool, $CODER_AGENT_BLOCK_FILE_TRANSFER (default: false) + Block file transfer using known applications: nc,rsync,scp,sftp. + --debug-address string, $CODER_AGENT_DEBUG_ADDRESS (default: 127.0.0.1:2113) The bind address to serve a debug HTTP server. From c7e7312cb0bce5568f1ba11f9c224d710219f074 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 10 Jun 2024 13:28:21 -0500 Subject: [PATCH 052/168] fix(site): don't show start button while starting (#13495) --- .../pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 6bef6f864d961..86a53d592243e 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -177,6 +177,7 @@ export const WorkspaceActions: FC = ({ )} {!canBeUpdated && + !isUpdating && workspace.template_require_active_version && buttonMapping.start} From 5b9a65e5c137232351381fc337d9784bc9aeecfc Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 10 Jun 2024 15:35:23 -0400 Subject: [PATCH 053/168] chore: move Batcher and Tracker to workspacestats (#13418) --- cli/server.go | 13 +++-- coderd/agentapi/stats.go | 6 --- coderd/agentapi/stats_test.go | 2 +- coderd/coderd.go | 19 +++---- coderd/coderdtest/coderdtest.go | 17 +++--- coderd/insights_test.go | 17 +++--- .../prometheusmetrics_test.go | 8 +-- coderd/workspaces.go | 2 +- .../{batchstats => workspacestats}/batcher.go | 54 ++++++++++--------- .../batcher_internal_test.go | 10 ++-- coderd/workspacestats/reporter.go | 15 ++++-- .../tracker.go | 40 +++++++------- .../tracker_test.go | 10 ++-- 13 files changed, 105 insertions(+), 108 deletions(-) rename coderd/{batchstats => workspacestats}/batcher.go (86%) rename coderd/{batchstats => workspacestats}/batcher_internal_test.go (98%) rename coderd/{workspaceusage => workspacestats}/tracker.go (86%) rename coderd/{workspaceusage => workspacestats}/tracker_test.go (96%) diff --git a/cli/server.go b/cli/server.go index 855c5e6c547bf..11c3ec50ba833 100644 --- a/cli/server.go +++ b/cli/server.go @@ -62,7 +62,6 @@ import ( "github.com/coder/coder/v2/cli/config" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/autobuild" - "github.com/coder/coder/v2/coderd/batchstats" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/awsiamrds" "github.com/coder/coder/v2/coderd/database/dbmem" @@ -87,7 +86,7 @@ import ( stringutil "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" - "github.com/coder/coder/v2/coderd/workspaceusage" + "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/drpc" "github.com/coder/coder/v2/cryptorand" @@ -870,9 +869,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.SwaggerEndpoint = vals.Swagger.Enable.Value() } - batcher, closeBatcher, err := batchstats.New(ctx, - batchstats.WithLogger(options.Logger.Named("batchstats")), - batchstats.WithStore(options.Database), + batcher, closeBatcher, err := workspacestats.NewBatcher(ctx, + workspacestats.BatcherWithLogger(options.Logger.Named("batchstats")), + workspacestats.BatcherWithStore(options.Database), ) if err != nil { return xerrors.Errorf("failed to create agent stats batcher: %w", err) @@ -977,8 +976,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. defer purger.Close() // Updates workspace usage - tracker := workspaceusage.New(options.Database, - workspaceusage.WithLogger(logger.Named("workspace_usage_tracker")), + tracker := workspacestats.NewTracker(options.Database, + workspacestats.TrackerWithLogger(logger.Named("workspace_usage_tracker")), ) options.WorkspaceUsageTracker = tracker defer tracker.Close() diff --git a/coderd/agentapi/stats.go b/coderd/agentapi/stats.go index ee17897572f3d..a167fb5d6f275 100644 --- a/coderd/agentapi/stats.go +++ b/coderd/agentapi/stats.go @@ -7,8 +7,6 @@ import ( "golang.org/x/xerrors" "google.golang.org/protobuf/types/known/durationpb" - "github.com/google/uuid" - "cdr.dev/slog" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/database" @@ -16,10 +14,6 @@ import ( "github.com/coder/coder/v2/coderd/workspacestats" ) -type StatsBatcher interface { - Add(now time.Time, agentID uuid.UUID, templateID uuid.UUID, userID uuid.UUID, workspaceID uuid.UUID, st *agentproto.Stats) error -} - type StatsAPI struct { AgentFn func(context.Context) (database.WorkspaceAgent, error) Database database.Store diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index c304dea93ecc9..8b4d72fc1d579 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -39,7 +39,7 @@ type statsBatcher struct { lastStats *agentproto.Stats } -var _ agentapi.StatsBatcher = &statsBatcher{} +var _ workspacestats.Batcher = &statsBatcher{} func (b *statsBatcher) Add(now time.Time, agentID uuid.UUID, templateID uuid.UUID, userID uuid.UUID, workspaceID uuid.UUID, st *agentproto.Stats) error { b.mu.Lock() diff --git a/coderd/coderd.go b/coderd/coderd.go index 60bfe9813c559..22154b97d963d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -43,7 +43,6 @@ import ( "github.com/coder/coder/v2/coderd/appearance" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/awsidentity" - "github.com/coder/coder/v2/coderd/batchstats" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbrollup" @@ -69,7 +68,6 @@ import ( "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/workspacestats" - "github.com/coder/coder/v2/coderd/workspaceusage" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/drpc" "github.com/coder/coder/v2/codersdk/healthsdk" @@ -189,7 +187,7 @@ type Options struct { HTTPClient *http.Client UpdateAgentMetrics func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric) - StatsBatcher *batchstats.Batcher + StatsBatcher *workspacestats.DBBatcher WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions @@ -206,7 +204,7 @@ type Options struct { // stats. This is used to provide insights in the WebUI. DatabaseRolluper *dbrollup.Rolluper // WorkspaceUsageTracker tracks workspace usage by the CLI. - WorkspaceUsageTracker *workspaceusage.Tracker + WorkspaceUsageTracker *workspacestats.UsageTracker } // @title Coder API @@ -384,8 +382,8 @@ func New(options *Options) *API { } if options.WorkspaceUsageTracker == nil { - options.WorkspaceUsageTracker = workspaceusage.New(options.Database, - workspaceusage.WithLogger(options.Logger.Named("workspace_usage_tracker")), + options.WorkspaceUsageTracker = workspacestats.NewTracker(options.Database, + workspacestats.TrackerWithLogger(options.Logger.Named("workspace_usage_tracker")), ) } @@ -434,8 +432,7 @@ func New(options *Options) *API { options.Database, options.Pubsub, ), - dbRolluper: options.DatabaseRolluper, - workspaceUsageTracker: options.WorkspaceUsageTracker, + dbRolluper: options.DatabaseRolluper, } var customRoleHandler CustomRoleHandler = &agplCustomRoleHandler{} @@ -557,6 +554,7 @@ func New(options *Options) *API { Pubsub: options.Pubsub, TemplateScheduleStore: options.TemplateScheduleStore, StatsBatcher: options.StatsBatcher, + UsageTracker: options.WorkspaceUsageTracker, UpdateAgentMetricsFn: options.UpdateAgentMetrics, AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize, }) @@ -1301,8 +1299,7 @@ type API struct { Acquirer *provisionerdserver.Acquirer // dbRolluper rolls up template usage stats from raw agent and app // stats. This is used to provide insights in the WebUI. - dbRolluper *dbrollup.Rolluper - workspaceUsageTracker *workspaceusage.Tracker + dbRolluper *dbrollup.Rolluper } // Close waits for all WebSocket connections to drain before returning. @@ -1341,7 +1338,7 @@ func (api *API) Close() error { _ = (*coordinator).Close() } _ = api.agentProvider.Close() - api.workspaceUsageTracker.Close() + _ = api.statsReporter.Close() return nil } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 3ebca07686d0e..9ca2f551978f1 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -54,7 +54,6 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/awsidentity" - "github.com/coder/coder/v2/coderd/batchstats" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbrollup" @@ -71,7 +70,7 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" - "github.com/coder/coder/v2/coderd/workspaceusage" + "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/drpc" @@ -145,7 +144,7 @@ type Options struct { // Logger should only be overridden if you expect errors // as part of your test. Logger *slog.Logger - StatsBatcher *batchstats.Batcher + StatsBatcher *workspacestats.DBBatcher WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions AllowWorkspaceRenames bool @@ -272,10 +271,10 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options.StatsBatcher == nil { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - batcher, closeBatcher, err := batchstats.New(ctx, - batchstats.WithStore(options.Database), + batcher, closeBatcher, err := workspacestats.NewBatcher(ctx, + workspacestats.BatcherWithStore(options.Database), // Avoid cluttering up test output. - batchstats.WithLogger(slog.Make(sloghuman.Sink(io.Discard))), + workspacestats.BatcherWithLogger(slog.Make(sloghuman.Sink(io.Discard))), ) require.NoError(t, err, "create stats batcher") options.StatsBatcher = batcher @@ -337,10 +336,10 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can options.WorkspaceUsageTrackerTick = make(chan time.Time, 1) // buffering just in case } // Close is called by API.Close() - wuTracker := workspaceusage.New( + wuTracker := workspacestats.NewTracker( options.Database, - workspaceusage.WithLogger(options.Logger.Named("workspace_usage_tracker")), - workspaceusage.WithTickFlush(options.WorkspaceUsageTrackerTick, options.WorkspaceUsageTrackerFlush), + workspacestats.TrackerWithLogger(options.Logger.Named("workspace_usage_tracker")), + workspacestats.TrackerWithTickFlush(options.WorkspaceUsageTrackerTick, options.WorkspaceUsageTrackerFlush), ) var mutex sync.RWMutex diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 22e7ed6947bac..2447ec37f3516 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -21,7 +21,6 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent/agenttest" agentproto "github.com/coder/coder/v2/agent/proto" - "github.com/coder/coder/v2/coderd/batchstats" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -684,11 +683,11 @@ func TestTemplateInsights_Golden(t *testing.T) { // NOTE(mafredri): Ideally we would pass batcher as a coderd option and // insert using the agentClient, but we have a circular dependency on // the database. - batcher, batcherCloser, err := batchstats.New( + batcher, batcherCloser, err := workspacestats.NewBatcher( ctx, - batchstats.WithStore(db), - batchstats.WithLogger(logger.Named("batchstats")), - batchstats.WithInterval(time.Hour), + workspacestats.BatcherWithStore(db), + workspacestats.BatcherWithLogger(logger.Named("batchstats")), + workspacestats.BatcherWithInterval(time.Hour), ) require.NoError(t, err) defer batcherCloser() // Flushes the stats, this is to ensure they're written. @@ -1583,11 +1582,11 @@ func TestUserActivityInsights_Golden(t *testing.T) { // NOTE(mafredri): Ideally we would pass batcher as a coderd option and // insert using the agentClient, but we have a circular dependency on // the database. - batcher, batcherCloser, err := batchstats.New( + batcher, batcherCloser, err := workspacestats.NewBatcher( ctx, - batchstats.WithStore(db), - batchstats.WithLogger(logger.Named("batchstats")), - batchstats.WithInterval(time.Hour), + workspacestats.BatcherWithStore(db), + workspacestats.BatcherWithLogger(logger.Named("batchstats")), + workspacestats.BatcherWithInterval(time.Hour), ) require.NoError(t, err) defer batcherCloser() // Flushes the stats, this is to ensure they're written. diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 9c4c9fca0b66f..3e42b1ea783c6 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -21,7 +21,6 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/agentmetrics" - "github.com/coder/coder/v2/coderd/batchstats" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -29,6 +28,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/prometheusmetrics" + "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/cryptorand" @@ -391,14 +391,14 @@ func TestAgentStats(t *testing.T) { db, pubsub := dbtestutil.NewDB(t) log := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - batcher, closeBatcher, err := batchstats.New(ctx, + batcher, closeBatcher, err := workspacestats.NewBatcher(ctx, // We had previously set the batch size to 1 here, but that caused // intermittent test flakes due to a race between the batcher completing // its flush and the test asserting that the metrics were collected. // Instead, we close the batcher after all stats have been posted, which // forces a flush. - batchstats.WithStore(db), - batchstats.WithLogger(log), + workspacestats.BatcherWithStore(db), + workspacestats.BatcherWithLogger(log), ) require.NoError(t, err, "create stats batcher failed") t.Cleanup(closeBatcher) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 7d0344be4e321..1b3f076e8f4bf 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1115,7 +1115,7 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { return } - api.workspaceUsageTracker.Add(workspace.ID) + api.statsReporter.TrackUsage(workspace.ID) rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/batchstats/batcher.go b/coderd/workspacestats/batcher.go similarity index 86% rename from coderd/batchstats/batcher.go rename to coderd/workspacestats/batcher.go index bbff38b0413c0..2872c368dc61c 100644 --- a/coderd/batchstats/batcher.go +++ b/coderd/workspacestats/batcher.go @@ -1,4 +1,4 @@ -package batchstats +package workspacestats import ( "context" @@ -24,9 +24,13 @@ const ( defaultFlushInterval = time.Second ) -// Batcher holds a buffer of agent stats and periodically flushes them to -// its configured store. It also updates the workspace's last used time. -type Batcher struct { +type Batcher interface { + Add(now time.Time, agentID uuid.UUID, templateID uuid.UUID, userID uuid.UUID, workspaceID uuid.UUID, st *agentproto.Stats) error +} + +// DBBatcher holds a buffer of agent stats and periodically flushes them to +// its configured store. +type DBBatcher struct { store database.Store log slog.Logger @@ -50,39 +54,39 @@ type Batcher struct { } // Option is a functional option for configuring a Batcher. -type Option func(b *Batcher) +type BatcherOption func(b *DBBatcher) -// WithStore sets the store to use for storing stats. -func WithStore(store database.Store) Option { - return func(b *Batcher) { +// BatcherWithStore sets the store to use for storing stats. +func BatcherWithStore(store database.Store) BatcherOption { + return func(b *DBBatcher) { b.store = store } } -// WithBatchSize sets the number of stats to store in a batch. -func WithBatchSize(size int) Option { - return func(b *Batcher) { +// BatcherWithBatchSize sets the number of stats to store in a batch. +func BatcherWithBatchSize(size int) BatcherOption { + return func(b *DBBatcher) { b.batchSize = size } } -// WithInterval sets the interval for flushes. -func WithInterval(d time.Duration) Option { - return func(b *Batcher) { +// BatcherWithInterval sets the interval for flushes. +func BatcherWithInterval(d time.Duration) BatcherOption { + return func(b *DBBatcher) { b.interval = d } } -// WithLogger sets the logger to use for logging. -func WithLogger(log slog.Logger) Option { - return func(b *Batcher) { +// BatcherWithLogger sets the logger to use for logging. +func BatcherWithLogger(log slog.Logger) BatcherOption { + return func(b *DBBatcher) { b.log = log } } -// New creates a new Batcher and starts it. -func New(ctx context.Context, opts ...Option) (*Batcher, func(), error) { - b := &Batcher{} +// NewBatcher creates a new Batcher and starts it. +func NewBatcher(ctx context.Context, opts ...BatcherOption) (*DBBatcher, func(), error) { + b := &DBBatcher{} b.log = slog.Make(sloghuman.Sink(os.Stderr)) b.flushLever = make(chan struct{}, 1) // Buffered so that it doesn't block. for _, opt := range opts { @@ -127,7 +131,7 @@ func New(ctx context.Context, opts ...Option) (*Batcher, func(), error) { } // Add adds a stat to the batcher for the given workspace and agent. -func (b *Batcher) Add( +func (b *DBBatcher) Add( now time.Time, agentID uuid.UUID, templateID uuid.UUID, @@ -174,7 +178,7 @@ func (b *Batcher) Add( } // Run runs the batcher. -func (b *Batcher) run(ctx context.Context) { +func (b *DBBatcher) run(ctx context.Context) { // nolint:gocritic // This is only ever used for one thing - inserting agent stats. authCtx := dbauthz.AsSystemRestricted(ctx) for { @@ -199,7 +203,7 @@ func (b *Batcher) run(ctx context.Context) { } // flush flushes the batcher's buffer. -func (b *Batcher) flush(ctx context.Context, forced bool, reason string) { +func (b *DBBatcher) flush(ctx context.Context, forced bool, reason string) { b.mu.Lock() b.flushForced.Store(true) start := time.Now() @@ -256,7 +260,7 @@ func (b *Batcher) flush(ctx context.Context, forced bool, reason string) { } // initBuf resets the buffer. b MUST be locked. -func (b *Batcher) initBuf(size int) { +func (b *DBBatcher) initBuf(size int) { b.buf = &database.InsertWorkspaceAgentStatsParams{ ID: make([]uuid.UUID, 0, b.batchSize), CreatedAt: make([]time.Time, 0, b.batchSize), @@ -280,7 +284,7 @@ func (b *Batcher) initBuf(size int) { b.connectionsByProto = make([]map[string]int64, 0, size) } -func (b *Batcher) resetBuf() { +func (b *DBBatcher) resetBuf() { b.buf.ID = b.buf.ID[:0] b.buf.CreatedAt = b.buf.CreatedAt[:0] b.buf.UserID = b.buf.UserID[:0] diff --git a/coderd/batchstats/batcher_internal_test.go b/coderd/workspacestats/batcher_internal_test.go similarity index 98% rename from coderd/batchstats/batcher_internal_test.go rename to coderd/workspacestats/batcher_internal_test.go index d153ac283b086..0e797986555e5 100644 --- a/coderd/batchstats/batcher_internal_test.go +++ b/coderd/workspacestats/batcher_internal_test.go @@ -1,4 +1,4 @@ -package batchstats +package workspacestats import ( "context" @@ -35,10 +35,10 @@ func TestBatchStats(t *testing.T) { tick := make(chan time.Time) flushed := make(chan int, 1) - b, closer, err := New(ctx, - WithStore(store), - WithLogger(log), - func(b *Batcher) { + b, closer, err := NewBatcher(ctx, + BatcherWithStore(store), + BatcherWithLogger(log), + func(b *DBBatcher) { b.tickCh = tick b.flushed = flushed }, diff --git a/coderd/workspacestats/reporter.go b/coderd/workspacestats/reporter.go index 8ae4bdd827ac3..c6b7afb3c68ad 100644 --- a/coderd/workspacestats/reporter.go +++ b/coderd/workspacestats/reporter.go @@ -22,16 +22,13 @@ import ( "github.com/coder/coder/v2/codersdk" ) -type StatsBatcher interface { - Add(now time.Time, agentID uuid.UUID, templateID uuid.UUID, userID uuid.UUID, workspaceID uuid.UUID, st *agentproto.Stats) error -} - type ReporterOptions struct { Database database.Store Logger slog.Logger Pubsub pubsub.Pubsub TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] - StatsBatcher StatsBatcher + StatsBatcher Batcher + UsageTracker *UsageTracker UpdateAgentMetricsFn func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric) AppStatBatchSize int @@ -205,3 +202,11 @@ func UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, db database.Store, } return nil } + +func (r *Reporter) TrackUsage(workspaceID uuid.UUID) { + r.opts.UsageTracker.Add(workspaceID) +} + +func (r *Reporter) Close() error { + return r.opts.UsageTracker.Close() +} diff --git a/coderd/workspaceusage/tracker.go b/coderd/workspacestats/tracker.go similarity index 86% rename from coderd/workspaceusage/tracker.go rename to coderd/workspacestats/tracker.go index 118b021d71d52..33532247b36e0 100644 --- a/coderd/workspaceusage/tracker.go +++ b/coderd/workspacestats/tracker.go @@ -1,4 +1,4 @@ -package workspaceusage +package workspacestats import ( "bytes" @@ -25,10 +25,10 @@ type Store interface { BatchUpdateWorkspaceLastUsedAt(context.Context, database.BatchUpdateWorkspaceLastUsedAtParams) error } -// Tracker tracks and de-bounces updates to workspace usage activity. +// UsageTracker tracks and de-bounces updates to workspace usage activity. // It keeps an internal map of workspace IDs that have been used and // periodically flushes this to its configured Store. -type Tracker struct { +type UsageTracker struct { log slog.Logger // you know, for logs flushLock sync.Mutex // protects m flushErrors int // tracks the number of consecutive errors flushing @@ -42,10 +42,10 @@ type Tracker struct { flushCh chan int // used for testing. } -// New returns a new Tracker. It is the caller's responsibility +// NewTracker returns a new Tracker. It is the caller's responsibility // to call Close(). -func New(s Store, opts ...Option) *Tracker { - tr := &Tracker{ +func NewTracker(s Store, opts ...TrackerOption) *UsageTracker { + tr := &UsageTracker{ log: slog.Make(sloghuman.Sink(os.Stderr)), m: &uuidSet{}, s: s, @@ -67,33 +67,33 @@ func New(s Store, opts ...Option) *Tracker { return tr } -type Option func(*Tracker) +type TrackerOption func(*UsageTracker) -// WithLogger sets the logger to be used by Tracker. -func WithLogger(log slog.Logger) Option { - return func(h *Tracker) { +// TrackerWithLogger sets the logger to be used by Tracker. +func TrackerWithLogger(log slog.Logger) TrackerOption { + return func(h *UsageTracker) { h.log = log } } -// WithFlushInterval allows configuring the flush interval of Tracker. -func WithFlushInterval(d time.Duration) Option { - return func(h *Tracker) { +// TrackerWithFlushInterval allows configuring the flush interval of Tracker. +func TrackerWithFlushInterval(d time.Duration) TrackerOption { + return func(h *UsageTracker) { ticker := time.NewTicker(d) h.tickCh = ticker.C h.stopTick = ticker.Stop } } -// WithTickFlush allows passing two channels: one that reads +// TrackerWithTickFlush allows passing two channels: one that reads // a time.Time, and one that returns the number of marked workspaces // every time Tracker flushes. // For testing only and will panic if used outside of tests. -func WithTickFlush(tickCh <-chan time.Time, flushCh chan int) Option { +func TrackerWithTickFlush(tickCh <-chan time.Time, flushCh chan int) TrackerOption { if flag.Lookup("test.v") == nil { panic("developer error: WithTickFlush is not to be used outside of tests.") } - return func(h *Tracker) { + return func(h *UsageTracker) { h.tickCh = tickCh h.stopTick = func() {} h.flushCh = flushCh @@ -102,14 +102,14 @@ func WithTickFlush(tickCh <-chan time.Time, flushCh chan int) Option { // Add marks the workspace with the given ID as having been used recently. // Tracker will periodically flush this to its configured Store. -func (tr *Tracker) Add(workspaceID uuid.UUID) { +func (tr *UsageTracker) Add(workspaceID uuid.UUID) { tr.m.Add(workspaceID) } // flush updates last_used_at of all current workspace IDs. // If this is held while a previous flush is in progress, it will // deadlock until the previous flush has completed. -func (tr *Tracker) flush(now time.Time) { +func (tr *UsageTracker) flush(now time.Time) { // Copy our current set of IDs ids := tr.m.UniqueAndClear() count := len(ids) @@ -154,7 +154,7 @@ func (tr *Tracker) flush(now time.Time) { // loop periodically flushes every tick. // If loop is called after Close, it will exit immediately and log an error. -func (tr *Tracker) loop() { +func (tr *UsageTracker) loop() { select { case <-tr.doneCh: tr.log.Error(context.Background(), "developer error: Loop called after Close") @@ -186,7 +186,7 @@ func (tr *Tracker) loop() { // Close stops Tracker and returns once Loop has exited. // After calling Close(), Loop must not be called. -func (tr *Tracker) Close() error { +func (tr *UsageTracker) Close() error { tr.stopOnce.Do(func() { tr.stopCh <- struct{}{} tr.stopTick() diff --git a/coderd/workspaceusage/tracker_test.go b/coderd/workspacestats/tracker_test.go similarity index 96% rename from coderd/workspaceusage/tracker_test.go rename to coderd/workspacestats/tracker_test.go index ae9a9d2162d1c..99e9f9503b645 100644 --- a/coderd/workspaceusage/tracker_test.go +++ b/coderd/workspacestats/tracker_test.go @@ -1,4 +1,4 @@ -package workspaceusage_test +package workspacestats_test import ( "bytes" @@ -21,7 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" - "github.com/coder/coder/v2/coderd/workspaceusage" + "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -35,9 +35,9 @@ func TestTracker(t *testing.T) { tickCh := make(chan time.Time) flushCh := make(chan int, 1) - wut := workspaceusage.New(mDB, - workspaceusage.WithLogger(log), - workspaceusage.WithTickFlush(tickCh, flushCh), + wut := workspacestats.NewTracker(mDB, + workspacestats.TrackerWithLogger(log), + workspacestats.TrackerWithTickFlush(tickCh, flushCh), ) defer wut.Close() From 363dbad3a3ea2004635a5577241b5c131149d482 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 01:06:40 +0300 Subject: [PATCH 054/168] ci: bump the github-actions group with 2 updates (#13521) Bumps the github-actions group with 2 updates: [crate-ci/typos](https://github.com/crate-ci/typos) and [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action). Updates `crate-ci/typos` from 1.21.0 to 1.22.3 - [Release notes](https://github.com/crate-ci/typos/releases) - [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md) - [Commits](https://github.com/crate-ci/typos/compare/v1.21.0...v1.22.3) Updates `aquasecurity/trivy-action` from 0.21.0 to 0.22.0 - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/fd25fed6972e341ff0007ddb61f77e88103953c2...595be6a0f6560a0a8fc419ddf630567fc623531d) --- updated-dependencies: - dependency-name: crate-ci/typos dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: aquasecurity/trivy-action dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/security.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7d1e9837c8682..850c8f0c6d238 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -170,7 +170,7 @@ jobs: # Check for any typos - name: Check for typos - uses: crate-ci/typos@v1.21.0 + uses: crate-ci/typos@v1.22.3 with: config: .github/workflows/typos.toml diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 1bf0bf4b63180..07581dfef4fca 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -114,7 +114,7 @@ jobs: echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@fd25fed6972e341ff0007ddb61f77e88103953c2 + uses: aquasecurity/trivy-action@595be6a0f6560a0a8fc419ddf630567fc623531d with: image-ref: ${{ steps.build.outputs.image }} format: sarif From e7bea17e702a14574a27f157d4c72f9165e0cfbc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 03:54:42 +0300 Subject: [PATCH 055/168] chore: bump braces from 3.0.2 to 3.0.3 in /site (#13526) Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3. - [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3) --- updated-dependencies: - dependency-name: braces dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/pnpm-lock.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 288e31f3515b9..d4c27a5cb92d0 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -5951,11 +5951,11 @@ packages: balanced-match: 1.0.2 dev: true - /braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + /braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} dependencies: - fill-range: 7.0.1 + fill-range: 7.1.1 dev: true /browser-assert@1.2.1: @@ -6187,7 +6187,7 @@ packages: engines: {node: '>= 8.10.0'} dependencies: anymatch: 3.1.3 - braces: 3.0.2 + braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 is-glob: 4.0.3 @@ -7711,8 +7711,8 @@ packages: minimatch: 5.1.6 dev: true - /fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + /fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 @@ -10120,7 +10120,7 @@ packages: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} dependencies: - braces: 3.0.2 + braces: 3.0.3 picomatch: 2.3.1 dev: true From dd243686e49aae892471f0c4507090d7a19d9641 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:22:59 +1000 Subject: [PATCH 056/168] chore!: remove deprecated agent v1 routes (#13486) --- coderd/apidoc/docs.go | 646 +----------------- coderd/apidoc/swagger.json | 566 --------------- coderd/coderd.go | 12 - coderd/deprecated.go | 56 -- .../insights/metricscollector_test.go | 37 +- .../prometheusmetrics_test.go | 57 +- coderd/workspaceagents.go | 645 ----------------- coderd/workspaceagents_test.go | 143 +--- coderd/workspaceagentsrpc.go | 25 - coderd/workspaceagentsrpc_test.go | 48 ++ codersdk/agentsdk/agentsdk.go | 55 -- codersdk/agentsdk/convert.go | 8 + docs/api/agents.md | 338 --------- docs/api/schemas.md | 411 ----------- site/e2e/tests/outdatedAgent.spec.ts | 4 +- 15 files changed, 156 insertions(+), 2895 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5fe1e929d83bd..81c16ba784798 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5696,62 +5696,6 @@ const docTemplate = `{ } } }, - "/workspaceagents/me/app-health": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Submit workspace agent application health", - "operationId": "submit-workspace-agent-application-health", - "parameters": [ - { - "description": "Application health request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PostAppHealthsRequest" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/workspaceagents/me/coordinate": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "description": "It accepts a WebSocket connection to an agent that listens to\nincoming connections and publishes node updates.", - "tags": [ - "Agents" - ], - "summary": "Coordinate workspace agent via Tailnet", - "operationId": "coordinate-workspace-agent-via-tailnet", - "responses": { - "101": { - "description": "Switching Protocols" - } - } - } - }, "/workspaceagents/me/external-auth": { "get": { "security": [ @@ -5949,287 +5893,25 @@ const docTemplate = `{ } } }, - "/workspaceagents/me/manifest": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Get authorized workspace agent manifest", - "operationId": "get-authorized-workspace-agent-manifest", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/agentsdk.Manifest" - } - } - } - } - }, - "/workspaceagents/me/metadata": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Submit workspace agent metadata", - "operationId": "submit-workspace-agent-metadata", - "parameters": [ - { - "description": "Workspace agent metadata request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.PostMetadataRequest" - } - } - } - ], - "responses": { - "204": { - "description": "Success" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/metadata/{key}": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Removed: Submit workspace agent metadata", - "operationId": "removed-submit-workspace-agent-metadata", - "parameters": [ - { - "description": "Workspace agent metadata request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PostMetadataRequestDeprecated" - } - }, - { - "type": "string", - "format": "string", - "description": "metadata key", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "Success" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/report-lifecycle": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Submit workspace agent lifecycle state", - "operationId": "submit-workspace-agent-lifecycle-state", - "parameters": [ - { - "description": "Workspace agent lifecycle request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PostLifecycleRequest" - } - } - ], - "responses": { - "204": { - "description": "Success" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/report-stats": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Submit workspace agent stats", - "operationId": "submit-workspace-agent-stats", - "deprecated": true, - "parameters": [ - { - "description": "Stats request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.Stats" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/agentsdk.StatsResponse" - } - } - } - } - }, "/workspaceagents/me/rpc": { "get": { "security": [ { - "CoderSessionToken": [] - } - ], - "tags": [ - "Agents" - ], - "summary": "Workspace agent RPC API", - "operationId": "workspace-agent-rpc-api", - "responses": { - "101": { - "description": "Switching Protocols" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/startup": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Submit workspace agent startup", - "operationId": "submit-workspace-agent-startup", - "parameters": [ - { - "description": "Startup request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PostStartupRequest" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/startup-logs": { - "patch": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Removed: Patch workspace agent logs", - "operationId": "removed-patch-workspace-agent-logs", - "parameters": [ - { - "description": "logs", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PatchLogs" - } + "CoderSessionToken": [] } ], + "tags": [ + "Agents" + ], + "summary": "Workspace agent RPC API", + "operationId": "workspace-agent-rpc-api", "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.Response" - } + "101": { + "description": "Switching Protocols" } + }, + "x-apidocgen": { + "skip": true } } }, @@ -7882,65 +7564,6 @@ const docTemplate = `{ } } }, - "agentsdk.AgentMetric": { - "type": "object", - "required": [ - "name", - "type", - "value" - ], - "properties": { - "labels": { - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.AgentMetricLabel" - } - }, - "name": { - "type": "string" - }, - "type": { - "enum": [ - "counter", - "gauge" - ], - "allOf": [ - { - "$ref": "#/definitions/agentsdk.AgentMetricType" - } - ] - }, - "value": { - "type": "number" - } - } - }, - "agentsdk.AgentMetricLabel": { - "type": "object", - "required": [ - "name", - "value" - ], - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "agentsdk.AgentMetricType": { - "type": "string", - "enum": [ - "counter", - "gauge" - ], - "x-enum-varnames": [ - "AgentMetricTypeCounter", - "AgentMetricTypeGauge" - ] - }, "agentsdk.AuthenticateResponse": { "type": "object", "properties": { @@ -8025,95 +7648,6 @@ const docTemplate = `{ } } }, - "agentsdk.Manifest": { - "type": "object", - "properties": { - "agent_id": { - "type": "string" - }, - "agent_name": { - "type": "string" - }, - "apps": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.WorkspaceApp" - } - }, - "derp_force_websockets": { - "type": "boolean" - }, - "derpmap": { - "$ref": "#/definitions/tailcfg.DERPMap" - }, - "directory": { - "type": "string" - }, - "disable_direct_connections": { - "type": "boolean" - }, - "environment_variables": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "git_auth_configs": { - "description": "GitAuthConfigs stores the number of Git configurations\nthe Coder deployment has. If this number is \u003e0, we\nset up special configuration in the workspace.", - "type": "integer" - }, - "metadata": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.WorkspaceAgentMetadataDescription" - } - }, - "motd_file": { - "type": "string" - }, - "owner_name": { - "description": "OwnerName and WorkspaceID are used by an open-source user to identify the workspace.\nWe do not provide insurance that this will not be removed in the future,\nbut if it's easy to persist lets keep it around.", - "type": "string" - }, - "scripts": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.WorkspaceAgentScript" - } - }, - "vscode_port_proxy_uri": { - "type": "string" - }, - "workspace_id": { - "type": "string" - }, - "workspace_name": { - "type": "string" - } - } - }, - "agentsdk.Metadata": { - "type": "object", - "properties": { - "age": { - "description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.", - "type": "integer" - }, - "collected_at": { - "type": "string", - "format": "date-time" - }, - "error": { - "type": "string" - }, - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, "agentsdk.PatchLogs": { "type": "object", "properties": { @@ -8128,29 +7662,6 @@ const docTemplate = `{ } } }, - "agentsdk.PostAppHealthsRequest": { - "type": "object", - "properties": { - "healths": { - "description": "Healths is a map of the workspace app name and the health of the app.", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/codersdk.WorkspaceAppHealth" - } - } - } - }, - "agentsdk.PostLifecycleRequest": { - "type": "object", - "properties": { - "changed_at": { - "type": "string" - }, - "state": { - "$ref": "#/definitions/codersdk.WorkspaceAgentLifecycle" - } - } - }, "agentsdk.PostLogSourceRequest": { "type": "object", "properties": { @@ -8166,121 +7677,6 @@ const docTemplate = `{ } } }, - "agentsdk.PostMetadataRequest": { - "type": "object", - "properties": { - "metadata": { - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.Metadata" - } - } - } - }, - "agentsdk.PostMetadataRequestDeprecated": { - "type": "object", - "properties": { - "age": { - "description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.", - "type": "integer" - }, - "collected_at": { - "type": "string", - "format": "date-time" - }, - "error": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "agentsdk.PostStartupRequest": { - "type": "object", - "properties": { - "expanded_directory": { - "type": "string" - }, - "subsystems": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.AgentSubsystem" - } - }, - "version": { - "type": "string" - } - } - }, - "agentsdk.Stats": { - "type": "object", - "properties": { - "connection_count": { - "description": "ConnectionCount is the number of connections received by an agent.", - "type": "integer" - }, - "connection_median_latency_ms": { - "description": "ConnectionMedianLatencyMS is the median latency of all connections in milliseconds.", - "type": "number" - }, - "connections_by_proto": { - "description": "ConnectionsByProto is a count of connections by protocol.", - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "metrics": { - "description": "Metrics collected by the agent", - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.AgentMetric" - } - }, - "rx_bytes": { - "description": "RxBytes is the number of received bytes.", - "type": "integer" - }, - "rx_packets": { - "description": "RxPackets is the number of received packets.", - "type": "integer" - }, - "session_count_jetbrains": { - "description": "SessionCountJetBrains is the number of connections received by an agent\nthat are from our JetBrains extension.", - "type": "integer" - }, - "session_count_reconnecting_pty": { - "description": "SessionCountReconnectingPTY is the number of connections received by an agent\nthat are from the reconnecting web terminal.", - "type": "integer" - }, - "session_count_ssh": { - "description": "SessionCountSSH is the number of connections received by an agent\nthat are normal, non-tagged SSH sessions.", - "type": "integer" - }, - "session_count_vscode": { - "description": "SessionCountVSCode is the number of connections received by an agent\nthat are from our VS Code extension.", - "type": "integer" - }, - "tx_bytes": { - "description": "TxBytes is the number of transmitted bytes.", - "type": "integer" - }, - "tx_packets": { - "description": "TxPackets is the number of transmitted bytes.", - "type": "integer" - } - } - }, - "agentsdk.StatsResponse": { - "type": "object", - "properties": { - "report_interval": { - "description": "ReportInterval is the duration after which the agent should send stats\nagain.", - "type": "integer" - } - } - }, "coderd.SCIMUser": { "type": "object", "properties": { @@ -13166,26 +12562,6 @@ const docTemplate = `{ } } }, - "codersdk.WorkspaceAgentMetadataDescription": { - "type": "object", - "properties": { - "display_name": { - "type": "string" - }, - "interval": { - "type": "integer" - }, - "key": { - "type": "string" - }, - "script": { - "type": "string" - }, - "timeout": { - "type": "integer" - } - } - }, "codersdk.WorkspaceAgentPortShare": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 18eb052c3fd64..7859bcb5ded02 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5024,54 +5024,6 @@ } } }, - "/workspaceagents/me/app-health": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Agents"], - "summary": "Submit workspace agent application health", - "operationId": "submit-workspace-agent-application-health", - "parameters": [ - { - "description": "Application health request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PostAppHealthsRequest" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/workspaceagents/me/coordinate": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "description": "It accepts a WebSocket connection to an agent that listens to\nincoming connections and publishes node updates.", - "tags": ["Agents"], - "summary": "Coordinate workspace agent via Tailnet", - "operationId": "coordinate-workspace-agent-via-tailnet", - "responses": { - "101": { - "description": "Switching Protocols" - } - } - } - }, "/workspaceagents/me/external-auth": { "get": { "security": [ @@ -5245,168 +5197,6 @@ } } }, - "/workspaceagents/me/manifest": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Agents"], - "summary": "Get authorized workspace agent manifest", - "operationId": "get-authorized-workspace-agent-manifest", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/agentsdk.Manifest" - } - } - } - } - }, - "/workspaceagents/me/metadata": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "tags": ["Agents"], - "summary": "Submit workspace agent metadata", - "operationId": "submit-workspace-agent-metadata", - "parameters": [ - { - "description": "Workspace agent metadata request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.PostMetadataRequest" - } - } - } - ], - "responses": { - "204": { - "description": "Success" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/metadata/{key}": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "tags": ["Agents"], - "summary": "Removed: Submit workspace agent metadata", - "operationId": "removed-submit-workspace-agent-metadata", - "parameters": [ - { - "description": "Workspace agent metadata request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PostMetadataRequestDeprecated" - } - }, - { - "type": "string", - "format": "string", - "description": "metadata key", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "Success" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/report-lifecycle": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "tags": ["Agents"], - "summary": "Submit workspace agent lifecycle state", - "operationId": "submit-workspace-agent-lifecycle-state", - "parameters": [ - { - "description": "Workspace agent lifecycle request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PostLifecycleRequest" - } - } - ], - "responses": { - "204": { - "description": "Success" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/report-stats": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Agents"], - "summary": "Submit workspace agent stats", - "operationId": "submit-workspace-agent-stats", - "deprecated": true, - "parameters": [ - { - "description": "Stats request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.Stats" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/agentsdk.StatsResponse" - } - } - } - } - }, "/workspaceagents/me/rpc": { "get": { "security": [ @@ -5427,72 +5217,6 @@ } } }, - "/workspaceagents/me/startup": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Agents"], - "summary": "Submit workspace agent startup", - "operationId": "submit-workspace-agent-startup", - "parameters": [ - { - "description": "Startup request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PostStartupRequest" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - }, - "x-apidocgen": { - "skip": true - } - } - }, - "/workspaceagents/me/startup-logs": { - "patch": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Agents"], - "summary": "Removed: Patch workspace agent logs", - "operationId": "removed-patch-workspace-agent-logs", - "parameters": [ - { - "description": "logs", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/agentsdk.PatchLogs" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.Response" - } - } - } - } - }, "/workspaceagents/{workspaceagent}": { "get": { "security": [ @@ -6969,49 +6693,6 @@ } } }, - "agentsdk.AgentMetric": { - "type": "object", - "required": ["name", "type", "value"], - "properties": { - "labels": { - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.AgentMetricLabel" - } - }, - "name": { - "type": "string" - }, - "type": { - "enum": ["counter", "gauge"], - "allOf": [ - { - "$ref": "#/definitions/agentsdk.AgentMetricType" - } - ] - }, - "value": { - "type": "number" - } - } - }, - "agentsdk.AgentMetricLabel": { - "type": "object", - "required": ["name", "value"], - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "agentsdk.AgentMetricType": { - "type": "string", - "enum": ["counter", "gauge"], - "x-enum-varnames": ["AgentMetricTypeCounter", "AgentMetricTypeGauge"] - }, "agentsdk.AuthenticateResponse": { "type": "object", "properties": { @@ -7091,95 +6772,6 @@ } } }, - "agentsdk.Manifest": { - "type": "object", - "properties": { - "agent_id": { - "type": "string" - }, - "agent_name": { - "type": "string" - }, - "apps": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.WorkspaceApp" - } - }, - "derp_force_websockets": { - "type": "boolean" - }, - "derpmap": { - "$ref": "#/definitions/tailcfg.DERPMap" - }, - "directory": { - "type": "string" - }, - "disable_direct_connections": { - "type": "boolean" - }, - "environment_variables": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "git_auth_configs": { - "description": "GitAuthConfigs stores the number of Git configurations\nthe Coder deployment has. If this number is \u003e0, we\nset up special configuration in the workspace.", - "type": "integer" - }, - "metadata": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.WorkspaceAgentMetadataDescription" - } - }, - "motd_file": { - "type": "string" - }, - "owner_name": { - "description": "OwnerName and WorkspaceID are used by an open-source user to identify the workspace.\nWe do not provide insurance that this will not be removed in the future,\nbut if it's easy to persist lets keep it around.", - "type": "string" - }, - "scripts": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.WorkspaceAgentScript" - } - }, - "vscode_port_proxy_uri": { - "type": "string" - }, - "workspace_id": { - "type": "string" - }, - "workspace_name": { - "type": "string" - } - } - }, - "agentsdk.Metadata": { - "type": "object", - "properties": { - "age": { - "description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.", - "type": "integer" - }, - "collected_at": { - "type": "string", - "format": "date-time" - }, - "error": { - "type": "string" - }, - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, "agentsdk.PatchLogs": { "type": "object", "properties": { @@ -7194,29 +6786,6 @@ } } }, - "agentsdk.PostAppHealthsRequest": { - "type": "object", - "properties": { - "healths": { - "description": "Healths is a map of the workspace app name and the health of the app.", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/codersdk.WorkspaceAppHealth" - } - } - } - }, - "agentsdk.PostLifecycleRequest": { - "type": "object", - "properties": { - "changed_at": { - "type": "string" - }, - "state": { - "$ref": "#/definitions/codersdk.WorkspaceAgentLifecycle" - } - } - }, "agentsdk.PostLogSourceRequest": { "type": "object", "properties": { @@ -7232,121 +6801,6 @@ } } }, - "agentsdk.PostMetadataRequest": { - "type": "object", - "properties": { - "metadata": { - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.Metadata" - } - } - } - }, - "agentsdk.PostMetadataRequestDeprecated": { - "type": "object", - "properties": { - "age": { - "description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.", - "type": "integer" - }, - "collected_at": { - "type": "string", - "format": "date-time" - }, - "error": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "agentsdk.PostStartupRequest": { - "type": "object", - "properties": { - "expanded_directory": { - "type": "string" - }, - "subsystems": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.AgentSubsystem" - } - }, - "version": { - "type": "string" - } - } - }, - "agentsdk.Stats": { - "type": "object", - "properties": { - "connection_count": { - "description": "ConnectionCount is the number of connections received by an agent.", - "type": "integer" - }, - "connection_median_latency_ms": { - "description": "ConnectionMedianLatencyMS is the median latency of all connections in milliseconds.", - "type": "number" - }, - "connections_by_proto": { - "description": "ConnectionsByProto is a count of connections by protocol.", - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "metrics": { - "description": "Metrics collected by the agent", - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.AgentMetric" - } - }, - "rx_bytes": { - "description": "RxBytes is the number of received bytes.", - "type": "integer" - }, - "rx_packets": { - "description": "RxPackets is the number of received packets.", - "type": "integer" - }, - "session_count_jetbrains": { - "description": "SessionCountJetBrains is the number of connections received by an agent\nthat are from our JetBrains extension.", - "type": "integer" - }, - "session_count_reconnecting_pty": { - "description": "SessionCountReconnectingPTY is the number of connections received by an agent\nthat are from the reconnecting web terminal.", - "type": "integer" - }, - "session_count_ssh": { - "description": "SessionCountSSH is the number of connections received by an agent\nthat are normal, non-tagged SSH sessions.", - "type": "integer" - }, - "session_count_vscode": { - "description": "SessionCountVSCode is the number of connections received by an agent\nthat are from our VS Code extension.", - "type": "integer" - }, - "tx_bytes": { - "description": "TxBytes is the number of transmitted bytes.", - "type": "integer" - }, - "tx_packets": { - "description": "TxPackets is the number of transmitted bytes.", - "type": "integer" - } - } - }, - "agentsdk.StatsResponse": { - "type": "object", - "properties": { - "report_interval": { - "description": "ReportInterval is the duration after which the agent should send stats\nagain.", - "type": "integer" - } - } - }, "coderd.SCIMUser": { "type": "object", "properties": { @@ -11976,26 +11430,6 @@ } } }, - "codersdk.WorkspaceAgentMetadataDescription": { - "type": "object", - "properties": { - "display_name": { - "type": "string" - }, - "interval": { - "type": "integer" - }, - "key": { - "type": "string" - }, - "script": { - "type": "string" - }, - "timeout": { - "type": "integer" - } - } - }, "codersdk.WorkspaceAgentPortShare": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 22154b97d963d..f7f1f52ee5bea 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1002,23 +1002,11 @@ func New(options *Options) *API { Optional: false, })) r.Get("/rpc", api.workspaceAgentRPC) - r.Get("/manifest", api.workspaceAgentManifest) - // This route is deprecated and will be removed in a future release. - // New agents will use /me/manifest instead. - r.Get("/metadata", api.workspaceAgentManifest) - r.Post("/startup", api.postWorkspaceAgentStartup) - r.Patch("/startup-logs", api.patchWorkspaceAgentLogsDeprecated) r.Patch("/logs", api.patchWorkspaceAgentLogs) - r.Post("/app-health", api.postWorkspaceAppHealth) // Deprecated: Required to support legacy agents r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/external-auth", api.workspaceAgentsExternalAuth) r.Get("/gitsshkey", api.agentGitSSHKey) - r.Get("/coordinate", api.workspaceAgentCoordinate) - r.Post("/report-stats", api.workspaceAgentReportStats) - r.Post("/report-lifecycle", api.workspaceAgentReportLifecycle) - r.Post("/metadata", api.workspaceAgentPostMetadata) - r.Post("/metadata/{key}", api.workspaceAgentPostMetadataDeprecated) r.Post("/log-source", api.workspaceAgentPostLogSource) }) r.Route("/{workspaceagent}", func(r chi.Router) { diff --git a/coderd/deprecated.go b/coderd/deprecated.go index 762b5bc931e38..6dc03e540ce33 100644 --- a/coderd/deprecated.go +++ b/coderd/deprecated.go @@ -3,13 +3,9 @@ package coderd import ( "net/http" - "github.com/go-chi/chi/v5" - - "cdr.dev/slog" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agentsdk" ) // @Summary Removed: Get parameters by template version @@ -34,19 +30,6 @@ func templateVersionSchemaDeprecated(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, []struct{}{}) } -// @Summary Removed: Patch workspace agent logs -// @ID removed-patch-workspace-agent-logs -// @Security CoderSessionToken -// @Accept json -// @Produce json -// @Tags Agents -// @Param request body agentsdk.PatchLogs true "logs" -// @Success 200 {object} codersdk.Response -// @Router /workspaceagents/me/startup-logs [patch] -func (api *API) patchWorkspaceAgentLogsDeprecated(rw http.ResponseWriter, r *http.Request) { - api.patchWorkspaceAgentLogs(rw, r) -} - // @Summary Removed: Get logs by workspace agent // @ID removed-get-logs-by-workspace-agent // @Security CoderSessionToken @@ -77,45 +60,6 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) api.workspaceAgentsExternalAuth(rw, r) } -// @Summary Removed: Submit workspace agent metadata -// @ID removed-submit-workspace-agent-metadata -// @Security CoderSessionToken -// @Accept json -// @Tags Agents -// @Param request body agentsdk.PostMetadataRequestDeprecated true "Workspace agent metadata request" -// @Param key path string true "metadata key" format(string) -// @Success 204 "Success" -// @Router /workspaceagents/me/metadata/{key} [post] -// @x-apidocgen {"skip": true} -func (api *API) workspaceAgentPostMetadataDeprecated(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var req agentsdk.PostMetadataRequestDeprecated - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - workspaceAgent := httpmw.WorkspaceAgent(r) - - key := chi.URLParam(r, "key") - - err := api.workspaceAgentUpdateMetadata(ctx, workspaceAgent, agentsdk.PostMetadataRequest{ - Metadata: []agentsdk.Metadata{ - { - Key: key, - WorkspaceAgentMetadataResult: req, - }, - }, - }) - if err != nil { - api.Logger.Error(ctx, "failed to handle metadata request", slog.Error(err)) - httpapi.InternalServerError(rw, err) - return - } - - httpapi.Write(ctx, rw, http.StatusNoContent, nil) -} - // @Summary Removed: Get workspace resources for workspace build // @ID removed-get-workspace-resources-for-workspace-build // @Security CoderSessionToken diff --git a/coderd/prometheusmetrics/insights/metricscollector_test.go b/coderd/prometheusmetrics/insights/metricscollector_test.go index 91ef3c7ee88fa..9179c9896235d 100644 --- a/coderd/prometheusmetrics/insights/metricscollector_test.go +++ b/coderd/prometheusmetrics/insights/metricscollector_test.go @@ -18,6 +18,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -87,25 +88,37 @@ func TestCollectInsights(t *testing.T) { ) // Start an agent so that we can generate stats. - var agentClients []*agentsdk.Client + var agentClients []agentproto.DRPCAgentClient for i, agent := range []database.WorkspaceAgent{agent1, agent2} { agentClient := agentsdk.New(client.URL) agentClient.SetSessionToken(agent.AuthToken.String()) agentClient.SDK.SetLogger(logger.Leveled(slog.LevelDebug).Named(fmt.Sprintf("agent%d", i+1))) - agentClients = append(agentClients, agentClient) + conn, err := agentClient.ConnectRPC(context.Background()) + require.NoError(t, err) + agentAPI := agentproto.NewDRPCAgentClient(conn) + agentClients = append(agentClients, agentAPI) } + defer func() { + for a := range agentClients { + err := agentClients[a].DRPCConn().Close() + require.NoError(t, err) + } + }() + // Fake app stats - _, err = agentClients[0].PostStats(context.Background(), &agentsdk.Stats{ - // ConnectionCount must be positive as database query ignores stats with no active connections at the time frame - ConnectionsByProto: map[string]int64{"TCP": 1}, - ConnectionCount: 1, - ConnectionMedianLatencyMS: 15, - // Session counts must be positive, but the exact value is ignored. - // Database query approximates it to 60s of usage. - SessionCountSSH: 99, - SessionCountJetBrains: 47, - SessionCountVSCode: 34, + _, err = agentClients[0].UpdateStats(context.Background(), &agentproto.UpdateStatsRequest{ + Stats: &agentproto.Stats{ + // ConnectionCount must be positive as database query ignores stats with no active connections at the time frame + ConnectionsByProto: map[string]int64{"TCP": 1}, + ConnectionCount: 1, + ConnectionMedianLatencyMs: 15, + // Session counts must be positive, but the exact value is ignored. + // Database query approximates it to 60s of usage. + SessionCountSsh: 99, + SessionCountJetbrains: 47, + SessionCountVscode: 34, + }, }) require.NoError(t, err, "unable to post fake stats") diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 3e42b1ea783c6..8a4a152a86b4c 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -20,6 +20,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentmetrics" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" @@ -415,36 +416,45 @@ func TestAgentStats(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) - agent1 := prepareWorkspaceAndAgent(t, client, user, 1) - agent2 := prepareWorkspaceAndAgent(t, client, user, 2) - agent3 := prepareWorkspaceAndAgent(t, client, user, 3) + agent1 := prepareWorkspaceAndAgent(ctx, t, client, user, 1) + agent2 := prepareWorkspaceAndAgent(ctx, t, client, user, 2) + agent3 := prepareWorkspaceAndAgent(ctx, t, client, user, 3) + defer agent1.DRPCConn().Close() + defer agent2.DRPCConn().Close() + defer agent3.DRPCConn().Close() registry := prometheus.NewRegistry() // given var i int64 for i = 0; i < 3; i++ { - _, err = agent1.PostStats(ctx, &agentsdk.Stats{ - TxBytes: 1 + i, RxBytes: 2 + i, - SessionCountVSCode: 3 + i, SessionCountJetBrains: 4 + i, SessionCountReconnectingPTY: 5 + i, SessionCountSSH: 6 + i, - ConnectionCount: 7 + i, ConnectionMedianLatencyMS: 8000, - ConnectionsByProto: map[string]int64{"TCP": 1}, + _, err = agent1.UpdateStats(ctx, &agentproto.UpdateStatsRequest{ + Stats: &agentproto.Stats{ + TxBytes: 1 + i, RxBytes: 2 + i, + SessionCountVscode: 3 + i, SessionCountJetbrains: 4 + i, SessionCountReconnectingPty: 5 + i, SessionCountSsh: 6 + i, + ConnectionCount: 7 + i, ConnectionMedianLatencyMs: 8000, + ConnectionsByProto: map[string]int64{"TCP": 1}, + }, }) require.NoError(t, err) - _, err = agent2.PostStats(ctx, &agentsdk.Stats{ - TxBytes: 2 + i, RxBytes: 4 + i, - SessionCountVSCode: 6 + i, SessionCountJetBrains: 8 + i, SessionCountReconnectingPTY: 10 + i, SessionCountSSH: 12 + i, - ConnectionCount: 8 + i, ConnectionMedianLatencyMS: 10000, - ConnectionsByProto: map[string]int64{"TCP": 1}, + _, err = agent2.UpdateStats(ctx, &agentproto.UpdateStatsRequest{ + Stats: &agentproto.Stats{ + TxBytes: 2 + i, RxBytes: 4 + i, + SessionCountVscode: 6 + i, SessionCountJetbrains: 8 + i, SessionCountReconnectingPty: 10 + i, SessionCountSsh: 12 + i, + ConnectionCount: 8 + i, ConnectionMedianLatencyMs: 10000, + ConnectionsByProto: map[string]int64{"TCP": 1}, + }, }) require.NoError(t, err) - _, err = agent3.PostStats(ctx, &agentsdk.Stats{ - TxBytes: 3 + i, RxBytes: 6 + i, - SessionCountVSCode: 12 + i, SessionCountJetBrains: 14 + i, SessionCountReconnectingPTY: 16 + i, SessionCountSSH: 18 + i, - ConnectionCount: 9 + i, ConnectionMedianLatencyMS: 12000, - ConnectionsByProto: map[string]int64{"TCP": 1}, + _, err = agent3.UpdateStats(ctx, &agentproto.UpdateStatsRequest{ + Stats: &agentproto.Stats{ + TxBytes: 3 + i, RxBytes: 6 + i, + SessionCountVscode: 12 + i, SessionCountJetbrains: 14 + i, SessionCountReconnectingPty: 16 + i, SessionCountSsh: 18 + i, + ConnectionCount: 9 + i, ConnectionMedianLatencyMs: 12000, + ConnectionsByProto: map[string]int64{"TCP": 1}, + }, }) require.NoError(t, err) } @@ -596,7 +606,7 @@ func TestExperimentsMetric(t *testing.T) { } } -func prepareWorkspaceAndAgent(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse, workspaceNum int) *agentsdk.Client { +func prepareWorkspaceAndAgent(ctx context.Context, t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse, workspaceNum int) agentproto.DRPCAgentClient { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ @@ -611,9 +621,12 @@ func prepareWorkspaceAndAgent(t *testing.T, client *codersdk.Client, user coders }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - return agentClient + ac := agentsdk.New(client.URL) + ac.SetSessionToken(authToken) + conn, err := ac.ConnectRPC(ctx) + require.NoError(t, err) + agentAPI := agentproto.NewDRPCAgentClient(conn) + return agentAPI } var ( diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index c45fae8726480..e9e2ab18027d9 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -18,14 +18,12 @@ import ( "github.com/sqlc-dev/pqtype" "golang.org/x/exp/maps" "golang.org/x/exp/slices" - "golang.org/x/mod/semver" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "nhooyr.io/websocket" "tailscale.com/tailcfg" "cdr.dev/slog" - agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentapi" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -136,144 +134,8 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, apiAgent) } -// @Summary Get authorized workspace agent manifest -// @ID get-authorized-workspace-agent-manifest -// @Security CoderSessionToken -// @Produce json -// @Tags Agents -// @Success 200 {object} agentsdk.Manifest -// @Router /workspaceagents/me/manifest [get] -func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - workspaceAgent := httpmw.WorkspaceAgent(r) - - // As this API becomes deprecated, use the new protobuf API and convert the - // types back to the SDK types. - manifestAPI := &agentapi.ManifestAPI{ - AccessURL: api.AccessURL, - AppHostname: api.AppHostname, - ExternalAuthConfigs: api.ExternalAuthConfigs, - DisableDirectConnections: api.DeploymentValues.DERP.Config.BlockDirect.Value(), - DerpForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), - - AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) { return workspaceAgent, nil }, - WorkspaceIDFn: func(ctx context.Context, wa *database.WorkspaceAgent) (uuid.UUID, error) { - // Sadly this results in a double query, but it's only temporary for - // now. - ws, err := api.Database.GetWorkspaceByAgentID(ctx, wa.ID) - if err != nil { - return uuid.Nil, err - } - return ws.Workspace.ID, nil - }, - Database: api.Database, - DerpMapFn: api.DERPMap, - } - manifest, err := manifestAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace agent manifest.", - Detail: err.Error(), - }) - return - } - sdkManifest, err := agentsdk.ManifestFromProto(manifest) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error converting manifest.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, sdkManifest) -} - const AgentAPIVersionREST = "1.0" -// @Summary Submit workspace agent startup -// @ID submit-workspace-agent-startup -// @Security CoderSessionToken -// @Accept json -// @Produce json -// @Tags Agents -// @Param request body agentsdk.PostStartupRequest true "Startup request" -// @Success 200 -// @Router /workspaceagents/me/startup [post] -// @x-apidocgen {"skip": true} -func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - workspaceAgent := httpmw.WorkspaceAgent(r) - apiAgent, err := db2sdk.WorkspaceAgent( - api.DERPMap(), *api.TailnetCoordinator.Load(), workspaceAgent, nil, nil, nil, api.AgentInactiveDisconnectTimeout, - api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), - ) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error reading workspace agent.", - Detail: err.Error(), - }) - return - } - - var req agentsdk.PostStartupRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - api.Logger.Debug( - ctx, - "post workspace agent version", - slog.F("agent_id", apiAgent.ID), - slog.F("agent_version", req.Version), - slog.F("remote_addr", r.RemoteAddr), - ) - - if !semver.IsValid(req.Version) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid workspace agent version provided.", - Detail: fmt.Sprintf("invalid semver version: %q", req.Version), - }) - return - } - - // Validate subsystems. - seen := make(map[codersdk.AgentSubsystem]bool) - for _, s := range req.Subsystems { - if !s.Valid() { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid workspace agent subsystem provided.", - Detail: fmt.Sprintf("invalid subsystem: %q", s), - }) - return - } - if seen[s] { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid workspace agent subsystem provided.", - Detail: fmt.Sprintf("duplicate subsystem: %q", s), - }) - return - } - seen[s] = true - } - - if err := api.Database.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{ - ID: apiAgent.ID, - Version: req.Version, - ExpandedDirectory: req.ExpandedDirectory, - Subsystems: convertWorkspaceAgentSubsystems(req.Subsystems), - APIVersion: AgentAPIVersionREST, - }); err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Error setting agent version", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, nil) -} - // @Summary Patch workspace agent logs // @ID patch-workspace-agent-logs // @Security CoderSessionToken @@ -938,79 +800,6 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { } } -// @Summary Coordinate workspace agent via Tailnet -// @Description It accepts a WebSocket connection to an agent that listens to -// @Description incoming connections and publishes node updates. -// @ID coordinate-workspace-agent-via-tailnet -// @Security CoderSessionToken -// @Tags Agents -// @Success 101 -// @Router /workspaceagents/me/coordinate [get] -func (api *API) workspaceAgentCoordinate(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - api.WebsocketWaitMutex.Lock() - api.WebsocketWaitGroup.Add(1) - api.WebsocketWaitMutex.Unlock() - defer api.WebsocketWaitGroup.Done() - // The middleware only accept agents for resources on the latest build. - workspaceAgent := httpmw.WorkspaceAgent(r) - build := httpmw.LatestBuild(r) - - workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Internal error fetching workspace.", - Detail: err.Error(), - }) - return - } - - owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Internal error fetching user.", - Detail: err.Error(), - }) - return - } - - conn, err := websocket.Accept(rw, r, nil) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to accept websocket.", - Detail: err.Error(), - }) - return - } - - ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary) - defer wsNetConn.Close() - - closeCtx, closeCtxCancel := context.WithCancel(ctx) - defer closeCtxCancel() - monitor := api.startAgentWebsocketMonitor(closeCtx, workspaceAgent, build, conn) - defer monitor.close() - - api.Logger.Debug(ctx, "accepting agent", - slog.F("owner", owner.Username), - slog.F("workspace", workspace.Name), - slog.F("name", workspaceAgent.Name), - ) - api.Logger.Debug(ctx, "accepting agent details", slog.F("agent", workspaceAgent)) - - defer conn.Close(websocket.StatusNormalClosure, "") - - err = (*api.TailnetCoordinator.Load()).ServeAgent(wsNetConn, workspaceAgent.ID, - fmt.Sprintf("%s-%s-%s", owner.Username, workspace.Name, workspaceAgent.Name), - ) - if err != nil { - api.Logger.Warn(ctx, "tailnet coordinator agent error", slog.Error(err)) - _ = conn.Close(websocket.StatusInternalError, err.Error()) - return - } -} - // workspaceAgentClientCoordinate accepts a WebSocket that reads node network updates. // After accept a PubSub starts listening for new connection node updates // which are written to the WebSocket. @@ -1171,214 +960,6 @@ func convertScripts(dbScripts []database.WorkspaceAgentScript) []codersdk.Worksp return scripts } -// @Summary Submit workspace agent stats -// @ID submit-workspace-agent-stats -// @Security CoderSessionToken -// @Accept json -// @Produce json -// @Tags Agents -// @Param request body agentsdk.Stats true "Stats request" -// @Success 200 {object} agentsdk.StatsResponse -// @Router /workspaceagents/me/report-stats [post] -// @Deprecated Uses agent API v2 endpoint instead. -func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - workspaceAgent := httpmw.WorkspaceAgent(r) - row, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to get workspace.", - Detail: err.Error(), - }) - return - } - workspace := row.Workspace - - var req agentsdk.Stats - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - // An empty stat means it's just looking for the report interval. - if req.ConnectionsByProto == nil { - httpapi.Write(ctx, rw, http.StatusOK, agentsdk.StatsResponse{ - ReportInterval: api.AgentStatsRefreshInterval, - }) - return - } - - api.Logger.Debug(ctx, "read stats report", - slog.F("interval", api.AgentStatsRefreshInterval), - slog.F("workspace_agent_id", workspaceAgent.ID), - slog.F("workspace_id", workspace.ID), - slog.F("payload", req), - ) - - protoStats := &agentproto.Stats{ - ConnectionsByProto: req.ConnectionsByProto, - ConnectionCount: req.ConnectionCount, - ConnectionMedianLatencyMs: req.ConnectionMedianLatencyMS, - RxPackets: req.RxPackets, - RxBytes: req.RxBytes, - TxPackets: req.TxPackets, - TxBytes: req.TxBytes, - SessionCountVscode: req.SessionCountVSCode, - SessionCountJetbrains: req.SessionCountJetBrains, - SessionCountReconnectingPty: req.SessionCountReconnectingPTY, - SessionCountSsh: req.SessionCountSSH, - Metrics: make([]*agentproto.Stats_Metric, len(req.Metrics)), - } - for i, metric := range req.Metrics { - metricType := agentproto.Stats_Metric_TYPE_UNSPECIFIED - switch metric.Type { - case agentsdk.AgentMetricTypeCounter: - metricType = agentproto.Stats_Metric_COUNTER - case agentsdk.AgentMetricTypeGauge: - metricType = agentproto.Stats_Metric_GAUGE - } - - protoStats.Metrics[i] = &agentproto.Stats_Metric{ - Name: metric.Name, - Type: metricType, - Value: metric.Value, - Labels: make([]*agentproto.Stats_Metric_Label, len(metric.Labels)), - } - for j, label := range metric.Labels { - protoStats.Metrics[i].Labels[j] = &agentproto.Stats_Metric_Label{ - Name: label.Name, - Value: label.Value, - } - } - } - err = api.statsReporter.ReportAgentStats( - ctx, - dbtime.Now(), - workspace, - workspaceAgent, - row.TemplateName, - protoStats, - ) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, agentsdk.StatsResponse{ - ReportInterval: api.AgentStatsRefreshInterval, - }) -} - -func ellipse(v string, n int) string { - if len(v) > n { - return v[:n] + "..." - } - return v -} - -// @Summary Submit workspace agent metadata -// @ID submit-workspace-agent-metadata -// @Security CoderSessionToken -// @Accept json -// @Tags Agents -// @Param request body []agentsdk.PostMetadataRequest true "Workspace agent metadata request" -// @Success 204 "Success" -// @Router /workspaceagents/me/metadata [post] -// @x-apidocgen {"skip": true} -func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var req agentsdk.PostMetadataRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - workspaceAgent := httpmw.WorkspaceAgent(r) - - // Split into function to allow call by deprecated handler. - err := api.workspaceAgentUpdateMetadata(ctx, workspaceAgent, req) - if err != nil { - api.Logger.Error(ctx, "failed to handle metadata request", slog.Error(err)) - httpapi.InternalServerError(rw, err) - return - } - - httpapi.Write(ctx, rw, http.StatusNoContent, nil) -} - -func (api *API) workspaceAgentUpdateMetadata(ctx context.Context, workspaceAgent database.WorkspaceAgent, req agentsdk.PostMetadataRequest) error { - const ( - // maxValueLen is set to 2048 to stay under the 8000 byte Postgres - // NOTIFY limit. Since both value and error can be set, the real - // payload limit is 2 * 2048 * 4/3 = 5461 bytes + a few hundred bytes for JSON - // syntax, key names, and metadata. - maxValueLen = 2048 - maxErrorLen = maxValueLen - ) - - collectedAt := time.Now() - - datum := database.UpdateWorkspaceAgentMetadataParams{ - WorkspaceAgentID: workspaceAgent.ID, - Key: make([]string, 0, len(req.Metadata)), - Value: make([]string, 0, len(req.Metadata)), - Error: make([]string, 0, len(req.Metadata)), - CollectedAt: make([]time.Time, 0, len(req.Metadata)), - } - - for _, md := range req.Metadata { - metadataError := md.Error - - // We overwrite the error if the provided payload is too long. - if len(md.Value) > maxValueLen { - metadataError = fmt.Sprintf("value of %d bytes exceeded %d bytes", len(md.Value), maxValueLen) - md.Value = md.Value[:maxValueLen] - } - - if len(md.Error) > maxErrorLen { - metadataError = fmt.Sprintf("error of %d bytes exceeded %d bytes", len(md.Error), maxErrorLen) - md.Error = md.Error[:maxErrorLen] - } - - // We don't want a misconfigured agent to fill the database. - datum.Key = append(datum.Key, md.Key) - datum.Value = append(datum.Value, md.Value) - datum.Error = append(datum.Error, metadataError) - // We ignore the CollectedAt from the agent to avoid bugs caused by - // clock skew. - datum.CollectedAt = append(datum.CollectedAt, collectedAt) - - api.Logger.Debug( - ctx, "accepted metadata report", - slog.F("workspace_agent_id", workspaceAgent.ID), - slog.F("collected_at", collectedAt), - slog.F("original_collected_at", md.CollectedAt), - slog.F("key", md.Key), - slog.F("value", ellipse(md.Value, 16)), - ) - } - - payload, err := json.Marshal(agentapi.WorkspaceAgentMetadataChannelPayload{ - CollectedAt: collectedAt, - Keys: datum.Key, - }) - if err != nil { - return err - } - - err = api.Database.UpdateWorkspaceAgentMetadata(ctx, datum) - if err != nil { - return err - } - - err = api.Pubsub.Publish(agentapi.WatchWorkspaceAgentMetadataChannel(workspaceAgent.ID), payload) - if err != nil { - return err - } - - return nil -} - // @Summary Watch for workspace agent metadata updates // @ID watch-for-workspace-agent-metadata-updates // @Security CoderSessionToken @@ -1612,211 +1193,6 @@ func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []code return result } -// @Summary Submit workspace agent lifecycle state -// @ID submit-workspace-agent-lifecycle-state -// @Security CoderSessionToken -// @Accept json -// @Tags Agents -// @Param request body agentsdk.PostLifecycleRequest true "Workspace agent lifecycle request" -// @Success 204 "Success" -// @Router /workspaceagents/me/report-lifecycle [post] -// @x-apidocgen {"skip": true} -func (api *API) workspaceAgentReportLifecycle(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - workspaceAgent := httpmw.WorkspaceAgent(r) - row, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to get workspace.", - Detail: err.Error(), - }) - return - } - workspace := row.Workspace - - var req agentsdk.PostLifecycleRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - logger := api.Logger.With( - slog.F("workspace_agent_id", workspaceAgent.ID), - slog.F("workspace_id", workspace.ID), - slog.F("payload", req), - ) - logger.Debug(ctx, "workspace agent state report") - - lifecycleState := req.State - dbLifecycleState := database.WorkspaceAgentLifecycleState(lifecycleState) - if !dbLifecycleState.Valid() { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid lifecycle state.", - Detail: fmt.Sprintf("Invalid lifecycle state %q, must be be one of %q.", lifecycleState, database.AllWorkspaceAgentLifecycleStateValues()), - }) - return - } - - if req.ChangedAt.IsZero() { - // Backwards compatibility with older agents. - req.ChangedAt = dbtime.Now() - } - changedAt := sql.NullTime{Time: req.ChangedAt, Valid: true} - - startedAt := workspaceAgent.StartedAt - readyAt := workspaceAgent.ReadyAt - switch lifecycleState { - case codersdk.WorkspaceAgentLifecycleStarting: - startedAt = changedAt - readyAt.Valid = false // This agent is re-starting, so it's not ready yet. - case codersdk.WorkspaceAgentLifecycleReady, codersdk.WorkspaceAgentLifecycleStartError: - readyAt = changedAt - } - - err = api.Database.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ - ID: workspaceAgent.ID, - LifecycleState: dbLifecycleState, - StartedAt: startedAt, - ReadyAt: readyAt, - }) - if err != nil { - if !xerrors.Is(err, context.Canceled) { - // not an error if we are canceled - logger.Error(ctx, "failed to update lifecycle state", slog.Error(err)) - } - httpapi.InternalServerError(rw, err) - return - } - - api.publishWorkspaceUpdate(ctx, workspace.ID) - - httpapi.Write(ctx, rw, http.StatusNoContent, nil) -} - -// @Summary Submit workspace agent application health -// @ID submit-workspace-agent-application-health -// @Security CoderSessionToken -// @Accept json -// @Produce json -// @Tags Agents -// @Param request body agentsdk.PostAppHealthsRequest true "Application health request" -// @Success 200 -// @Router /workspaceagents/me/app-health [post] -func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - workspaceAgent := httpmw.WorkspaceAgent(r) - var req agentsdk.PostAppHealthsRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - if req.Healths == nil || len(req.Healths) == 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Health field is empty", - }) - return - } - - apps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Error getting agent apps", - Detail: err.Error(), - }) - return - } - - var newApps []database.WorkspaceApp - for id, newHealth := range req.Healths { - old := func() *database.WorkspaceApp { - for _, app := range apps { - if app.ID == id { - return &app - } - } - - return nil - }() - if old == nil { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "Error setting workspace app health", - Detail: xerrors.Errorf("workspace app name %s not found", id).Error(), - }) - return - } - - if old.HealthcheckUrl == "" { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "Error setting workspace app health", - Detail: xerrors.Errorf("health checking is disabled for workspace app %s", id).Error(), - }) - return - } - - switch newHealth { - case codersdk.WorkspaceAppHealthInitializing: - case codersdk.WorkspaceAppHealthHealthy: - case codersdk.WorkspaceAppHealthUnhealthy: - default: - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Error setting workspace app health", - Detail: xerrors.Errorf("workspace app health %s is not a valid value", newHealth).Error(), - }) - return - } - - // don't save if the value hasn't changed - if old.Health == database.WorkspaceAppHealth(newHealth) { - continue - } - old.Health = database.WorkspaceAppHealth(newHealth) - - newApps = append(newApps, *old) - } - - for _, app := range newApps { - err = api.Database.UpdateWorkspaceAppHealthByID(ctx, database.UpdateWorkspaceAppHealthByIDParams{ - ID: app.ID, - Health: app.Health, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Error setting workspace app health", - Detail: err.Error(), - }) - return - } - } - - resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace resource.", - Detail: err.Error(), - }) - return - } - job, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace build.", - Detail: err.Error(), - }) - return - } - workspace, err := api.Database.GetWorkspaceByID(ctx, job.WorkspaceID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace.", - Detail: err.Error(), - }) - return - } - api.publishWorkspaceUpdate(ctx, workspace.ID) - - httpapi.Write(ctx, rw, http.StatusOK, nil) -} - // workspaceAgentsExternalAuth returns an access token for a given URL // or finds a provider by ID. // @@ -2117,24 +1493,3 @@ func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.Work SourceID: logEntry.LogSourceID, } } - -func convertWorkspaceAgentSubsystems(ss []codersdk.AgentSubsystem) []database.WorkspaceAgentSubsystem { - out := make([]database.WorkspaceAgentSubsystem, 0, len(ss)) - for _, s := range ss { - switch s { - case codersdk.AgentSubsystemEnvbox: - out = append(out, database.WorkspaceAgentSubsystemEnvbox) - case codersdk.AgentSubsystemEnvbuilder: - out = append(out, database.WorkspaceAgentSubsystemEnvbuilder) - case codersdk.AgentSubsystemExectrace: - out = append(out, database.WorkspaceAgentSubsystemExectrace) - default: - // Invalid, drop it. - } - } - - sort.Slice(out, func(i, j int) bool { - return out[i] < out[j] - }) - return out -} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 7052d59144e1b..f50a886205cdf 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/tailcfg" "cdr.dev/slog" @@ -34,7 +35,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" @@ -963,110 +963,12 @@ func TestWorkspaceAgentPostLogSource(t *testing.T) { }) } -// TestWorkspaceAgentReportStats tests the legacy (agent API v1) report stats endpoint. -func TestWorkspaceAgentReportStats(t *testing.T) { - t.Parallel() - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - client, db := coderdtest.NewWithDatabase(t, nil) - user := coderdtest.CreateFirstUser(t, client) - r := dbfake.WorkspaceBuild(t, db, database.Workspace{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithAgent().Do() - - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(r.AgentToken) - - _, err := agentClient.PostStats(context.Background(), &agentsdk.Stats{ - ConnectionsByProto: map[string]int64{"TCP": 1}, - ConnectionCount: 1, - RxPackets: 1, - RxBytes: 1, - TxPackets: 1, - TxBytes: 1, - SessionCountVSCode: 1, - SessionCountJetBrains: 0, - SessionCountReconnectingPTY: 0, - SessionCountSSH: 0, - ConnectionMedianLatencyMS: 10, - }) - require.NoError(t, err) - - newWorkspace, err := client.Workspace(context.Background(), r.Workspace.ID) - require.NoError(t, err) - - assert.True(t, - newWorkspace.LastUsedAt.After(r.Workspace.LastUsedAt), - "%s is not after %s", newWorkspace.LastUsedAt, r.Workspace.LastUsedAt, - ) - }) - - t.Run("FailDeleted", func(t *testing.T) { - t.Parallel() - - owner, db := coderdtest.NewWithDatabase(t, nil) - ownerUser := coderdtest.CreateFirstUser(t, owner) - client, admin := coderdtest.CreateAnotherUser(t, owner, ownerUser.OrganizationID, rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()) - r := dbfake.WorkspaceBuild(t, db, database.Workspace{ - OrganizationID: admin.OrganizationIDs[0], - OwnerID: admin.ID, - }).WithAgent().Do() - - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(r.AgentToken) - - _, err := agentClient.PostStats(context.Background(), &agentsdk.Stats{ - ConnectionsByProto: map[string]int64{"TCP": 1}, - ConnectionCount: 1, - RxPackets: 1, - RxBytes: 1, - TxPackets: 1, - TxBytes: 1, - SessionCountVSCode: 0, - SessionCountJetBrains: 0, - SessionCountReconnectingPTY: 0, - SessionCountSSH: 0, - ConnectionMedianLatencyMS: 10, - }) - require.NoError(t, err) - - newWorkspace, err := client.Workspace(context.Background(), r.Workspace.ID) - require.NoError(t, err) - - // nolint:gocritic // using db directly over creating a delete job - err = db.UpdateWorkspaceDeletedByID(dbauthz.As(context.Background(), - coderdtest.AuthzUserSubject(admin, ownerUser.OrganizationID)), - database.UpdateWorkspaceDeletedByIDParams{ - ID: newWorkspace.ID, - Deleted: true, - }) - require.NoError(t, err) - - _, err = agentClient.PostStats(context.Background(), &agentsdk.Stats{ - ConnectionsByProto: map[string]int64{"TCP": 1}, - ConnectionCount: 1, - RxPackets: 1, - RxBytes: 1, - TxPackets: 1, - TxBytes: 1, - SessionCountVSCode: 1, - SessionCountJetBrains: 0, - SessionCountReconnectingPTY: 0, - SessionCountSSH: 0, - ConnectionMedianLatencyMS: 10, - }) - require.ErrorContains(t, err, "agent is invalid") - }) -} - func TestWorkspaceAgent_LifecycleState(t *testing.T) { t.Parallel() t.Run("Set", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) client, db := coderdtest.NewWithDatabase(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -1082,8 +984,15 @@ func TestWorkspaceAgent_LifecycleState(t *testing.T) { } } - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(r.AgentToken) + ac := agentsdk.New(client.URL) + ac.SetSessionToken(r.AgentToken) + conn, err := ac.ConnectRPC(ctx) + require.NoError(t, err) + defer func() { + cErr := conn.Close() + require.NoError(t, cErr) + }() + agentAPI := agentproto.NewDRPCAgentClient(conn) tests := []struct { state codersdk.WorkspaceAgentLifecycle @@ -1105,16 +1014,17 @@ func TestWorkspaceAgent_LifecycleState(t *testing.T) { for _, tt := range tests { tt := tt t.Run(string(tt.state), func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - err := agentClient.PostLifecycle(ctx, agentsdk.PostLifecycleRequest{ - State: tt.state, - ChangedAt: time.Now(), - }) + state, err := agentsdk.ProtoFromLifecycleState(tt.state) if tt.wantErr { require.Error(t, err) return } + _, err = agentAPI.UpdateLifecycle(ctx, &agentproto.UpdateLifecycleRequest{ + Lifecycle: &agentproto.Lifecycle{ + State: state, + ChangedAt: timestamppb.Now(), + }, + }) require.NoError(t, err, "post lifecycle state %q", tt.state) workspace, err = client.Workspace(ctx, workspace.ID) @@ -1197,11 +1107,11 @@ func TestWorkspaceAgent_Metadata(t *testing.T) { require.EqualValues(t, 3, manifest.Metadata[0].Timeout) post := func(ctx context.Context, key string, mr codersdk.WorkspaceAgentMetadataResult) { - err := agentClient.PostMetadata(ctx, agentsdk.PostMetadataRequest{ - Metadata: []agentsdk.Metadata{ + _, err := aAPI.BatchUpdateMetadata(ctx, &agentproto.BatchUpdateMetadataRequest{ + Metadata: []*agentproto.Metadata{ { - Key: key, - WorkspaceAgentMetadataResult: mr, + Key: key, + Result: agentsdk.ProtoFromMetadataResult(mr), }, }, }) @@ -1452,17 +1362,18 @@ func TestWorkspaceAgent_Metadata_CatchMemoryLeak(t *testing.T) { manifest := requireGetManifest(ctx, t, aAPI) post := func(ctx context.Context, key, value string) error { - return agentClient.PostMetadata(ctx, agentsdk.PostMetadataRequest{ - Metadata: []agentsdk.Metadata{ + _, err := aAPI.BatchUpdateMetadata(ctx, &agentproto.BatchUpdateMetadataRequest{ + Metadata: []*agentproto.Metadata{ { Key: key, - WorkspaceAgentMetadataResult: codersdk.WorkspaceAgentMetadataResult{ + Result: agentsdk.ProtoFromMetadataResult(codersdk.WorkspaceAgentMetadataResult{ CollectedAt: time.Now(), Value: value, - }, + }), }, }, }) + return err } workspace, err = client.Workspace(ctx, workspace.ID) diff --git a/coderd/workspaceagentsrpc.go b/coderd/workspaceagentsrpc.go index 24b6088ddd8f2..ec8dcd8a0e3fc 100644 --- a/coderd/workspaceagentsrpc.go +++ b/coderd/workspaceagentsrpc.go @@ -164,31 +164,6 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) { } } -func (api *API) startAgentWebsocketMonitor(ctx context.Context, - workspaceAgent database.WorkspaceAgent, workspaceBuild database.WorkspaceBuild, - conn *websocket.Conn, -) *agentConnectionMonitor { - monitor := &agentConnectionMonitor{ - apiCtx: api.ctx, - workspaceAgent: workspaceAgent, - workspaceBuild: workspaceBuild, - conn: conn, - pingPeriod: api.AgentConnectionUpdateFrequency, - db: api.Database, - replicaID: api.ID, - updater: api, - disconnectTimeout: api.AgentInactiveDisconnectTimeout, - logger: api.Logger.With( - slog.F("workspace_id", workspaceBuild.WorkspaceID), - slog.F("agent_id", workspaceAgent.ID), - ), - } - monitor.init() - monitor.start(ctx) - - return monitor -} - type yamuxPingerCloser struct { mux *yamux.Session } diff --git a/coderd/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go index a92fbdcd1ca1a..ca8f334d4e766 100644 --- a/coderd/workspaceagentsrpc_test.go +++ b/coderd/workspaceagentsrpc_test.go @@ -1,8 +1,10 @@ package coderd_test import ( + "context" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" agentproto "github.com/coder/coder/v2/agent/proto" @@ -14,6 +16,52 @@ import ( "github.com/coder/coder/v2/testutil" ) +// Ported to RPC API from coderd/workspaceagents_test.go +func TestWorkspaceAgentReportStats(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + ac := agentsdk.New(client.URL) + ac.SetSessionToken(r.AgentToken) + conn, err := ac.ConnectRPC(context.Background()) + require.NoError(t, err) + defer func() { + _ = conn.Close() + }() + agentAPI := agentproto.NewDRPCAgentClient(conn) + + _, err = agentAPI.UpdateStats(context.Background(), &agentproto.UpdateStatsRequest{ + Stats: &agentproto.Stats{ + ConnectionsByProto: map[string]int64{"TCP": 1}, + ConnectionCount: 1, + RxPackets: 1, + RxBytes: 1, + TxPackets: 1, + TxBytes: 1, + SessionCountVscode: 1, + SessionCountJetbrains: 0, + SessionCountReconnectingPty: 0, + SessionCountSsh: 0, + ConnectionMedianLatencyMs: 10, + }, + }) + require.NoError(t, err) + + newWorkspace, err := client.Workspace(context.Background(), r.Workspace.ID) + require.NoError(t, err) + + assert.True(t, + newWorkspace.LastUsedAt.After(r.Workspace.LastUsedAt), + "%s is not after %s", newWorkspace.LastUsedAt, r.Workspace.LastUsedAt, + ) +} + func TestAgentAPI_LargeManifest(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index f3a09c5357711..32222479b37ee 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -84,23 +84,6 @@ type PostMetadataRequest struct { // performance. type PostMetadataRequestDeprecated = codersdk.WorkspaceAgentMetadataResult -// PostMetadata posts agent metadata to the Coder server. -// -// Deprecated: use BatchUpdateMetadata on the agent dRPC API instead -func (c *Client) PostMetadata(ctx context.Context, req PostMetadataRequest) error { - res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/metadata", req) - if err != nil { - return xerrors.Errorf("execute request: %w", err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusNoContent { - return codersdk.ReadBodyAsError(res) - } - - return nil -} - type Manifest struct { AgentID uuid.UUID `json:"agent_id"` AgentName string `json:"agent_name"` @@ -457,49 +440,11 @@ type StatsResponse struct { ReportInterval time.Duration `json:"report_interval"` } -// PostStats sends agent stats to the coder server -// -// Deprecated: uses agent API v1 endpoint -func (c *Client) PostStats(ctx context.Context, stats *Stats) (StatsResponse, error) { - res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/report-stats", stats) - if err != nil { - return StatsResponse{}, xerrors.Errorf("send request: %w", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return StatsResponse{}, codersdk.ReadBodyAsError(res) - } - - var interval StatsResponse - err = json.NewDecoder(res.Body).Decode(&interval) - if err != nil { - return StatsResponse{}, xerrors.Errorf("decode stats response: %w", err) - } - - return interval, nil -} - type PostLifecycleRequest struct { State codersdk.WorkspaceAgentLifecycle `json:"state"` ChangedAt time.Time `json:"changed_at"` } -// PostLifecycle posts the agent's lifecycle to the Coder server. -// -// Deprecated: Use UpdateLifecycle on the dRPC API instead -func (c *Client) PostLifecycle(ctx context.Context, req PostLifecycleRequest) error { - res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/report-lifecycle", req) - if err != nil { - return xerrors.Errorf("agent state post request: %w", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusNoContent { - return codersdk.ReadBodyAsError(res) - } - - return nil -} - type PostStartupRequest struct { Version string `json:"version"` ExpandedDirectory string `json:"expanded_directory"` diff --git a/codersdk/agentsdk/convert.go b/codersdk/agentsdk/convert.go index e60685d029507..fcd2dda414165 100644 --- a/codersdk/agentsdk/convert.go +++ b/codersdk/agentsdk/convert.go @@ -371,3 +371,11 @@ func LifecycleStateFromProto(s proto.Lifecycle_State) (codersdk.WorkspaceAgentLi } return codersdk.WorkspaceAgentLifecycle(strings.ToLower(caps)), nil } + +func ProtoFromLifecycleState(s codersdk.WorkspaceAgentLifecycle) (proto.Lifecycle_State, error) { + caps, ok := proto.Lifecycle_State_value[strings.ToUpper(string(s))] + if !ok { + return 0, xerrors.Errorf("unknown lifecycle state: %s", s) + } + return proto.Lifecycle_State(caps), nil +} diff --git a/docs/api/agents.md b/docs/api/agents.md index 13e5c38590d5c..e32fb0ac10f7a 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -160,67 +160,6 @@ curl -X POST http://coder-server:8080/api/v2/workspaceagents/google-instance-ide To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Submit workspace agent application health - -### Code samples - -```shell -# Example request using curl -curl -X POST http://coder-server:8080/api/v2/workspaceagents/me/app-health \ - -H 'Content-Type: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`POST /workspaceagents/me/app-health` - -> Body parameter - -```json -{ - "healths": { - "property1": "disabled", - "property2": "disabled" - } -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -| ------ | ---- | -------------------------------------------------------------------------- | -------- | -------------------------- | -| `body` | body | [agentsdk.PostAppHealthsRequest](schemas.md#agentsdkpostapphealthsrequest) | true | Application health request | - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Coordinate workspace agent via Tailnet - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/coordinate \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /workspaceagents/me/coordinate` - -It accepts a WebSocket connection to an agent that listens to -incoming connections and publishes node updates. - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------------------------ | ------------------- | ------ | -| 101 | [Switching Protocols](https://tools.ietf.org/html/rfc7231#section-6.2.2) | Switching Protocols | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Get workspace agent external auth ### Code samples @@ -453,283 +392,6 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceagents/me/logs \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get authorized workspace agent manifest - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /workspaceagents/me/manifest` - -### Example responses - -> 200 Response - -```json -{ - "agent_id": "string", - "agent_name": "string", - "apps": [ - { - "command": "string", - "display_name": "string", - "external": true, - "health": "disabled", - "healthcheck": { - "interval": 0, - "threshold": 0, - "url": "string" - }, - "icon": "string", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "sharing_level": "owner", - "slug": "string", - "subdomain": true, - "subdomain_name": "string", - "url": "string" - } - ], - "derp_force_websockets": true, - "derpmap": { - "homeParams": { - "regionScore": { - "property1": 0, - "property2": 0 - } - }, - "omitDefaultRegions": true, - "regions": { - "property1": { - "avoid": true, - "embeddedRelay": true, - "nodes": [ - { - "canPort80": true, - "certName": "string", - "derpport": 0, - "forceHTTP": true, - "hostName": "string", - "insecureForTests": true, - "ipv4": "string", - "ipv6": "string", - "name": "string", - "regionID": 0, - "stunonly": true, - "stunport": 0, - "stuntestIP": "string" - } - ], - "regionCode": "string", - "regionID": 0, - "regionName": "string" - }, - "property2": { - "avoid": true, - "embeddedRelay": true, - "nodes": [ - { - "canPort80": true, - "certName": "string", - "derpport": 0, - "forceHTTP": true, - "hostName": "string", - "insecureForTests": true, - "ipv4": "string", - "ipv6": "string", - "name": "string", - "regionID": 0, - "stunonly": true, - "stunport": 0, - "stuntestIP": "string" - } - ], - "regionCode": "string", - "regionID": 0, - "regionName": "string" - } - } - }, - "directory": "string", - "disable_direct_connections": true, - "environment_variables": { - "property1": "string", - "property2": "string" - }, - "git_auth_configs": 0, - "metadata": [ - { - "display_name": "string", - "interval": 0, - "key": "string", - "script": "string", - "timeout": 0 - } - ], - "motd_file": "string", - "owner_name": "string", - "scripts": [ - { - "cron": "string", - "log_path": "string", - "log_source_id": "4197ab25-95cf-4b91-9c78-f7f2af5d353a", - "run_on_start": true, - "run_on_stop": true, - "script": "string", - "start_blocks_login": true, - "timeout": 0 - } - ], - "vscode_port_proxy_uri": "string", - "workspace_id": "string", - "workspace_name": "string" -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.Manifest](schemas.md#agentsdkmanifest) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Submit workspace agent stats - -### Code samples - -```shell -# Example request using curl -curl -X POST http://coder-server:8080/api/v2/workspaceagents/me/report-stats \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`POST /workspaceagents/me/report-stats` - -> Body parameter - -```json -{ - "connection_count": 0, - "connection_median_latency_ms": 0, - "connections_by_proto": { - "property1": 0, - "property2": 0 - }, - "metrics": [ - { - "labels": [ - { - "name": "string", - "value": "string" - } - ], - "name": "string", - "type": "counter", - "value": 0 - } - ], - "rx_bytes": 0, - "rx_packets": 0, - "session_count_jetbrains": 0, - "session_count_reconnecting_pty": 0, - "session_count_ssh": 0, - "session_count_vscode": 0, - "tx_bytes": 0, - "tx_packets": 0 -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -| ------ | ---- | ------------------------------------------ | -------- | ------------- | -| `body` | body | [agentsdk.Stats](schemas.md#agentsdkstats) | true | Stats request | - -### Example responses - -> 200 Response - -```json -{ - "report_interval": 0 -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.StatsResponse](schemas.md#agentsdkstatsresponse) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Removed: Patch workspace agent logs - -### Code samples - -```shell -# Example request using curl -curl -X PATCH http://coder-server:8080/api/v2/workspaceagents/me/startup-logs \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`PATCH /workspaceagents/me/startup-logs` - -> Body parameter - -```json -{ - "log_source_id": "string", - "logs": [ - { - "created_at": "string", - "level": "trace", - "output": "string" - } - ] -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -| ------ | ---- | -------------------------------------------------- | -------- | ----------- | -| `body` | body | [agentsdk.PatchLogs](schemas.md#agentsdkpatchlogs) | true | logs | - -### Example responses - -> 200 Response - -```json -{ - "detail": "string", - "message": "string", - "validations": [ - { - "detail": "string", - "field": "string" - } - ] -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Get workspace agent by ID ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 55070fb629864..348ce54e11ba3 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -16,69 +16,6 @@ | `document` | string | true | | | | `signature` | string | true | | | -## agentsdk.AgentMetric - -```json -{ - "labels": [ - { - "name": "string", - "value": "string" - } - ], - "name": "string", - "type": "counter", - "value": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| -------- | --------------------------------------------------------------- | -------- | ------------ | ----------- | -| `labels` | array of [agentsdk.AgentMetricLabel](#agentsdkagentmetriclabel) | false | | | -| `name` | string | true | | | -| `type` | [agentsdk.AgentMetricType](#agentsdkagentmetrictype) | true | | | -| `value` | number | true | | | - -#### Enumerated Values - -| Property | Value | -| -------- | --------- | -| `type` | `counter` | -| `type` | `gauge` | - -## agentsdk.AgentMetricLabel - -```json -{ - "name": "string", - "value": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| ------- | ------ | -------- | ------------ | ----------- | -| `name` | string | true | | | -| `value` | string | true | | | - -## agentsdk.AgentMetricType - -```json -"counter" -``` - -### Properties - -#### Enumerated Values - -| Value | -| --------- | -| `counter` | -| `gauge` | - ## agentsdk.AuthenticateResponse ```json @@ -181,172 +118,6 @@ | `level` | [codersdk.LogLevel](#codersdkloglevel) | false | | | | `output` | string | false | | | -## agentsdk.Manifest - -```json -{ - "agent_id": "string", - "agent_name": "string", - "apps": [ - { - "command": "string", - "display_name": "string", - "external": true, - "health": "disabled", - "healthcheck": { - "interval": 0, - "threshold": 0, - "url": "string" - }, - "icon": "string", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "sharing_level": "owner", - "slug": "string", - "subdomain": true, - "subdomain_name": "string", - "url": "string" - } - ], - "derp_force_websockets": true, - "derpmap": { - "homeParams": { - "regionScore": { - "property1": 0, - "property2": 0 - } - }, - "omitDefaultRegions": true, - "regions": { - "property1": { - "avoid": true, - "embeddedRelay": true, - "nodes": [ - { - "canPort80": true, - "certName": "string", - "derpport": 0, - "forceHTTP": true, - "hostName": "string", - "insecureForTests": true, - "ipv4": "string", - "ipv6": "string", - "name": "string", - "regionID": 0, - "stunonly": true, - "stunport": 0, - "stuntestIP": "string" - } - ], - "regionCode": "string", - "regionID": 0, - "regionName": "string" - }, - "property2": { - "avoid": true, - "embeddedRelay": true, - "nodes": [ - { - "canPort80": true, - "certName": "string", - "derpport": 0, - "forceHTTP": true, - "hostName": "string", - "insecureForTests": true, - "ipv4": "string", - "ipv6": "string", - "name": "string", - "regionID": 0, - "stunonly": true, - "stunport": 0, - "stuntestIP": "string" - } - ], - "regionCode": "string", - "regionID": 0, - "regionName": "string" - } - } - }, - "directory": "string", - "disable_direct_connections": true, - "environment_variables": { - "property1": "string", - "property2": "string" - }, - "git_auth_configs": 0, - "metadata": [ - { - "display_name": "string", - "interval": 0, - "key": "string", - "script": "string", - "timeout": 0 - } - ], - "motd_file": "string", - "owner_name": "string", - "scripts": [ - { - "cron": "string", - "log_path": "string", - "log_source_id": "4197ab25-95cf-4b91-9c78-f7f2af5d353a", - "run_on_start": true, - "run_on_stop": true, - "script": "string", - "start_blocks_login": true, - "timeout": 0 - } - ], - "vscode_port_proxy_uri": "string", - "workspace_id": "string", - "workspace_name": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agent_id` | string | false | | | -| `agent_name` | string | false | | | -| `apps` | array of [codersdk.WorkspaceApp](#codersdkworkspaceapp) | false | | | -| `derp_force_websockets` | boolean | false | | | -| `derpmap` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | -| `directory` | string | false | | | -| `disable_direct_connections` | boolean | false | | | -| `environment_variables` | object | false | | | -| » `[any property]` | string | false | | | -| `git_auth_configs` | integer | false | | Git auth configs stores the number of Git configurations the Coder deployment has. If this number is >0, we set up special configuration in the workspace. | -| `metadata` | array of [codersdk.WorkspaceAgentMetadataDescription](#codersdkworkspaceagentmetadatadescription) | false | | | -| `motd_file` | string | false | | | -| `owner_name` | string | false | | Owner name and WorkspaceID are used by an open-source user to identify the workspace. We do not provide insurance that this will not be removed in the future, but if it's easy to persist lets keep it around. | -| `scripts` | array of [codersdk.WorkspaceAgentScript](#codersdkworkspaceagentscript) | false | | | -| `vscode_port_proxy_uri` | string | false | | | -| `workspace_id` | string | false | | | -| `workspace_name` | string | false | | | - -## agentsdk.Metadata - -```json -{ - "age": 0, - "collected_at": "2019-08-24T14:15:22Z", - "error": "string", - "key": "string", - "value": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| -------------- | ------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -| `age` | integer | false | | Age is the number of seconds since the metadata was collected. It is provided in addition to CollectedAt to protect against clock skew. | -| `collected_at` | string | false | | | -| `error` | string | false | | | -| `key` | string | false | | | -| `value` | string | false | | | - ## agentsdk.PatchLogs ```json @@ -369,40 +140,6 @@ | `log_source_id` | string | false | | | | `logs` | array of [agentsdk.Log](#agentsdklog) | false | | | -## agentsdk.PostAppHealthsRequest - -```json -{ - "healths": { - "property1": "disabled", - "property2": "disabled" - } -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| ------------------ | ---------------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------- | -| `healths` | object | false | | Healths is a map of the workspace app name and the health of the app. | -| » `[any property]` | [codersdk.WorkspaceAppHealth](#codersdkworkspaceapphealth) | false | | | - -## agentsdk.PostLifecycleRequest - -```json -{ - "changed_at": "string", - "state": "created" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| ------------ | -------------------------------------------------------------------- | -------- | ------------ | ----------- | -| `changed_at` | string | false | | | -| `state` | [codersdk.WorkspaceAgentLifecycle](#codersdkworkspaceagentlifecycle) | false | | | - ## agentsdk.PostLogSourceRequest ```json @@ -421,132 +158,6 @@ | `icon` | string | false | | | | `id` | string | false | | ID is a unique identifier for the log source. It is scoped to a workspace agent, and can be statically defined inside code to prevent duplicate sources from being created for the same agent. | -## agentsdk.PostMetadataRequest - -```json -{ - "metadata": [ - { - "age": 0, - "collected_at": "2019-08-24T14:15:22Z", - "error": "string", - "key": "string", - "value": "string" - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| ---------- | ----------------------------------------------- | -------- | ------------ | ----------- | -| `metadata` | array of [agentsdk.Metadata](#agentsdkmetadata) | false | | | - -## agentsdk.PostMetadataRequestDeprecated - -```json -{ - "age": 0, - "collected_at": "2019-08-24T14:15:22Z", - "error": "string", - "value": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| -------------- | ------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -| `age` | integer | false | | Age is the number of seconds since the metadata was collected. It is provided in addition to CollectedAt to protect against clock skew. | -| `collected_at` | string | false | | | -| `error` | string | false | | | -| `value` | string | false | | | - -## agentsdk.PostStartupRequest - -```json -{ - "expanded_directory": "string", - "subsystems": ["envbox"], - "version": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| -------------------- | ----------------------------------------------------------- | -------- | ------------ | ----------- | -| `expanded_directory` | string | false | | | -| `subsystems` | array of [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | -| `version` | string | false | | | - -## agentsdk.Stats - -```json -{ - "connection_count": 0, - "connection_median_latency_ms": 0, - "connections_by_proto": { - "property1": 0, - "property2": 0 - }, - "metrics": [ - { - "labels": [ - { - "name": "string", - "value": "string" - } - ], - "name": "string", - "type": "counter", - "value": 0 - } - ], - "rx_bytes": 0, - "rx_packets": 0, - "session_count_jetbrains": 0, - "session_count_reconnecting_pty": 0, - "session_count_ssh": 0, - "session_count_vscode": 0, - "tx_bytes": 0, - "tx_packets": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| -------------------------------- | ----------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------- | -| `connection_count` | integer | false | | Connection count is the number of connections received by an agent. | -| `connection_median_latency_ms` | number | false | | Connection median latency ms is the median latency of all connections in milliseconds. | -| `connections_by_proto` | object | false | | Connections by proto is a count of connections by protocol. | -| » `[any property]` | integer | false | | | -| `metrics` | array of [agentsdk.AgentMetric](#agentsdkagentmetric) | false | | Metrics collected by the agent | -| `rx_bytes` | integer | false | | Rx bytes is the number of received bytes. | -| `rx_packets` | integer | false | | Rx packets is the number of received packets. | -| `session_count_jetbrains` | integer | false | | Session count jetbrains is the number of connections received by an agent that are from our JetBrains extension. | -| `session_count_reconnecting_pty` | integer | false | | Session count reconnecting pty is the number of connections received by an agent that are from the reconnecting web terminal. | -| `session_count_ssh` | integer | false | | Session count ssh is the number of connections received by an agent that are normal, non-tagged SSH sessions. | -| `session_count_vscode` | integer | false | | Session count vscode is the number of connections received by an agent that are from our VS Code extension. | -| `tx_bytes` | integer | false | | Tx bytes is the number of transmitted bytes. | -| `tx_packets` | integer | false | | Tx packets is the number of transmitted bytes. | - -## agentsdk.StatsResponse - -```json -{ - "report_interval": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| ----------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------ | -| `report_interval` | integer | false | | Report interval is the duration after which the agent should send stats again. | - ## coderd.SCIMUser ```json @@ -6433,28 +6044,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `id` | string | false | | | | `workspace_agent_id` | string | false | | | -## codersdk.WorkspaceAgentMetadataDescription - -```json -{ - "display_name": "string", - "interval": 0, - "key": "string", - "script": "string", - "timeout": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| -------------- | ------- | -------- | ------------ | ----------- | -| `display_name` | string | false | | | -| `interval` | integer | false | | | -| `key` | string | false | | | -| `script` | string | false | | | -| `timeout` | integer | false | | | - ## codersdk.WorkspaceAgentPortShare ```json diff --git a/site/e2e/tests/outdatedAgent.spec.ts b/site/e2e/tests/outdatedAgent.spec.ts index c6bccba658be4..48393c63f7d0e 100644 --- a/site/e2e/tests/outdatedAgent.spec.ts +++ b/site/e2e/tests/outdatedAgent.spec.ts @@ -11,8 +11,8 @@ import { } from "../helpers"; import { beforeCoderTest } from "../hooks"; -// we no longer support versions prior to single tailnet: https://github.com/coder/coder/commit/d7cbdbd9c64ad26821e6b35834c59ecf85dcd9d4 -const agentVersion = "v0.27.0"; +// we no longer support versions w/o DRPC +const agentVersion = "v2.12.1"; test.beforeEach(({ page }) => beforeCoderTest(page)); From 1f9bdc36bfbb7ad53751c93d65568b55802962d4 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 11 Jun 2024 11:16:49 +0400 Subject: [PATCH 057/168] fix: ignore yamux.ErrSessionShutdown on TestTailnetAPIConnector_Disconnects (#13532) --- codersdk/workspacesdk/connector_internal_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/codersdk/workspacesdk/connector_internal_test.go b/codersdk/workspacesdk/connector_internal_test.go index 06ff3e2c668df..0b75e460ad669 100644 --- a/codersdk/workspacesdk/connector_internal_test.go +++ b/codersdk/workspacesdk/connector_internal_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/hashicorp/yamux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "nhooyr.io/websocket" @@ -34,8 +35,10 @@ func TestTailnetAPIConnector_Disconnects(t *testing.T) { testCtx := testutil.Context(t, testutil.WaitShort) ctx, cancel := context.WithCancel(testCtx) logger := slogtest.Make(t, &slogtest.Options{ - // we get EOF when we simulate a DERPMap error - IgnoredErrorIs: append(slogtest.DefaultIgnoredErrorIs, io.EOF), + IgnoredErrorIs: append(slogtest.DefaultIgnoredErrorIs, + io.EOF, // we get EOF when we simulate a DERPMap error + yamux.ErrSessionShutdown, // coordination can throw these when DERP error tears down session + ), }).Leveled(slog.LevelDebug) agentID := uuid.UUID{0x55} clientID := uuid.UUID{0x66} From 7958c52918b2cb387b42cf1aaacda8b1be30c488 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 11 Jun 2024 11:29:29 +0200 Subject: [PATCH 058/168] docs: faq: restrict file transfers from workspaces (#13534) --- docs/faqs.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/faqs.md b/docs/faqs.md index 9ee9d30ef26e1..bec3b4f66a406 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -501,3 +501,36 @@ Note that the JetBrains Gateway configuration blocks for each host in your SSH config file will be overwritten by the JetBrains Gateway client when it re-authenticates to your Coder deployment so you must add the above config as a separate block and not add it to any existing ones. + +### How can I restrict inbound/outbound file transfers from Coder workspaces? + +In certain environments, it is essential to keep confidential files within +workspaces and prevent users from uploading or downloading resources using tools +like `scp` or `rsync`. + +To achieve this, template admins can use the environment variable +`CODER_AGENT_BLOCK_FILE_TRANSFER` to enable additional SSH command controls. +This variable allows the system to check if the executed application is on the +block list, which includes `scp`, `rsync`, `ftp`, and `nc`. + +```hcl +resource "docker_container" "workspace" { + ... + env = [ + "CODER_AGENT_TOKEN=${coder_agent.main.token}", + "CODER_AGENT_BLOCK_FILE_TRANSFER=true", + ... + ] +} +``` + +#### Important Notice + +This control operates at the `ssh-exec` level or during `sftp` sessions. While +it can help prevent automated file transfers using the specified tools, users +can still SSH into the workspace and manually initiate file transfers. The +primary purpose of this feature is to warn and discourage users from downloading +confidential resources to their local machines. + +For more advanced security needs, consider adopting an endpoint security +solution. From c9cca9d56ede99289708bbe39ca52d6e15ada179 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 11 Jun 2024 09:34:05 -0400 Subject: [PATCH 059/168] fix: transform underscores to hyphens for github login (#13384) Fixes #13339. --- coderd/userauth.go | 12 +++++++++-- coderd/userauth_test.go | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/coderd/userauth.go b/coderd/userauth.go index b41f496814306..6079772945027 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -644,7 +644,15 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { if user.ID == uuid.Nil { aReq.Action = database.AuditActionRegister } - + // See: https://github.com/coder/coder/discussions/13340 + // In GitHub Enterprise, admins are permitted to have `_` + // in their usernames. This is janky, but much better + // than changing the username format globally. + username := ghUser.GetLogin() + if strings.Contains(username, "_") { + api.Logger.Warn(ctx, "login associates a github username that contains underscores. underscores are not permitted in usernames, replacing with `-`", slog.F("username", username)) + username = strings.ReplaceAll(username, "_", "-") + } params := (&oauthLoginParams{ User: user, Link: link, @@ -653,7 +661,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { LoginType: database.LoginTypeGithub, AllowSignups: api.GithubOAuth2Config.AllowSignups, Email: verifiedEmail.GetEmail(), - Username: ghUser.GetLogin(), + Username: username, AvatarURL: ghUser.GetAvatarURL(), Name: normName, DebugContext: OauthDebugContext{}, diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 1c647f3cca281..ef62005b9e1f4 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -665,6 +665,50 @@ func TestUserOAuth2Github(t *testing.T) { require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) }) + t.Run("SignupReplaceUnderscores", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{ + Auditor: auditor, + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + AllowSignups: true, + AllowEveryone: true, + OAuth2Config: &testutil.OAuth2Config{}, + ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) { + return []*github.Membership{}, nil + }, + TeamMembership: func(_ context.Context, _ *http.Client, _, _, _ string) (*github.Membership, error) { + return nil, xerrors.New("no teams") + }, + AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) { + return &github.User{ + ID: github.Int64(100), + Login: github.String("mathias_coder"), + }, nil + }, + ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) { + return []*github.UserEmail{{ + Email: github.String("mathias@coder.com"), + Verified: github.Bool(true), + Primary: github.Bool(true), + }}, nil + }, + }, + }) + numLogs := len(auditor.AuditLogs()) + + resp := oauth2Callback(t, client) + numLogs++ // add an audit log for login + + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + require.Len(t, auditor.AuditLogs(), numLogs) + require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action) + + client.SetSessionToken(authCookieValue(resp.Cookies())) + user, err := client.User(context.Background(), "me") + require.NoError(t, err) + require.Equal(t, "mathias-coder", user.Username) + }) t.Run("SignupFailedInactiveInOrg", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ From 5ccf5084e8307c33cf841cf46b8507730f8c4eab Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 11 Jun 2024 08:55:28 -0500 Subject: [PATCH 060/168] chore: create type for unique role names (#13506) * chore: create type for unique role names Using `string` was confusing when something should be combined with org context, and when not to. Naming this new name, "RoleIdentifier" --- cli/server_createadminuser.go | 4 +- cli/server_createadminuser_test.go | 5 +- coderd/audit.go | 3 +- coderd/coderdtest/authorize.go | 7 +- coderd/coderdtest/coderdtest.go | 46 ++-- coderd/database/db2sdk/db2sdk.go | 30 ++- coderd/database/dbauthz/customroles_test.go | 6 +- coderd/database/dbauthz/dbauthz.go | 94 ++++--- coderd/database/dbauthz/dbauthz_test.go | 14 +- coderd/database/dbauthz/setup_test.go | 2 +- coderd/database/dbfake/dbfake.go | 2 +- coderd/database/dbgen/dbgen.go | 2 +- coderd/database/dbmem/dbmem.go | 2 +- coderd/database/modelmethods.go | 20 ++ coderd/httpmw/apikey.go | 10 +- coderd/httpmw/authorize_test.go | 40 +-- coderd/httpmw/authz_test.go | 3 +- coderd/httpmw/organizationparam_test.go | 5 +- coderd/httpmw/ratelimit_test.go | 3 +- coderd/httpmw/workspaceagent.go | 11 +- coderd/identityprovider/tokens.go | 16 +- coderd/members.go | 2 +- coderd/organizations.go | 2 +- coderd/rbac/authz.go | 14 +- coderd/rbac/authz_internal_test.go | 38 +-- coderd/rbac/authz_test.go | 22 +- coderd/rbac/roles.go | 248 +++++++++--------- coderd/rbac/roles_internal_test.go | 43 ++- coderd/rbac/roles_test.go | 85 +++--- coderd/rbac/rolestore/rolestore.go | 33 +-- coderd/rbac/rolestore/rolestore_test.go | 2 +- coderd/rbac/scopes.go | 14 +- coderd/rbac/subject_test.go | 14 +- coderd/roles.go | 6 +- coderd/roles_test.go | 46 ++-- coderd/searchquery/search_test.go | 7 +- coderd/userauth.go | 12 +- coderd/users.go | 4 +- coderd/users_test.go | 26 +- coderd/workspacebuilds.go | 2 +- coderd/workspacebuilds_test.go | 2 +- coderd/workspaces_test.go | 2 +- .../workspacestats/batcher_internal_test.go | 4 +- codersdk/rbacroles.go | 13 + enterprise/coderd/coderd.go | 2 +- enterprise/coderd/coderd_test.go | 2 +- enterprise/coderd/insights_test.go | 6 +- enterprise/coderd/roles_test.go | 7 +- enterprise/coderd/userauth_test.go | 22 +- enterprise/tailnet/pgcoord.go | 2 +- 50 files changed, 551 insertions(+), 456 deletions(-) create mode 100644 codersdk/rbacroles.go diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index 9f8d5ffe3ccf9..e43a9c401b8a0 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -192,7 +192,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { HashedPassword: []byte(hashedPassword), CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), - RBACRoles: []string{rbac.RoleOwner()}, + RBACRoles: []string{rbac.RoleOwner().String()}, LoginType: database.LoginTypePassword, }) if err != nil { @@ -222,7 +222,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { UserID: newUser.ID, CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), - Roles: []string{rbac.ScopedRoleOrgAdmin(org.ID)}, + Roles: []string{rbac.RoleOrgAdmin()}, }) if err != nil { return xerrors.Errorf("insert organization member: %w", err) diff --git a/cli/server_createadminuser_test.go b/cli/server_createadminuser_test.go index 6510e1332f120..9bc6add2ecbd2 100644 --- a/cli/server_createadminuser_test.go +++ b/cli/server_createadminuser_test.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" ) @@ -56,7 +57,7 @@ func TestServerCreateAdminUser(t *testing.T) { require.NoError(t, err) require.True(t, ok, "password does not match") - require.EqualValues(t, []string{rbac.RoleOwner()}, user.RBACRoles, "user does not have owner role") + require.EqualValues(t, []string{codersdk.RoleOwner}, user.RBACRoles, "user does not have owner role") // Check that user is admin in every org. orgs, err := db.GetOrganizations(ctx) @@ -71,7 +72,7 @@ func TestServerCreateAdminUser(t *testing.T) { orgIDs2 := make(map[uuid.UUID]struct{}, len(orgMemberships)) for _, membership := range orgMemberships { orgIDs2[membership.OrganizationID] = struct{}{} - assert.Equal(t, []string{rbac.ScopedRoleOrgAdmin(membership.OrganizationID)}, membership.Roles, "user is not org admin") + assert.Equal(t, []string{rbac.RoleOrgAdmin()}, membership.Roles, "user is not org admin") } require.Equal(t, orgIDs, orgIDs2, "user is not in all orgs") diff --git a/coderd/audit.go b/coderd/audit.go index 315913dff49c2..ab82e91698d8c 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -199,7 +199,8 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs Roles: []codersdk.SlimRole{}, } - for _, roleName := range dblog.UserRoles { + for _, input := range dblog.UserRoles { + roleName, _ := rbac.RoleNameFromString(input) rbacRole, _ := rbac.RoleByName(roleName) user.Roles = append(user.Roles, db2sdk.SlimRole(rbacRole)) } diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index e753e66f2d2f6..9586289d60025 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -60,10 +60,13 @@ func AssertRBAC(t *testing.T, api *coderd.API, client *codersdk.Client) RBACAsse roles, err := api.Database.GetAuthorizationUserRoles(ctx, key.UserID) require.NoError(t, err, "fetch user roles") + roleNames, err := roles.RoleNames() + require.NoError(t, err) + return RBACAsserter{ Subject: rbac.Subject{ ID: key.UserID.String(), - Roles: rbac.RoleNames(roles.Roles), + Roles: rbac.RoleIdentifiers(roleNames), Groups: roles.Groups, Scope: rbac.ScopeName(key.Scope), }, @@ -435,7 +438,7 @@ func randomRBACType() string { func RandomRBACSubject() rbac.Subject { return rbac.Subject{ ID: uuid.NewString(), - Roles: rbac.RoleNames{rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, Groups: []string{namesgenerator.GetRandomName(1)}, Scope: rbac.ScopeAll, } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 9ca2f551978f1..49388aa3537a5 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -55,6 +55,7 @@ import ( "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/awsidentity" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbrollup" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -663,21 +664,25 @@ func CreateFirstUser(t testing.TB, client *codersdk.Client) codersdk.CreateFirst // CreateAnotherUser creates and authenticates a new user. // Roles can include org scoped roles with 'roleName:' -func CreateAnotherUser(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, roles ...string) (*codersdk.Client, codersdk.User) { +func CreateAnotherUser(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, roles ...rbac.RoleIdentifier) (*codersdk.Client, codersdk.User) { return createAnotherUserRetry(t, client, organizationID, 5, roles) } -func CreateAnotherUserMutators(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) { +func CreateAnotherUserMutators(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, roles []rbac.RoleIdentifier, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) { return createAnotherUserRetry(t, client, organizationID, 5, roles, mutators...) } // AuthzUserSubject does not include the user's groups. func AuthzUserSubject(user codersdk.User, orgID uuid.UUID) rbac.Subject { - roles := make(rbac.RoleNames, 0, len(user.Roles)) + roles := make(rbac.RoleIdentifiers, 0, len(user.Roles)) // Member role is always implied roles = append(roles, rbac.RoleMember()) for _, r := range user.Roles { - roles = append(roles, r.Name) + orgID, _ := uuid.Parse(r.OrganizationID) // defaults to nil + roles = append(roles, rbac.RoleIdentifier{ + Name: r.Name, + OrganizationID: orgID, + }) } // We assume only 1 org exists roles = append(roles, rbac.ScopedRoleOrgMember(orgID)) @@ -690,7 +695,7 @@ func AuthzUserSubject(user codersdk.User, orgID uuid.UUID) rbac.Subject { } } -func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, retries int, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) { +func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, retries int, roles []rbac.RoleIdentifier, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) { req := codersdk.CreateUserRequest{ Email: namesgenerator.GetRandomName(10) + "@coder.com", Username: RandomUsername(t), @@ -748,36 +753,37 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI if len(roles) > 0 { // Find the roles for the org vs the site wide roles - orgRoles := make(map[string][]string) - var siteRoles []string + orgRoles := make(map[uuid.UUID][]rbac.RoleIdentifier) + var siteRoles []rbac.RoleIdentifier for _, roleName := range roles { - roleName := roleName - orgID, ok := rbac.IsOrgRole(roleName) - roleName, _, err = rbac.RoleSplit(roleName) - require.NoError(t, err, "split org role name") + ok := roleName.IsOrgRole() if ok { - roleName, _, err = rbac.RoleSplit(roleName) - require.NoError(t, err, "split rolename") - orgRoles[orgID] = append(orgRoles[orgID], roleName) + orgRoles[roleName.OrganizationID] = append(orgRoles[roleName.OrganizationID], roleName) } else { siteRoles = append(siteRoles, roleName) } } // Update the roles for _, r := range user.Roles { - siteRoles = append(siteRoles, r.Name) + orgID, _ := uuid.Parse(r.OrganizationID) + siteRoles = append(siteRoles, rbac.RoleIdentifier{ + Name: r.Name, + OrganizationID: orgID, + }) + } + + onlyName := func(role rbac.RoleIdentifier) string { + return role.Name } - user, err = client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: siteRoles}) + user, err = client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: db2sdk.List(siteRoles, onlyName)}) require.NoError(t, err, "update site roles") // Update org roles for orgID, roles := range orgRoles { - organizationID, err := uuid.Parse(orgID) - require.NoError(t, err, fmt.Sprintf("parse org id %q", orgID)) - _, err = client.UpdateOrganizationMemberRoles(context.Background(), organizationID, user.ID.String(), - codersdk.UpdateRoles{Roles: roles}) + _, err = client.UpdateOrganizationMemberRoles(context.Background(), orgID, user.ID.String(), + codersdk.UpdateRoles{Roles: db2sdk.List(roles, onlyName)}) require.NoError(t, err, "update org membership roles") } } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 1dbd62b4d6a96..6734dac38d8c3 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -170,7 +170,12 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User { } for _, roleName := range user.RBACRoles { - rbacRole, err := rbac.RoleByName(roleName) + // TODO: Currently the api only returns site wide roles. + // Should it return organization roles? + rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{ + Name: roleName, + OrganizationID: uuid.Nil, + }) if err == nil { convertedUser.Roles = append(convertedUser.Roles, SlimRole(rbacRole)) } else { @@ -519,29 +524,26 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner } func SlimRole(role rbac.Role) codersdk.SlimRole { - roleName, orgIDStr, err := rbac.RoleSplit(role.Name) - if err != nil { - roleName = role.Name + orgID := "" + if role.Identifier.OrganizationID != uuid.Nil { + orgID = role.Identifier.OrganizationID.String() } return codersdk.SlimRole{ DisplayName: role.DisplayName, - Name: roleName, - OrganizationID: orgIDStr, + Name: role.Identifier.Name, + OrganizationID: orgID, } } func RBACRole(role rbac.Role) codersdk.Role { - roleName, orgIDStr, err := rbac.RoleSplit(role.Name) - if err != nil { - roleName = role.Name - } - orgPerms := role.Org[orgIDStr] + slim := SlimRole(role) + orgPerms := role.Org[slim.OrganizationID] return codersdk.Role{ - Name: roleName, - OrganizationID: orgIDStr, - DisplayName: role.DisplayName, + Name: slim.Name, + OrganizationID: slim.OrganizationID, + DisplayName: slim.DisplayName, SitePermissions: List(role.Site, RBACPermission), OrganizationPermissions: List(orgPerms, RBACPermission), UserPermissions: List(role.User, RBACPermission), diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index 814ba88a1b18c..1a9049044e0ce 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -35,7 +35,7 @@ func TestUpsertCustomRoles(t *testing.T) { } canAssignRole := rbac.Role{ - Name: "can-assign", + Identifier: rbac.RoleIdentifier{Name: "can-assign"}, DisplayName: "", Site: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceAssignRole.Type: {policy.ActionRead, policy.ActionCreate}, @@ -51,7 +51,7 @@ func TestUpsertCustomRoles(t *testing.T) { all = append(all, t) case rbac.ExpandableRoles: all = append(all, must(t.Expand())...) - case string: + case rbac.RoleIdentifier: all = append(all, must(rbac.RoleByName(t))) default: panic("unknown type") @@ -80,7 +80,7 @@ func TestUpsertCustomRoles(t *testing.T) { { // No roles, so no assign role name: "no-roles", - subject: rbac.RoleNames([]string{}), + subject: rbac.RoleIdentifiers{}, errorContains: "forbidden", }, { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 73c73176b5953..bc8bf19763c73 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -162,7 +162,7 @@ var ( ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { - Name: "provisionerd", + Identifier: rbac.RoleIdentifier{Name: "provisionerd"}, DisplayName: "Provisioner Daemon", Site: rbac.Permissions(map[string][]policy.Action{ // TODO: Add ProvisionerJob resource type. @@ -191,7 +191,7 @@ var ( ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { - Name: "autostart", + Identifier: rbac.RoleIdentifier{Name: "autostart"}, DisplayName: "Autostart Daemon", Site: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceSystem.Type: {policy.WildcardSymbol}, @@ -213,7 +213,7 @@ var ( ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { - Name: "hangdetector", + Identifier: rbac.RoleIdentifier{Name: "hangdetector"}, DisplayName: "Hang Detector Daemon", Site: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceSystem.Type: {policy.WildcardSymbol}, @@ -232,7 +232,7 @@ var ( ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { - Name: "system", + Identifier: rbac.RoleIdentifier{Name: "system"}, DisplayName: "Coder", Site: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceWildcard.Type: {policy.ActionRead}, @@ -582,8 +582,38 @@ func (q *querier) authorizeUpdateFileTemplate(ctx context.Context, file database } } +// convertToOrganizationRoles converts a set of scoped role names to their unique +// scoped names. The database stores roles as an array of strings, and needs to be +// converted. +// TODO: Maybe make `[]rbac.RoleIdentifier` a custom type that implements a sql scanner +// to remove the need for these converters? +func (*querier) convertToOrganizationRoles(organizationID uuid.UUID, names []string) ([]rbac.RoleIdentifier, error) { + uniques := make([]rbac.RoleIdentifier, 0, len(names)) + for _, name := range names { + // This check is a developer safety check. Old code might try to invoke this code path with + // organization id suffixes. Catch this and return a nice error so it can be fixed. + if strings.Contains(name, ":") { + return nil, xerrors.Errorf("attempt to assign a role %q, remove the ': suffix", name) + } + + uniques = append(uniques, rbac.RoleIdentifier{Name: name, OrganizationID: organizationID}) + } + + return uniques, nil +} + +// convertToDeploymentRoles converts string role names into deployment wide roles. +func (*querier) convertToDeploymentRoles(names []string) []rbac.RoleIdentifier { + uniques := make([]rbac.RoleIdentifier, 0, len(names)) + for _, name := range names { + uniques = append(uniques, rbac.RoleIdentifier{Name: name}) + } + + return uniques +} + // canAssignRoles handles assigning built in and custom roles. -func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, removed []string) error { +func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, removed []rbac.RoleIdentifier) error { actor, ok := ActorFromContext(ctx) if !ok { return NoActorError @@ -597,28 +627,24 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r } grantedRoles := append(added, removed...) - customRoles := make([]string, 0) + customRoles := make([]rbac.RoleIdentifier, 0) // Validate that the roles being assigned are valid. for _, r := range grantedRoles { - roleOrgIDStr, isOrgRole := rbac.IsOrgRole(r) + isOrgRole := r.OrganizationID != uuid.Nil if shouldBeOrgRoles && !isOrgRole { return xerrors.Errorf("Must only update org roles") } + if !shouldBeOrgRoles && isOrgRole { return xerrors.Errorf("Must only update site wide roles") } if shouldBeOrgRoles { - roleOrgID, err := uuid.Parse(roleOrgIDStr) - if err != nil { - return xerrors.Errorf("role %q has invalid uuid for org: %w", r, err) - } - if orgID == nil { return xerrors.Errorf("should never happen, orgID is nil, but trying to assign an organization role") } - if roleOrgID != *orgID { + if r.OrganizationID != *orgID { return xerrors.Errorf("attempted to assign role from a different org, role %q to %q", r, orgID.String()) } } @@ -629,7 +655,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r } } - customRolesMap := make(map[string]struct{}, len(customRoles)) + customRolesMap := make(map[rbac.RoleIdentifier]struct{}, len(customRoles)) for _, r := range customRoles { customRolesMap[r] = struct{}{} } @@ -649,7 +675,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r // returns them all, but then someone could pass in a large list to make us do // a lot of loop iterations. if !slices.ContainsFunc(expandedCustomRoles, func(customRole rbac.Role) bool { - return strings.EqualFold(customRole.Name, role) + return strings.EqualFold(customRole.Identifier.Name, role.Name) && customRole.Identifier.OrganizationID == role.OrganizationID }) { return xerrors.Errorf("%q is not a supported role", role) } @@ -2471,9 +2497,14 @@ func (q *querier) InsertOrganization(ctx context.Context, arg database.InsertOrg } func (q *querier) InsertOrganizationMember(ctx context.Context, arg database.InsertOrganizationMemberParams) (database.OrganizationMember, error) { + orgRoles, err := q.convertToOrganizationRoles(arg.OrganizationID, arg.Roles) + if err != nil { + return database.OrganizationMember{}, xerrors.Errorf("converting to organization roles: %w", err) + } + // All roles are added roles. Org member is always implied. - addedRoles := append(arg.Roles, rbac.ScopedRoleOrgMember(arg.OrganizationID)) - err := q.canAssignRoles(ctx, &arg.OrganizationID, addedRoles, []string{}) + addedRoles := append(orgRoles, rbac.ScopedRoleOrgMember(arg.OrganizationID)) + err = q.canAssignRoles(ctx, &arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{}) if err != nil { return database.OrganizationMember{}, err } @@ -2559,8 +2590,8 @@ func (q *querier) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg dat func (q *querier) InsertUser(ctx context.Context, arg database.InsertUserParams) (database.User, error) { // Always check if the assigned roles can actually be assigned by this actor. - impliedRoles := append([]string{rbac.RoleMember()}, arg.RBACRoles...) - err := q.canAssignRoles(ctx, nil, impliedRoles, []string{}) + impliedRoles := append([]rbac.RoleIdentifier{rbac.RoleMember()}, q.convertToDeploymentRoles(arg.RBACRoles)...) + err := q.canAssignRoles(ctx, nil, impliedRoles, []rbac.RoleIdentifier{}) if err != nil { return database.User{}, err } @@ -2847,23 +2878,22 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb return database.OrganizationMember{}, err } + originalRoles, err := q.convertToOrganizationRoles(member.OrganizationID, member.Roles) + if err != nil { + return database.OrganizationMember{}, xerrors.Errorf("convert original roles: %w", err) + } + // The 'rbac' package expects role names to be scoped. // Convert the argument roles for validation. - scopedGranted := make([]string, 0, len(arg.GrantedRoles)) - for _, grantedRole := range arg.GrantedRoles { - // This check is a developer safety check. Old code might try to invoke this code path with - // organization id suffixes. Catch this and return a nice error so it can be fixed. - _, foundOrg, _ := rbac.RoleSplit(grantedRole) - if foundOrg != "" { - return database.OrganizationMember{}, xerrors.Errorf("attempt to assign a role %q, remove the ': suffix", grantedRole) - } - - scopedGranted = append(scopedGranted, rbac.RoleName(grantedRole, arg.OrgID.String())) + scopedGranted, err := q.convertToOrganizationRoles(arg.OrgID, arg.GrantedRoles) + if err != nil { + return database.OrganizationMember{}, err } // The org member role is always implied. impliedTypes := append(scopedGranted, rbac.ScopedRoleOrgMember(arg.OrgID)) - added, removed := rbac.ChangeRoleSet(member.Roles, impliedTypes) + + added, removed := rbac.ChangeRoleSet(originalRoles, impliedTypes) err = q.canAssignRoles(ctx, &arg.OrgID, added, removed) if err != nil { return database.OrganizationMember{}, err @@ -3204,9 +3234,9 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo } // The member role is always implied. - impliedTypes := append(arg.GrantedRoles, rbac.RoleMember()) + impliedTypes := append(q.convertToDeploymentRoles(arg.GrantedRoles), rbac.RoleMember()) // If the changeset is nothing, less rbac checks need to be done. - added, removed := rbac.ChangeRoleSet(user.RBACRoles, impliedTypes) + added, removed := rbac.ChangeRoleSet(q.convertToDeploymentRoles(user.RBACRoles), impliedTypes) err = q.canAssignRoles(ctx, nil, added, removed) if err != nil { return database.User{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index dbfb4e15e0de0..9d90a4d44114a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -82,7 +82,7 @@ func TestInTX(t *testing.T) { }, slog.Make(), coderdtest.AccessControlStorePointer()) actor := rbac.Subject{ ID: uuid.NewString(), - Roles: rbac.RoleNames{rbac.RoleOwner()}, + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, Groups: []string{}, Scope: rbac.ScopeAll, } @@ -136,7 +136,7 @@ func TestDBAuthzRecursive(t *testing.T) { }, slog.Make(), coderdtest.AccessControlStorePointer()) actor := rbac.Subject{ ID: uuid.NewString(), - Roles: rbac.RoleNames{rbac.RoleOwner()}, + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, Groups: []string{}, Scope: rbac.ScopeAll, } @@ -636,7 +636,7 @@ func (s *MethodTestSuite) TestOrganization() { check.Args(database.InsertOrganizationMemberParams{ OrganizationID: o.ID, UserID: u.ID, - Roles: []string{rbac.ScopedRoleOrgAdmin(o.ID)}, + Roles: []string{codersdk.RoleOrganizationAdmin}, }).Asserts( rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionAssign, rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate) @@ -664,7 +664,7 @@ func (s *MethodTestSuite) TestOrganization() { mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ OrganizationID: o.ID, UserID: u.ID, - Roles: []string{rbac.ScopedRoleOrgAdmin(o.ID)}, + Roles: []string{codersdk.RoleOrganizationAdmin}, }) out := mem out.Roles = []string{} @@ -1179,11 +1179,11 @@ func (s *MethodTestSuite) TestUser() { }).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionUpdatePersonal).Returns(link) })) s.Run("UpdateUserRoles", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{RBACRoles: []string{rbac.RoleTemplateAdmin()}}) + u := dbgen.User(s.T(), db, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}}) o := u - o.RBACRoles = []string{rbac.RoleUserAdmin()} + o.RBACRoles = []string{codersdk.RoleUserAdmin} check.Args(database.UpdateUserRolesParams{ - GrantedRoles: []string{rbac.RoleUserAdmin()}, + GrantedRoles: []string{codersdk.RoleUserAdmin}, ID: u.ID, }).Asserts( u, policy.ActionRead, diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 95d8b70a42b40..e391b9e2ef3c6 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -123,7 +123,7 @@ func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expec az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer()) actor := rbac.Subject{ ID: testActorID.String(), - Roles: rbac.RoleNames{rbac.RoleOwner()}, + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, Groups: []string{}, Scope: rbac.ScopeAll, } diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 6cb2d94429eb1..4f9d6ddc5b28c 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -26,7 +26,7 @@ import ( var ownerCtx = dbauthz.As(context.Background(), rbac.Subject{ ID: "owner", - Roles: rbac.Roles(must(rbac.RoleNames{rbac.RoleOwner()}.Expand())), + Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand())), Groups: []string{}, Scope: rbac.ExpandableScope(rbac.ScopeAll), }) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 61c44d0779307..bc18da548d683 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -33,7 +33,7 @@ import ( // genCtx is to give all generator functions permission if the db is a dbauthz db. var genCtx = dbauthz.As(context.Background(), rbac.Subject{ ID: "owner", - Roles: rbac.Roles(must(rbac.RoleNames{rbac.RoleOwner()}.Expand())), + Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand())), Groups: []string{}, Scope: rbac.ExpandableScope(rbac.ScopeAll), }) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 147eb8eca6a05..55251f71227ca 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -4808,7 +4808,7 @@ func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams users = usersFilteredByStatus } - if len(params.RbacRole) > 0 && !slice.Contains(params.RbacRole, rbac.RoleMember()) { + if len(params.RbacRole) > 0 && !slice.Contains(params.RbacRole, rbac.RoleMember().String()) { usersFilteredByRole := make([]database.User, 0, len(users)) for i, user := range users { if slice.OverlapCompare(params.RbacRole, user.RBACRoles, strings.EqualFold) { diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index d71c63b089556..e5fd1db60337f 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -7,6 +7,7 @@ import ( "golang.org/x/exp/maps" "golang.org/x/oauth2" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" @@ -373,3 +374,22 @@ func (p ProvisionerJob) FinishedAt() time.Time { return time.Time{} } + +func (r CustomRole) RoleIdentifier() rbac.RoleIdentifier { + return rbac.RoleIdentifier{ + Name: r.Name, + OrganizationID: r.OrganizationID.UUID, + } +} + +func (r GetAuthorizationUserRolesRow) RoleNames() ([]rbac.RoleIdentifier, error) { + names := make([]rbac.RoleIdentifier, 0, len(r.Roles)) + for _, role := range r.Roles { + value, err := rbac.RoleNameFromString(role) + if err != nil { + return nil, xerrors.Errorf("convert role %q: %w", role, err) + } + names = append(names, value) + } + return names, nil +} diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 5bb45424b57f9..bce375337b5c9 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -438,8 +438,16 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } + roleNames, err := roles.RoleNames() + if err != nil { + return write(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal Server Error", + Detail: err.Error(), + }) + } + //nolint:gocritic // Permission to lookup custom roles the user has assigned. - rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), cfg.DB, roles.Roles) + rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), cfg.DB, roleNames) if err != nil { return write(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to expand authenticated user roles", diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 131040b89b1f4..5d04c5afacdb3 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -27,27 +27,26 @@ func TestExtractUserRoles(t *testing.T) { t.Parallel() testCases := []struct { Name string - AddUser func(db database.Store) (database.User, []string, string) + AddUser func(db database.Store) (database.User, []rbac.RoleIdentifier, string) }{ { Name: "Member", - AddUser: func(db database.Store) (database.User, []string, string) { - roles := []string{} - user, token := addUser(t, db, roles...) - return user, append(roles, rbac.RoleMember()), token + AddUser: func(db database.Store) (database.User, []rbac.RoleIdentifier, string) { + user, token := addUser(t, db) + return user, []rbac.RoleIdentifier{rbac.RoleMember()}, token }, }, { - Name: "Admin", - AddUser: func(db database.Store) (database.User, []string, string) { - roles := []string{rbac.RoleOwner()} + Name: "Owner", + AddUser: func(db database.Store) (database.User, []rbac.RoleIdentifier, string) { + roles := []string{codersdk.RoleOwner} user, token := addUser(t, db, roles...) - return user, append(roles, rbac.RoleMember()), token + return user, []rbac.RoleIdentifier{rbac.RoleOwner(), rbac.RoleMember()}, token }, }, { Name: "OrgMember", - AddUser: func(db database.Store) (database.User, []string, string) { + AddUser: func(db database.Store) (database.User, []rbac.RoleIdentifier, string) { roles := []string{} user, token := addUser(t, db, roles...) org, err := db.InsertOrganization(context.Background(), database.InsertOrganizationParams{ @@ -68,15 +67,15 @@ func TestExtractUserRoles(t *testing.T) { Roles: orgRoles, }) require.NoError(t, err) - return user, append(roles, append(orgRoles, rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID))...), token + return user, []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID)}, token }, }, { Name: "MultipleOrgMember", - AddUser: func(db database.Store) (database.User, []string, string) { - roles := []string{} - user, token := addUser(t, db, roles...) - roles = append(roles, rbac.RoleMember()) + AddUser: func(db database.Store) (database.User, []rbac.RoleIdentifier, string) { + expected := []rbac.RoleIdentifier{} + user, token := addUser(t, db) + expected = append(expected, rbac.RoleMember()) for i := 0; i < 3; i++ { organization, err := db.InsertOrganization(context.Background(), database.InsertOrganizationParams{ ID: uuid.New(), @@ -89,8 +88,8 @@ func TestExtractUserRoles(t *testing.T) { orgRoles := []string{} if i%2 == 0 { - orgRoles = append(orgRoles, rbac.RoleOrgAdmin()) - roles = append(roles, rbac.ScopedRoleOrgAdmin(organization.ID)) + orgRoles = append(orgRoles, codersdk.RoleOrganizationAdmin) + expected = append(expected, rbac.ScopedRoleOrgAdmin(organization.ID)) } _, err = db.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, @@ -100,9 +99,9 @@ func TestExtractUserRoles(t *testing.T) { Roles: orgRoles, }) require.NoError(t, err) - roles = append(roles, rbac.ScopedRoleOrgMember(organization.ID)) + expected = append(expected, rbac.ScopedRoleOrgMember(organization.ID)) } - return user, roles, token + return user, expected, token }, }, } @@ -147,6 +146,9 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s id, secret = randomAPIKeyParts() hashed = sha256.Sum256([]byte(secret)) ) + if roles == nil { + roles = []string{} + } user, err := db.InsertUser(context.Background(), database.InsertUserParams{ ID: uuid.New(), diff --git a/coderd/httpmw/authz_test.go b/coderd/httpmw/authz_test.go index b469a8f23a5ed..706590e210c1f 100644 --- a/coderd/httpmw/authz_test.go +++ b/coderd/httpmw/authz_test.go @@ -11,6 +11,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" ) func TestAsAuthzSystem(t *testing.T) { @@ -34,7 +35,7 @@ func TestAsAuthzSystem(t *testing.T) { actor, ok := dbauthz.ActorFromContext(req.Context()) assert.True(t, ok, "actor should exist") assert.False(t, userActor.Equal(actor), "systemActor should not be the user actor") - assert.Contains(t, actor.Roles.Names(), "system", "should have system role") + assert.Contains(t, actor.Roles.Names(), rbac.RoleIdentifier{Name: "system"}, "should have system role") }) mwAssertUser := mwAssert(func(req *http.Request) { diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index 44d63cd664460..ca3adcabbae01 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -16,7 +16,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -152,11 +151,11 @@ func TestOrganizationParam(t *testing.T) { _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ OrganizationID: organization.ID, UserID: user.ID, - Roles: []string{rbac.ScopedRoleOrgMember(organization.ID)}, + Roles: []string{codersdk.RoleOrganizationMember}, }) _, err := db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ ID: user.ID, - GrantedRoles: []string{rbac.RoleTemplateAdmin()}, + GrantedRoles: []string{codersdk.RoleTemplateAdmin}, }) require.NoError(t, err) diff --git a/coderd/httpmw/ratelimit_test.go b/coderd/httpmw/ratelimit_test.go index a320e05af7ffe..1dd12da89df1a 100644 --- a/coderd/httpmw/ratelimit_test.go +++ b/coderd/httpmw/ratelimit_test.go @@ -16,7 +16,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) @@ -117,7 +116,7 @@ func TestRateLimit(t *testing.T) { db := dbmem.New() u := dbgen.User(t, db, database.User{ - RBACRoles: []string{rbac.RoleOwner()}, + RBACRoles: []string{codersdk.RoleOwner}, }) _, key := dbgen.APIKey(t, db, database.APIKey{UserID: u.ID}) diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index a72d05caecbb2..99889c0bae5fc 100644 --- a/coderd/httpmw/workspaceagent.go +++ b/coderd/httpmw/workspaceagent.go @@ -119,9 +119,18 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil return } + roleNames, err := roles.RoleNames() + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal server error", + Detail: err.Error(), + }) + return + } + subject := rbac.Subject{ ID: row.Workspace.OwnerID.String(), - Roles: rbac.RoleNames(roles.Roles), + Roles: rbac.RoleIdentifiers(roleNames), Groups: roles.Groups, Scope: rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{ WorkspaceID: row.Workspace.ID, diff --git a/coderd/identityprovider/tokens.go b/coderd/identityprovider/tokens.go index e9c9e743e7225..324ff2819400d 100644 --- a/coderd/identityprovider/tokens.go +++ b/coderd/identityprovider/tokens.go @@ -214,9 +214,15 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database if err != nil { return oauth2.Token{}, err } + + roleNames, err := roles.RoleNames() + if err != nil { + return oauth2.Token{}, xerrors.Errorf("role names: %w", err) + } + userSubj := rbac.Subject{ ID: dbCode.UserID.String(), - Roles: rbac.RoleNames(roles.Roles), + Roles: rbac.RoleIdentifiers(roleNames), Groups: roles.Groups, Scope: rbac.ScopeAll, } @@ -310,9 +316,15 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut if err != nil { return oauth2.Token{}, err } + + roleNames, err := roles.RoleNames() + if err != nil { + return oauth2.Token{}, xerrors.Errorf("role names: %w", err) + } + userSubj := rbac.Subject{ ID: prevKey.UserID.String(), - Roles: rbac.RoleNames(roles.Roles), + Roles: rbac.RoleIdentifiers(roleNames), Groups: roles.Groups, Scope: rbac.ScopeAll, } diff --git a/coderd/members.go b/coderd/members.go index 36660e5cb968e..3110cc51dbcf2 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -68,7 +68,7 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz } for _, roleName := range mem.Roles { - rbacRole, _ := rbac.RoleByName(rbac.RoleName(roleName, mem.OrganizationID.String())) + rbacRole, _ := rbac.RoleByName(rbac.RoleIdentifier{Name: roleName, OrganizationID: mem.OrganizationID}) convertedMember.Roles = append(convertedMember.Roles, db2sdk.SlimRole(rbacRole)) } return convertedMember diff --git a/coderd/organizations.go b/coderd/organizations.go index 6ae3358e9a2f2..259fb6486dfd8 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -99,7 +99,7 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { // come back to determining the default role of the person who // creates the org. Until that happens, all users in an organization // should be just regular members. - rbac.ScopedRoleOrgMember(organization.ID), + rbac.RoleOrgMember(), }, }) if err != nil { diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 859782d0286b1..614150bb1522c 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -110,13 +110,13 @@ func (s Subject) SafeScopeName() string { if s.Scope == nil { return "no-scope" } - return s.Scope.Name() + return s.Scope.Name().String() } // SafeRoleNames prevent nil pointer dereference. -func (s Subject) SafeRoleNames() []string { +func (s Subject) SafeRoleNames() []RoleIdentifier { if s.Roles == nil { - return []string{} + return []RoleIdentifier{} } return s.Roles.Names() } @@ -707,9 +707,15 @@ func (c *authCache) Prepare(ctx context.Context, subject Subject, action policy. // rbacTraceAttributes are the attributes that are added to all spans created by // the rbac package. These attributes should help to debug slow spans. func rbacTraceAttributes(actor Subject, action policy.Action, objectType string, extra ...attribute.KeyValue) trace.SpanStartOption { + uniqueRoleNames := actor.SafeRoleNames() + roleStrings := make([]string, 0, len(uniqueRoleNames)) + for _, roleName := range uniqueRoleNames { + roleName := roleName + roleStrings = append(roleStrings, roleName.String()) + } return trace.WithAttributes( append(extra, - attribute.StringSlice("subject_roles", actor.SafeRoleNames()), + attribute.StringSlice("subject_roles", roleStrings), attribute.Int("num_subject_roles", len(actor.SafeRoleNames())), attribute.Int("num_groups", len(actor.Groups)), attribute.String("scope", actor.SafeScopeName()), diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index d3d1ae8d9f765..79fe9af67a607 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -56,7 +56,7 @@ func TestFilterError(t *testing.T) { auth := NewAuthorizer(prometheus.NewRegistry()) subject := Subject{ ID: uuid.NewString(), - Roles: RoleNames{}, + Roles: RoleIdentifiers{}, Groups: []string{}, Scope: ScopeAll, } @@ -77,7 +77,7 @@ func TestFilterError(t *testing.T) { subject := Subject{ ID: uuid.NewString(), - Roles: RoleNames{ + Roles: RoleIdentifiers{ RoleOwner(), }, Groups: []string{}, @@ -159,7 +159,7 @@ func TestFilter(t *testing.T) { Name: "NoRoles", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{}, + Roles: RoleIdentifiers{}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -168,7 +168,7 @@ func TestFilter(t *testing.T) { Name: "Admin", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{ScopedRoleOrgMember(orgIDs[0]), "auditor", RoleOwner(), RoleMember()}, + Roles: RoleIdentifiers{ScopedRoleOrgMember(orgIDs[0]), RoleAuditor(), RoleOwner(), RoleMember()}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -177,7 +177,7 @@ func TestFilter(t *testing.T) { Name: "OrgAdmin", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgAdmin(orgIDs[0]), RoleMember()}, + Roles: RoleIdentifiers{ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgAdmin(orgIDs[0]), RoleMember()}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -186,7 +186,7 @@ func TestFilter(t *testing.T) { Name: "OrgMember", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgMember(orgIDs[1]), RoleMember()}, + Roles: RoleIdentifiers{ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgMember(orgIDs[1]), RoleMember()}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -195,7 +195,7 @@ func TestFilter(t *testing.T) { Name: "ManyRoles", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{ + Roles: RoleIdentifiers{ ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgAdmin(orgIDs[0]), ScopedRoleOrgMember(orgIDs[1]), ScopedRoleOrgAdmin(orgIDs[1]), ScopedRoleOrgMember(orgIDs[2]), ScopedRoleOrgAdmin(orgIDs[2]), @@ -211,7 +211,7 @@ func TestFilter(t *testing.T) { Name: "SiteMember", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{RoleMember()}, + Roles: RoleIdentifiers{RoleMember()}, }, ObjectType: ResourceUser.Type, Action: policy.ActionRead, @@ -220,7 +220,7 @@ func TestFilter(t *testing.T) { Name: "ReadOrgs", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{ + Roles: RoleIdentifiers{ ScopedRoleOrgMember(orgIDs[0]), ScopedRoleOrgMember(orgIDs[1]), ScopedRoleOrgMember(orgIDs[2]), @@ -235,7 +235,7 @@ func TestFilter(t *testing.T) { Name: "ScopeApplicationConnect", Actor: Subject{ ID: userIDs[0].String(), - Roles: RoleNames{ScopedRoleOrgMember(orgIDs[0]), "auditor", RoleOwner(), RoleMember()}, + Roles: RoleIdentifiers{ScopedRoleOrgMember(orgIDs[0]), RoleAuditor(), RoleOwner(), RoleMember()}, }, ObjectType: ResourceWorkspace.Type, Action: policy.ActionRead, @@ -394,7 +394,7 @@ func TestAuthorizeDomain(t *testing.T) { ID: "me", Scope: must(ExpandScope(ScopeAll)), Roles: Roles{{ - Name: "deny-all", + Identifier: RoleIdentifier{Name: "deny-all"}, // List out deny permissions explicitly Site: []Permission{ { @@ -607,8 +607,8 @@ func TestAuthorizeDomain(t *testing.T) { Scope: must(ExpandScope(ScopeAll)), Roles: Roles{ { - Name: "ReadOnlyOrgAndUser", - Site: []Permission{}, + Identifier: RoleIdentifier{Name: "ReadOnlyOrgAndUser"}, + Site: []Permission{}, Org: map[string][]Permission{ defOrg.String(): {{ Negate: false, @@ -701,7 +701,7 @@ func TestAuthorizeLevels(t *testing.T) { Roles: Roles{ must(RoleByName(RoleOwner())), { - Name: "org-deny:" + defOrg.String(), + Identifier: RoleIdentifier{Name: "org-deny:", OrganizationID: defOrg}, Org: map[string][]Permission{ defOrg.String(): { { @@ -713,7 +713,7 @@ func TestAuthorizeLevels(t *testing.T) { }, }, { - Name: "user-deny-all", + Identifier: RoleIdentifier{Name: "user-deny-all"}, // List out deny permissions explicitly User: []Permission{ { @@ -761,7 +761,7 @@ func TestAuthorizeLevels(t *testing.T) { Scope: must(ExpandScope(ScopeAll)), Roles: Roles{ { - Name: "site-noise", + Identifier: RoleIdentifier{Name: "site-noise"}, Site: []Permission{ { Negate: true, @@ -772,7 +772,7 @@ func TestAuthorizeLevels(t *testing.T) { }, must(RoleByName(ScopedRoleOrgAdmin(defOrg))), { - Name: "user-deny-all", + Identifier: RoleIdentifier{Name: "user-deny-all"}, // List out deny permissions explicitly User: []Permission{ { @@ -896,7 +896,7 @@ func TestAuthorizeScope(t *testing.T) { }, Scope: Scope{ Role: Role{ - Name: "workspace_agent", + Identifier: RoleIdentifier{Name: "workspace_agent"}, DisplayName: "Workspace Agent", Site: Permissions(map[string][]policy.Action{ // Only read access for workspaces. @@ -985,7 +985,7 @@ func TestAuthorizeScope(t *testing.T) { }, Scope: Scope{ Role: Role{ - Name: "create_workspace", + Identifier: RoleIdentifier{Name: "create_workspace"}, DisplayName: "Create Workspace", Site: Permissions(map[string][]policy.Action{ // Only read access for workspaces. diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index 344d85562a094..0c46096c74e6f 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -41,7 +41,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "NoRoles", Actor: rbac.Subject{ ID: user.String(), - Roles: rbac.RoleNames{}, + Roles: rbac.RoleIdentifiers{}, Scope: rbac.ScopeAll, }, }, @@ -49,7 +49,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "Admin", Actor: rbac.Subject{ // Give some extra roles that an admin might have - Roles: rbac.RoleNames{rbac.ScopedRoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember()}, ID: user.String(), Scope: rbac.ScopeAll, Groups: noiseGroups, @@ -58,7 +58,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U { Name: "OrgAdmin", Actor: rbac.Subject{ - Roles: rbac.RoleNames{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), rbac.RoleMember()}, ID: user.String(), Scope: rbac.ScopeAll, Groups: noiseGroups, @@ -68,7 +68,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "OrgMember", Actor: rbac.Subject{ // Member of 2 orgs - Roles: rbac.RoleNames{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgMember(orgs[1]), rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgMember(orgs[1]), rbac.RoleMember()}, ID: user.String(), Scope: rbac.ScopeAll, Groups: noiseGroups, @@ -78,7 +78,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "ManyRoles", Actor: rbac.Subject{ // Admin of many orgs - Roles: rbac.RoleNames{ + Roles: rbac.RoleIdentifiers{ rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), rbac.ScopedRoleOrgMember(orgs[1]), rbac.ScopedRoleOrgAdmin(orgs[1]), rbac.ScopedRoleOrgMember(orgs[2]), rbac.ScopedRoleOrgAdmin(orgs[2]), @@ -93,7 +93,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "ManyRolesCachedSubject", Actor: rbac.Subject{ // Admin of many orgs - Roles: rbac.RoleNames{ + Roles: rbac.RoleIdentifiers{ rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), rbac.ScopedRoleOrgMember(orgs[1]), rbac.ScopedRoleOrgAdmin(orgs[1]), rbac.ScopedRoleOrgMember(orgs[2]), rbac.ScopedRoleOrgAdmin(orgs[2]), @@ -108,7 +108,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "AdminWithScope", Actor: rbac.Subject{ // Give some extra roles that an admin might have - Roles: rbac.RoleNames{rbac.ScopedRoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember()}, ID: user.String(), Scope: rbac.ScopeApplicationConnect, Groups: noiseGroups, @@ -119,8 +119,8 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "StaticRoles", Actor: rbac.Subject{ // Give some extra roles that an admin might have - Roles: rbac.RoleNames{ - "auditor", rbac.RoleOwner(), rbac.RoleMember(), + Roles: rbac.RoleIdentifiers{ + rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember(), rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), }, ID: user.String(), @@ -133,8 +133,8 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Name: "StaticRolesWithCache", Actor: rbac.Subject{ // Give some extra roles that an admin might have - Roles: rbac.RoleNames{ - "auditor", rbac.RoleOwner(), rbac.RoleMember(), + Roles: rbac.RoleIdentifiers{ + rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember(), rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), }, ID: user.String(), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index fae31150e2053..41411a2a968a2 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -1,6 +1,7 @@ package rbac import ( + "encoding/json" "errors" "sort" "strings" @@ -34,42 +35,99 @@ func init() { ReloadBuiltinRoles(nil) } -// RoleNames is a list of user assignable role names. The role names must be +// RoleIdentifiers is a list of user assignable role names. The role names must be // in the builtInRoles map. Any non-user assignable roles will generate an // error on Expand. -type RoleNames []string +type RoleIdentifiers []RoleIdentifier -func (names RoleNames) Expand() ([]Role, error) { +func (names RoleIdentifiers) Expand() ([]Role, error) { return rolesByNames(names) } -func (names RoleNames) Names() []string { +func (names RoleIdentifiers) Names() []RoleIdentifier { return names } -// The functions below ONLY need to exist for roles that are "defaulted" in some way. -// Any other roles (like auditor), can be listed and let the user select/assigned. -// Once we have a database implementation, the "default" roles can be defined on the -// site and orgs, and these functions can be removed. +// RoleIdentifier contains both the name of the role, and any organizational scope. +// Both fields are required to be globally unique and identifiable. +type RoleIdentifier struct { + Name string + // OrganizationID is uuid.Nil for unscoped roles (aka deployment wide) + OrganizationID uuid.UUID +} -func RoleOwner() string { - return RoleName(owner, "") +func (r RoleIdentifier) IsOrgRole() bool { + return r.OrganizationID != uuid.Nil } -func CustomSiteRole() string { return RoleName(customSiteRole, "") } +// RoleNameFromString takes a formatted string '[:org_id]'. +func RoleNameFromString(input string) (RoleIdentifier, error) { + var role RoleIdentifier + + arr := strings.Split(input, ":") + if len(arr) > 2 { + return role, xerrors.Errorf("too many colons in role name") + } + + if len(arr) == 0 { + return role, xerrors.Errorf("empty string not a valid role") + } + + if arr[0] == "" { + return role, xerrors.Errorf("role cannot be the empty string") + } + + role.Name = arr[0] -func RoleTemplateAdmin() string { - return RoleName(templateAdmin, "") + if len(arr) == 2 { + orgID, err := uuid.Parse(arr[1]) + if err != nil { + return role, xerrors.Errorf("%q not a valid uuid: %w", arr[1], err) + } + role.OrganizationID = orgID + } + return role, nil } -func RoleUserAdmin() string { - return RoleName(userAdmin, "") +func (r RoleIdentifier) String() string { + if r.OrganizationID != uuid.Nil { + return r.Name + ":" + r.OrganizationID.String() + } + return r.Name } -func RoleMember() string { - return RoleName(member, "") +func (r *RoleIdentifier) MarshalJSON() ([]byte, error) { + return json.Marshal(r.String()) } +func (r *RoleIdentifier) UnmarshalJSON(data []byte) error { + var str string + err := json.Unmarshal(data, &str) + if err != nil { + return err + } + + v, err := RoleNameFromString(str) + if err != nil { + return err + } + + *r = v + return nil +} + +// The functions below ONLY need to exist for roles that are "defaulted" in some way. +// Any other roles (like auditor), can be listed and let the user select/assigned. +// Once we have a database implementation, the "default" roles can be defined on the +// site and orgs, and these functions can be removed. + +func RoleOwner() RoleIdentifier { return RoleIdentifier{Name: owner} } +func CustomSiteRole() RoleIdentifier { return RoleIdentifier{Name: customSiteRole} } +func RoleTemplateAdmin() RoleIdentifier { return RoleIdentifier{Name: templateAdmin} } +func RoleUserAdmin() RoleIdentifier { return RoleIdentifier{Name: userAdmin} } +func RoleMember() RoleIdentifier { return RoleIdentifier{Name: member} } +func RoleAuditor() RoleIdentifier { return RoleIdentifier{Name: auditor} } + func RoleOrgAdmin() string { return orgAdmin } @@ -81,15 +139,15 @@ func RoleOrgMember() string { // ScopedRoleOrgAdmin is the org role with the organization ID // Deprecated This was used before organization scope was included as a // field in all user facing APIs. Usage of 'ScopedRoleOrgAdmin()' is preferred. -func ScopedRoleOrgAdmin(organizationID uuid.UUID) string { - return RoleName(orgAdmin, organizationID.String()) +func ScopedRoleOrgAdmin(organizationID uuid.UUID) RoleIdentifier { + return RoleIdentifier{Name: orgAdmin, OrganizationID: organizationID} } // ScopedRoleOrgMember is the org role with the organization ID // Deprecated This was used before organization scope was included as a // field in all user facing APIs. Usage of 'ScopedRoleOrgMember()' is preferred. -func ScopedRoleOrgMember(organizationID uuid.UUID) string { - return RoleName(orgMember, organizationID.String()) +func ScopedRoleOrgMember(organizationID uuid.UUID) RoleIdentifier { + return RoleIdentifier{Name: orgMember, OrganizationID: organizationID} } func allPermsExcept(excepts ...Objecter) []Permission { @@ -127,7 +185,7 @@ func allPermsExcept(excepts ...Objecter) []Permission { // // This map will be replaced by database storage defined by this ticket. // https://github.com/coder/coder/issues/1194 -var builtInRoles map[string]func(orgID string) Role +var builtInRoles map[string]func(orgID uuid.UUID) Role type RoleOptions struct { NoOwnerWorkspaceExec bool @@ -158,7 +216,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // on every authorize call. 'withCachedRegoValue' can be used as well to // preallocate the rego value that is used by the rego eval engine. ownerRole := Role{ - Name: owner, + Identifier: RoleOwner(), DisplayName: "Owner", Site: append( // Workspace dormancy and workspace are omitted. @@ -174,7 +232,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { }.withCachedRegoValue() memberRole := Role{ - Name: member, + Identifier: RoleMember(), DisplayName: "Member", Site: Permissions(map[string][]policy.Action{ ResourceAssignRole.Type: {policy.ActionRead}, @@ -200,7 +258,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { }.withCachedRegoValue() auditorRole := Role{ - Name: auditor, + Identifier: RoleAuditor(), DisplayName: "Auditor", Site: Permissions(map[string][]policy.Action{ // Should be able to read all template details, even in orgs they @@ -220,7 +278,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { }.withCachedRegoValue() templateAdminRole := Role{ - Name: templateAdmin, + Identifier: RoleTemplateAdmin(), DisplayName: "Template Admin", Site: Permissions(map[string][]policy.Action{ ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights}, @@ -241,7 +299,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { }.withCachedRegoValue() userAdminRole := Role{ - Name: userAdmin, + Identifier: RoleUserAdmin(), DisplayName: "User Admin", Site: Permissions(map[string][]policy.Action{ ResourceAssignRole.Type: {policy.ActionAssign, policy.ActionDelete, policy.ActionRead}, @@ -257,42 +315,42 @@ func ReloadBuiltinRoles(opts *RoleOptions) { User: []Permission{}, }.withCachedRegoValue() - builtInRoles = map[string]func(orgID string) Role{ + builtInRoles = map[string]func(orgID uuid.UUID) Role{ // admin grants all actions to all resources. - owner: func(_ string) Role { + owner: func(_ uuid.UUID) Role { return ownerRole }, // member grants all actions to all resources owned by the user - member: func(_ string) Role { + member: func(_ uuid.UUID) Role { return memberRole }, // auditor provides all permissions required to effectively read and understand // audit log events. // TODO: Finish the auditor as we add resources. - auditor: func(_ string) Role { + auditor: func(_ uuid.UUID) Role { return auditorRole }, - templateAdmin: func(_ string) Role { + templateAdmin: func(_ uuid.UUID) Role { return templateAdminRole }, - userAdmin: func(_ string) Role { + userAdmin: func(_ uuid.UUID) Role { return userAdminRole }, // orgAdmin returns a role with all actions allows in a given // organization scope. - orgAdmin: func(organizationID string) Role { + orgAdmin: func(organizationID uuid.UUID) Role { return Role{ - Name: RoleName(orgAdmin, organizationID), + Identifier: RoleIdentifier{Name: orgAdmin, OrganizationID: organizationID}, DisplayName: "Organization Admin", Site: []Permission{}, Org: map[string][]Permission{ // Org admins should not have workspace exec perms. - organizationID: append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant), Permissions(map[string][]policy.Action{ + organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant), Permissions(map[string][]policy.Action{ ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop}, ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH), })...), @@ -303,13 +361,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // orgMember has an empty set of permissions, this just implies their membership // in an organization. - orgMember: func(organizationID string) Role { + orgMember: func(organizationID uuid.UUID) Role { return Role{ - Name: RoleName(orgMember, organizationID), + Identifier: RoleIdentifier{Name: orgMember, OrganizationID: organizationID}, DisplayName: "", Site: []Permission{}, Org: map[string][]Permission{ - organizationID: { + organizationID.String(): { { // All org members can read the organization ResourceType: ResourceOrganization.Type, @@ -370,7 +428,7 @@ var assignRoles = map[string]map[string]bool{ } // ExpandableRoles is any type that can be expanded into a []Role. This is implemented -// as an interface so we can have RoleNames for user defined roles, and implement +// as an interface so we can have RoleIdentifiers for user defined roles, and implement // custom ExpandableRoles for system type users (eg autostart/autostop system role). // We want a clear divide between the two types of roles so users have no codepath // to interact or assign system roles. @@ -381,7 +439,7 @@ type ExpandableRoles interface { Expand() ([]Role, error) // Names is for logging and tracing purposes, we want to know the human // names of the expanded roles. - Names() []string + Names() []RoleIdentifier } // Permission is the format passed into the rego. @@ -424,7 +482,7 @@ func (perm Permission) Valid() error { // Users of this package should instead **only** use the role names, and // this package will expand the role names into their json payloads. type Role struct { - Name string `json:"name"` + Identifier RoleIdentifier `json:"name"` // DisplayName is used for UI purposes. If the role has no display name, // that means the UI should never display it. DisplayName string `json:"display_name"` @@ -474,10 +532,10 @@ func (roles Roles) Expand() ([]Role, error) { return roles, nil } -func (roles Roles) Names() []string { - names := make([]string, 0, len(roles)) +func (roles Roles) Names() []RoleIdentifier { + names := make([]RoleIdentifier, 0, len(roles)) for _, r := range roles { - names = append(names, r.Name) + names = append(names, r.Identifier) } return names } @@ -485,32 +543,22 @@ func (roles Roles) Names() []string { // CanAssignRole is a helper function that returns true if the user can assign // the specified role. This also can be used for removing a role. // This is a simple implementation for now. -func CanAssignRole(expandable ExpandableRoles, assignedRole string) bool { +func CanAssignRole(subjectHasRoles ExpandableRoles, assignedRole RoleIdentifier) bool { // For CanAssignRole, we only care about the names of the roles. - roles := expandable.Names() + roles := subjectHasRoles.Names() - assigned, assignedOrg, err := RoleSplit(assignedRole) - if err != nil { - return false - } - - for _, longRole := range roles { - role, orgID, err := RoleSplit(longRole) - if err != nil { - continue - } - - if orgID != "" && orgID != assignedOrg { + for _, myRole := range roles { + if myRole.OrganizationID != uuid.Nil && myRole.OrganizationID != assignedRole.OrganizationID { // Org roles only apply to the org they are assigned to. continue } - allowed, ok := assignRoles[role] + allowedAssignList, ok := assignRoles[myRole.Name] if !ok { continue } - if allowed[assigned] { + if allowedAssignList[assignedRole.Name] { return true } } @@ -523,29 +571,24 @@ func CanAssignRole(expandable ExpandableRoles, assignedRole string) bool { // This function is exported so that the Display name can be returned to the // api. We should maybe make an exported function that returns just the // human-readable content of the Role struct (name + display name). -func RoleByName(name string) (Role, error) { - roleName, orgID, err := RoleSplit(name) - if err != nil { - return Role{}, xerrors.Errorf("parse role name: %w", err) - } - - roleFunc, ok := builtInRoles[roleName] +func RoleByName(name RoleIdentifier) (Role, error) { + roleFunc, ok := builtInRoles[name.Name] if !ok { // No role found - return Role{}, xerrors.Errorf("role %q not found", roleName) + return Role{}, xerrors.Errorf("role %q not found", name.String()) } // Ensure all org roles are properly scoped a non-empty organization id. // This is just some defensive programming. - role := roleFunc(orgID) - if len(role.Org) > 0 && orgID == "" { - return Role{}, xerrors.Errorf("expect a org id for role %q", roleName) + role := roleFunc(name.OrganizationID) + if len(role.Org) > 0 && name.OrganizationID == uuid.Nil { + return Role{}, xerrors.Errorf("expect a org id for role %q", name.String()) } return role, nil } -func rolesByNames(roleNames []string) ([]Role, error) { +func rolesByNames(roleNames []RoleIdentifier) ([]Role, error) { roles := make([]Role, 0, len(roleNames)) for _, n := range roleNames { r, err := RoleByName(n) @@ -557,14 +600,6 @@ func rolesByNames(roleNames []string) ([]Role, error) { return roles, nil } -func IsOrgRole(roleName string) (string, bool) { - _, orgID, err := RoleSplit(roleName) - if err == nil && orgID != "" { - return orgID, true - } - return "", false -} - // OrganizationRoles lists all roles that can be applied to an organization user // in the given organization. This is the list of available roles, // and specific to an organization. @@ -574,13 +609,8 @@ func IsOrgRole(roleName string) (string, bool) { func OrganizationRoles(organizationID uuid.UUID) []Role { var roles []Role for _, roleF := range builtInRoles { - role := roleF(organizationID.String()) - _, scope, err := RoleSplit(role.Name) - if err != nil { - // This should never happen - continue - } - if scope == organizationID.String() { + role := roleF(organizationID) + if role.Identifier.OrganizationID == organizationID { roles = append(roles, role) } } @@ -595,13 +625,9 @@ func OrganizationRoles(organizationID uuid.UUID) []Role { func SiteRoles() []Role { var roles []Role for _, roleF := range builtInRoles { - role := roleF("random") - _, scope, err := RoleSplit(role.Name) - if err != nil { - // This should never happen - continue - } - if scope == "" { + // Must provide some non-nil uuid to filter out org roles. + role := roleF(uuid.New()) + if !role.Identifier.IsOrgRole() { roles = append(roles, role) } } @@ -613,8 +639,8 @@ func SiteRoles() []Role { // removing roles. This set determines the changes, so that the appropriate // RBAC checks can be applied using "ActionCreate" and "ActionDelete" for // "added" and "removed" roles respectively. -func ChangeRoleSet(from []string, to []string) (added []string, removed []string) { - has := make(map[string]struct{}) +func ChangeRoleSet(from []RoleIdentifier, to []RoleIdentifier) (added []RoleIdentifier, removed []RoleIdentifier) { + has := make(map[RoleIdentifier]struct{}) for _, exists := range from { has[exists] = struct{}{} } @@ -639,34 +665,6 @@ func ChangeRoleSet(from []string, to []string) (added []string, removed []string return added, removed } -// RoleName is a quick helper function to return -// -// role_name:scopeID -// -// If no scopeID is required, only 'role_name' is returned -func RoleName(name string, orgID string) string { - if orgID == "" { - return name - } - return name + ":" + orgID -} - -func RoleSplit(role string) (name string, orgID string, err error) { - arr := strings.Split(role, ":") - if len(arr) > 2 { - return "", "", xerrors.Errorf("too many colons in role name") - } - - if arr[0] == "" { - return "", "", xerrors.Errorf("role cannot be the empty string") - } - - if len(arr) == 2 { - return arr[0], arr[1], nil - } - return arr[0], "", nil -} - // Permissions is just a helper function to make building roles that list out resources // and actions a bit easier. func Permissions(perms map[string][]policy.Action) []Permission { diff --git a/coderd/rbac/roles_internal_test.go b/coderd/rbac/roles_internal_test.go index 70296f7519b97..3f2d0d89fe455 100644 --- a/coderd/rbac/roles_internal_test.go +++ b/coderd/rbac/roles_internal_test.go @@ -20,7 +20,7 @@ import ( // A possible large improvement would be to implement the ast.Value interface directly. func BenchmarkRBACValueAllocation(b *testing.B) { actor := Subject{ - Roles: RoleNames{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}, + Roles: RoleIdentifiers{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}, ID: uuid.NewString(), Scope: ScopeAll, Groups: []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}, @@ -73,7 +73,7 @@ func TestRegoInputValue(t *testing.T) { // Expand all roles and make sure we have a good copy. // This is because these tests modify the roles, and we don't want to // modify the original roles. - roles, err := RoleNames{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}.Expand() + roles, err := RoleIdentifiers{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}.Expand() require.NoError(t, err, "failed to expand roles") for i := range roles { // If all cached values are nil, then the role will not use @@ -213,25 +213,25 @@ func TestRoleByName(t *testing.T) { testCases := []struct { Role Role }{ - {Role: builtInRoles[owner]("")}, - {Role: builtInRoles[member]("")}, - {Role: builtInRoles[templateAdmin]("")}, - {Role: builtInRoles[userAdmin]("")}, - {Role: builtInRoles[auditor]("")}, - - {Role: builtInRoles[orgAdmin]("4592dac5-0945-42fd-828d-a903957d3dbb")}, - {Role: builtInRoles[orgAdmin]("24c100c5-1920-49c0-8c38-1b640ac4b38c")}, - {Role: builtInRoles[orgAdmin]("4a00f697-0040-4079-b3ce-d24470281a62")}, - - {Role: builtInRoles[orgMember]("3293c50e-fa5d-414f-a461-01112a4dfb6f")}, - {Role: builtInRoles[orgMember]("f88dd23d-bdbd-469d-b82e-36ee06c3d1e1")}, - {Role: builtInRoles[orgMember]("02cfd2a5-016c-4d8d-8290-301f5f18023d")}, + {Role: builtInRoles[owner](uuid.Nil)}, + {Role: builtInRoles[member](uuid.Nil)}, + {Role: builtInRoles[templateAdmin](uuid.Nil)}, + {Role: builtInRoles[userAdmin](uuid.Nil)}, + {Role: builtInRoles[auditor](uuid.Nil)}, + + {Role: builtInRoles[orgAdmin](uuid.New())}, + {Role: builtInRoles[orgAdmin](uuid.New())}, + {Role: builtInRoles[orgAdmin](uuid.New())}, + + {Role: builtInRoles[orgMember](uuid.New())}, + {Role: builtInRoles[orgMember](uuid.New())}, + {Role: builtInRoles[orgMember](uuid.New())}, } for _, c := range testCases { c := c - t.Run(c.Role.Name, func(t *testing.T) { - role, err := RoleByName(c.Role.Name) + t.Run(c.Role.Identifier.String(), func(t *testing.T) { + role, err := RoleByName(c.Role.Identifier) require.NoError(t, err, "role exists") equalRoles(t, c.Role, role) }) @@ -242,20 +242,17 @@ func TestRoleByName(t *testing.T) { t.Run("Errors", func(t *testing.T) { var err error - _, err = RoleByName("") + _, err = RoleByName(RoleIdentifier{}) require.Error(t, err, "empty role") - _, err = RoleByName("too:many:colons") - require.Error(t, err, "too many colons") - - _, err = RoleByName(orgMember) + _, err = RoleByName(RoleIdentifier{Name: orgMember}) require.Error(t, err, "expect orgID") }) } // SameAs compares 2 roles for equality. func equalRoles(t *testing.T, a, b Role) { - require.Equal(t, a.Name, b.Name, "role names") + require.Equal(t, a.Identifier, b.Identifier, "role names") require.Equal(t, a.DisplayName, b.DisplayName, "role display names") require.ElementsMatch(t, a.Site, b.Site, "site permissions") require.ElementsMatch(t, a.User, b.User, "user permissions") diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index f2f0d1d3399e2..a1f607ac756c8 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -26,7 +26,7 @@ func TestBuiltInRoles(t *testing.T) { t.Parallel() for _, r := range rbac.SiteRoles() { r := r - t.Run(r.Name, func(t *testing.T) { + t.Run(r.Identifier.String(), func(t *testing.T) { t.Parallel() require.NoError(t, r.Valid(), "invalid role") }) @@ -34,7 +34,7 @@ func TestBuiltInRoles(t *testing.T) { for _, r := range rbac.OrganizationRoles(uuid.New()) { r := r - t.Run(r.Name, func(t *testing.T) { + t.Run(r.Identifier.String(), func(t *testing.T) { t.Parallel() require.NoError(t, r.Valid(), "invalid role") }) @@ -45,7 +45,7 @@ func TestBuiltInRoles(t *testing.T) { func TestOwnerExec(t *testing.T) { owner := rbac.Subject{ ID: uuid.NewString(), - Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOwner()}, + Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}, Scope: rbac.ScopeAll, } @@ -98,17 +98,17 @@ func TestRolePermissions(t *testing.T) { apiKeyID := uuid.New() // Subjects to user - memberMe := authSubject{Name: "member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleNames{rbac.RoleMember()}}} - orgMemberMe := authSubject{Name: "org_member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID)}}} + memberMe := authSubject{Name: "member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember()}}} + orgMemberMe := authSubject{Name: "org_member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID)}}} - owner := authSubject{Name: "owner", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOwner()}}} - orgAdmin := authSubject{Name: "org_admin", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAdmin(orgID)}}} + owner := authSubject{Name: "owner", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}}} + orgAdmin := authSubject{Name: "org_admin", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAdmin(orgID)}}} - otherOrgMember := authSubject{Name: "org_member_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.ScopedRoleOrgMember(otherOrg)}}} - otherOrgAdmin := authSubject{Name: "org_admin_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.ScopedRoleOrgMember(otherOrg), rbac.ScopedRoleOrgAdmin(otherOrg)}}} + otherOrgMember := authSubject{Name: "org_member_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(otherOrg)}}} + otherOrgAdmin := authSubject{Name: "org_admin_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(otherOrg), rbac.ScopedRoleOrgAdmin(otherOrg)}}} - templateAdmin := authSubject{Name: "template-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleTemplateAdmin()}}} - userAdmin := authSubject{Name: "user-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleUserAdmin()}}} + templateAdmin := authSubject{Name: "template-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleTemplateAdmin()}}} + userAdmin := authSubject{Name: "user-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleUserAdmin()}}} // requiredSubjects are required to be asserted in each test case. This is // to make sure one is not forgotten. @@ -616,50 +616,40 @@ func TestIsOrgRole(t *testing.T) { require.NoError(t, err) testCases := []struct { - RoleName string - OrgRole bool - OrgID string + Identifier rbac.RoleIdentifier + OrgRole bool + OrgID uuid.UUID }{ // Not org roles - {RoleName: rbac.RoleOwner()}, - {RoleName: rbac.RoleMember()}, - {RoleName: "auditor"}, - - { - RoleName: "a:bad:role", - OrgRole: false, - }, + {Identifier: rbac.RoleOwner()}, + {Identifier: rbac.RoleMember()}, + {Identifier: rbac.RoleAuditor()}, { - RoleName: "", - OrgRole: false, + Identifier: rbac.RoleIdentifier{}, + OrgRole: false, }, // Org roles { - RoleName: rbac.ScopedRoleOrgAdmin(randomUUID), - OrgRole: true, - OrgID: randomUUID.String(), - }, - { - RoleName: rbac.ScopedRoleOrgMember(randomUUID), - OrgRole: true, - OrgID: randomUUID.String(), + Identifier: rbac.ScopedRoleOrgAdmin(randomUUID), + OrgRole: true, + OrgID: randomUUID, }, { - RoleName: "test:example", - OrgRole: true, - OrgID: "example", + Identifier: rbac.ScopedRoleOrgMember(randomUUID), + OrgRole: true, + OrgID: randomUUID, }, } // nolint:paralleltest for _, c := range testCases { c := c - t.Run(c.RoleName, func(t *testing.T) { + t.Run(c.Identifier.String(), func(t *testing.T) { t.Parallel() - orgID, ok := rbac.IsOrgRole(c.RoleName) + ok := c.Identifier.IsOrgRole() require.Equal(t, c.OrgRole, ok, "match expected org role") - require.Equal(t, c.OrgID, orgID, "match expected org id") + require.Equal(t, c.OrgID, c.Identifier.OrganizationID, "match expected org id") }) } } @@ -670,7 +660,7 @@ func TestListRoles(t *testing.T) { siteRoles := rbac.SiteRoles() siteRoleNames := make([]string, 0, len(siteRoles)) for _, role := range siteRoles { - siteRoleNames = append(siteRoleNames, role.Name) + siteRoleNames = append(siteRoleNames, role.Identifier.Name) } // If this test is ever failing, just update the list to the roles @@ -690,7 +680,7 @@ func TestListRoles(t *testing.T) { orgRoles := rbac.OrganizationRoles(orgID) orgRoleNames := make([]string, 0, len(orgRoles)) for _, role := range orgRoles { - orgRoleNames = append(orgRoleNames, role.Name) + orgRoleNames = append(orgRoleNames, role.Identifier.String()) } require.ElementsMatch(t, []string{ @@ -738,13 +728,22 @@ func TestChangeSet(t *testing.T) { }, } + convert := func(s []string) rbac.RoleIdentifiers { + tmp := make([]rbac.RoleIdentifier, 0, len(s)) + for _, e := range s { + tmp = append(tmp, rbac.RoleIdentifier{Name: e}) + } + return tmp + } + for _, c := range testCases { c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() - add, remove := rbac.ChangeRoleSet(c.From, c.To) - require.ElementsMatch(t, c.ExpAdd, add, "expect added") - require.ElementsMatch(t, c.ExpRemove, remove, "expect removed") + + add, remove := rbac.ChangeRoleSet(convert(c.From), convert(c.To)) + require.ElementsMatch(t, convert(c.ExpAdd), add, "expect added") + require.ElementsMatch(t, convert(c.ExpRemove), remove, "expect removed") }) } } diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 80cbd1165073b..26d354084c7b5 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -39,14 +39,14 @@ func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] { } // Expand will expand built in roles, and fetch custom roles from the database. -func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, error) { +func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier) (rbac.Roles, error) { if len(names) == 0 { // That was easy return []rbac.Role{}, nil } cache := roleCache(ctx) - lookup := make([]string, 0) + lookup := make([]rbac.RoleIdentifier, 0) roles := make([]rbac.Role, 0, len(names)) for _, name := range names { @@ -58,7 +58,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, } // Check custom role cache - customRole, ok := cache.Load(name) + customRole, ok := cache.Load(name.String()) if ok { roles = append(roles, customRole) continue @@ -69,26 +69,11 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, } if len(lookup) > 0 { - // The set of roles coming in are formatted as 'rolename[:]'. - // In the database, org roles are scoped with an organization column. lookupArgs := make([]database.NameOrganizationPair, 0, len(lookup)) for _, name := range lookup { - roleName, orgID, err := rbac.RoleSplit(name) - if err != nil { - continue - } - - parsedOrgID := uuid.Nil // Default to no org ID - if orgID != "" { - parsedOrgID, err = uuid.Parse(orgID) - if err != nil { - continue - } - } - lookupArgs = append(lookupArgs, database.NameOrganizationPair{ - Name: roleName, - OrganizationID: parsedOrgID, + Name: name.Name, + OrganizationID: name.OrganizationID, }) } @@ -111,7 +96,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, return nil, xerrors.Errorf("convert db role %q: %w", dbrole.Name, err) } roles = append(roles, converted) - cache.Store(dbrole.Name, converted) + cache.Store(dbrole.RoleIdentifier().String(), converted) } } @@ -133,12 +118,8 @@ func convertPermissions(dbPerms []database.CustomRolePermission) []rbac.Permissi // ConvertDBRole should not be used by any human facing apis. It is used // for authz purposes. func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { - name := dbRole.Name - if dbRole.OrganizationID.Valid { - name = rbac.RoleName(dbRole.Name, dbRole.OrganizationID.UUID.String()) - } role := rbac.Role{ - Name: name, + Identifier: dbRole.RoleIdentifier(), DisplayName: dbRole.DisplayName, Site: convertPermissions(dbRole.SitePermissions), Org: nil, diff --git a/coderd/rbac/rolestore/rolestore_test.go b/coderd/rbac/rolestore/rolestore_test.go index 318f2f579b340..b7712357d0721 100644 --- a/coderd/rbac/rolestore/rolestore_test.go +++ b/coderd/rbac/rolestore/rolestore_test.go @@ -35,7 +35,7 @@ func TestExpandCustomRoleRoles(t *testing.T) { }) ctx := testutil.Context(t, testutil.WaitShort) - roles, err := rolestore.Expand(ctx, db, []string{rbac.RoleName(roleName, org.ID.String())}) + roles, err := rolestore.Expand(ctx, db, []rbac.RoleIdentifier{{Name: roleName, OrganizationID: org.ID}}) require.NoError(t, err) require.Len(t, roles, 1, "role found") } diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 3eccd8194f31a..d6a95ccec1b35 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -58,7 +58,7 @@ var builtinScopes = map[ScopeName]Scope{ // authorize checks it is usually not used directly and skips scope checks. ScopeAll: { Role: Role{ - Name: fmt.Sprintf("Scope_%s", ScopeAll), + Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", ScopeAll)}, DisplayName: "All operations", Site: Permissions(map[string][]policy.Action{ ResourceWildcard.Type: {policy.WildcardSymbol}, @@ -71,7 +71,7 @@ var builtinScopes = map[ScopeName]Scope{ ScopeApplicationConnect: { Role: Role{ - Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect), + Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect)}, DisplayName: "Ability to connect to applications", Site: Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: {policy.ActionApplicationConnect}, @@ -87,7 +87,7 @@ type ExpandableScope interface { Expand() (Scope, error) // Name is for logging and tracing purposes, we want to know the human // name of the scope. - Name() string + Name() RoleIdentifier } type ScopeName string @@ -96,8 +96,8 @@ func (name ScopeName) Expand() (Scope, error) { return ExpandScope(name) } -func (name ScopeName) Name() string { - return string(name) +func (name ScopeName) Name() RoleIdentifier { + return RoleIdentifier{Name: string(name)} } // Scope acts the exact same as a Role with the addition that is can also @@ -114,8 +114,8 @@ func (s Scope) Expand() (Scope, error) { return s, nil } -func (s Scope) Name() string { - return s.Role.Name +func (s Scope) Name() RoleIdentifier { + return s.Role.Identifier } func ExpandScope(scope ScopeName) (Scope, error) { diff --git a/coderd/rbac/subject_test.go b/coderd/rbac/subject_test.go index 330ad7403797b..e2a2f24932c36 100644 --- a/coderd/rbac/subject_test.go +++ b/coderd/rbac/subject_test.go @@ -24,13 +24,13 @@ func TestSubjectEqual(t *testing.T) { Name: "Same", A: rbac.Subject{ ID: "id", - Roles: rbac.RoleNames{rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, Groups: []string{"group"}, Scope: rbac.ScopeAll, }, B: rbac.Subject{ ID: "id", - Roles: rbac.RoleNames{rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, Groups: []string{"group"}, Scope: rbac.ScopeAll, }, @@ -49,7 +49,7 @@ func TestSubjectEqual(t *testing.T) { { Name: "RolesNilVs0", A: rbac.Subject{ - Roles: rbac.RoleNames{}, + Roles: rbac.RoleIdentifiers{}, }, B: rbac.Subject{ Roles: nil, @@ -69,20 +69,20 @@ func TestSubjectEqual(t *testing.T) { { Name: "DifferentRoles", A: rbac.Subject{ - Roles: rbac.RoleNames{rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, }, B: rbac.Subject{ - Roles: rbac.RoleNames{rbac.RoleOwner()}, + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, }, Expected: false, }, { Name: "Different#Roles", A: rbac.Subject{ - Roles: rbac.RoleNames{rbac.RoleMember()}, + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, }, B: rbac.Subject{ - Roles: rbac.RoleNames{rbac.RoleMember(), rbac.RoleOwner()}, + Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}, }, Expected: false, }, diff --git a/coderd/roles.go b/coderd/roles.go index 1e7f1b1473b9a..f4e66b7a56a50 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -133,12 +133,12 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customR // The member role is implied, and not assignable. // If there is no display name, then the role is also unassigned. // This is not the ideal logic, but works for now. - if role.Name == rbac.RoleMember() || (role.DisplayName == "") { + if role.Identifier == rbac.RoleMember() || (role.DisplayName == "") { continue } assignable = append(assignable, codersdk.AssignableRoles{ Role: db2sdk.RBACRole(role), - Assignable: rbac.CanAssignRole(actorRoles, role.Name), + Assignable: rbac.CanAssignRole(actorRoles, role.Identifier), BuiltIn: true, }) } @@ -146,7 +146,7 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customR for _, role := range customRoles { assignable = append(assignable, codersdk.AssignableRoles{ Role: db2sdk.Role(role), - Assignable: rbac.CanAssignRole(actorRoles, role.Name), + Assignable: rbac.CanAssignRole(actorRoles, role.RoleIdentifier()), BuiltIn: false, }) } diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 24845fea3fa3d..de9724b4bcb4b 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -51,11 +51,11 @@ func TestListRoles(t *testing.T) { x, err := member.ListSiteRoles(ctx) return x, err }, - ExpectedRoles: convertRoles(map[string]bool{ - "owner": false, - "auditor": false, - "template-admin": false, - "user-admin": false, + ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ + {Name: codersdk.RoleOwner}: false, + {Name: codersdk.RoleAuditor}: false, + {Name: codersdk.RoleTemplateAdmin}: false, + {Name: codersdk.RoleUserAdmin}: false, }), }, { @@ -63,8 +63,8 @@ func TestListRoles(t *testing.T) { APICall: func(ctx context.Context) ([]codersdk.AssignableRoles, error) { return member.ListOrganizationRoles(ctx, owner.OrganizationID) }, - ExpectedRoles: convertRoles(map[string]bool{ - rbac.ScopedRoleOrgAdmin(owner.OrganizationID): false, + ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ + {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: false, }), }, { @@ -80,11 +80,11 @@ func TestListRoles(t *testing.T) { APICall: func(ctx context.Context) ([]codersdk.AssignableRoles, error) { return orgAdmin.ListSiteRoles(ctx) }, - ExpectedRoles: convertRoles(map[string]bool{ - "owner": false, - "auditor": false, - "template-admin": false, - "user-admin": false, + ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ + {Name: codersdk.RoleOwner}: false, + {Name: codersdk.RoleAuditor}: false, + {Name: codersdk.RoleTemplateAdmin}: false, + {Name: codersdk.RoleUserAdmin}: false, }), }, { @@ -92,8 +92,8 @@ func TestListRoles(t *testing.T) { APICall: func(ctx context.Context) ([]codersdk.AssignableRoles, error) { return orgAdmin.ListOrganizationRoles(ctx, owner.OrganizationID) }, - ExpectedRoles: convertRoles(map[string]bool{ - rbac.ScopedRoleOrgAdmin(owner.OrganizationID): true, + ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ + {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true, }), }, { @@ -109,11 +109,11 @@ func TestListRoles(t *testing.T) { APICall: func(ctx context.Context) ([]codersdk.AssignableRoles, error) { return client.ListSiteRoles(ctx) }, - ExpectedRoles: convertRoles(map[string]bool{ - "owner": true, - "auditor": true, - "template-admin": true, - "user-admin": true, + ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ + {Name: codersdk.RoleOwner}: true, + {Name: codersdk.RoleAuditor}: true, + {Name: codersdk.RoleTemplateAdmin}: true, + {Name: codersdk.RoleUserAdmin}: true, }), }, { @@ -121,8 +121,8 @@ func TestListRoles(t *testing.T) { APICall: func(ctx context.Context) ([]codersdk.AssignableRoles, error) { return client.ListOrganizationRoles(ctx, owner.OrganizationID) }, - ExpectedRoles: convertRoles(map[string]bool{ - rbac.ScopedRoleOrgAdmin(owner.OrganizationID): true, + ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ + {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true, }), }, } @@ -200,12 +200,12 @@ func TestListCustomRoles(t *testing.T) { }) } -func convertRole(roleName string) codersdk.Role { +func convertRole(roleName rbac.RoleIdentifier) codersdk.Role { role, _ := rbac.RoleByName(roleName) return db2sdk.RBACRole(role) } -func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { +func convertRoles(assignableRoles map[rbac.RoleIdentifier]bool) []codersdk.AssignableRoles { converted := make([]codersdk.AssignableRoles, 0, len(assignableRoles)) for roleName, assignable := range assignableRoles { role := convertRole(roleName) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 45f6de2d8bf8a..a0799991d8f9d 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" ) @@ -381,7 +380,7 @@ func TestSearchUsers(t *testing.T) { Expected: database.GetUsersParams{ Search: "user-name", Status: []database.UserStatus{database.UserStatusActive}, - RbacRole: []string{rbac.RoleOwner()}, + RbacRole: []string{codersdk.RoleOwner}, }, }, { @@ -390,7 +389,7 @@ func TestSearchUsers(t *testing.T) { Expected: database.GetUsersParams{ Search: "user name", Status: []database.UserStatus{database.UserStatusSuspended}, - RbacRole: []string{rbac.RoleMember()}, + RbacRole: []string{codersdk.RoleMember}, }, }, { @@ -399,7 +398,7 @@ func TestSearchUsers(t *testing.T) { Expected: database.GetUsersParams{ Search: "user-name", Status: []database.UserStatus{database.UserStatusActive}, - RbacRole: []string{rbac.RoleOwner()}, + RbacRole: []string{codersdk.RoleOwner}, }, }, { diff --git a/coderd/userauth.go b/coderd/userauth.go index 6079772945027..306982b29c9ab 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -240,9 +240,15 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) { return } + roleNames, err := roles.RoleNames() + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + userSubj := rbac.Subject{ ID: user.ID.String(), - Roles: rbac.RoleNames(roles.Roles), + Roles: rbac.RoleIdentifiers(roleNames), Groups: roles.Groups, Scope: rbac.ScopeAll, } @@ -1539,7 +1545,9 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C ignored := make([]string, 0) filtered := make([]string, 0, len(params.Roles)) for _, role := range params.Roles { - if _, err := rbac.RoleByName(role); err == nil { + // TODO: This only supports mapping deployment wide roles. Organization scoped roles + // are unsupported. + if _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: role}); err == nil { filtered = append(filtered, role) } else { ignored = append(ignored, role) diff --git a/coderd/users.go b/coderd/users.go index 8db74cadadc9b..1e375232b48e7 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -223,7 +223,7 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { // Add the admin role to this first user. //nolint:gocritic // needed to create first user _, err = api.Database.UpdateUserRoles(dbauthz.AsSystemRestricted(ctx), database.UpdateUserRolesParams{ - GrantedRoles: []string{rbac.RoleOwner()}, + GrantedRoles: []string{rbac.RoleOwner().String()}, ID: user.ID, }) if err != nil { @@ -805,7 +805,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW Message: "You cannot suspend yourself.", }) return - case slice.Contains(user.RBACRoles, rbac.RoleOwner()): + case slice.Contains(user.RBACRoles, rbac.RoleOwner().String()): // You may not suspend an owner httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("You cannot suspend a user with the %q role. You must remove the role first.", rbac.RoleOwner()), diff --git a/coderd/users_test.go b/coderd/users_test.go index 0fa42c4578c6d..65a16cef2dedd 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -994,7 +994,7 @@ func TestGrantSiteRoles(t *testing.T) { Name: "UserNotExists", Client: admin, AssignToUser: uuid.NewString(), - Roles: []string{rbac.RoleOwner()}, + Roles: []string{codersdk.RoleOwner}, Error: true, StatusCode: http.StatusBadRequest, }, @@ -1020,7 +1020,7 @@ func TestGrantSiteRoles(t *testing.T) { Client: admin, OrgID: first.OrganizationID, AssignToUser: codersdk.Me, - Roles: []string{rbac.RoleOwner()}, + Roles: []string{codersdk.RoleOwner}, Error: true, StatusCode: http.StatusBadRequest, }, @@ -1057,9 +1057,9 @@ func TestGrantSiteRoles(t *testing.T) { Name: "UserAdminMakeMember", Client: userAdmin, AssignToUser: newUser, - Roles: []string{rbac.RoleMember()}, + Roles: []string{codersdk.RoleMember}, ExpectedRoles: []string{ - rbac.RoleMember(), + codersdk.RoleMember, }, Error: false, }, @@ -1124,7 +1124,7 @@ func TestInitialRoles(t *testing.T) { roles, err := client.UserRoles(ctx, codersdk.Me) require.NoError(t, err) require.ElementsMatch(t, roles.Roles, []string{ - rbac.RoleOwner(), + codersdk.RoleOwner, }, "should be a member and admin") require.ElementsMatch(t, roles.OrganizationRoles[first.OrganizationID], []string{}, "should be a member") @@ -1289,12 +1289,12 @@ func TestUsersFilter(t *testing.T) { users := make([]codersdk.User, 0) users = append(users, firstUser) for i := 0; i < 15; i++ { - roles := []string{} + roles := []rbac.RoleIdentifier{} if i%2 == 0 { roles = append(roles, rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()) } if i%3 == 0 { - roles = append(roles, "auditor") + roles = append(roles, rbac.RoleAuditor()) } userClient, userData := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, roles...) // Set the last seen for each user to a unique day @@ -1379,12 +1379,12 @@ func TestUsersFilter(t *testing.T) { { Name: "Admins", Filter: codersdk.UsersRequest{ - Role: rbac.RoleOwner(), + Role: codersdk.RoleOwner, Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, }, FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { for _, r := range u.Roles { - if r.Name == rbac.RoleOwner() { + if r.Name == codersdk.RoleOwner { return true } } @@ -1399,7 +1399,7 @@ func TestUsersFilter(t *testing.T) { }, FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { for _, r := range u.Roles { - if r.Name == rbac.RoleOwner() { + if r.Name == codersdk.RoleOwner { return true } } @@ -1409,7 +1409,7 @@ func TestUsersFilter(t *testing.T) { { Name: "Members", Filter: codersdk.UsersRequest{ - Role: rbac.RoleMember(), + Role: codersdk.RoleMember, Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive, }, FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { @@ -1423,7 +1423,7 @@ func TestUsersFilter(t *testing.T) { }, FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { for _, r := range u.Roles { - if r.Name == rbac.RoleOwner() { + if r.Name == codersdk.RoleOwner { return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) && u.Status == codersdk.UserStatusActive } @@ -1438,7 +1438,7 @@ func TestUsersFilter(t *testing.T) { }, FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { for _, r := range u.Roles { - if r.Name == rbac.RoleOwner() { + if r.Name == codersdk.RoleOwner { return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) && u.Status == codersdk.UserStatusActive } diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index ef5b63a1e5b19..e04e585d4aa53 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -555,7 +555,7 @@ func (api *API) verifyUserCanCancelWorkspaceBuilds(ctx context.Context, userID u if err != nil { return false, xerrors.New("user does not exist") } - return slices.Contains(user.RBACRoles, rbac.RoleOwner()), nil // only user with "owner" role can cancel workspace builds + return slices.Contains(user.RBACRoles, rbac.RoleOwner().String()), nil // only user with "owner" role can cancel workspace builds } // @Summary Get build parameters for workspace build diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 5d99e56820aa1..389e0563f46f8 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -224,7 +224,7 @@ func TestWorkspaceBuilds(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) first := coderdtest.CreateFirstUser(t, client) - second, secondUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, "owner") + second, secondUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleOwner()) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index d91de4a5e26a1..a20a26d2ab161 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -484,7 +484,7 @@ func TestWorkspacesSortOrder(t *testing.T) { client, db := coderdtest.NewWithDatabase(t, nil) firstUser := coderdtest.CreateFirstUser(t, client) - secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []string{"owner"}, func(r *codersdk.CreateUserRequest) { + secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []rbac.RoleIdentifier{rbac.RoleOwner()}, func(r *codersdk.CreateUserRequest) { r.Username = "zzz" }) diff --git a/coderd/workspacestats/batcher_internal_test.go b/coderd/workspacestats/batcher_internal_test.go index 0e797986555e5..97fdaf9f2aec5 100644 --- a/coderd/workspacestats/batcher_internal_test.go +++ b/coderd/workspacestats/batcher_internal_test.go @@ -9,6 +9,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/codersdk" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/database" @@ -16,7 +17,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/cryptorand" ) @@ -177,7 +177,7 @@ func setupDeps(t *testing.T, store database.Store, ps pubsub.Pubsub) deps { _, err := store.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{ OrganizationID: org.ID, UserID: user.ID, - Roles: []string{rbac.ScopedRoleOrgMember(org.ID)}, + Roles: []string{codersdk.RoleOrganizationMember}, }) require.NoError(t, err) tv := dbgen.TemplateVersion(t, store, database.TemplateVersion{ diff --git a/codersdk/rbacroles.go b/codersdk/rbacroles.go new file mode 100644 index 0000000000000..fe90d98f77384 --- /dev/null +++ b/codersdk/rbacroles.go @@ -0,0 +1,13 @@ +package codersdk + +// Ideally this roles would be generated from the rbac/roles.go package. +const ( + RoleOwner string = "owner" + RoleMember string = "member" + RoleTemplateAdmin string = "template-admin" + RoleUserAdmin string = "user-admin" + RoleAuditor string = "auditor" + + RoleOrganizationAdmin string = "organization-admin" + RoleOrganizationMember string = "organization-member" +) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 26fdab6ec1bfb..743bd628d8630 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -496,7 +496,7 @@ func (api *API) writeEntitlementWarningsHeader(a rbac.Subject, header http.Heade // The member role is implied, and not assignable. // If there is no display name, then the role is also unassigned. // This is not the ideal logic, but works for now. - if role.Name == rbac.RoleMember() || (role.DisplayName == "") { + if role.Identifier == rbac.RoleMember() || (role.DisplayName == "") { continue } nonMemberRoles++ diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index d881a21e49423..5183a1d4f6a21 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -497,7 +497,7 @@ func testDBAuthzRole(ctx context.Context) context.Context { ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { - Name: "testing", + Identifier: rbac.RoleIdentifier{Name: "testing"}, DisplayName: "Unit Tests", Site: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceWildcard.Type: {policy.WildcardSymbol}, diff --git a/enterprise/coderd/insights_test.go b/enterprise/coderd/insights_test.go index c2d97fea913e3..044c5988eb036 100644 --- a/enterprise/coderd/insights_test.go +++ b/enterprise/coderd/insights_test.go @@ -78,15 +78,15 @@ func TestTemplateInsightsWithRole(t *testing.T) { type test struct { interval codersdk.InsightsReportInterval - role string + role rbac.RoleIdentifier allowed bool } tests := []test{ {codersdk.InsightsReportIntervalDay, rbac.RoleTemplateAdmin(), true}, {"", rbac.RoleTemplateAdmin(), true}, - {codersdk.InsightsReportIntervalDay, "auditor", true}, - {"", "auditor", true}, + {codersdk.InsightsReportIntervalDay, rbac.RoleAuditor(), true}, + {"", rbac.RoleAuditor(), true}, {codersdk.InsightsReportIntervalDay, rbac.RoleUserAdmin(), false}, {"", rbac.RoleUserAdmin(), false}, {codersdk.InsightsReportIntervalDay, rbac.RoleMember(), false}, diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index e1d6855aff002..239a055540075 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -57,7 +58,7 @@ func TestCustomOrganizationRole(t *testing.T) { require.NoError(t, err, "upsert role") // Assign the custom template admin role - tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName()) + tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleIdentifier{Name: role.Name, OrganizationID: first.OrganizationID}) // Assert the role exists // TODO: At present user roles are not returned by the user endpoints. @@ -124,7 +125,7 @@ func TestCustomOrganizationRole(t *testing.T) { require.ErrorContains(t, err, "roles are not enabled") // Assign the custom template admin role - tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName()) + tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleIdentifier{Name: role.Name, OrganizationID: first.OrganizationID}) // Try to create a template version, eg using the custom role coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) @@ -152,7 +153,7 @@ func TestCustomOrganizationRole(t *testing.T) { require.NoError(t, err, "upsert role") // Assign the custom template admin role - tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName()) + tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleIdentifier{Name: role.Name, OrganizationID: first.OrganizationID}) // Try to create a template version, eg using the custom role coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 36e0d69cbd622..f30474d607319 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -66,7 +66,7 @@ func TestUserOIDC(t *testing.T) { cfg.AllowSignups = true cfg.UserRoleField = "roles" cfg.UserRoleMapping = map[string][]string{ - oidcRoleName: {rbac.RoleTemplateAdmin()}, + oidcRoleName: {rbac.RoleTemplateAdmin().String()}, } }, }) @@ -79,7 +79,7 @@ func TestUserOIDC(t *testing.T) { "roles": oidcRoleName, }) require.Equal(t, http.StatusOK, resp.StatusCode) - runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin()}) + runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin().String()}) }) // A user has some roles, then on an oauth refresh will lose said @@ -92,12 +92,12 @@ func TestUserOIDC(t *testing.T) { const oidcRoleName = "TemplateAuthor" runner := setupOIDCTest(t, oidcTestConfig{ - Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}}, + Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()}}, Config: func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true cfg.UserRoleField = "roles" cfg.UserRoleMapping = map[string][]string{ - oidcRoleName: {rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}, + oidcRoleName: {rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()}, } }, }) @@ -105,10 +105,10 @@ func TestUserOIDC(t *testing.T) { // User starts with the owner role client, resp := runner.Login(t, jwt.MapClaims{ "email": "alice@coder.com", - "roles": []string{"random", oidcRoleName, rbac.RoleOwner()}, + "roles": []string{"random", oidcRoleName, rbac.RoleOwner().String()}, }) require.Equal(t, http.StatusOK, resp.StatusCode) - runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), rbac.RoleOwner()}) + runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String(), rbac.RoleOwner().String()}) // Now refresh the oauth, and check the roles are removed. // Force a refresh, and assert nothing has changes @@ -126,12 +126,12 @@ func TestUserOIDC(t *testing.T) { const oidcRoleName = "TemplateAuthor" runner := setupOIDCTest(t, oidcTestConfig{ - Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}}, + Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()}}, Config: func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true cfg.UserRoleField = "roles" cfg.UserRoleMapping = map[string][]string{ - oidcRoleName: {rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}, + oidcRoleName: {rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()}, } }, }) @@ -139,10 +139,10 @@ func TestUserOIDC(t *testing.T) { // User starts with the owner role _, resp := runner.Login(t, jwt.MapClaims{ "email": "alice@coder.com", - "roles": []string{"random", oidcRoleName, rbac.RoleOwner()}, + "roles": []string{"random", oidcRoleName, rbac.RoleOwner().String()}, }) require.Equal(t, http.StatusOK, resp.StatusCode) - runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), rbac.RoleOwner()}) + runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String(), rbac.RoleOwner().String()}) // Now login with oauth again, and check the roles are removed. _, resp = runner.Login(t, jwt.MapClaims{ @@ -175,7 +175,7 @@ func TestUserOIDC(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) _, err := runner.AdminClient.UpdateUserRoles(ctx, "alice", codersdk.UpdateRoles{ Roles: []string{ - rbac.RoleTemplateAdmin(), + rbac.RoleTemplateAdmin().String(), }, }) require.Error(t, err) diff --git a/enterprise/tailnet/pgcoord.go b/enterprise/tailnet/pgcoord.go index 104a649d87839..6bb21d2931689 100644 --- a/enterprise/tailnet/pgcoord.go +++ b/enterprise/tailnet/pgcoord.go @@ -101,7 +101,7 @@ var pgCoordSubject = rbac.Subject{ ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { - Name: "tailnetcoordinator", + Identifier: rbac.RoleIdentifier{Name: "tailnetcoordinator"}, DisplayName: "Tailnet Coordinator", Site: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceTailnetCoordinator.Type: {policy.WildcardSymbol}, From dd99897bb20bd0179169c9b91a476c956e3a60c7 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 11 Jun 2024 12:59:33 -0400 Subject: [PATCH 061/168] chore: updating Ashby link to be position agnostic (#13543) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a39b8219074b2..fd3ac6564e3bf 100644 --- a/README.md +++ b/README.md @@ -125,4 +125,4 @@ contributions! ## Hiring -Apply [here](https://cdr.co/github-apply) if you're interested in joining our team. +Apply [here](https://jobs.ashbyhq.com/coder?utm_source=github&utm_medium=readme&utm_campaign=unknown) if you're interested in joining our team. From a11f8b003bbf85079932eeda15b71e1bd63677fd Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Wed, 12 Jun 2024 17:10:28 +1000 Subject: [PATCH 062/168] chore: write speedtest connection updates to stderr (#13550) --- cli/speedtest.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/speedtest.go b/cli/speedtest.go index 3e0e74668b3b7..42fe7604c6dc4 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -139,14 +139,14 @@ func (r *RootCmd) speedtest() *serpent.Command { } peer := status.Peer[status.Peers()[0]] if !p2p && direct { - cliui.Infof(inv.Stdout, "Waiting for a direct connection... (%dms via %s)", dur.Milliseconds(), peer.Relay) + cliui.Infof(inv.Stderr, "Waiting for a direct connection... (%dms via %s)", dur.Milliseconds(), peer.Relay) continue } via := peer.Relay if via == "" { via = "direct" } - cliui.Infof(inv.Stdout, "%dms via %s", dur.Milliseconds(), via) + cliui.Infof(inv.Stderr, "%dms via %s", dur.Milliseconds(), via) break } } else { From 0c627a4cb9534142f5e3a0349fabaef2d6781a9b Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 12 Jun 2024 10:22:20 -0300 Subject: [PATCH 063/168] refactor(site): refactor filter search field (#13545) --- site/src/components/Filter/filter.tsx | 56 +++--------------- .../SearchField/SearchField.stories.tsx | 45 ++++++++++++++ .../components/SearchField/SearchField.tsx | 58 +++++++++++++++++++ 3 files changed, 112 insertions(+), 47 deletions(-) create mode 100644 site/src/components/SearchField/SearchField.stories.tsx create mode 100644 site/src/components/SearchField/SearchField.tsx diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx index 8335408c11733..91d8d78ee1cf4 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/filter.tsx @@ -1,19 +1,13 @@ import { useTheme } from "@emotion/react"; import CheckOutlined from "@mui/icons-material/CheckOutlined"; -import CloseOutlined from "@mui/icons-material/CloseOutlined"; import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; -import SearchOutlined from "@mui/icons-material/SearchOutlined"; import Button, { type ButtonProps } from "@mui/material/Button"; import Divider from "@mui/material/Divider"; -import IconButton from "@mui/material/IconButton"; -import InputAdornment from "@mui/material/InputAdornment"; import Menu, { type MenuProps } from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import MenuList from "@mui/material/MenuList"; import Skeleton, { type SkeletonProps } from "@mui/material/Skeleton"; -import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; import { type FC, type ReactNode, @@ -35,6 +29,7 @@ import { SearchInput, searchStyles, } from "components/Search/Search"; +import { SearchField } from "components/SearchField/SearchField"; import { useDebouncedFunction } from "hooks/debounce"; import type { useFilterMenu } from "./menu"; import type { BaseOption } from "./options"; @@ -199,7 +194,6 @@ export const Filter: FC = ({ }, [filter.query]); const shouldDisplayError = hasError(error) && isApiValidationError(error); - const hasFilterQuery = filter.query !== ""; return (
= ({ learnMoreLabel2={learnMoreLabel2} learnMoreLink2={learnMoreLink2} /> - = ({ ? getValidationErrorMessage(error) : undefined } - size="small" + placeholder="Search..." + value={queryCopy} + onChange={(query) => { + setQueryCopy(query); + filter.debounceUpdate(query); + }} InputProps={{ - "aria-label": "Filter", - name: "query", - placeholder: "Search...", - value: queryCopy, ref: textboxInputRef, - onChange: (e) => { - setQueryCopy(e.target.value); - filter.debounceUpdate(e.target.value); - }, + "aria-label": "Filter", onBlur: () => { if (queryCopy !== filter.query) { setQueryCopy(filter.query); @@ -258,40 +250,10 @@ export const Filter: FC = ({ "&:hover": { zIndex: 2, }, - "& input::placeholder": { - color: theme.palette.text.secondary, - }, - "& .MuiInputAdornment-root": { - marginLeft: 0, - }, "&.Mui-error": { zIndex: 3, }, }, - startAdornment: ( - - - - ), - endAdornment: hasFilterQuery && ( - - - { - filter.update(""); - }} - > - - - - - ), }} />
diff --git a/site/src/components/SearchField/SearchField.stories.tsx b/site/src/components/SearchField/SearchField.stories.tsx new file mode 100644 index 0000000000000..254de5fe637eb --- /dev/null +++ b/site/src/components/SearchField/SearchField.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within } from "@storybook/test"; +import { useState } from "react"; +import { SearchField } from "./SearchField"; + +const meta: Meta = { + title: "components/SearchField", + component: SearchField, + args: { + placeholder: "Search...", + }, + render: function StatefulWrapper(args) { + const [value, setValue] = useState(args.value); + return ; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = {}; + +export const DefaultValue: Story = { + args: { + value: "owner:me", + }, +}; + +export const TypeValue: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox"); + await userEvent.type(input, "owner:me"); + }, +}; + +export const ClearValue: Story = { + args: { + value: "owner:me", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Clear field" })); + }, +}; diff --git a/site/src/components/SearchField/SearchField.tsx b/site/src/components/SearchField/SearchField.tsx new file mode 100644 index 0000000000000..9e81b74e972ac --- /dev/null +++ b/site/src/components/SearchField/SearchField.tsx @@ -0,0 +1,58 @@ +import { useTheme } from "@emotion/react"; +import CloseIcon from "@mui/icons-material/CloseOutlined"; +import SearchIcon from "@mui/icons-material/SearchOutlined"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField, { type TextFieldProps } from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import visuallyHidden from "@mui/utils/visuallyHidden"; +import type { FC } from "react"; + +export type SearchFieldProps = Omit & { + onChange: (query: string) => void; +}; + +export const SearchField: FC = ({ + value = "", + onChange, + InputProps, + ...textFieldProps +}) => { + const theme = useTheme(); + return ( + onChange(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: value !== "" && ( + + + { + onChange(""); + }} + > + + Clear field + + + + ), + ...InputProps, + }} + {...textFieldProps} + /> + ); +}; From ba7d1835e5c58971771df838d617245182df7269 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 12 Jun 2024 18:33:22 +0400 Subject: [PATCH 064/168] fix: fix flake in TestWorkspaceAgent_Metadata_CatchMemoryLeak (#13553) Fixes flake seen here: https://github.com/coder/coder/actions/runs/9461246505/job/26061605278 #13486 subtly changes the test so that `post` uses the new v2 Agent API, and when canceling context, there is a race condition where the yamux session underpinning the API can get torn down before the RPC processes the canceled context, yielding a different error response than the test was previously expecting. I've refactored the test to just stop posting when the test finishes, rather than depend on a context cancel to end the posting goroutine. --- coderd/workspaceagents_test.go | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index f50a886205cdf..a2915d2633f13 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1350,7 +1350,7 @@ func TestWorkspaceAgent_Metadata_CatchMemoryLeak(t *testing.T) { agentClient := agentsdk.New(client.URL) agentClient.SetSessionToken(r.AgentToken) - ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitSuperLong)) + ctx := testutil.Context(t, testutil.WaitSuperLong) conn, err := agentClient.ConnectRPC(ctx) require.NoError(t, err) defer func() { @@ -1404,20 +1404,21 @@ func TestWorkspaceAgent_Metadata_CatchMemoryLeak(t *testing.T) { postDone := testutil.Go(t, func() { for { + select { + case <-metadataDone: + return + default: + } // We need to send two separate metadata updates to trigger the // memory leak. foo2 will cause the number of foo1 to be doubled, etc. - err = post(ctx, "foo1", "hi") + err := post(ctx, "foo1", "hi") if err != nil { - if !xerrors.Is(err, context.Canceled) { - assert.NoError(t, err, "post metadata foo1") - } + assert.NoError(t, err, "post metadata foo1") return } err = post(ctx, "foo2", "bye") if err != nil { - if !xerrors.Is(err, context.Canceled) { - assert.NoError(t, err, "post metadata foo1") - } + assert.NoError(t, err, "post metadata foo1") return } } @@ -1436,13 +1437,8 @@ func TestWorkspaceAgent_Metadata_CatchMemoryLeak(t *testing.T) { // testing it is not straightforward. db.err.Store(&wantErr) - select { - case <-ctx.Done(): - t.Fatal("timeout waiting for SSE to close") - case <-metadataDone: - } - cancel() - <-postDone + testutil.RequireRecvCtx(ctx, t, metadataDone) + testutil.RequireRecvCtx(ctx, t, postDone) } func TestWorkspaceAgent_Startup(t *testing.T) { From 58bf0ec1c6f64ea2c61e383444f2e25ea14d2cc8 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Thu, 13 Jun 2024 02:02:34 +1000 Subject: [PATCH 065/168] chore: add additional tailnet topology integration tests (#13549) --- tailnet/test/integration/integration.go | 85 +++++++++++++++++++++++-- tailnet/test/integration/suite.go | 56 +++++++++++++++- 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/tailnet/test/integration/integration.go b/tailnet/test/integration/integration.go index 3877542c8eafc..938ed29e8d555 100644 --- a/tailnet/test/integration/integration.go +++ b/tailnet/test/integration/integration.go @@ -111,6 +111,53 @@ type SimpleServerOptions struct { var _ ServerStarter = SimpleServerOptions{} +type connManager struct { + mu sync.Mutex + conns map[uuid.UUID]net.Conn +} + +func (c *connManager) Add(id uuid.UUID, conn net.Conn) func() { + c.mu.Lock() + defer c.mu.Unlock() + c.conns[id] = conn + return func() { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.conns, id) + } +} + +func (c *connManager) CloseAll() { + c.mu.Lock() + defer c.mu.Unlock() + for _, conn := range c.conns { + _ = conn.Close() + } + c.conns = make(map[uuid.UUID]net.Conn) +} + +type derpServer struct { + http.Handler + srv *derp.Server + closeFn func() +} + +func newDerpServer(t *testing.T, logger slog.Logger) *derpServer { + derpSrv := derp.NewServer(key.NewNode(), tailnet.Logger(logger.Named("derp"))) + derpHandler, derpCloseFunc := tailnet.WithWebsocketSupport(derpSrv, derphttp.Handler(derpSrv)) + t.Cleanup(derpCloseFunc) + return &derpServer{ + srv: derpSrv, + Handler: derpHandler, + closeFn: derpCloseFunc, + } +} + +func (s *derpServer) Close() { + s.srv.Close() + s.closeFn() +} + //nolint:revive func (o SimpleServerOptions) Router(t *testing.T, logger slog.Logger) *chi.Mux { coord := tailnet.NewCoordinator(logger) @@ -118,6 +165,10 @@ func (o SimpleServerOptions) Router(t *testing.T, logger slog.Logger) *chi.Mux { coordPtr.Store(&coord) t.Cleanup(func() { _ = coord.Close() }) + cm := connManager{ + conns: make(map[uuid.UUID]net.Conn), + } + csvc, err := tailnet.NewClientService(logger, &coordPtr, 10*time.Minute, func() *tailcfg.DERPMap { return &tailcfg.DERPMap{ // Clients will set their own based on their custom access URL. @@ -126,9 +177,11 @@ func (o SimpleServerOptions) Router(t *testing.T, logger slog.Logger) *chi.Mux { }) require.NoError(t, err) - derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(logger.Named("derp"))) - derpHandler, derpCloseFunc := tailnet.WithWebsocketSupport(derpServer, derphttp.Handler(derpServer)) - t.Cleanup(derpCloseFunc) + derpServer := atomic.Pointer[derpServer]{} + derpServer.Store(newDerpServer(t, logger)) + t.Cleanup(func() { + derpServer.Load().Close() + }) r := chi.NewRouter() r.Use( @@ -166,11 +219,32 @@ func (o SimpleServerOptions) Router(t *testing.T, logger slog.Logger) *chi.Mux { return } - derpHandler.ServeHTTP(w, r) + derpServer.Load().ServeHTTP(w, r) }) r.Get("/latency-check", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + r.Post("/restart", func(w http.ResponseWriter, r *http.Request) { + oldServer := derpServer.Swap(newDerpServer(t, logger)) + oldServer.Close() + w.WriteHeader(http.StatusOK) + }) + }) + + // /restart?derp=[true|false]&coordinator=[true|false] + r.Post("/restart", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("derp") == "true" { + logger.Info(r.Context(), "killing DERP server") + oldServer := derpServer.Swap(newDerpServer(t, logger)) + oldServer.Close() + logger.Info(r.Context(), "restarted DERP server") + } + + if r.URL.Query().Get("coordinator") == "true" { + logger.Info(r.Context(), "simulating coordinator restart") + cm.CloseAll() + } + w.WriteHeader(http.StatusOK) }) r.Get("/api/v2/workspaceagents/{id}/coordinate", func(w http.ResponseWriter, r *http.Request) { @@ -199,6 +273,9 @@ func (o SimpleServerOptions) Router(t *testing.T, logger slog.Logger) *chi.Mux { ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary) defer wsNetConn.Close() + cleanFn := cm.Add(id, wsNetConn) + defer cleanFn() + err = csvc.ServeConnV2(ctx, wsNetConn, tailnet.StreamID{ Name: "client-" + id.String(), ID: id, diff --git a/tailnet/test/integration/suite.go b/tailnet/test/integration/suite.go index 32d9adb2e4a14..e3403da32b359 100644 --- a/tailnet/test/integration/suite.go +++ b/tailnet/test/integration/suite.go @@ -4,8 +4,10 @@ package integration import ( + "net/http" "net/url" "testing" + "time" "github.com/stretchr/testify/require" @@ -14,9 +16,34 @@ import ( "github.com/coder/coder/v2/testutil" ) +// nolint:revive +func sendRestart(t *testing.T, serverURL *url.URL, derp bool, coordinator bool) { + t.Helper() + ctx := testutil.Context(t, 2*time.Second) + + serverURL, err := url.Parse(serverURL.String() + "/restart") + q := serverURL.Query() + if derp { + q.Set("derp", "true") + } + if coordinator { + q.Set("coordinator", "true") + } + serverURL.RawQuery = q.Encode() + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL.String(), nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "unexpected status code %d", resp.StatusCode) +} + // TODO: instead of reusing one conn for each suite, maybe we should make a new // one for each subtest? -func TestSuite(t *testing.T, _ slog.Logger, _ *url.URL, conn *tailnet.Conn, _, peer Client) { +func TestSuite(t *testing.T, _ slog.Logger, serverURL *url.URL, conn *tailnet.Conn, _, peer Client) { t.Parallel() t.Run("Connectivity", func(t *testing.T) { @@ -26,5 +53,30 @@ func TestSuite(t *testing.T, _ slog.Logger, _ *url.URL, conn *tailnet.Conn, _, p require.NoError(t, err, "ping peer") }) - // TODO: more + t.Run("RestartDERP", func(t *testing.T) { + peerIP := tailnet.IPFromUUID(peer.ID) + _, _, _, err := conn.Ping(testutil.Context(t, testutil.WaitLong), peerIP) + require.NoError(t, err, "ping peer") + sendRestart(t, serverURL, true, false) + _, _, _, err = conn.Ping(testutil.Context(t, testutil.WaitLong), peerIP) + require.NoError(t, err, "ping peer after derp restart") + }) + + t.Run("RestartCoordinator", func(t *testing.T) { + peerIP := tailnet.IPFromUUID(peer.ID) + _, _, _, err := conn.Ping(testutil.Context(t, testutil.WaitLong), peerIP) + require.NoError(t, err, "ping peer") + sendRestart(t, serverURL, false, true) + _, _, _, err = conn.Ping(testutil.Context(t, testutil.WaitLong), peerIP) + require.NoError(t, err, "ping peer after coordinator restart") + }) + + t.Run("RestartBoth", func(t *testing.T) { + peerIP := tailnet.IPFromUUID(peer.ID) + _, _, _, err := conn.Ping(testutil.Context(t, testutil.WaitLong), peerIP) + require.NoError(t, err, "ping peer") + sendRestart(t, serverURL, true, true) + _, _, _, err = conn.Ping(testutil.Context(t, testutil.WaitLong), peerIP) + require.NoError(t, err, "ping peer after restart") + }) } From 28228f1bcb0215fee62a6e096ac4fc93581d2726 Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Wed, 12 Jun 2024 12:28:13 -0600 Subject: [PATCH 066/168] feat: allow editing org icon (#13547) --- clock/mock.go | 5 +-- coderd/apidoc/docs.go | 9 ++++++ coderd/apidoc/swagger.json | 9 ++++++ coderd/database/dbgen/dbgen.go | 1 + coderd/database/dbmem/dbmem.go | 3 ++ coderd/database/dump.sql | 3 +- .../000219_organization_icon.down.sql | 2 ++ .../000219_organization_icon.up.sql | 2 ++ coderd/database/models.go | 1 + coderd/database/queries.sql.go | 32 +++++++++++++------ coderd/database/queries/organizations.sql | 7 ++-- coderd/organizations.go | 10 ++++-- coderd/organizations_test.go | 27 +++++++++++++++- codersdk/organizations.go | 9 ++++-- docs/api/organizations.md | 5 +++ docs/api/schemas.md | 6 ++++ docs/api/users.md | 3 ++ site/src/api/typesGenerated.ts | 3 ++ site/src/testHelpers/entities.ts | 1 + 19 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 coderd/database/migrations/000219_organization_icon.down.sql create mode 100644 coderd/database/migrations/000219_organization_icon.up.sql diff --git a/clock/mock.go b/clock/mock.go index 6e66206c1614d..97e7a16874851 100644 --- a/clock/mock.go +++ b/clock/mock.go @@ -2,12 +2,13 @@ package clock import ( "context" - "errors" "fmt" "slices" "sync" "testing" "time" + + "golang.org/x/xerrors" ) // Mock is the testing implementation of Clock. It tracks a time that monotonically increases @@ -571,7 +572,7 @@ func (t *Trap) Close() { close(t.done) } -var ErrTrapClosed = errors.New("trap closed") +var ErrTrapClosed = xerrors.New("trap closed") func (t *Trap) Wait(ctx context.Context) (*Call, error) { select { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81c16ba784798..70988aa071afb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8376,6 +8376,9 @@ const docTemplate = `{ "description": "DisplayName will default to the same value as ` + "`" + `Name` + "`" + ` if not provided.", "type": "string" }, + "icon": { + "type": "string" + }, "name": { "type": "string" } @@ -10007,6 +10010,9 @@ const docTemplate = `{ "display_name": { "type": "string" }, + "icon": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" @@ -11724,6 +11730,9 @@ const docTemplate = `{ "display_name": { "type": "string" }, + "icon": { + "type": "string" + }, "name": { "type": "string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7859bcb5ded02..32dd1565a5a28 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7456,6 +7456,9 @@ "description": "DisplayName will default to the same value as `Name` if not provided.", "type": "string" }, + "icon": { + "type": "string" + }, "name": { "type": "string" } @@ -8996,6 +8999,9 @@ "display_name": { "type": "string" }, + "icon": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" @@ -10628,6 +10634,9 @@ "display_name": { "type": "string" }, + "icon": { + "type": "string" + }, "name": { "type": "string" } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index bc18da548d683..23c6e8a351da0 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -338,6 +338,7 @@ func Organization(t testing.TB, db database.Store, orig database.Organization) d Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), DisplayName: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), Description: takeFirst(orig.Description, namesgenerator.GetRandomName(1)), + Icon: takeFirst(orig.Icon, ""), CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), }) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 55251f71227ca..d1e80305db109 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -88,6 +88,7 @@ func New() database.Store { Name: "first-organization", DisplayName: "first-organization", Description: "Builtin default organization.", + Icon: "", CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), }) @@ -6189,6 +6190,7 @@ func (q *FakeQuerier) InsertOrganization(_ context.Context, arg database.InsertO Name: arg.Name, DisplayName: arg.DisplayName, Description: arg.Description, + Icon: arg.Icon, CreatedAt: arg.CreatedAt, UpdatedAt: arg.UpdatedAt, IsDefault: len(q.organizations) == 0, @@ -7334,6 +7336,7 @@ func (q *FakeQuerier) UpdateOrganization(_ context.Context, arg database.UpdateO org.Name = arg.Name org.DisplayName = arg.DisplayName org.Description = arg.Description + org.Icon = arg.Icon q.organizations[i] = org return org, nil } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 83eea6e3583a6..ca063f4b08eb1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -595,7 +595,8 @@ CREATE TABLE organizations ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, is_default boolean DEFAULT false NOT NULL, - display_name text NOT NULL + display_name text NOT NULL, + icon text DEFAULT ''::text NOT NULL ); CREATE TABLE parameter_schemas ( diff --git a/coderd/database/migrations/000219_organization_icon.down.sql b/coderd/database/migrations/000219_organization_icon.down.sql new file mode 100644 index 0000000000000..99b32ec8dab41 --- /dev/null +++ b/coderd/database/migrations/000219_organization_icon.down.sql @@ -0,0 +1,2 @@ +alter table organizations + drop column icon; diff --git a/coderd/database/migrations/000219_organization_icon.up.sql b/coderd/database/migrations/000219_organization_icon.up.sql new file mode 100644 index 0000000000000..6690301a3b549 --- /dev/null +++ b/coderd/database/migrations/000219_organization_icon.up.sql @@ -0,0 +1,2 @@ +alter table organizations + add column icon text not null default ''; diff --git a/coderd/database/models.go b/coderd/database/models.go index 8a558f5beeb0b..963587875b372 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1933,6 +1933,7 @@ type Organization struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` IsDefault bool `db:"is_default" json:"is_default"` DisplayName string `db:"display_name" json:"display_name"` + Icon string `db:"icon" json:"icon"` } type OrganizationMember struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 823cf2cc45796..4866bedfb8ebb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3949,7 +3949,7 @@ func (q *sqlQuerier) DeleteOrganization(ctx context.Context, id uuid.UUID) error const getDefaultOrganization = `-- name: GetDefaultOrganization :one SELECT - id, name, description, created_at, updated_at, is_default, display_name + id, name, description, created_at, updated_at, is_default, display_name, icon FROM organizations WHERE @@ -3969,13 +3969,14 @@ func (q *sqlQuerier) GetDefaultOrganization(ctx context.Context) (Organization, &i.UpdatedAt, &i.IsDefault, &i.DisplayName, + &i.Icon, ) return i, err } const getOrganizationByID = `-- name: GetOrganizationByID :one SELECT - id, name, description, created_at, updated_at, is_default, display_name + id, name, description, created_at, updated_at, is_default, display_name, icon FROM organizations WHERE @@ -3993,13 +3994,14 @@ func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Org &i.UpdatedAt, &i.IsDefault, &i.DisplayName, + &i.Icon, ) return i, err } const getOrganizationByName = `-- name: GetOrganizationByName :one SELECT - id, name, description, created_at, updated_at, is_default, display_name + id, name, description, created_at, updated_at, is_default, display_name, icon FROM organizations WHERE @@ -4019,13 +4021,14 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, name string) (Or &i.UpdatedAt, &i.IsDefault, &i.DisplayName, + &i.Icon, ) return i, err } const getOrganizations = `-- name: GetOrganizations :many SELECT - id, name, description, created_at, updated_at, is_default, display_name + id, name, description, created_at, updated_at, is_default, display_name, icon FROM organizations ` @@ -4047,6 +4050,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context) ([]Organization, erro &i.UpdatedAt, &i.IsDefault, &i.DisplayName, + &i.Icon, ); err != nil { return nil, err } @@ -4063,7 +4067,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context) ([]Organization, erro const getOrganizationsByUserID = `-- name: GetOrganizationsByUserID :many SELECT - id, name, description, created_at, updated_at, is_default, display_name + id, name, description, created_at, updated_at, is_default, display_name, icon FROM organizations WHERE @@ -4094,6 +4098,7 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.U &i.UpdatedAt, &i.IsDefault, &i.DisplayName, + &i.Icon, ); err != nil { return nil, err } @@ -4110,10 +4115,10 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.U const insertOrganization = `-- name: InsertOrganization :one INSERT INTO - organizations (id, "name", display_name, description, created_at, updated_at, is_default) + organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default) VALUES -- If no organizations exist, and this is the first, make it the default. - ($1, $2, $3, $4, $5, $6, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name + ($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon ` type InsertOrganizationParams struct { @@ -4121,6 +4126,7 @@ type InsertOrganizationParams struct { Name string `db:"name" json:"name"` DisplayName string `db:"display_name" json:"display_name"` Description string `db:"description" json:"description"` + Icon string `db:"icon" json:"icon"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } @@ -4131,6 +4137,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat arg.Name, arg.DisplayName, arg.Description, + arg.Icon, arg.CreatedAt, arg.UpdatedAt, ) @@ -4143,6 +4150,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat &i.UpdatedAt, &i.IsDefault, &i.DisplayName, + &i.Icon, ) return i, err } @@ -4154,10 +4162,11 @@ SET updated_at = $1, name = $2, display_name = $3, - description = $4 + description = $4, + icon = $5 WHERE - id = $5 -RETURNING id, name, description, created_at, updated_at, is_default, display_name + id = $6 +RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon ` type UpdateOrganizationParams struct { @@ -4165,6 +4174,7 @@ type UpdateOrganizationParams struct { Name string `db:"name" json:"name"` DisplayName string `db:"display_name" json:"display_name"` Description string `db:"description" json:"description"` + Icon string `db:"icon" json:"icon"` ID uuid.UUID `db:"id" json:"id"` } @@ -4174,6 +4184,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat arg.Name, arg.DisplayName, arg.Description, + arg.Icon, arg.ID, ) var i Organization @@ -4185,6 +4196,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat &i.UpdatedAt, &i.IsDefault, &i.DisplayName, + &i.Icon, ) return i, err } diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index dbefb9f8ad711..787985c3bdbbc 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -49,10 +49,10 @@ WHERE -- name: InsertOrganization :one INSERT INTO - organizations (id, "name", display_name, description, created_at, updated_at, is_default) + organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default) VALUES -- If no organizations exist, and this is the first, make it the default. - (@id, @name, @display_name, @description, @created_at, @updated_at, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *; + (@id, @name, @display_name, @description, @icon, @created_at, @updated_at, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *; -- name: UpdateOrganization :one UPDATE @@ -61,7 +61,8 @@ SET updated_at = @updated_at, name = @name, display_name = @display_name, - description = @description + description = @description, + icon = @icon WHERE id = @id RETURNING *; diff --git a/coderd/organizations.go b/coderd/organizations.go index 259fb6486dfd8..6c0b14697a642 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -83,6 +83,7 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { Name: req.Name, DisplayName: req.DisplayName, Description: req.Description, + Icon: req.Icon, CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), }) @@ -164,6 +165,7 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) { Name: organization.Name, DisplayName: organization.DisplayName, Description: organization.Description, + Icon: organization.Icon, } if req.Name != "" { @@ -172,8 +174,11 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) { if req.DisplayName != "" { updateOrgParams.DisplayName = req.DisplayName } - if req.Description != "" { - updateOrgParams.Description = req.Description + if req.Description != nil { + updateOrgParams.Description = *req.Description + } + if req.Icon != nil { + updateOrgParams.Icon = *req.Icon } organization, err = tx.UpdateOrganization(ctx, updateOrgParams) @@ -248,6 +253,7 @@ func convertOrganization(organization database.Organization) codersdk.Organizati Name: organization.Name, DisplayName: organization.DisplayName, Description: organization.Description, + Icon: organization.Icon, CreatedAt: organization.CreatedAt, UpdatedAt: organization.UpdatedAt, IsDefault: organization.IsDefault, diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index 20fb7243faa5b..0dafb53590814 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -142,11 +143,13 @@ func TestPostOrganizationsByUser(t *testing.T) { Name: "new", DisplayName: "New", Description: "A new organization to love and cherish forever.", + Icon: "/emojis/1f48f-1f3ff.png", }) require.NoError(t, err) require.Equal(t, "new", o.Name) require.Equal(t, "New", o.DisplayName) require.Equal(t, "A new organization to love and cherish forever.", o.Description) + require.Equal(t, "/emojis/1f48f-1f3ff.png", o.Icon) }) t.Run("CreateWithoutExplicitDisplayName", func(t *testing.T) { @@ -300,7 +303,7 @@ func TestPatchOrganizationsByUser(t *testing.T) { require.NoError(t, err) o, err = client.UpdateOrganization(ctx, o.Name, codersdk.UpdateOrganizationRequest{ - Description: "wow, this organization description is so updated!", + Description: ptr.Ref("wow, this organization description is so updated!"), }) require.NoError(t, err) @@ -308,6 +311,28 @@ func TestPatchOrganizationsByUser(t *testing.T) { require.Equal(t, "New", o.DisplayName) // didn't change require.Equal(t, "wow, this organization description is so updated!", o.Description) }) + + t.Run("UpdateIcon", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "new", + DisplayName: "New", + }) + require.NoError(t, err) + + o, err = client.UpdateOrganization(ctx, o.Name, codersdk.UpdateOrganizationRequest{ + Icon: ptr.Ref("/emojis/1f48f-1f3ff.png"), + }) + + require.NoError(t, err) + require.Equal(t, "new", o.Name) // didn't change + require.Equal(t, "New", o.DisplayName) // didn't change + require.Equal(t, "/emojis/1f48f-1f3ff.png", o.Icon) + }) } func TestDeleteOrganizationsByUser(t *testing.T) { diff --git a/codersdk/organizations.go b/codersdk/organizations.go index b9ff98d1a3917..f125dbca3dc58 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -47,6 +47,7 @@ type Organization struct { CreatedAt time.Time `table:"created_at" json:"created_at" validate:"required" format:"date-time"` UpdatedAt time.Time `table:"updated_at" json:"updated_at" validate:"required" format:"date-time"` IsDefault bool `table:"default" json:"is_default" validate:"required"` + Icon string `table:"icon" json:"icon"` } type OrganizationMember struct { @@ -62,12 +63,14 @@ type CreateOrganizationRequest struct { // DisplayName will default to the same value as `Name` if not provided. DisplayName string `json:"display_name" validate:"omitempty,organization_display_name"` Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` } type UpdateOrganizationRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,organization_name"` - DisplayName string `json:"display_name,omitempty" validate:"omitempty,organization_display_name"` - Description string `json:"description,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,organization_name"` + DisplayName string `json:"display_name,omitempty" validate:"omitempty,organization_display_name"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` } // CreateTemplateVersionRequest enables callers to create a new Template Version. diff --git a/docs/api/organizations.md b/docs/api/organizations.md index 820d4be64d281..a1f8273549f80 100644 --- a/docs/api/organizations.md +++ b/docs/api/organizations.md @@ -107,6 +107,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations \ { "description": "string", "display_name": "string", + "icon": "string", "name": "string" } ``` @@ -126,6 +127,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations \ "created_at": "2019-08-24T14:15:22Z", "description": "string", "display_name": "string", + "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", @@ -169,6 +171,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization} \ "created_at": "2019-08-24T14:15:22Z", "description": "string", "display_name": "string", + "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", @@ -248,6 +251,7 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization} \ { "description": "string", "display_name": "string", + "icon": "string", "name": "string" } ``` @@ -268,6 +272,7 @@ curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization} \ "created_at": "2019-08-24T14:15:22Z", "description": "string", "display_name": "string", + "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 348ce54e11ba3..a825e21778c74 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1029,6 +1029,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "description": "string", "display_name": "string", + "icon": "string", "name": "string" } ``` @@ -1039,6 +1040,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | -------------- | ------ | -------- | ------------ | ---------------------------------------------------------------------- | | `description` | string | false | | | | `display_name` | string | false | | Display name will default to the same value as `Name` if not provided. | +| `icon` | string | false | | | | `name` | string | true | | | ## codersdk.CreateTemplateRequest @@ -3214,6 +3216,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "created_at": "2019-08-24T14:15:22Z", "description": "string", "display_name": "string", + "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", @@ -3228,6 +3231,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `created_at` | string | true | | | | `description` | string | false | | | | `display_name` | string | true | | | +| `icon` | string | false | | | | `id` | string | true | | | | `is_default` | boolean | true | | | | `name` | string | true | | | @@ -5000,6 +5004,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o { "description": "string", "display_name": "string", + "icon": "string", "name": "string" } ``` @@ -5010,6 +5015,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | -------------- | ------ | -------- | ------------ | ----------- | | `description` | string | false | | | | `display_name` | string | false | | | +| `icon` | string | false | | | | `name` | string | false | | | ## codersdk.UpdateRoles diff --git a/docs/api/users.md b/docs/api/users.md index 2a40ba1e8577b..1f6a37346e1f1 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -1000,6 +1000,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations \ "created_at": "2019-08-24T14:15:22Z", "description": "string", "display_name": "string", + "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", @@ -1024,6 +1025,7 @@ Status Code **200** | `» created_at` | string(date-time) | true | | | | `» description` | string | false | | | | `» display_name` | string | true | | | +| `» icon` | string | false | | | | `» id` | string(uuid) | true | | | | `» is_default` | boolean | true | | | | `» name` | string | true | | | @@ -1060,6 +1062,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/organizations/{organiza "created_at": "2019-08-24T14:15:22Z", "description": "string", "display_name": "string", + "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_default": true, "name": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a53717e3e0229..971fae1149075 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -228,6 +228,7 @@ export interface CreateOrganizationRequest { readonly name: string; readonly display_name: string; readonly description?: string; + readonly icon?: string; } // From codersdk/organizations.go @@ -784,6 +785,7 @@ export interface Organization { readonly created_at: string; readonly updated_at: string; readonly is_default: boolean; + readonly icon: string; } // From codersdk/organizations.go @@ -1330,6 +1332,7 @@ export interface UpdateOrganizationRequest { readonly name?: string; readonly display_name?: string; readonly description?: string; + readonly icon?: string; } // From codersdk/users.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 424c1b7ef331d..bd7627f070fdf 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -16,6 +16,7 @@ export const MockOrganization: TypesGen.Organization = { name: "test-organization", display_name: "Test Organization", description: "", + icon: "", created_at: "", updated_at: "", is_default: true, From 1ca5dc03282020264f71b85f6533d28dfa3fa590 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 12 Jun 2024 14:52:35 -0400 Subject: [PATCH 067/168] chore: always use the latest released version tag when building (#13556) * chore: always use the latest released version tag when building * Update version.sh Co-authored-by: Dean Sheather --------- Co-authored-by: Dean Sheather --- scripts/version.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/version.sh b/scripts/version.sh index eba2f63cbc40e..4a87853d2c99d 100755 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -41,7 +41,14 @@ if ! [[ ${remote_url} =~ [@/]github.com ]] && ! [[ ${remote_url} =~ [:/]coder/co log last_tag="v2.0.0" else - last_tag="$(git describe --tags --abbrev=0)" + current_commit=$(git rev-parse HEAD) + # Try to find the last tag that contains the current commit + last_tag=$(git tag --contains "$current_commit" --sort=version:refname | head -n 1) + # If there is no tag that contains the current commit, + # get the latest tag sorted by semver. + if [[ -z "${last_tag}" ]]; then + last_tag=$(git tag --sort=version:refname | tail -n 1) + fi fi version="${last_tag}" From de9e6889bb9d2127c23a4b1f9bdecd9aa687d67e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 12 Jun 2024 09:23:48 -1000 Subject: [PATCH 068/168] chore: merge organization member db queries (#13542) Merge members queries into 1 that also joins in the user table for username. Required to list organization members on UI/cli --- cli/server_createadminuser_test.go | 6 +- coderd/database/dbauthz/dbauthz.go | 18 +-- coderd/database/dbauthz/dbauthz_test.go | 42 +++--- coderd/database/dbauthz/setup_test.go | 40 +++++- coderd/database/dbgen/dbgen_test.go | 4 +- coderd/database/dbmem/dbmem.go | 63 ++++----- coderd/database/dbmetrics/dbmetrics.go | 21 +-- coderd/database/dbmock/dbmock.go | 45 ++---- coderd/database/modelmethods.go | 4 + coderd/database/modelqueries.go | 24 ++++ coderd/database/querier.go | 7 +- coderd/database/querier_test.go | 36 +++++ coderd/database/queries.sql.go | 129 +++++++++--------- .../database/queries/organizationmembers.sql | 35 +++-- coderd/httpmw/organizationparam.go | 6 +- coderd/userauth.go | 9 +- coderd/users.go | 8 +- enterprise/coderd/groups.go | 4 +- 18 files changed, 290 insertions(+), 211 deletions(-) diff --git a/cli/server_createadminuser_test.go b/cli/server_createadminuser_test.go index 9bc6add2ecbd2..6e3939ea298d6 100644 --- a/cli/server_createadminuser_test.go +++ b/cli/server_createadminuser_test.go @@ -67,12 +67,12 @@ func TestServerCreateAdminUser(t *testing.T) { orgIDs[org.ID] = struct{}{} } - orgMemberships, err := db.GetOrganizationMembershipsByUserID(ctx, user.ID) + orgMemberships, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{UserID: user.ID}) require.NoError(t, err) orgIDs2 := make(map[uuid.UUID]struct{}, len(orgMemberships)) for _, membership := range orgMemberships { - orgIDs2[membership.OrganizationID] = struct{}{} - assert.Equal(t, []string{rbac.RoleOrgAdmin()}, membership.Roles, "user is not org admin") + orgIDs2[membership.OrganizationMember.OrganizationID] = struct{}{} + assert.Equal(t, []string{rbac.RoleOrgAdmin()}, membership.OrganizationMember.Roles, "user is not org admin") } require.Equal(t, orgIDs, orgIDs2, "user is not in all orgs") diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bc8bf19763c73..85659751a9107 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1476,14 +1476,6 @@ func (q *querier) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid. return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOrganizationIDsByMemberIDs)(ctx, ids) } -func (q *querier) GetOrganizationMemberByUserID(ctx context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { - return fetch(q.log, q.auth, q.db.GetOrganizationMemberByUserID)(ctx, arg) -} - -func (q *querier) GetOrganizationMembershipsByUserID(ctx context.Context, userID uuid.UUID) ([]database.OrganizationMember, error) { - return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOrganizationMembershipsByUserID)(ctx, userID) -} - func (q *querier) GetOrganizations(ctx context.Context) ([]database.Organization, error) { fetch := func(ctx context.Context, _ interface{}) ([]database.Organization, error) { return q.db.GetOrganizations(ctx) @@ -2771,6 +2763,10 @@ func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID return q.db.ListWorkspaceAgentPortShares(ctx, workspaceID) } +func (q *querier) OrganizationMembers(ctx context.Context, arg database.OrganizationMembersParams) ([]database.OrganizationMembersRow, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.OrganizationMembers)(ctx, arg) +} + func (q *querier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { template, err := q.db.GetTemplateByID(ctx, templateID) if err != nil { @@ -2870,15 +2866,15 @@ func (q *querier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfte func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { // Authorized fetch will check that the actor has read access to the org member since the org member is returned. - member, err := q.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{ + member, err := database.ExpectOne(q.OrganizationMembers(ctx, database.OrganizationMembersParams{ OrganizationID: arg.OrgID, UserID: arg.UserID, - }) + })) if err != nil { return database.OrganizationMember{}, err } - originalRoles, err := q.convertToOrganizationRoles(member.OrganizationID, member.Roles) + originalRoles, err := q.convertToOrganizationRoles(member.OrganizationMember.OrganizationID, member.OrganizationMember.Roles) if err != nil { return database.OrganizationMember{}, xerrors.Errorf("convert original roles: %w", err) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 9d90a4d44114a..44d45118ce1ea 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -596,19 +596,6 @@ func (s *MethodTestSuite) TestOrganization() { check.Args([]uuid.UUID{ma.UserID, mb.UserID}). Asserts(rbac.ResourceUserObject(ma.UserID), policy.ActionRead, rbac.ResourceUserObject(mb.UserID), policy.ActionRead) })) - s.Run("GetOrganizationMemberByUserID", s.Subtest(func(db database.Store, check *expects) { - mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{}) - check.Args(database.GetOrganizationMemberByUserIDParams{ - OrganizationID: mem.OrganizationID, - UserID: mem.UserID, - }).Asserts(mem, policy.ActionRead).Returns(mem) - })) - s.Run("GetOrganizationMembershipsByUserID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - a := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID}) - b := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID}) - check.Args(u.ID).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) - })) s.Run("GetOrganizations", s.Subtest(func(db database.Store, check *expects) { def, _ := db.GetDefaultOrganization(context.Background()) a := dbgen.Organization(s.T(), db, database.Organization{}) @@ -658,6 +645,22 @@ func (s *MethodTestSuite) TestOrganization() { o.ID, ).Asserts(o, policy.ActionDelete) })) + s.Run("OrganizationMembers", s.Subtest(func(db database.Store, check *expects) { + o := dbgen.Organization(s.T(), db, database.Organization{}) + u := dbgen.User(s.T(), db, database.User{}) + mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ + OrganizationID: o.ID, + UserID: u.ID, + Roles: []string{rbac.RoleOrgAdmin()}, + }) + + check.Args(database.OrganizationMembersParams{ + OrganizationID: uuid.UUID{}, + UserID: uuid.UUID{}, + }).Asserts( + mem, policy.ActionRead, + ) + })) s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) u := dbgen.User(s.T(), db, database.User{}) @@ -673,11 +676,14 @@ func (s *MethodTestSuite) TestOrganization() { GrantedRoles: []string{}, UserID: u.ID, OrgID: o.ID, - }).Asserts( - mem, policy.ActionRead, - rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionAssign, // org-mem - rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionDelete, // org-admin - ).Returns(out) + }). + WithNotAuthorized(sql.ErrNoRows.Error()). + WithCancelled(sql.ErrNoRows.Error()). + Asserts( + mem, policy.ActionRead, + rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionAssign, // org-mem + rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionDelete, // org-admin + ).Returns(out) })) } diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index e391b9e2ef3c6..4df38a3ca4b98 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -157,7 +157,7 @@ func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expec if len(testCase.assertions) > 0 { // Only run these tests if we know the underlying call makes // rbac assertions. - s.NotAuthorizedErrorTest(ctx, fakeAuthorizer, callMethod) + s.NotAuthorizedErrorTest(ctx, fakeAuthorizer, testCase, callMethod) } if len(testCase.assertions) > 0 || @@ -230,7 +230,7 @@ func (s *MethodTestSuite) NoActorErrorTest(callMethod func(ctx context.Context) // NotAuthorizedErrorTest runs the given method with an authorizer that will fail authz. // Asserts that the error returned is a NotAuthorizedError. -func (s *MethodTestSuite) NotAuthorizedErrorTest(ctx context.Context, az *coderdtest.FakeAuthorizer, callMethod func(ctx context.Context) ([]reflect.Value, error)) { +func (s *MethodTestSuite) NotAuthorizedErrorTest(ctx context.Context, az *coderdtest.FakeAuthorizer, testCase expects, callMethod func(ctx context.Context) ([]reflect.Value, error)) { s.Run("NotAuthorized", func() { az.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("Always fail authz"), rbac.Subject{}, "", rbac.Object{}, nil) @@ -242,9 +242,14 @@ func (s *MethodTestSuite) NotAuthorizedErrorTest(ctx context.Context, az *coderd // This is unfortunate, but if we are using `Filter` the error returned will be nil. So filter out // any case where the error is nil and the response is an empty slice. if err != nil || !hasEmptySliceResponse(resp) { - s.ErrorContainsf(err, "unauthorized", "error string should have a good message") - s.Errorf(err, "method should an error with disallow authz") - s.ErrorAs(err, &dbauthz.NotAuthorizedError{}, "error should be NotAuthorizedError") + // Expect the default error + if testCase.notAuthorizedExpect == "" { + s.ErrorContainsf(err, "unauthorized", "error string should have a good message") + s.Errorf(err, "method should an error with disallow authz") + s.ErrorAs(err, &dbauthz.NotAuthorizedError{}, "error should be NotAuthorizedError") + } else { + s.ErrorContains(err, testCase.notAuthorizedExpect) + } } }) @@ -263,8 +268,12 @@ func (s *MethodTestSuite) NotAuthorizedErrorTest(ctx context.Context, az *coderd // This is unfortunate, but if we are using `Filter` the error returned will be nil. So filter out // any case where the error is nil and the response is an empty slice. if err != nil || !hasEmptySliceResponse(resp) { - s.Errorf(err, "method should an error with cancellation") - s.ErrorIsf(err, context.Canceled, "error should match context.Canceled") + if testCase.cancelledCtxExpect == "" { + s.Errorf(err, "method should an error with cancellation") + s.ErrorIsf(err, context.Canceled, "error should match context.Canceled") + } else { + s.ErrorContains(err, testCase.cancelledCtxExpect) + } } }) } @@ -308,6 +317,13 @@ type expects struct { // outputs is optional. Can assert non-error return values. outputs []reflect.Value err error + + // Optional override of the default error checks. + // By default, we search for the expected error strings. + // If these strings are present, these strings will be searched + // instead. + notAuthorizedExpect string + cancelledCtxExpect string } // Asserts is required. Asserts the RBAC authorize calls that should be made. @@ -338,6 +354,16 @@ func (m *expects) Errors(err error) *expects { return m } +func (m *expects) WithNotAuthorized(contains string) *expects { + m.notAuthorizedExpect = contains + return m +} + +func (m *expects) WithCancelled(contains string) *expects { + m.cancelledCtxExpect = contains + return m +} + // AssertRBAC contains the object and actions to be asserted. type AssertRBAC struct { Object rbac.Object diff --git a/coderd/database/dbgen/dbgen_test.go b/coderd/database/dbgen/dbgen_test.go index eaf5a0e764482..2681f6eb1fece 100644 --- a/coderd/database/dbgen/dbgen_test.go +++ b/coderd/database/dbgen/dbgen_test.go @@ -119,10 +119,10 @@ func TestGenerator(t *testing.T) { t.Parallel() db := dbmem.New() exp := dbgen.OrganizationMember(t, db, database.OrganizationMember{}) - require.Equal(t, exp, must(db.GetOrganizationMemberByUserID(context.Background(), database.GetOrganizationMemberByUserIDParams{ + require.Equal(t, exp, must(database.ExpectOne(db.OrganizationMembers(context.Background(), database.OrganizationMembersParams{ OrganizationID: exp.OrganizationID, UserID: exp.UserID, - }))) + }))).OrganizationMember) }) t.Run("Workspace", func(t *testing.T) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index d1e80305db109..92961d4cc84ed 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2760,41 +2760,6 @@ func (q *FakeQuerier) GetOrganizationIDsByMemberIDs(_ context.Context, ids []uui return getOrganizationIDsByMemberIDRows, nil } -func (q *FakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { - if err := validateDatabaseType(arg); err != nil { - return database.OrganizationMember{}, err - } - - q.mutex.RLock() - defer q.mutex.RUnlock() - - for _, organizationMember := range q.organizationMembers { - if organizationMember.OrganizationID != arg.OrganizationID { - continue - } - if organizationMember.UserID != arg.UserID { - continue - } - return organizationMember, nil - } - return database.OrganizationMember{}, sql.ErrNoRows -} - -func (q *FakeQuerier) GetOrganizationMembershipsByUserID(_ context.Context, userID uuid.UUID) ([]database.OrganizationMember, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - var memberships []database.OrganizationMember - for _, organizationMember := range q.organizationMembers { - mem := organizationMember - if mem.UserID != userID { - continue - } - memberships = append(memberships, mem) - } - return memberships, nil -} - func (q *FakeQuerier) GetOrganizations(_ context.Context) ([]database.Organization, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -6965,6 +6930,34 @@ func (q *FakeQuerier) ListWorkspaceAgentPortShares(_ context.Context, workspaceI return shares, nil } +func (q *FakeQuerier) OrganizationMembers(_ context.Context, arg database.OrganizationMembersParams) ([]database.OrganizationMembersRow, error) { + if err := validateDatabaseType(arg); err != nil { + return []database.OrganizationMembersRow{}, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + tmp := make([]database.OrganizationMembersRow, 0) + for _, organizationMember := range q.organizationMembers { + if arg.OrganizationID != uuid.Nil && organizationMember.OrganizationID != arg.OrganizationID { + continue + } + + if arg.UserID != uuid.Nil && organizationMember.UserID != arg.UserID { + continue + } + + organizationMember := organizationMember + user, _ := q.getUserByIDNoLock(organizationMember.UserID) + tmp = append(tmp, database.OrganizationMembersRow{ + OrganizationMember: organizationMember, + Username: user.Username, + }) + } + return tmp, nil +} + func (q *FakeQuerier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(_ context.Context, templateID uuid.UUID) error { err := validateDatabaseType(templateID) if err != nil { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index aff562fcdb89f..1891fe6f999e9 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -760,20 +760,6 @@ func (m metricsStore) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []u return organizations, err } -func (m metricsStore) GetOrganizationMemberByUserID(ctx context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { - start := time.Now() - member, err := m.s.GetOrganizationMemberByUserID(ctx, arg) - m.queryLatencies.WithLabelValues("GetOrganizationMemberByUserID").Observe(time.Since(start).Seconds()) - return member, err -} - -func (m metricsStore) GetOrganizationMembershipsByUserID(ctx context.Context, userID uuid.UUID) ([]database.OrganizationMember, error) { - start := time.Now() - memberships, err := m.s.GetOrganizationMembershipsByUserID(ctx, userID) - m.queryLatencies.WithLabelValues("GetOrganizationMembershipsByUserID").Observe(time.Since(start).Seconds()) - return memberships, err -} - func (m metricsStore) GetOrganizations(ctx context.Context) ([]database.Organization, error) { start := time.Now() organizations, err := m.s.GetOrganizations(ctx) @@ -1747,6 +1733,13 @@ func (m metricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspac return r0, r1 } +func (m metricsStore) OrganizationMembers(ctx context.Context, arg database.OrganizationMembersParams) ([]database.OrganizationMembersRow, error) { + start := time.Now() + r0, r1 := m.s.OrganizationMembers(ctx, arg) + m.queryLatencies.WithLabelValues("OrganizationMembers").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error { start := time.Now() r0 := m.s.ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx, templateID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 3ef96d13f8b33..b49d3e7f06c76 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1514,36 +1514,6 @@ func (mr *MockStoreMockRecorder) GetOrganizationIDsByMemberIDs(arg0, arg1 any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationIDsByMemberIDs", reflect.TypeOf((*MockStore)(nil).GetOrganizationIDsByMemberIDs), arg0, arg1) } -// GetOrganizationMemberByUserID mocks base method. -func (m *MockStore) GetOrganizationMemberByUserID(arg0 context.Context, arg1 database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetOrganizationMemberByUserID", arg0, arg1) - ret0, _ := ret[0].(database.OrganizationMember) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetOrganizationMemberByUserID indicates an expected call of GetOrganizationMemberByUserID. -func (mr *MockStoreMockRecorder) GetOrganizationMemberByUserID(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationMemberByUserID", reflect.TypeOf((*MockStore)(nil).GetOrganizationMemberByUserID), arg0, arg1) -} - -// GetOrganizationMembershipsByUserID mocks base method. -func (m *MockStore) GetOrganizationMembershipsByUserID(arg0 context.Context, arg1 uuid.UUID) ([]database.OrganizationMember, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetOrganizationMembershipsByUserID", arg0, arg1) - ret0, _ := ret[0].([]database.OrganizationMember) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetOrganizationMembershipsByUserID indicates an expected call of GetOrganizationMembershipsByUserID. -func (mr *MockStoreMockRecorder) GetOrganizationMembershipsByUserID(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationMembershipsByUserID", reflect.TypeOf((*MockStore)(nil).GetOrganizationMembershipsByUserID), arg0, arg1) -} - // GetOrganizations mocks base method. func (m *MockStore) GetOrganizations(arg0 context.Context) ([]database.Organization, error) { m.ctrl.T.Helper() @@ -3661,6 +3631,21 @@ func (mr *MockStoreMockRecorder) ListWorkspaceAgentPortShares(arg0, arg1 any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkspaceAgentPortShares", reflect.TypeOf((*MockStore)(nil).ListWorkspaceAgentPortShares), arg0, arg1) } +// OrganizationMembers mocks base method. +func (m *MockStore) OrganizationMembers(arg0 context.Context, arg1 database.OrganizationMembersParams) ([]database.OrganizationMembersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OrganizationMembers", arg0, arg1) + ret0, _ := ret[0].([]database.OrganizationMembersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// OrganizationMembers indicates an expected call of OrganizationMembers. +func (mr *MockStoreMockRecorder) OrganizationMembers(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OrganizationMembers", reflect.TypeOf((*MockStore)(nil).OrganizationMembers), arg0, arg1) +} + // Ping mocks base method. func (m *MockStore) Ping(arg0 context.Context) (time.Duration, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index e5fd1db60337f..ee22ae1ad42ba 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -179,6 +179,10 @@ func (m OrganizationMember) RBACObject() rbac.Object { WithOwner(m.UserID.String()) } +func (m OrganizationMembersRow) RBACObject() rbac.Object { + return m.OrganizationMember.RBACObject() +} + func (m GetOrganizationIDsByMemberIDsRow) RBACObject() rbac.Object { // TODO: This feels incorrect as we are really returning a list of orgmembers. // This return type should be refactored to return a list of orgmembers, not this diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index ca38505b28ef0..9cc5d7792101c 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -2,6 +2,7 @@ package database import ( "context" + "database/sql" "fmt" "strings" @@ -17,6 +18,29 @@ const ( authorizedQueryPlaceholder = "-- @authorize_filter" ) +// ExpectOne can be used to convert a ':many:' query into a ':one' +// query. To reduce the quantity of SQL queries, a :many with a filter is used. +// These filters sometimes are expected to return just 1 row. +// +// A :many query will never return a sql.ErrNoRows, but a :one does. +// This function will correct the error for the empty set. +func ExpectOne[T any](ret []T, err error) (T, error) { + var empty T + if err != nil { + return empty, err + } + + if len(ret) == 0 { + return empty, sql.ErrNoRows + } + + if len(ret) > 1 { + return empty, xerrors.Errorf("too many rows returned, expected 1") + } + + return ret[0], nil +} + // customQuerier encompasses all non-generated queries. // It provides a flexible way to write queries for cases // where sqlc proves inadequate. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 6e2b1ff60cfdf..f87e6015b517e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -151,8 +151,6 @@ type sqlcQuerier interface { GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, name string) (Organization, error) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error) - GetOrganizationMemberByUserID(ctx context.Context, arg GetOrganizationMemberByUserIDParams) (OrganizationMember, error) - GetOrganizationMembershipsByUserID(ctx context.Context, userID uuid.UUID) ([]OrganizationMember, error) GetOrganizations(ctx context.Context) ([]Organization, error) GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]Organization, error) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) @@ -349,6 +347,11 @@ type sqlcQuerier interface { InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) + // Arguments are optional with uuid.Nil to ignore. + // - Use just 'organization_id' to get all members of an org + // - Use just 'user_id' to get all orgs a user is a member of + // - Use both to get a specific org member row + OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 0d523c25290e2..22004e6fab71c 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -903,6 +903,42 @@ func TestArchiveVersions(t *testing.T) { }) } +func TestExpectOne(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + t.Run("ErrNoRows", func(t *testing.T) { + t.Parallel() + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + ctx := context.Background() + + _, err = database.ExpectOne(db.GetUsers(ctx, database.GetUsersParams{})) + require.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("TooMany", func(t *testing.T) { + t.Parallel() + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + ctx := context.Background() + + // Create 2 organizations so the query returns >1 + dbgen.Organization(t, db, database.Organization{}) + dbgen.Organization(t, db, database.Organization{}) + + // Organizations is an easy table without foreign key dependencies + _, err = database.ExpectOne(db.GetOrganizations(ctx)) + require.ErrorContains(t, err, "too many rows returned") + }) +} + func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) { t.Helper() require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4866bedfb8ebb..ac05a3f26d061 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3795,25 +3795,35 @@ func (q *sqlQuerier) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uu return items, nil } -const getOrganizationMemberByUserID = `-- name: GetOrganizationMemberByUserID :one -SELECT - user_id, organization_id, created_at, updated_at, roles -FROM - organization_members -WHERE - organization_id = $1 - AND user_id = $2 -LIMIT - 1 +const insertOrganizationMember = `-- name: InsertOrganizationMember :one +INSERT INTO + organization_members ( + organization_id, + user_id, + created_at, + updated_at, + roles + ) +VALUES + ($1, $2, $3, $4, $5) RETURNING user_id, organization_id, created_at, updated_at, roles ` -type GetOrganizationMemberByUserIDParams struct { +type InsertOrganizationMemberParams struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Roles []string `db:"roles" json:"roles"` } -func (q *sqlQuerier) GetOrganizationMemberByUserID(ctx context.Context, arg GetOrganizationMemberByUserIDParams) (OrganizationMember, error) { - row := q.db.QueryRowContext(ctx, getOrganizationMemberByUserID, arg.OrganizationID, arg.UserID) +func (q *sqlQuerier) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) { + row := q.db.QueryRowContext(ctx, insertOrganizationMember, + arg.OrganizationID, + arg.UserID, + arg.CreatedAt, + arg.UpdatedAt, + pq.Array(arg.Roles), + ) var i OrganizationMember err := row.Scan( &i.UserID, @@ -3825,30 +3835,59 @@ func (q *sqlQuerier) GetOrganizationMemberByUserID(ctx context.Context, arg GetO return i, err } -const getOrganizationMembershipsByUserID = `-- name: GetOrganizationMembershipsByUserID :many +const organizationMembers = `-- name: OrganizationMembers :many SELECT - user_id, organization_id, created_at, updated_at, roles + organization_members.user_id, organization_members.organization_id, organization_members.created_at, organization_members.updated_at, organization_members.roles, + users.username FROM organization_members + INNER JOIN + users ON organization_members.user_id = users.id WHERE - user_id = $1 + -- Filter by organization id + CASE + WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + organization_id = $1 + ELSE true + END + -- Filter by user id + AND CASE + WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + user_id = $2 + ELSE true + END ` -func (q *sqlQuerier) GetOrganizationMembershipsByUserID(ctx context.Context, userID uuid.UUID) ([]OrganizationMember, error) { - rows, err := q.db.QueryContext(ctx, getOrganizationMembershipsByUserID, userID) +type OrganizationMembersParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` +} + +type OrganizationMembersRow struct { + OrganizationMember OrganizationMember `db:"organization_member" json:"organization_member"` + Username string `db:"username" json:"username"` +} + +// Arguments are optional with uuid.Nil to ignore. +// - Use just 'organization_id' to get all members of an org +// - Use just 'user_id' to get all orgs a user is a member of +// - Use both to get a specific org member row +func (q *sqlQuerier) OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) { + rows, err := q.db.QueryContext(ctx, organizationMembers, arg.OrganizationID, arg.UserID) if err != nil { return nil, err } defer rows.Close() - var items []OrganizationMember + var items []OrganizationMembersRow for rows.Next() { - var i OrganizationMember + var i OrganizationMembersRow if err := rows.Scan( - &i.UserID, - &i.OrganizationID, - &i.CreatedAt, - &i.UpdatedAt, - pq.Array(&i.Roles), + &i.OrganizationMember.UserID, + &i.OrganizationMember.OrganizationID, + &i.OrganizationMember.CreatedAt, + &i.OrganizationMember.UpdatedAt, + pq.Array(&i.OrganizationMember.Roles), + &i.Username, ); err != nil { return nil, err } @@ -3863,46 +3902,6 @@ func (q *sqlQuerier) GetOrganizationMembershipsByUserID(ctx context.Context, use return items, nil } -const insertOrganizationMember = `-- name: InsertOrganizationMember :one -INSERT INTO - organization_members ( - organization_id, - user_id, - created_at, - updated_at, - roles - ) -VALUES - ($1, $2, $3, $4, $5) RETURNING user_id, organization_id, created_at, updated_at, roles -` - -type InsertOrganizationMemberParams struct { - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Roles []string `db:"roles" json:"roles"` -} - -func (q *sqlQuerier) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) { - row := q.db.QueryRowContext(ctx, insertOrganizationMember, - arg.OrganizationID, - arg.UserID, - arg.CreatedAt, - arg.UpdatedAt, - pq.Array(arg.Roles), - ) - var i OrganizationMember - err := row.Scan( - &i.UserID, - &i.OrganizationID, - &i.CreatedAt, - &i.UpdatedAt, - pq.Array(&i.Roles), - ) - return i, err -} - const updateMemberRoles = `-- name: UpdateMemberRoles :one UPDATE organization_members diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 10a45d25eb2c5..d32d9a8e8abc8 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -1,13 +1,28 @@ --- name: GetOrganizationMemberByUserID :one +-- name: OrganizationMembers :many +-- Arguments are optional with uuid.Nil to ignore. +-- - Use just 'organization_id' to get all members of an org +-- - Use just 'user_id' to get all orgs a user is a member of +-- - Use both to get a specific org member row SELECT - * + sqlc.embed(organization_members), + users.username FROM organization_members + INNER JOIN + users ON organization_members.user_id = users.id WHERE - organization_id = $1 - AND user_id = $2 -LIMIT - 1; + -- Filter by organization id + CASE + WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + organization_id = @organization_id + ELSE true + END + -- Filter by user id + AND CASE + WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + user_id = @user_id + ELSE true + END; -- name: InsertOrganizationMember :one INSERT INTO @@ -22,14 +37,6 @@ VALUES ($1, $2, $3, $4, $5) RETURNING *; --- name: GetOrganizationMembershipsByUserID :many -SELECT - * -FROM - organization_members -WHERE - user_id = $1; - -- name: GetOrganizationIDsByMemberIDs :many SELECT user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs" diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index 0c8ccae96c519..9ec0af6a460cf 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -124,10 +124,10 @@ func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.H } organization := OrganizationParam(r) - organizationMember, err := db.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{ + organizationMember, err := database.ExpectOne(db.OrganizationMembers(ctx, database.OrganizationMembersParams{ OrganizationID: organization.ID, UserID: user.ID, - }) + })) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return @@ -141,7 +141,7 @@ func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.H } ctx = context.WithValue(ctx, organizationMemberParamContextKey{}, OrganizationMember{ - OrganizationMember: organizationMember, + OrganizationMember: organizationMember.OrganizationMember, // Here we're making two exceptions to the rule about not leaking data about the user // to the API handler, which is to include the username and avatar URL. // If the caller has permission to read the OrganizationMember, then we're explicitly diff --git a/coderd/userauth.go b/coderd/userauth.go index 306982b29c9ab..b9d163a6afdac 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1518,15 +1518,18 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C } //nolint:gocritic // No user present in the context. - memberships, err := tx.GetOrganizationMembershipsByUserID(dbauthz.AsSystemRestricted(ctx), user.ID) + memberships, err := tx.OrganizationMembers(dbauthz.AsSystemRestricted(ctx), database.OrganizationMembersParams{ + UserID: user.ID, + OrganizationID: uuid.Nil, + }) if err != nil { return xerrors.Errorf("get organization memberships: %w", err) } // If the user is not in the default organization, then we can't assign groups. // A user cannot be in groups to an org they are not a member of. - if !slices.ContainsFunc(memberships, func(member database.OrganizationMember) bool { - return member.OrganizationID == defaultOrganization.ID + if !slices.ContainsFunc(memberships, func(member database.OrganizationMembersRow) bool { + return member.OrganizationMember.OrganizationID == defaultOrganization.ID }) { return xerrors.Errorf("user %s is not a member of the default organization, cannot assign to groups in the org", user.ID) } diff --git a/coderd/users.go b/coderd/users.go index 1e375232b48e7..b8a3306b12121 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1027,12 +1027,16 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { return } + // TODO: Replace this with "GetAuthorizationUserRoles" resp := codersdk.UserRoles{ Roles: user.RBACRoles, OrganizationRoles: make(map[uuid.UUID][]string), } - memberships, err := api.Database.GetOrganizationMembershipsByUserID(ctx, user.ID) + memberships, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ + UserID: user.ID, + OrganizationID: uuid.Nil, + }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching user's organization memberships.", @@ -1042,7 +1046,7 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { } for _, mem := range memberships { - resp.OrganizationRoles[mem.OrganizationID] = mem.Roles + resp.OrganizationRoles[mem.OrganizationMember.OrganizationID] = mem.OrganizationMember.Roles } httpapi.Write(ctx, rw, http.StatusOK, resp) diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index dea135f683fb8..65220e5cbabf7 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -166,10 +166,10 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { } // TODO: It would be nice to enforce this at the schema level // but unfortunately our org_members table does not have an ID. - _, err := api.Database.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{ + _, err := database.ExpectOne(api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ OrganizationID: group.OrganizationID, UserID: uuid.MustParse(id), - }) + })) if xerrors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("User %q must be a member of organization %q", id, group.ID), From bbe23edc7d88f42afb256b5d9899a5c322efcc12 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 12 Jun 2024 09:52:18 -1000 Subject: [PATCH 069/168] chore: implement api layer for listing organization members (#13546) --- coderd/apidoc/docs.go | 67 ++++++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 63 ++++++++++++++++++++++++++++++++ coderd/coderd.go | 1 + coderd/members.go | 41 +++++++++++++++++++++ coderd/members_test.go | 60 ++++++++++++++++++++++++++++++ codersdk/organizations.go | 5 +++ codersdk/users.go | 14 +++++++ docs/api/members.md | 67 ++++++++++++++++++++++++++++++++++ docs/api/schemas.md | 30 +++++++++++++++ site/src/api/typesGenerated.ts | 5 +++ 10 files changed, 353 insertions(+) create mode 100644 coderd/members_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 70988aa071afb..80db374931774 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2245,6 +2245,43 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/members": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "List organization members", + "operationId": "list-organization-members", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithName" + } + } + } + } + } + }, "/organizations/{organization}/members/roles": { "get": { "security": [ @@ -10056,6 +10093,36 @@ const docTemplate = `{ } } }, + "codersdk.OrganizationMemberWithName": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, "codersdk.PatchGroupRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 32dd1565a5a28..069ffecea9c11 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1963,6 +1963,39 @@ } } }, + "/organizations/{organization}/members": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "List organization members", + "operationId": "list-organization-members", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithName" + } + } + } + } + } + }, "/organizations/{organization}/members/roles": { "get": { "security": [ @@ -9045,6 +9078,36 @@ } } }, + "codersdk.OrganizationMemberWithName": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, "codersdk.PatchGroupRequest": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index f7f1f52ee5bea..e8a698de0de34 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -837,6 +837,7 @@ func New(options *Options) *API { }) }) r.Route("/members", func(r chi.Router) { + r.Get("/", api.listMembers) r.Route("/roles", func(r chi.Router) { r.Get("/", api.assignableOrgRoles) r.With(httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentCustomRoles)). diff --git a/coderd/members.go b/coderd/members.go index 3110cc51dbcf2..1877cad78a614 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -3,6 +3,8 @@ package coderd import ( "net/http" + "github.com/google/uuid" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac" @@ -12,6 +14,36 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// @Summary List organization members +// @ID list-organization-members +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Param organization path string true "Organization ID" +// @Success 200 {object} []codersdk.OrganizationMemberWithName +// @Router /organizations/{organization}/members [get] +func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + ) + + members, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ + OrganizationID: organization.ID, + UserID: uuid.Nil, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(members, convertOrganizationMemberRow)) +} + // @Summary Assign role to organization member // @ID assign-role-to-organization-member // @Security CoderSessionToken @@ -73,3 +105,12 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz } return convertedMember } + +func convertOrganizationMemberRow(row database.OrganizationMembersRow) codersdk.OrganizationMemberWithName { + convertedMember := codersdk.OrganizationMemberWithName{ + Username: row.Username, + OrganizationMember: convertOrganizationMember(row.OrganizationMember), + } + + return convertedMember +} diff --git a/coderd/members_test.go b/coderd/members_test.go new file mode 100644 index 0000000000000..250a594a150f5 --- /dev/null +++ b/coderd/members_test.go @@ -0,0 +1,60 @@ +package coderd_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestListMembers(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + + client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + + ctx := testutil.Context(t, testutil.WaitShort) + members, err := client.OrganizationMembers(ctx, first.OrganizationID) + require.NoError(t, err) + require.Len(t, members, 2) + require.ElementsMatch(t, + []uuid.UUID{first.UserID, user.ID}, + db2sdk.List(members, onlyIDs)) + }) + + // Calling it from a user without the org access. + t.Run("NotInOrg", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + + client, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + + ctx := testutil.Context(t, testutil.WaitShort) + org, err := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "test", + DisplayName: "", + Description: "", + }) + require.NoError(t, err, "create organization") + + // 404 error is expected instead of a 403/401 to not leak existence of + // an organization. + _, err = client.OrganizationMembers(ctx, org.ID) + require.ErrorContains(t, err, "404") + }) +} + +func onlyIDs(u codersdk.OrganizationMemberWithName) uuid.UUID { + return u.UserID +} diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f125dbca3dc58..d8f4bc4c2aea7 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -58,6 +58,11 @@ type OrganizationMember struct { Roles []SlimRole `db:"roles" json:"roles"` } +type OrganizationMemberWithName struct { + Username string `table:"username,default_sort" json:"username"` + OrganizationMember `table:"m,recursive_inline"` +} + type CreateOrganizationRequest struct { Name string `json:"name" validate:"required,organization_name"` // DisplayName will default to the same value as `Name` if not provided. diff --git a/codersdk/users.go b/codersdk/users.go index 003ede2f9bd60..f16780aa2eb7c 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -379,6 +379,20 @@ func (c *Client) UpdateUserPassword(ctx context.Context, user string, req Update return nil } +// OrganizationMembers lists all members in an organization +func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]OrganizationMemberWithName, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var members []OrganizationMemberWithName + return members, json.NewDecoder(res.Body).Decode(&members) +} + // UpdateUserRoles grants the userID the specified roles. // Include ALL roles the user has. func (c *Client) UpdateUserRoles(ctx context.Context, user string, req UpdateRoles) (User, error) { diff --git a/docs/api/members.md b/docs/api/members.md index ce7cc81f1762b..77ef260131e29 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -1,5 +1,72 @@ # Members +## List organization members + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/members` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | --------------- | +| `organization` | path | string | true | Organization ID | + +### Example responses + +> 200 Response + +```json +[ + { + "created_at": "2019-08-24T14:15:22Z", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OrganizationMemberWithName](schemas.md#codersdkorganizationmemberwithname) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------------- | ----------------- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» roles` | array | false | | | +| `»» display_name` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» user_id` | string(uuid) | false | | | +| `» username` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get member roles by organization ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a825e21778c74..c8aacbc39439b 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3265,6 +3265,36 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `updated_at` | string | false | | | | `user_id` | string | false | | | +## codersdk.OrganizationMemberWithName + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `organization_id` | string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `updated_at` | string | false | | | +| `user_id` | string | false | | | +| `username` | string | false | | | + ## codersdk.PatchGroupRequest ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 971fae1149075..4610863299874 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -797,6 +797,11 @@ export interface OrganizationMember { readonly roles: readonly SlimRole[]; } +// From codersdk/organizations.go +export interface OrganizationMemberWithName extends OrganizationMember { + readonly username: string; +} + // From codersdk/pagination.go export interface Pagination { readonly after_id?: string; From d0fc81a51c3d5f854c7c4fd78ae341ca1d75300e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 12 Jun 2024 10:07:12 -1000 Subject: [PATCH 070/168] chore: implement cli list organization members (#13555) example cli command: `coder organization members` --- cli/cliui/table.go | 18 ++++ cli/cliui/table_test.go | 28 ++--- cli/organization.go | 10 +- cli/organizationmembers.go | 52 +++++++++ cli/organizationmembers_test.go | 36 +++++++ coderd/members.go | 120 +++++++++++++++++---- coderd/rbac/roles.go | 4 + codersdk/organizations.go | 10 +- codersdk/roles.go | 16 +++ enterprise/cli/organizationmembers_test.go | 68 ++++++++++++ 10 files changed, 317 insertions(+), 45 deletions(-) create mode 100644 cli/organizationmembers.go create mode 100644 cli/organizationmembers_test.go create mode 100644 enterprise/cli/organizationmembers_test.go diff --git a/cli/cliui/table.go b/cli/cliui/table.go index f1fb8075133c8..c9f3ee69936b4 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -205,6 +205,24 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string } } + // Guard against nil dereferences + if v != nil { + rt := reflect.TypeOf(v) + switch rt.Kind() { + case reflect.Slice: + // By default, the behavior is '%v', which just returns a string like + // '[a b c]'. This will add commas in between each value. + strs := make([]string, 0) + vt := reflect.ValueOf(v) + for i := 0; i < vt.Len(); i++ { + strs = append(strs, fmt.Sprintf("%v", vt.Index(i).Interface())) + } + v = "[" + strings.Join(strs, ", ") + "]" + default: + // Leave it as it is + } + } + rowSlice[i] = v } diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index 5772c5cf5869e..bb46219c3c80e 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -138,10 +138,10 @@ func Test_DisplayTable(t *testing.T) { t.Parallel() expected := ` -NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR -bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z -baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z -foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z +NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z +baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z +foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z ` // Test with non-pointer values. @@ -165,10 +165,10 @@ foo 10 [a b c] foo1 11 foo2 12 foo3 t.Parallel() expected := ` -NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR -foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z -bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z -baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z +NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z +bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z +baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z ` out, err := cliui.DisplayTable(in, "age", nil) @@ -235,12 +235,12 @@ Alice 25 t.Run("WithSeparator", func(t *testing.T) { t.Parallel() expected := ` -NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR -bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z -------------------------------------------------------------------------------------------------------------------------------------------------------------- -baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z -------------------------------------------------------------------------------------------------------------------------------------------------------------- -foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z +NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z +--------------------------------------------------------------------------------------------------------------------------------------------------------------- +baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z +--------------------------------------------------------------------------------------------------------------------------------------------------------------- +foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z ` var inlineIn []any diff --git a/cli/organization.go b/cli/organization.go index beb52cb5df8f2..36ea0737812b0 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -18,11 +18,10 @@ import ( func (r *RootCmd) organizations() *serpent.Command { cmd := &serpent.Command{ - Annotations: workspaceCommand, - Use: "organizations [subcommand]", - Short: "Organization related commands", - Aliases: []string{"organization", "org", "orgs"}, - Hidden: true, // Hidden until these commands are complete. + Use: "organizations [subcommand]", + Short: "Organization related commands", + Aliases: []string{"organization", "org", "orgs"}, + Hidden: true, // Hidden until these commands are complete. Handler: func(inv *serpent.Invocation) error { return inv.Command.HelpHandler(inv) }, @@ -31,6 +30,7 @@ func (r *RootCmd) organizations() *serpent.Command { r.switchOrganization(), r.createOrganization(), r.organizationRoles(), + r.organizationMembers(), }, } diff --git a/cli/organizationmembers.go b/cli/organizationmembers.go new file mode 100644 index 0000000000000..58138e65a3c37 --- /dev/null +++ b/cli/organizationmembers.go @@ -0,0 +1,52 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) organizationMembers() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]codersdk.OrganizationMemberWithName{}, []string{"username", "organization_roles"}), + cliui.JSONFormat(), + ) + + client := new(codersdk.Client) + cmd := &serpent.Command{ + Use: "members", + Short: "List all organization members", + Aliases: []string{"member"}, + Middleware: serpent.Chain( + serpent.RequireNArgs(0), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + organization, err := CurrentOrganization(r, inv, client) + if err != nil { + return err + } + + res, err := client.OrganizationMembers(ctx, organization.ID) + if err != nil { + return xerrors.Errorf("fetch members: %w", err) + } + + out, err := formatter.Format(inv.Context(), res) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + formatter.AttachOptions(&cmd.Options) + + return cmd +} diff --git a/cli/organizationmembers_test.go b/cli/organizationmembers_test.go new file mode 100644 index 0000000000000..077ec0e00ab83 --- /dev/null +++ b/cli/organizationmembers_test.go @@ -0,0 +1,36 @@ +package cli_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/testutil" +) + +func TestListOrganizationMembers(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ownerClient := coderdtest.New(t, &coderdtest.Options{}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "organization", "members", "-c", "user_id,username,roles") + clitest.SetupConfig(t, client, root) + + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, buf.String(), user.Username) + require.Contains(t, buf.String(), owner.UserID.String()) + }) +} diff --git a/coderd/members.go b/coderd/members.go index 1877cad78a614..eaa14ada67d8e 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -1,16 +1,17 @@ package coderd import ( + "context" "net/http" "github.com/google/uuid" - - "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/coderd/rbac" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) @@ -41,7 +42,13 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(members, convertOrganizationMemberRow)) + resp, err := convertOrganizationMemberRows(ctx, api.Database, members) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) } // @Summary Assign role to organization member @@ -87,30 +94,101 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, convertOrganizationMember(updatedUser)) + resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{updatedUser}) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + if len(resp) != 1 { + httpapi.InternalServerError(rw, xerrors.Errorf("failed to serialize member to response, update still succeeded")) + return + } + httpapi.Write(ctx, rw, http.StatusOK, resp[0]) } -func convertOrganizationMember(mem database.OrganizationMember) codersdk.OrganizationMember { - convertedMember := codersdk.OrganizationMember{ - UserID: mem.UserID, - OrganizationID: mem.OrganizationID, - CreatedAt: mem.CreatedAt, - UpdatedAt: mem.UpdatedAt, - Roles: make([]codersdk.SlimRole, 0, len(mem.Roles)), +// convertOrganizationMembers batches the role lookup to make only 1 sql call +// We +func convertOrganizationMembers(ctx context.Context, db database.Store, mems []database.OrganizationMember) ([]codersdk.OrganizationMember, error) { + converted := make([]codersdk.OrganizationMember, 0, len(mems)) + roleLookup := make([]database.NameOrganizationPair, 0) + + for _, m := range mems { + converted = append(converted, codersdk.OrganizationMember{ + UserID: m.UserID, + OrganizationID: m.OrganizationID, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + Roles: db2sdk.List(m.Roles, func(r string) codersdk.SlimRole { + // If it is a built-in role, no lookups are needed. + rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: r, OrganizationID: m.OrganizationID}) + if err == nil { + return db2sdk.SlimRole(rbacRole) + } + + // We know the role name and the organization ID. We are missing the + // display name. Append the lookup parameter, so we can get the display name + roleLookup = append(roleLookup, database.NameOrganizationPair{ + Name: r, + OrganizationID: m.OrganizationID, + }) + return codersdk.SlimRole{ + Name: r, + DisplayName: "", + OrganizationID: m.OrganizationID.String(), + } + }), + }) + } + + customRoles, err := db.CustomRoles(ctx, database.CustomRolesParams{ + LookupRoles: roleLookup, + ExcludeOrgRoles: false, + OrganizationID: uuid.UUID{}, + }) + if err != nil { + // We are missing the display names, but that is not absolutely required. So just + // return the converted and the names will be used instead of the display names. + return converted, xerrors.Errorf("lookup custom roles: %w", err) + } + + // Now map the customRoles back to the slimRoles for their display name. + customRolesMap := make(map[string]database.CustomRole) + for _, role := range customRoles { + customRolesMap[role.RoleIdentifier().UniqueName()] = role } - for _, roleName := range mem.Roles { - rbacRole, _ := rbac.RoleByName(rbac.RoleIdentifier{Name: roleName, OrganizationID: mem.OrganizationID}) - convertedMember.Roles = append(convertedMember.Roles, db2sdk.SlimRole(rbacRole)) + for i := range converted { + for j, role := range converted[i].Roles { + if cr, ok := customRolesMap[role.UniqueName()]; ok { + converted[i].Roles[j].DisplayName = cr.DisplayName + } + } } - return convertedMember + + return converted, nil } -func convertOrganizationMemberRow(row database.OrganizationMembersRow) codersdk.OrganizationMemberWithName { - convertedMember := codersdk.OrganizationMemberWithName{ - Username: row.Username, - OrganizationMember: convertOrganizationMember(row.OrganizationMember), +func convertOrganizationMemberRows(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow) ([]codersdk.OrganizationMemberWithName, error) { + members := make([]database.OrganizationMember, 0) + for _, row := range rows { + members = append(members, row.OrganizationMember) + } + + convertedMembers, err := convertOrganizationMembers(ctx, db, members) + if err != nil { + return nil, err + } + if len(convertedMembers) != len(rows) { + return nil, xerrors.Errorf("conversion failed, mismatch slice lengths") + } + + converted := make([]codersdk.OrganizationMemberWithName, 0) + for i := range convertedMembers { + converted = append(converted, codersdk.OrganizationMemberWithName{ + Username: rows[i].Username, + OrganizationMember: convertedMembers[i], + }) } - return convertedMember + return converted, nil } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 41411a2a968a2..14d18e2dd4e0e 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -96,6 +96,10 @@ func (r RoleIdentifier) String() string { return r.Name } +func (r RoleIdentifier) UniqueName() string { + return r.String() +} + func (r *RoleIdentifier) MarshalJSON() ([]byte, error) { return json.Marshal(r.String()) } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index d8f4bc4c2aea7..aed035799c8c8 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -51,11 +51,11 @@ type Organization struct { } type OrganizationMember struct { - UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"` - CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"` - Roles []SlimRole `db:"roles" json:"roles"` + UserID uuid.UUID `table:"user id" json:"user_id" format:"uuid"` + OrganizationID uuid.UUID `table:"organization id" json:"organization_id" format:"uuid"` + CreatedAt time.Time `table:"created at" json:"created_at" format:"date-time"` + UpdatedAt time.Time `table:"updated at" json:"updated_at" format:"date-time"` + Roles []SlimRole `table:"organization_roles" json:"roles"` } type OrganizationMemberWithName struct { diff --git a/codersdk/roles.go b/codersdk/roles.go index 6707bb1d6e276..7d1f007cc7463 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -19,6 +19,22 @@ type SlimRole struct { OrganizationID string `json:"organization_id,omitempty"` } +func (s SlimRole) String() string { + if s.DisplayName != "" { + return s.DisplayName + } + return s.Name +} + +// UniqueName concatenates the organization ID to create a globally unique +// string name for the role. +func (s SlimRole) UniqueName() string { + if s.OrganizationID != "" { + return s.Name + ":" + s.OrganizationID + } + return s.Name +} + type AssignableRoles struct { Role `table:"r,recursive_inline"` Assignable bool `json:"assignable" table:"assignable"` diff --git a/enterprise/cli/organizationmembers_test.go b/enterprise/cli/organizationmembers_test.go new file mode 100644 index 0000000000000..b308c4f249811 --- /dev/null +++ b/enterprise/cli/organizationmembers_test.go @@ -0,0 +1,68 @@ +package cli_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestEnterpriseListOrganizationMembers(t *testing.T) { + t.Parallel() + + t.Run("CustomRole", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + //nolint:gocritic // only owners can patch roles + customRole, err := ownerClient.PatchOrganizationRole(ctx, owner.OrganizationID, codersdk.Role{ + Name: "custom", + OrganizationID: owner.OrganizationID.String(), + DisplayName: "Custom Role", + SitePermissions: nil, + OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), + UserPermissions: nil, + }) + require.NoError(t, err) + + client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin(), rbac.RoleIdentifier{ + Name: customRole.Name, + OrganizationID: owner.OrganizationID, + }, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + + inv, root := clitest.New(t, "organization", "members", "-c", "user_id,username,organization_roles") + clitest.SetupConfig(t, client, root) + + buf := new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, buf.String(), user.Username) + require.Contains(t, buf.String(), owner.UserID.String()) + // Check the display name is the value in the cli list + require.Contains(t, buf.String(), customRole.DisplayName) + }) +} From fc09077b7b73012e8f8984e0146980eb4c9ac6c7 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 13 Jun 2024 10:19:36 +0400 Subject: [PATCH 071/168] feat!: add interface report to coder netcheck (#13562) re: #13327 Adds local interfaces to `coder netcheck` and checks their MTUs for potential problems. This is mostly relevant for end-user systems where VPNs are common. We _could_ also add it to coderd healthcheck, but until I see coderd connecting to workspaces over a VPN in the wild, I don't think its worth the UX effort. Netcheck results get the following: ``` "interfaces": { "error": null, "severity": "ok", "warnings": null, "dismissed": false, "interfaces": [ { "name": "lo0", "mtu": 16384, "addresses": [ "127.0.0.1/8", "::1/128", "fe80::1/64" ] }, { "name": "en8", "mtu": 1500, "addresses": [ "192.168.50.217/24", "fe80::c13:1a92:3fa5:dd7e/64" ] } ] } ``` _Technically_ not back compatible if anyone is parsing `coder netcheck` output as JSON, since the original output is now under `"derp"` in the output. --- cli/netcheck.go | 15 +- cli/netcheck_test.go | 6 +- coderd/healthcheck/health/model.go | 2 + codersdk/healthsdk/healthsdk.go | 6 + codersdk/healthsdk/interfaces.go | 73 +++++++ .../healthsdk/interfaces_internal_test.go | 192 ++++++++++++++++++ docs/admin/healthcheck.md | 11 + 7 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 codersdk/healthsdk/interfaces.go create mode 100644 codersdk/healthsdk/interfaces_internal_test.go diff --git a/cli/netcheck.go b/cli/netcheck.go index fb4042b600920..490ed25ce20b2 100644 --- a/cli/netcheck.go +++ b/cli/netcheck.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/v2/coderd/healthcheck/derphealth" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/healthsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/serpent" ) @@ -34,11 +35,21 @@ func (r *RootCmd) netcheck() *serpent.Command { _, _ = fmt.Fprint(inv.Stderr, "Gathering a network report. This may take a few seconds...\n\n") - var report derphealth.Report - report.Run(ctx, &derphealth.ReportOptions{ + var derpReport derphealth.Report + derpReport.Run(ctx, &derphealth.ReportOptions{ DERPMap: connInfo.DERPMap, }) + ifReport, err := healthsdk.RunInterfacesReport() + if err != nil { + return xerrors.Errorf("failed to run interfaces report: %w", err) + } + + report := healthsdk.ClientNetcheckReport{ + DERP: healthsdk.DERPHealthReport(derpReport), + Interfaces: ifReport, + } + raw, err := json.MarshalIndent(report, "", " ") if err != nil { return err diff --git a/cli/netcheck_test.go b/cli/netcheck_test.go index 16b72beb2fd89..bf124fc77896b 100644 --- a/cli/netcheck_test.go +++ b/cli/netcheck_test.go @@ -26,13 +26,13 @@ func TestNetcheck(t *testing.T) { b := out.Bytes() t.Log(string(b)) - var report healthsdk.DERPHealthReport + var report healthsdk.ClientNetcheckReport require.NoError(t, json.Unmarshal(b, &report)) // We do not assert that the report is healthy, just that // it has the expected number of reports per region. - require.Len(t, report.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region - for _, v := range report.Regions { + require.Len(t, report.DERP.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region + for _, v := range report.DERP.Regions { require.Len(t, v.NodeReports, len(v.Region.Nodes)) } } diff --git a/coderd/healthcheck/health/model.go b/coderd/healthcheck/health/model.go index ce332a0fe33ad..50f0078db10b2 100644 --- a/coderd/healthcheck/health/model.go +++ b/coderd/healthcheck/health/model.go @@ -43,6 +43,8 @@ const ( CodeProvisionerDaemonsNoProvisionerDaemons Code = `EPD01` CodeProvisionerDaemonVersionMismatch Code = `EPD02` CodeProvisionerDaemonAPIMajorVersionDeprecated Code = `EPD03` + + CodeInterfaceSmallMTU = `EIF01` ) // Default docs URL diff --git a/codersdk/healthsdk/healthsdk.go b/codersdk/healthsdk/healthsdk.go index 8a00a8a3d63a6..ff7f3b08cda4a 100644 --- a/codersdk/healthsdk/healthsdk.go +++ b/codersdk/healthsdk/healthsdk.go @@ -269,3 +269,9 @@ type WorkspaceProxyReport struct { BaseReport WorkspaceProxies codersdk.RegionsResponse[codersdk.WorkspaceProxy] `json:"workspace_proxies"` } + +// @typescript-ignore ClientNetcheckReport +type ClientNetcheckReport struct { + DERP DERPHealthReport `json:"derp"` + Interfaces InterfacesReport `json:"interfaces"` +} diff --git a/codersdk/healthsdk/interfaces.go b/codersdk/healthsdk/interfaces.go new file mode 100644 index 0000000000000..380a6a71ff1ca --- /dev/null +++ b/codersdk/healthsdk/interfaces.go @@ -0,0 +1,73 @@ +package healthsdk + +import ( + "net" + + "tailscale.com/net/interfaces" + + "github.com/coder/coder/v2/coderd/healthcheck/health" +) + +// @typescript-ignore InterfacesReport +type InterfacesReport struct { + BaseReport + Interfaces []Interface `json:"interfaces"` +} + +// @typescript-ignore Interface +type Interface struct { + Name string `json:"name"` + MTU int `json:"mtu"` + Addresses []string `json:"addresses"` +} + +func RunInterfacesReport() (InterfacesReport, error) { + st, err := interfaces.GetState() + if err != nil { + return InterfacesReport{}, err + } + return generateInterfacesReport(st), nil +} + +func generateInterfacesReport(st *interfaces.State) (report InterfacesReport) { + report.Severity = health.SeverityOK + for name, iface := range st.Interface { + // macOS has a ton of random interfaces, so to keep things helpful, let's filter out any + // that: + // + // - are not enabled + // - don't have any addresses + // - have only link-local addresses (e.g. fe80:...) + if (iface.Flags & net.FlagUp) == 0 { + continue + } + addrs := st.InterfaceIPs[name] + if len(addrs) == 0 { + continue + } + var r bool + healthIface := Interface{ + Name: iface.Name, + MTU: iface.MTU, + } + for _, addr := range addrs { + healthIface.Addresses = append(healthIface.Addresses, addr.String()) + if addr.Addr().IsLinkLocalUnicast() || addr.Addr().IsLinkLocalMulticast() { + continue + } + r = true + } + if !r { + continue + } + report.Interfaces = append(report.Interfaces, healthIface) + if iface.MTU < 1378 { + report.Severity = health.SeverityWarning + report.Warnings = append(report.Warnings, + health.Messagef(health.CodeInterfaceSmallMTU, + "network interface %s has MTU %d (less than 1378), which may cause problems with direct connections", iface.Name, iface.MTU), + ) + } + } + return report +} diff --git a/codersdk/healthsdk/interfaces_internal_test.go b/codersdk/healthsdk/interfaces_internal_test.go new file mode 100644 index 0000000000000..2996c6e1f09e3 --- /dev/null +++ b/codersdk/healthsdk/interfaces_internal_test.go @@ -0,0 +1,192 @@ +package healthsdk + +import ( + "net" + "net/netip" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" + "tailscale.com/net/interfaces" + + "github.com/coder/coder/v2/coderd/healthcheck/health" +) + +func Test_generateInterfacesReport(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + state interfaces.State + severity health.Severity + expectedInterfaces []string + expectedWarnings []string + }{ + { + name: "Empty", + state: interfaces.State{}, + severity: health.SeverityOK, + expectedInterfaces: []string{}, + }, + { + name: "Normal", + state: interfaces.State{ + Interface: map[string]interfaces.Interface{ + "en0": {Interface: &net.Interface{ + MTU: 1500, + Name: "en0", + Flags: net.FlagUp, + }}, + "lo0": {Interface: &net.Interface{ + MTU: 65535, + Name: "lo0", + Flags: net.FlagUp, + }}, + }, + InterfaceIPs: map[string][]netip.Prefix{ + "en0": { + netip.MustParsePrefix("192.168.100.1/24"), + netip.MustParsePrefix("fe80::c13:1a92:3fa5:dd7e/64"), + }, + "lo0": { + netip.MustParsePrefix("127.0.0.1/8"), + netip.MustParsePrefix("::1/128"), + netip.MustParsePrefix("fe80::1/64"), + }, + }, + }, + severity: health.SeverityOK, + expectedInterfaces: []string{"en0", "lo0"}, + }, + { + name: "IgnoreDisabled", + state: interfaces.State{ + Interface: map[string]interfaces.Interface{ + "en0": {Interface: &net.Interface{ + MTU: 1300, + Name: "en0", + Flags: 0, + }}, + "lo0": {Interface: &net.Interface{ + MTU: 65535, + Name: "lo0", + Flags: net.FlagUp, + }}, + }, + InterfaceIPs: map[string][]netip.Prefix{ + "en0": {netip.MustParsePrefix("192.168.100.1/24")}, + "lo0": {netip.MustParsePrefix("127.0.0.1/8")}, + }, + }, + severity: health.SeverityOK, + expectedInterfaces: []string{"lo0"}, + }, + { + name: "IgnoreLinkLocalOnly", + state: interfaces.State{ + Interface: map[string]interfaces.Interface{ + "en0": {Interface: &net.Interface{ + MTU: 1300, + Name: "en0", + Flags: net.FlagUp, + }}, + "lo0": {Interface: &net.Interface{ + MTU: 65535, + Name: "lo0", + Flags: net.FlagUp, + }}, + }, + InterfaceIPs: map[string][]netip.Prefix{ + "en0": {netip.MustParsePrefix("fe80::1:1/64")}, + "lo0": {netip.MustParsePrefix("127.0.0.1/8")}, + }, + }, + severity: health.SeverityOK, + expectedInterfaces: []string{"lo0"}, + }, + { + name: "IgnoreNoAddress", + state: interfaces.State{ + Interface: map[string]interfaces.Interface{ + "en0": {Interface: &net.Interface{ + MTU: 1300, + Name: "en0", + Flags: net.FlagUp, + }}, + "lo0": {Interface: &net.Interface{ + MTU: 65535, + Name: "lo0", + Flags: net.FlagUp, + }}, + }, + InterfaceIPs: map[string][]netip.Prefix{ + "en0": {}, + "lo0": {netip.MustParsePrefix("127.0.0.1/8")}, + }, + }, + severity: health.SeverityOK, + expectedInterfaces: []string{"lo0"}, + }, + { + name: "SmallMTUTunnel", + state: interfaces.State{ + Interface: map[string]interfaces.Interface{ + "en0": {Interface: &net.Interface{ + MTU: 1500, + Name: "en0", + Flags: net.FlagUp, + }}, + "lo0": {Interface: &net.Interface{ + MTU: 65535, + Name: "lo0", + Flags: net.FlagUp, + }}, + "tun0": {Interface: &net.Interface{ + MTU: 1280, + Name: "tun0", + Flags: net.FlagUp, + }}, + }, + InterfaceIPs: map[string][]netip.Prefix{ + "en0": {netip.MustParsePrefix("192.168.100.1/24")}, + "tun0": {netip.MustParsePrefix("10.3.55.9/8")}, + "lo0": {netip.MustParsePrefix("127.0.0.1/8")}, + }, + }, + severity: health.SeverityWarning, + expectedInterfaces: []string{"en0", "lo0", "tun0"}, + expectedWarnings: []string{"tun0"}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := generateInterfacesReport(&tc.state) + require.Equal(t, tc.severity, r.Severity) + gotInterfaces := []string{} + for _, i := range r.Interfaces { + gotInterfaces = append(gotInterfaces, i.Name) + } + slices.Sort(gotInterfaces) + slices.Sort(tc.expectedInterfaces) + require.Equal(t, tc.expectedInterfaces, gotInterfaces) + + require.Len(t, r.Warnings, len(tc.expectedWarnings), + "expected %d warnings, got %d", len(tc.expectedWarnings), len(r.Warnings)) + for _, name := range tc.expectedWarnings { + found := false + for _, w := range r.Warnings { + if strings.Contains(w.String(), name) { + found = true + break + } + } + if !found { + t.Errorf("missing warning for %s", name) + } + } + }) + } +} diff --git a/docs/admin/healthcheck.md b/docs/admin/healthcheck.md index 1b3918a3bb253..44d10dadc6862 100644 --- a/docs/admin/healthcheck.md +++ b/docs/admin/healthcheck.md @@ -328,6 +328,17 @@ version of Coder. > Note: This may be a transient issue if you are currently in the process of > updating your deployment. +### EIF01 + +_Interface with Small MTU_ + +**Problem:** One or more local interfaces have MTU smaller than 1378, which is +the minimum MTU for Coder to establish direct connections without fragmentation. + +**Solution:** Since IP fragmentation can be a source of performance problems, we +recommend you disable the interface when using Coder or +[disable direct connections](../../cli#--disable-direct-connections) + ## EUNKNOWN _Unknown Error_ From 88eb6ce378b9c9a9f05bff09e57b47933167229c Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 13 Jun 2024 11:38:51 +0400 Subject: [PATCH 072/168] fix: fix flake in TestDERPEndToEnd (#13564) --- enterprise/wsproxy/wsproxy_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index 145a69a95e846..cff04e26a4296 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -324,6 +324,7 @@ func TestDERPEndToEnd(t *testing.T) { deploymentValues.Experiments = []string{ "*", } + deploymentValues.DERP.Config.BlockDirect = true client, closer, api, user := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ Options: &coderdtest.Options{ From 4b0b9b08d5e3c586f8cbd30d52f92e708cf1375a Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 13 Jun 2024 13:09:54 +0400 Subject: [PATCH 073/168] feat: add interfaces report to support bundle (#13563) --- cli/support.go | 1 + cli/support_test.go | 4 ++++ codersdk/healthsdk/interfaces.go | 11 +++++++++-- support/support.go | 16 +++++++++++++--- support/support_test.go | 2 ++ 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/cli/support.go b/cli/support.go index f66bcda13ba6f..5dfe7a45a151b 100644 --- a/cli/support.go +++ b/cli/support.go @@ -254,6 +254,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { "deployment/health.json": src.Deployment.HealthReport, "network/connection_info.json": src.Network.ConnectionInfo, "network/netcheck.json": src.Network.Netcheck, + "network/interfaces.json": src.Network.Interfaces, "workspace/template.json": src.Workspace.Template, "workspace/template_version.json": src.Workspace.TemplateVersion, "workspace/parameters.json": src.Workspace.Parameters, diff --git a/cli/support_test.go b/cli/support_test.go index d9bee0fb2fb20..d53aac66c820c 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -197,6 +197,10 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge var v derphealth.Report decodeJSONFromZip(t, f, &v) require.NotEmpty(t, v, "netcheck should not be empty") + case "network/interfaces.json": + var v healthsdk.InterfacesReport + decodeJSONFromZip(t, f, &v) + require.NotEmpty(t, v, "interfaces should not be empty") case "workspace/workspace.json": var v codersdk.Workspace decodeJSONFromZip(t, f, &v) diff --git a/codersdk/healthsdk/interfaces.go b/codersdk/healthsdk/interfaces.go index 380a6a71ff1ca..6f4365aaeefac 100644 --- a/codersdk/healthsdk/interfaces.go +++ b/codersdk/healthsdk/interfaces.go @@ -8,6 +8,13 @@ import ( "github.com/coder/coder/v2/coderd/healthcheck/health" ) +// gVisor is nominally permitted to send packets up to 1280. +// Wireguard adds 30 bytes (1310) +// UDP adds 8 bytes (1318) +// IP adds 20-60 bytes (1338-1378) +// So, it really needs to be 1378 to be totally safe +const safeMTU = 1378 + // @typescript-ignore InterfacesReport type InterfacesReport struct { BaseReport @@ -61,11 +68,11 @@ func generateInterfacesReport(st *interfaces.State) (report InterfacesReport) { continue } report.Interfaces = append(report.Interfaces, healthIface) - if iface.MTU < 1378 { + if iface.MTU < safeMTU { report.Severity = health.SeverityWarning report.Warnings = append(report.Warnings, health.Messagef(health.CodeInterfaceSmallMTU, - "network interface %s has MTU %d (less than 1378), which may cause problems with direct connections", iface.Name, iface.MTU), + "network interface %s has MTU %d (less than %d), which may cause problems with direct connections", iface.Name, iface.MTU, safeMTU), ) } } diff --git a/support/support.go b/support/support.go index af3ad21200d02..5ae48ddb37cba 100644 --- a/support/support.go +++ b/support/support.go @@ -47,9 +47,10 @@ type Deployment struct { type Network struct { ConnectionInfo workspacesdk.AgentConnectionInfo - CoordinatorDebug string `json:"coordinator_debug"` - Netcheck *derphealth.Report `json:"netcheck"` - TailnetDebug string `json:"tailnet_debug"` + CoordinatorDebug string `json:"coordinator_debug"` + Netcheck *derphealth.Report `json:"netcheck"` + TailnetDebug string `json:"tailnet_debug"` + Interfaces healthsdk.InterfacesReport `json:"interfaces"` } type Netcheck struct { @@ -194,6 +195,15 @@ func NetworkInfo(ctx context.Context, client *codersdk.Client, log slog.Logger) return nil }) + eg.Go(func() error { + rpt, err := healthsdk.RunInterfacesReport() + if err != nil { + return xerrors.Errorf("run interfaces report: %w", err) + } + n.Interfaces = rpt + return nil + }) + if err := eg.Wait(); err != nil { log.Error(ctx, "fetch network information", slog.Error(err)) } diff --git a/support/support_test.go b/support/support_test.go index 55eb6a1f23bd9..cdd62ceeb8f9b 100644 --- a/support/support_test.go +++ b/support/support_test.go @@ -66,6 +66,7 @@ func TestRun(t *testing.T) { assertNotNilNotEmpty(t, bun.Network.CoordinatorDebug, "network coordinator debug should be present") assertNotNilNotEmpty(t, bun.Network.Netcheck, "network netcheck should be present") assertNotNilNotEmpty(t, bun.Network.TailnetDebug, "network tailnet debug should be present") + assertNotNilNotEmpty(t, bun.Network.Interfaces, "network interfaces health should be present") assertNotNilNotEmpty(t, bun.Workspace.Workspace, "workspace should be present") assertSanitizedWorkspace(t, bun.Workspace.Workspace) assertNotNilNotEmpty(t, bun.Workspace.BuildLogs, "workspace build logs should be present") @@ -114,6 +115,7 @@ func TestRun(t *testing.T) { assertNotNilNotEmpty(t, bun.Network.CoordinatorDebug, "network coordinator debug should be present") assertNotNilNotEmpty(t, bun.Network.Netcheck, "network netcheck should be present") assertNotNilNotEmpty(t, bun.Network.TailnetDebug, "network tailnet debug should be present") + assertNotNilNotEmpty(t, bun.Network.Interfaces, "network interfaces health should be present") assert.Empty(t, bun.Workspace.Workspace, "did not expect workspace to be present") assert.Empty(t, bun.Agent, "did not expect agent to be present") assertNotNilNotEmpty(t, bun.Logs, "bundle logs should be present") From 0268c7a65901ead9611af6e5d48d588b7ff91f16 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 13 Jun 2024 16:01:17 +0400 Subject: [PATCH 074/168] chore: refactor autobuild/notify to use clock test (#13566) Refactor autobuild/notify and tests to use the clock testing library. I also rewrote some of the comments because I didn't understand them when I was looking at the package. --- cli/ssh.go | 4 +- coderd/autobuild/notify/notifier.go | 69 ++++++++++---------- coderd/autobuild/notify/notifier_test.go | 80 ++++++++++++------------ 3 files changed, 80 insertions(+), 73 deletions(-) diff --git a/cli/ssh.go b/cli/ssh.go index aa8bdadb9d0dd..ac849649b9184 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -711,12 +711,12 @@ func tryPollWorkspaceAutostop(ctx context.Context, client *codersdk.Client, work lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String())) conditionCtx, cancelCondition := context.WithCancel(ctx) condition := notifyCondition(conditionCtx, client, workspace.ID, lock) - stopFunc := notify.Notify(condition, workspacePollInterval, autostopNotifyCountdown...) + notifier := notify.New(condition, workspacePollInterval, autostopNotifyCountdown) return func() { // With many "ssh" processes running, `lock.TryLockContext` can be hanging until the context canceled. // Without this cancellation, a CLI process with failed remote-forward could be hanging indefinitely. cancelCondition() - stopFunc() + notifier.Close() } } diff --git a/coderd/autobuild/notify/notifier.go b/coderd/autobuild/notify/notifier.go index e0db12af35475..d8226161507ef 100644 --- a/coderd/autobuild/notify/notifier.go +++ b/coderd/autobuild/notify/notifier.go @@ -5,9 +5,16 @@ import ( "sort" "sync" "time" + + "github.com/coder/coder/v2/clock" ) -// Notifier calls a Condition at most once for each count in countdown. +// Notifier triggers callbacks at given intervals until some event happens. The +// intervals (e.g. 10 minute warning, 5 minute warning) are given in the +// countdown. The Notifier periodically polls the condition to get the time of +// the event (the Condition's deadline) and the callback. The callback is +// called at most once per entry in the countdown, the first time the time to +// the deadline is shorter than the duration. type Notifier struct { ctx context.Context cancel context.CancelFunc @@ -17,12 +24,15 @@ type Notifier struct { condition Condition notifiedAt map[time.Duration]bool countdown []time.Duration + + // for testing + clock clock.Clock } -// Condition is a function that gets executed with a certain time. +// Condition is a function that gets executed periodically, and receives the +// current time as an argument. // - It should return the deadline for the notification, as well as a -// callback function to execute once the time to the deadline is -// less than one of the notify attempts. If deadline is the zero +// callback function to execute. If deadline is the zero // time, callback will not be executed. // - Callback is executed once for every time the difference between deadline // and the current time is less than an element of countdown. @@ -30,23 +40,19 @@ type Notifier struct { // the returned deadline to the minimum interval. type Condition func(now time.Time) (deadline time.Time, callback func()) -// Notify is a convenience function that initializes a new Notifier -// with the given condition, interval, and countdown. -// It is the responsibility of the caller to call close to stop polling. -func Notify(cond Condition, interval time.Duration, countdown ...time.Duration) (closeFunc func()) { - notifier := New(cond, countdown...) - ticker := time.NewTicker(interval) - go notifier.Poll(ticker.C) - return func() { - ticker.Stop() - _ = notifier.Close() +type Option func(*Notifier) + +// WithTestClock is used in tests to inject a mock Clock +func WithTestClock(clk clock.Clock) Option { + return func(n *Notifier) { + n.clock = clk } } // New returns a Notifier that calls cond once every time it polls. // - Duplicate values are removed from countdown, and it is sorted in // descending order. -func New(cond Condition, countdown ...time.Duration) *Notifier { +func New(cond Condition, interval time.Duration, countdown []time.Duration, opts ...Option) *Notifier { // Ensure countdown is sorted in descending order and contains no duplicates. ct := unique(countdown) sort.Slice(ct, func(i, j int) bool { @@ -61,38 +67,36 @@ func New(cond Condition, countdown ...time.Duration) *Notifier { countdown: ct, condition: cond, notifiedAt: make(map[time.Duration]bool), + clock: clock.NewReal(), } + for _, opt := range opts { + opt(n) + } + go n.poll(interval) return n } -// Poll polls once immediately, and then once for every value from ticker. +// poll polls once immediately, and then periodically according to the interval. // Poll exits when ticker is closed. -func (n *Notifier) Poll(ticker <-chan time.Time) { +func (n *Notifier) poll(interval time.Duration) { defer close(n.pollDone) // poll once immediately - n.pollOnce(time.Now()) - for { - select { - case <-n.ctx.Done(): - return - case t, ok := <-ticker: - if !ok { - return - } - n.pollOnce(t) - } - } + _ = n.pollOnce() + tkr := n.clock.TickerFunc(n.ctx, interval, n.pollOnce, "notifier", "poll") + _ = tkr.Wait() } -func (n *Notifier) Close() error { +func (n *Notifier) Close() { n.cancel() <-n.pollDone - return nil } -func (n *Notifier) pollOnce(tick time.Time) { +// pollOnce only returns an error so it matches the signature expected of TickerFunc +// nolint: revive // bare returns are fine here +func (n *Notifier) pollOnce() (_ error) { + tick := n.clock.Now() n.lock.Lock() defer n.lock.Unlock() @@ -113,6 +117,7 @@ func (n *Notifier) pollOnce(tick time.Time) { n.notifiedAt[tock] = true return } + return } func unique(ds []time.Duration) []time.Duration { diff --git a/coderd/autobuild/notify/notifier_test.go b/coderd/autobuild/notify/notifier_test.go index 09e8158abaa99..d53b06c1a2133 100644 --- a/coderd/autobuild/notify/notifier_test.go +++ b/coderd/autobuild/notify/notifier_test.go @@ -1,34 +1,36 @@ package notify_test import ( - "sync" "testing" "time" "github.com/stretchr/testify/require" - "go.uber.org/atomic" "go.uber.org/goleak" + "github.com/coder/coder/v2/clock" "github.com/coder/coder/v2/coderd/autobuild/notify" + "github.com/coder/coder/v2/testutil" ) func TestNotifier(t *testing.T) { t.Parallel() - now := time.Now() + now := time.Date(2022, 5, 13, 0, 0, 0, 0, time.UTC) testCases := []struct { Name string Countdown []time.Duration - Ticks []time.Time + PollInterval time.Duration + NTicks int ConditionDeadline time.Time - NumConditions int64 - NumCallbacks int64 + NumConditions int + NumCallbacks int }{ { Name: "zero deadline", Countdown: durations(), - Ticks: fakeTicker(now, time.Second, 0), + PollInterval: time.Second, + NTicks: 0, ConditionDeadline: time.Time{}, NumConditions: 1, NumCallbacks: 0, @@ -36,7 +38,8 @@ func TestNotifier(t *testing.T) { { Name: "no calls", Countdown: durations(), - Ticks: fakeTicker(now, time.Second, 0), + PollInterval: time.Second, + NTicks: 0, ConditionDeadline: now, NumConditions: 1, NumCallbacks: 0, @@ -44,7 +47,8 @@ func TestNotifier(t *testing.T) { { Name: "exactly one call", Countdown: durations(time.Second), - Ticks: fakeTicker(now, time.Second, 1), + PollInterval: time.Second, + NTicks: 1, ConditionDeadline: now.Add(time.Second), NumConditions: 2, NumCallbacks: 1, @@ -52,7 +56,8 @@ func TestNotifier(t *testing.T) { { Name: "two calls", Countdown: durations(4*time.Second, 2*time.Second), - Ticks: fakeTicker(now, time.Second, 5), + PollInterval: time.Second, + NTicks: 5, ConditionDeadline: now.Add(5 * time.Second), NumConditions: 6, NumCallbacks: 2, @@ -60,7 +65,8 @@ func TestNotifier(t *testing.T) { { Name: "wrong order should not matter", Countdown: durations(2*time.Second, 4*time.Second), - Ticks: fakeTicker(now, time.Second, 5), + PollInterval: time.Second, + NTicks: 5, ConditionDeadline: now.Add(5 * time.Second), NumConditions: 6, NumCallbacks: 2, @@ -68,7 +74,8 @@ func TestNotifier(t *testing.T) { { Name: "ssh autostop notify", Countdown: durations(5*time.Minute, time.Minute), - Ticks: fakeTicker(now, 30*time.Second, 120), + PollInterval: 30 * time.Second, + NTicks: 120, ConditionDeadline: now.Add(30 * time.Minute), NumConditions: 121, NumCallbacks: 2, @@ -79,30 +86,33 @@ func TestNotifier(t *testing.T) { testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() - ch := make(chan time.Time) - numConditions := atomic.NewInt64(0) - numCalls := atomic.NewInt64(0) + ctx := testutil.Context(t, testutil.WaitShort) + mClock := clock.NewMock(t) + mClock.Set(now).MustWait(ctx) + numConditions := 0 + numCalls := 0 cond := func(time.Time) (time.Time, func()) { - numConditions.Inc() + numConditions++ return testCase.ConditionDeadline, func() { - numCalls.Inc() + numCalls++ } } - var wg sync.WaitGroup - go func() { - defer wg.Done() - n := notify.New(cond, testCase.Countdown...) - defer n.Close() - n.Poll(ch) - }() - wg.Add(1) - for _, tick := range testCase.Ticks { - ch <- tick + + trap := mClock.Trap().TickerFunc("notifier", "poll") + defer trap.Close() + + n := notify.New(cond, testCase.PollInterval, testCase.Countdown, notify.WithTestClock(mClock)) + defer n.Close() + + trap.MustWait(ctx).Release() // ensure ticker started + for i := 0; i < testCase.NTicks; i++ { + interval, w := mClock.AdvanceNext() + w.MustWait(ctx) + require.Equal(t, testCase.PollInterval, interval) } - close(ch) - wg.Wait() - require.Equal(t, testCase.NumCallbacks, numCalls.Load()) - require.Equal(t, testCase.NumConditions, numConditions.Load()) + + require.Equal(t, testCase.NumCallbacks, numCalls) + require.Equal(t, testCase.NumConditions, numConditions) }) } } @@ -111,14 +121,6 @@ func durations(ds ...time.Duration) []time.Duration { return ds } -func fakeTicker(t time.Time, d time.Duration, n int) []time.Time { - var ts []time.Time - for i := 1; i <= n; i++ { - ts = append(ts, t.Add(time.Duration(n)*d)) - } - return ts -} - func TestMain(m *testing.M) { goleak.VerifyTestMain(m) } From 5d3f3c08cdbe19787d1a14e6fa7788e377298965 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 13 Jun 2024 14:31:49 +0100 Subject: [PATCH 075/168] chore(dogfood): add devcontainer for use with envbuilder (#13567) --- dogfood/devcontainer.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 dogfood/devcontainer.json diff --git a/dogfood/devcontainer.json b/dogfood/devcontainer.json new file mode 100644 index 0000000000000..3232c07ceafff --- /dev/null +++ b/dogfood/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "Develop Coder on Coder using Envbuilder", + "build": { + "dockerfile": "Dockerfile" + }, + + "features": {}, + "runArgs": ["--cap-add=SYS_PTRACE"] +} From c587af7c0e2bf5d186cc396210a57a880bc98add Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 13 Jun 2024 15:16:34 +0100 Subject: [PATCH 076/168] fix(dogfood/Dockerfile): add explicit --chown to COPY directive (#13569) --- dogfood/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dogfood/Dockerfile b/dogfood/Dockerfile index 19723853aa7ac..27f90a5752eaa 100644 --- a/dogfood/Dockerfile +++ b/dogfood/Dockerfile @@ -90,7 +90,7 @@ SHELL ["/bin/bash", "-c"] # the default mirror with teraswitch. RUN apt-get update && apt-get install --yes ca-certificates -COPY files / +COPY --chown=root:root files / # Install packages from apt repositories ARG DEBIAN_FRONTEND="noninteractive" From 87a172fb14dc579524bcb93eedeaef7ffa4cea3b Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Thu, 13 Jun 2024 13:00:26 -0400 Subject: [PATCH 077/168] docs: add validated architecture (#13561) * docs: add validated architecture * make: fmt * formatting * fix 404s * fix 404s pt 2 * fix 404s pt 3 --- .../architectures}/architecture.md | 31 +- .../{index.md => scale-testing.md} | 183 +-------- docs/admin/architectures/validated-arch.md | 363 ++++++++++++++++++ docs/admin/scale.md | 8 +- docs/manifest.json | 24 +- docs/platforms/other.md | 3 +- 6 files changed, 397 insertions(+), 215 deletions(-) rename docs/{about => admin/architectures}/architecture.md (93%) rename docs/admin/architectures/{index.md => scale-testing.md} (55%) create mode 100644 docs/admin/architectures/validated-arch.md diff --git a/docs/about/architecture.md b/docs/admin/architectures/architecture.md similarity index 93% rename from docs/about/architecture.md rename to docs/admin/architectures/architecture.md index af826ef784145..318e8e7d5356a 100644 --- a/docs/about/architecture.md +++ b/docs/admin/architectures/architecture.md @@ -4,9 +4,6 @@ The Coder deployment model is flexible and offers various components that platform administrators can deploy and scale depending on their use case. This page describes possible deployments, challenges, and risks associated with them. -Learn more about our [Reference Architectures](../admin/architectures/index.md) -and platform scaling capabilities. - ## Primary components ### coderd @@ -29,7 +26,7 @@ _provisionerd_ is the execution context for infrastructure modifying providers. At the moment, the only provider is Terraform (running `terraform`). By default, the Coder server runs multiple provisioner daemons. -[External provisioners](../admin/provisioners.md) can be added for security or +[External provisioners](../provisioners.md) can be added for security or scalability purposes. ### Agents @@ -46,7 +43,7 @@ It offers the following services along with much more: - `startup_script` automation Templates are responsible for -[creating and running agents](../templates/index.md#coder-agent) within +[creating and running agents](../../templates/index.md#coder-agent) within workspaces. ### Service Bundling @@ -76,7 +73,7 @@ they're destroyed on workspace stop. ### Single region architecture -![Architecture Diagram](../images/architecture-single-region.png) +![Architecture Diagram](../../images/architecture-single-region.png) #### Components @@ -121,11 +118,11 @@ and _Coder workspaces_ deployed in the same region. - Integrate with existing Single Sign-On (SSO) solutions used within the organization via the supported OAuth 2.0 or OpenID Connect standards. -- Learn more about [Authentication in Coder](../admin/auth.md). +- Learn more about [Authentication in Coder](../auth.md). ### Multi-region architecture -![Architecture Diagram](../images/architecture-multi-region.png) +![Architecture Diagram](../../images/architecture-multi-region.png) #### Components @@ -171,7 +168,7 @@ disruptions. Additionally, multi-cloud deployment enables organizations to leverage the unique features and capabilities offered by each cloud provider, such as region availability and pricing models. -![Architecture Diagram](../images/architecture-multi-cloud.png) +![Architecture Diagram](../../images/architecture-multi-cloud.png) #### Components @@ -205,7 +202,7 @@ nearest region and technical specifications provided by the cloud providers. **Workspace proxy** - _Security recommendation_: Use `coder` CLI to create - [authentication tokens for every workspace proxy](../admin/workspace-proxies.md#requirements), + [authentication tokens for every workspace proxy](../workspace-proxies.md#requirements), and keep them in regional secret stores. Remember to distribute them using safe, encrypted communication channel. @@ -226,8 +223,8 @@ nearest region and technical specifications provided by the cloud providers. See how to deploy [Coder on Azure Kubernetes Service](https://github.com/ericpaulsen/coder-aks). -Learn more about [security requirements](../install/kubernetes.md) for deploying -Coder on Kubernetes. +Learn more about [security requirements](../../install/kubernetes.md) for +deploying Coder on Kubernetes. **Load balancer** @@ -286,9 +283,9 @@ The key features of the air-gapped architecture include: - _Secure data transfer_: Enable encrypted communication channels and robust access controls to safeguard sensitive information. -Learn more about [offline deployments](../install/offline.md) of Coder. +Learn more about [offline deployments](../../install/offline.md) of Coder. -![Architecture Diagram](../images/architecture-air-gapped.png) +![Architecture Diagram](../../images/architecture-air-gapped.png) #### Components @@ -330,8 +327,8 @@ across multiple regions and diverse cloud platforms. - Since the _Registry_ is isolated from the internet, platform engineers are responsible for maintaining Workspace container images and conducting periodic updates of base Docker images. -- It is recommended to keep [Dev Containers](../templates/dev-containers.md) up - to date with the latest released +- It is recommended to keep [Dev Containers](../../templates/dev-containers.md) + up to date with the latest released [Envbuilder](https://github.com/coder/envbuilder) runtime. **Mirror of Terraform Registry** @@ -363,7 +360,7 @@ Learn more about [Dev containers support](https://coder.com/docs/v2/latest/templates/dev-containers) in Coder. -![Architecture Diagram](../images/architecture-devcontainers.png) +![Architecture Diagram](../../images/architecture-devcontainers.png) #### Components diff --git a/docs/admin/architectures/index.md b/docs/admin/architectures/scale-testing.md similarity index 55% rename from docs/admin/architectures/index.md rename to docs/admin/architectures/scale-testing.md index 85c06a650dee9..38e27b63be1ca 100644 --- a/docs/admin/architectures/index.md +++ b/docs/admin/architectures/scale-testing.md @@ -1,90 +1,4 @@ -# Reference Architectures - -This document provides prescriptive solutions and reference architectures to -support successful deployments of up to 3000 users and outlines at a high-level -the methodology currently used to scale-test Coder. - -## General concepts - -This section outlines core concepts and terminology essential for understanding -Coder's architecture and deployment strategies. - -### Administrator - -An administrator is a user role within the Coder platform with elevated -privileges. Admins have access to administrative functions such as user -management, template definitions, insights, and deployment configuration. - -### Coder - -Coder, also known as _coderd_, is the main service recommended for deployment -with multiple replicas to ensure high availability. It provides an API for -managing workspaces and templates. Each _coderd_ replica has the capability to -host multiple [provisioners](#provisioner). - -### User - -A user is an individual who utilizes the Coder platform to develop, test, and -deploy applications using workspaces. Users can select available templates to -provision workspaces. They interact with Coder using the web interface, the CLI -tool, or directly calling API methods. - -### Workspace - -A workspace refers to an isolated development environment where users can write, -build, and run code. Workspaces are fully configurable and can be tailored to -specific project requirements, providing developers with a consistent and -efficient development environment. Workspaces can be autostarted and -autostopped, enabling efficient resource management. - -Users can connect to workspaces using SSH or via workspace applications like -`code-server`, facilitating collaboration and remote access. Additionally, -workspaces can be parameterized, allowing users to customize settings and -configurations based on their unique needs. Workspaces are instantiated using -Coder templates and deployed on resources created by provisioners. - -### Template - -A template in Coder is a predefined configuration for creating workspaces. -Templates streamline the process of workspace creation by providing -pre-configured settings, tooling, and dependencies. They are built by template -administrators on top of Terraform, allowing for efficient management of -infrastructure resources. Additionally, templates can utilize Coder modules to -leverage existing features shared with other templates, enhancing flexibility -and consistency across deployments. Templates describe provisioning rules for -infrastructure resources offered by Terraform providers. - -### Workspace Proxy - -A workspace proxy serves as a relay connection option for developers connecting -to their workspace over SSH, a workspace app, or through port forwarding. It -helps reduce network latency for geo-distributed teams by minimizing the -distance network traffic needs to travel. Notably, workspace proxies do not -handle dashboard connections or API calls. - -### Provisioner - -Provisioners in Coder execute Terraform during workspace and template builds. -While the platform includes built-in provisioner daemons by default, there are -advantages to employing external provisioners. These external daemons provide -secure build environments and reduce server load, improving performance and -scalability. Each provisioner can handle a single concurrent workspace build, -allowing for efficient resource allocation and workload management. - -### Registry - -The Coder Registry is a platform where you can find starter templates and -_Modules_ for various cloud services and platforms. - -Templates help create self-service development environments using -Terraform-defined infrastructure, while _Modules_ simplify template creation by -providing common features like workspace applications, third-party integrations, -or helper scripts. - -Please note that the Registry is a hosted service and isn't available for -offline use. - -## Scale-testing methodology +## Scale Testing Scaling Coder involves planning and testing to ensure it can handle more load without compromising service. This process encompasses infrastructure setup, @@ -95,7 +9,7 @@ A dedicated Kubernetes cluster for Coder is Kubernetes cluster specifically configured to host and manage Coder workloads. Kubernetes provides container orchestration capabilities, allowing Coder to efficiently deploy, scale, and manage workspaces across a distributed infrastructure. This ensures high -availability, fault tolerance, and scalability for Coder deployments. Code is +availability, fault tolerance, and scalability for Coder deployments. Coder is deployed on this cluster using the [Helm chart](../../install/kubernetes.md#install-coder-with-helm). @@ -315,96 +229,3 @@ Scaling down workspace nodes to zero is not recommended, as it will result in longer wait times for workspace provisioning by users. However, this may be necessary for workspaces with special resource requirements (e.g. GPUs) that incur significant cost overheads. - -### Data plane: External database - -While running in production, Coder requires a access to an external PostgreSQL -database. Depending on the scale of the user-base, workspace activity, and High -Availability requirements, the amount of CPU and memory resources required by -Coder's database may differ. - -#### Scaling formula - -When determining scaling requirements, take into account the following -considerations: - -- `2 vCPU x 8 GB RAM x 512 GB storage`: A baseline for database requirements for - Coder deployment with less than 1000 users, and low activity level (30% active - users). This capacity should be sufficient to support 100 external - provisioners. -- Storage size depends on user activity, workspace builds, log verbosity, - overhead on database encryption, etc. -- Allocate two additional CPU core to the database instance for every 1000 - active users. -- Enable _High Availability_ mode for database engine for large scale - deployments. - -If you enable [database encryption](../encryption.md) in Coder, consider -allocating an additional CPU core to every `coderd` replica. - -#### Performance optimization guidelines - -We provide the following general recommendations for PostgreSQL settings: - -- Increase number of vCPU if CPU utilization or database latency is high. -- Allocate extra memory if database performance is poor, CPU utilization is low, - and memory utilization is high. -- Utilize faster disk options (higher IOPS) such as SSDs or NVMe drives for - optimal performance enhancement and possibly reduce database load. - -## Operational readiness - -Operational readiness in Coder is about ensuring that everything is set up -correctly before launching a platform into production. It involves making sure -that the service is reliable, secure, and easily scales accordingly to user-base -needs. Operational readiness is crucial because it helps prevent issues that -could affect workspace users experience once the platform is live. - -Learn about Coder design principles and architectural best practices described -in the -[Well-Architected Framework](https://coder.com/blog/coder-well-architected-framework). - -### Configuration - -1. Identify the required Helm values for configuration. -1. Create `values.yaml` and add it to a version control system. _Note:_ it is - highly recommended that you create a custom `values.yaml` as opposed to - copying the entire default values. -1. Determine the necessary environment variables. - -### Template configuration - -1. Establish a dedicated user account for the _Template Administrator_. -1. Maintain Coder templates using version control. -1. Consider implementing a GitOps workflow to automatically push new template. - For example, on Github, you can use the - [Update Coder Template](https://github.com/marketplace/actions/update-coder-template) - action. -1. Evaluate enabling automatic template updates upon workspace startup. - -### Deployment - -1. Leverage automation tooling to automate deployment and upgrades of Coder. - -### Observability - -1. Enable the Prometheus endpoint (environment variable: - `CODER_PROMETHEUS_ENABLE`). -1. Deploy a visual monitoring system such as Grafana for metrics visualization. -1. Deploy a centralized logs aggregation solution to collect and monitor - application logs. -1. Review the [Prometheus response](../prometheus.md) and set up alarms on - selected metrics. - -### Database backups - -1. Prepare internal scripts for dumping and restoring databases. -1. Schedule regular database backups, especially before release upgrades. - -### User support - -1. Incorporate [support links](../appearance.md#support-links) into internal - documentation accessible from the user context menu. Ensure that hyperlinks - are valid and lead to up-to-date materials. -1. Encourage the use of `coder support bundle` to allow workspace users to - generate and provide network-related diagnostic data. diff --git a/docs/admin/architectures/validated-arch.md b/docs/admin/architectures/validated-arch.md new file mode 100644 index 0000000000000..0595b29cf3019 --- /dev/null +++ b/docs/admin/architectures/validated-arch.md @@ -0,0 +1,363 @@ +# Coder Validated Architecture + +Many customers operate Coder in complex organizational environments, consisting +of multiple business units, agencies, and/or subsidiaries. This can lead to +numerous Coder deployments, caused by discrepancies in regulatory compliance, +data sovereignty, and level of funding across groups. The Coder Validated +Architecture (CVA) prescribes a Kubernetes-based deployment approach, enabling +your organization to deploy a stable Coder instance that is easier to maintain +and troubleshoot. + +The following sections will detail the components of the Coder Validated +Architecture, provide guidance on how to configure and deploy these components, +and offer insights into how to maintain and troubleshoot your Coder environment. + +- [General concepts](#general-concepts) +- [Kubernetes Infrastructure](#kubernetes-infrastructure) +- [PostgreSQL Database](#postgresql-database) +- [Operational readiness](#operational-readiness) + +## Who is this document for? + +This guide targets the following personas. It assumes a basic understanding of +cloud/on-premise computing, containerization, and the Coder platform. + +| Role | Description | +| ------------------------- | ------------------------------------------------------------------------------ | +| Platform Engineers | Responsible for deploying, operating the Coder deployment and infrastructure | +| Enterprise Architects | Responsible for architecting Coder deployments to meet enterprise requirements | +| Managed Service Providers | Entities that deploy and run Coder software as a service for customers | + +## CVA Guidance + +| CVA provides: | CVA does not provide: | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Single and multi-region K8s deployment options | Prescribing OS, or cloud vs. on-premise | +| Reference architectures for up to 3,000 users | An approval of your architecture; the CVA solely provides recommendations and guidelines | +| Best practices for building a Coder deployment | Recommendations for every possible deployment scenario | + +> For higher level design principles and architectural best practices, see +> Coder's +> [Well-Architected Framework](https://coder.com/blog/coder-well-architected-framework). + +## General concepts + +This section outlines core concepts and terminology essential for understanding +Coder's architecture and deployment strategies. + +### Administrator + +An administrator is a user role within the Coder platform with elevated +privileges. Admins have access to administrative functions such as user +management, template definitions, insights, and deployment configuration. + +### Coder control plane + +Coder's control plane, also known as _coderd_, is the main service recommended +for deployment with multiple replicas to ensure high availability. It provides +an API for managing workspaces and templates, and serves the dashboard UI. In +addition, each _coderd_ replica hosts 3 Terraform [provisioners](#provisioner) +by default. + +### User + +A [user](../users.md) is an individual who utilizes the Coder platform to +develop, test, and deploy applications using workspaces. Users can select +available templates to provision workspaces. They interact with Coder using the +web interface, the CLI tool, or directly calling API methods. + +### Workspace + +A [workspace](../../workspaces.md) refers to an isolated development environment +where users can write, build, and run code. Workspaces are fully configurable +and can be tailored to specific project requirements, providing developers with +a consistent and efficient development environment. Workspaces can be +autostarted and autostopped, enabling efficient resource management. + +Users can connect to workspaces using SSH or via workspace applications like +`code-server`, facilitating collaboration and remote access. Additionally, +workspaces can be parameterized, allowing users to customize settings and +configurations based on their unique needs. Workspaces are instantiated using +Coder templates and deployed on resources created by provisioners. + +### Template + +A [template](../../templates/index.md) in Coder is a predefined configuration +for creating workspaces. Templates streamline the process of workspace creation +by providing pre-configured settings, tooling, and dependencies. They are built +by template administrators on top of Terraform, allowing for efficient +management of infrastructure resources. Additionally, templates can utilize +Coder modules to leverage existing features shared with other templates, +enhancing flexibility and consistency across deployments. Templates describe +provisioning rules for infrastructure resources offered by Terraform providers. + +### Workspace Proxy + +A [workspace proxy](../workspace-proxies.md) serves as a relay connection option +for developers connecting to their workspace over SSH, a workspace app, or +through port forwarding. It helps reduce network latency for geo-distributed +teams by minimizing the distance network traffic needs to travel. Notably, +workspace proxies do not handle dashboard connections or API calls. + +### Provisioner + +Provisioners in Coder execute Terraform during workspace and template builds. +While the platform includes built-in provisioner daemons by default, there are +advantages to employing external provisioners. These external daemons provide +secure build environments and reduce server load, improving performance and +scalability. Each provisioner can handle a single concurrent workspace build, +allowing for efficient resource allocation and workload management. + +### Registry + +The [Coder Registry](https://registry.coder.com) is a platform where you can +find starter templates and _Modules_ for various cloud services and platforms. + +Templates help create self-service development environments using +Terraform-defined infrastructure, while _Modules_ simplify template creation by +providing common features like workspace applications, third-party integrations, +or helper scripts. + +Please note that the Registry is a hosted service and isn't available for +offline use. + +## Kubernetes Infrastructure + +Kubernetes is the recommended, and supported platform for deploying Coder in the +enterprise. It is the hosting platform of choice for a large majority of Coder's +Fortune 500 customers, and it is the platform in which we build and test against +here at Coder. + +### General recommendations + +In general, it is recommended to deploy Coder into its own respective cluster, +separate from production applications. Keep in mind that Coder runs development +workloads, so the cluster should be deployed as such, without production-level +configurations. + +### Compute + +Deploy your Kubernetes cluster with two node groups, one for Coder's control +plane, and another for user workspaces (if you intend on leveraging K8s for +end-user compute). + +#### Control plane nodes + +The Coder control plane node group must be static, to prevent scale down events +from dropping pods, and thus dropping user connections to the dashboard UI and +their workspaces. + +Coder's Helm Chart supports +[defining nodeSelectors, affinities, and tolerations](https://github.com/coder/coder/blob/e96652ebbcdd7554977594286b32015115c3f5b6/helm/coder/values.yaml#L221-L249) +to schedule the control plane pods on the appropriate node group. + +#### Workspace nodes + +Coder workspaces can be deployed either as Pods or Deployments in Kubernetes. +See our +[example Kubernetes workspace template](https://github.com/coder/coder/tree/main/examples/templates/kubernetes). +Configure the workspace node group to be auto-scaling, to dynamically allocate +compute as users start/stop workspaces at the beginning and end of their day. +Set nodeSelectors, affinities, and tolerations in Coder templates to assign +workspaces to the given node group: + +```hcl +resource "kubernetes_deployment" "coder" { + spec { + template { + metadata { + labels = { + app = "coder-workspace" + } + } + + spec { + affinity { + pod_anti_affinity { + preferred_during_scheduling_ignored_during_execution { + weight = 1 + pod_affinity_term { + label_selector { + match_expressions { + key = "app.kubernetes.io/instance" + operator = "In" + values = ["coder-workspace"] + } + } + topology_key = # add your node group label here + } + } + } + } + + tolerations { + # Add your tolerations here + } + + node_selector { + # Add your node selectors here + } + + container { + image = "coder-workspace:latest" + name = "dev" + } + } + } + } +} +``` + +#### Node sizing + +For sizing recommendations, see the below reference architectures: + +- [Up to 1,000 users](1k-users.md) + +- [Up to 2,000 users](2k-users.md) + +- [Up to 3,000 users](3k-users.md) + +### Networking + +It is likely your enterprise deploys Kubernetes clusters with various networking +restrictions. With this in mind, Coder requires the following connectivity: + +- Egress from workspace compute to the Coder control plane pods +- Egress from control plane pods to Coder's PostgreSQL database +- Egress from control plane pods to git and package repositories +- Ingress from user devices to the control plane Load Balancer or Ingress + controller + +We recommend configuring your network policies in accordance with the above. +Note that Coder workspaces do not require any ports to be open. + +### Storage + +If running Coder workspaces as Kubernetes Pods or Deployments, you will need to +assign persistent storage. We recommend leveraging a +[supported Container Storage Interface (CSI) driver](https://kubernetes-csi.github.io/docs/drivers.html) +in your cluster, with Dynamic Provisioning and read/write, to provide on-demand +storage to end-user workspaces. + +The following Kubernetes volume types have been validated by Coder internally, +and/or by our customers: + +- [PersistentVolumeClaim](https://kubernetes.io/docs/concepts/storage/volumes/#persistentvolumeclaim) +- [NFS](https://kubernetes.io/docs/concepts/storage/volumes/#nfs) +- [subPath](https://kubernetes.io/docs/concepts/storage/volumes/#using-subpath) +- [cephfs](https://kubernetes.io/docs/concepts/storage/volumes/#cephfs) + +Our +[example Kubernetes workspace template](https://github.com/coder/coder/blob/5b9a65e5c137232351381fc337d9784bc9aeecfc/examples/templates/kubernetes/main.tf#L191-L219) +provisions a PersistentVolumeClaim block storage device, attached to the +Deployment. + +It is not recommended to mount volumes from the host node(s) into workspaces, +for security and reliability purposes. The below volume types are _not_ +recommended for use with Coder: + +- [Local](https://kubernetes.io/docs/concepts/storage/volumes/#local) +- [hostPath](https://kubernetes.io/docs/concepts/storage/volumes/#hostpath) + +Not that Coder's control plane filesystem is ephemeral, so no persistent storage +is required. + +## PostgreSQL database + +Coder requires access to an external PostgreSQL database to store user data, +workspace state, template files, and more. Depending on the scale of the +user-base, workspace activity, and High Availability requirements, the amount of +CPU and memory resources required by Coder's database may differ. + +### Disaster recovery + +Prepare internal scripts for dumping and restoring your database. We recommend +scheduling regular database backups, especially before upgrading Coder to a new +release. Coder does not support downgrades without initially restoring the +database to the prior version. + +### Performance efficiency + +We highly recommend deploying the PostgreSQL instance in the same region (and if +possible, same availability zone) as the Coder server to optimize for low +latency connections. We recommend keeping latency under 10ms between the Coder +server and database. + +When determining scaling requirements, take into account the following +considerations: + +- `2 vCPU x 8 GB RAM x 512 GB storage`: A baseline for database requirements for + Coder deployment with less than 1000 users, and low activity level (30% active + users). This capacity should be sufficient to support 100 external + provisioners. +- Storage size depends on user activity, workspace builds, log verbosity, + overhead on database encryption, etc. +- Allocate two additional CPU core to the database instance for every 1000 + active users. +- Enable High Availability mode for database engine for large scale deployments. + +If you enable [database encryption](../encryption.md) in Coder, consider +allocating an additional CPU core to every `coderd` replica. + +#### Resource utilization guidelines + +Below are general recommendations for sizing your PostgreSQL instance: + +- Increase number of vCPU if CPU utilization or database latency is high. +- Allocate extra memory if database performance is poor, CPU utilization is low, + and memory utilization is high. +- Utilize faster disk options (higher IOPS) such as SSDs or NVMe drives for + optimal performance enhancement and possibly reduce database load. + +## Operational readiness + +Operational readiness in Coder is about ensuring that everything is set up +correctly before launching a platform into production. It involves making sure +that the service is reliable, secure, and easily scales accordingly to user-base +needs. Operational readiness is crucial because it helps prevent issues that +could affect workspace users experience once the platform is live. + +### Helm Chart Configuration + +1. Reference our [Helm chart values file](../../../helm/coder/values.yaml) and + identify the required values for deployment. +1. Create a `values.yaml` and add it to your version control system. +1. Determine the necessary environment variables. Here is the + [full list of supported server environment variables](../../cli/server.md). +1. Follow our documented + [steps for installing Coder via Helm](../../install/kubernetes.md). + +### Template configuration + +1. Establish dedicated accounts for users with the _Template Administrator_ + role. +1. Maintain Coder templates using + [version control](../../templates/change-management.md). +1. Consider implementing a GitOps workflow to automatically push new template + versions into Coder from git. For example, on Github, you can use the + [Update Coder Template](https://github.com/marketplace/actions/update-coder-template) + action. +1. Evaluate enabling + [automatic template updates](../../templates/general-settings.md#require-automatic-updates-enterprise) + upon workspace startup. + +### Observability + +1. Enable the Prometheus endpoint (environment variable: + `CODER_PROMETHEUS_ENABLE`). +1. Deploy the + [Coder Observability bundle](https://github.com/coder/observability) to + leverage pre-configured dashboards, alerts, and runbooks for monitoring + Coder. This includes integrations between Prometheus, Grafana, Loki, and + Alertmanager. +1. Review the [Prometheus response](../prometheus.md) and set up alarms on + selected metrics. + +### User support + +1. Incorporate [support links](../appearance.md#support-links) into internal + documentation accessible from the user context menu. Ensure that hyperlinks + are valid and lead to up-to-date materials. +1. Encourage the use of `coder support bundle` to allow workspace users to + generate and provide network-related diagnostic data. diff --git a/docs/admin/scale.md b/docs/admin/scale.md index 883516d9146f7..d8569fb8dffef 100644 --- a/docs/admin/scale.md +++ b/docs/admin/scale.md @@ -4,15 +4,15 @@ infrastructure. For scale-testing Kubernetes clusters we recommend to install and use the dedicated Coder template, [scaletest-runner](https://github.com/coder/coder/tree/main/scaletest/templates/scaletest-runner). -Learn more about [Coder’s architecture](../about/architecture.md) and our -[scale-testing methodology](architectures/index.md#scale-testing-methodology). +Learn more about [Coder’s architecture](architectures/architecture.md) and our +[scale-testing methodology](architectures/scale-testing.md). ## Recent scale tests > Note: the below information is for reference purposes only, and are not > intended to be used as guidelines for infrastructure sizing. Review the -> [Reference Architectures](architectures/index.md) for hardware sizing -> recommendations. +> [Reference Architectures](architectures/validated-arch.md#node-sizing) for +> hardware sizing recommendations. | Environment | Coder CPU | Coder RAM | Coder Replicas | Database | Users | Concurrent builds | Concurrent connections (Terminal/SSH) | Coder Version | Last tested | | ---------------- | --------- | --------- | -------------- | ----------------- | ----- | ----------------- | ------------------------------------- | ------------- | ------------ | diff --git a/docs/manifest.json b/docs/manifest.json index 067aecac8e69c..8acd4ad517313 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -5,15 +5,7 @@ "title": "About", "description": "About Coder", "path": "./README.md", - "icon_path": "./images/icons/home.svg", - "children": [ - { - "title": "Architecture", - "description": "Learn how Coder works", - "path": "./about/architecture.md", - "icon_path": "./images/icons/protractor.svg" - } - ] + "icon_path": "./images/icons/home.svg" }, { "title": "Installation", @@ -401,11 +393,19 @@ "icon_path": "./images/icons/scale.svg" }, { - "title": "Reference Architectures", - "description": "Learn about reference architectures for Coder", - "path": "./admin/architectures/index.md", + "title": "Architecture", + "description": "Learn about validated and reference architectures for Coder", + "path": "./admin/architectures/architecture.md", "icon_path": "./images/icons/scale.svg", "children": [ + { + "title": "Validated Architecture", + "path": "./admin/architectures/validated-arch.md" + }, + { + "title": "Scale Testing", + "path": "./admin/architectures/scale-testing.md" + }, { "title": "Up to 1,000 users", "path": "./admin/architectures/1k-users.md" diff --git a/docs/platforms/other.md b/docs/platforms/other.md index d2f08ebd2d357..474efe56a46e2 100644 --- a/docs/platforms/other.md +++ b/docs/platforms/other.md @@ -3,7 +3,8 @@ Coder is highly extensible and is not limited to the platforms outlined in these docs. The control plane can be provisioned on any VM or container compute, and workspaces can include any Terraform resource. See our -[architecture diagram](../about/architecture.md) for more details. +[architecture documentation](../admin/architectures/architecture.md) for more +details. The following resources may help as you're deploying Coder. From 7d51515f9dd37c6a71138425bc7bca3e65a54c2f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 13 Jun 2024 09:49:32 -1000 Subject: [PATCH 078/168] chore: implement assign organization roles from the cli (#13558) Basic functionality to assign roles to an organization member via cli. --- cli/organizationmembers.go | 66 +++++++++++++++++++++- cli/organizationmembers_test.go | 2 +- enterprise/cli/organizationmembers_test.go | 56 +++++++++++++++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/cli/organizationmembers.go b/cli/organizationmembers.go index 58138e65a3c37..d81f08f333474 100644 --- a/cli/organizationmembers.go +++ b/cli/organizationmembers.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "golang.org/x/xerrors" @@ -11,6 +12,66 @@ import ( ) func (r *RootCmd) organizationMembers() *serpent.Command { + cmd := &serpent.Command{ + Use: "members", + Aliases: []string{"member"}, + Short: "Manage organization members", + Children: []*serpent.Command{ + r.listOrganizationMembers(), + r.assignOrganizationRoles(), + }, + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + } + + return cmd +} + +func (r *RootCmd) assignOrganizationRoles() *serpent.Command { + client := new(codersdk.Client) + + cmd := &serpent.Command{ + Use: "edit-roles [roles...]", + Aliases: []string{"edit-role"}, + Short: "Edit organization member's roles", + Middleware: serpent.Chain( + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + organization, err := CurrentOrganization(r, inv, client) + if err != nil { + return err + } + + if len(inv.Args) < 1 { + return xerrors.Errorf("user_id or username is required as the first argument") + } + userIdentifier := inv.Args[0] + roles := inv.Args[1:] + + member, err := client.UpdateOrganizationMemberRoles(ctx, organization.ID, userIdentifier, codersdk.UpdateRoles{ + Roles: roles, + }) + if err != nil { + return xerrors.Errorf("update member roles: %w", err) + } + + updatedTo := make([]string, 0) + for _, role := range member.Roles { + updatedTo = append(updatedTo, role.String()) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Member roles updated to [%s]\n", strings.Join(updatedTo, ", ")) + return nil + }, + } + + return cmd +} + +func (r *RootCmd) listOrganizationMembers() *serpent.Command { formatter := cliui.NewOutputFormatter( cliui.TableFormat([]codersdk.OrganizationMemberWithName{}, []string{"username", "organization_roles"}), cliui.JSONFormat(), @@ -18,9 +79,8 @@ func (r *RootCmd) organizationMembers() *serpent.Command { client := new(codersdk.Client) cmd := &serpent.Command{ - Use: "members", - Short: "List all organization members", - Aliases: []string{"member"}, + Use: "list", + Short: "List all organization members", Middleware: serpent.Chain( serpent.RequireNArgs(0), r.InitClient(client), diff --git a/cli/organizationmembers_test.go b/cli/organizationmembers_test.go index 077ec0e00ab83..6cd8b9d3ccd4a 100644 --- a/cli/organizationmembers_test.go +++ b/cli/organizationmembers_test.go @@ -23,7 +23,7 @@ func TestListOrganizationMembers(t *testing.T) { client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) ctx := testutil.Context(t, testutil.WaitMedium) - inv, root := clitest.New(t, "organization", "members", "-c", "user_id,username,roles") + inv, root := clitest.New(t, "organization", "members", "list", "-c", "user_id,username,roles") clitest.SetupConfig(t, client, root) buf := new(bytes.Buffer) diff --git a/enterprise/cli/organizationmembers_test.go b/enterprise/cli/organizationmembers_test.go index b308c4f249811..fb6a7cb286058 100644 --- a/enterprise/cli/organizationmembers_test.go +++ b/enterprise/cli/organizationmembers_test.go @@ -53,7 +53,7 @@ func TestEnterpriseListOrganizationMembers(t *testing.T) { OrganizationID: owner.OrganizationID, }, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) - inv, root := clitest.New(t, "organization", "members", "-c", "user_id,username,organization_roles") + inv, root := clitest.New(t, "organization", "members", "list", "-c", "user_id,username,organization_roles") clitest.SetupConfig(t, client, root) buf := new(bytes.Buffer) @@ -66,3 +66,57 @@ func TestEnterpriseListOrganizationMembers(t *testing.T) { require.Contains(t, buf.String(), customRole.DisplayName) }) } + +func TestAssignOrganizationMemberRole(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + _, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + + ctx := testutil.Context(t, testutil.WaitMedium) + // nolint:gocritic // requires owner role to create + customRole, err := ownerClient.PatchOrganizationRole(ctx, owner.OrganizationID, codersdk.Role{ + Name: "custom-role", + OrganizationID: owner.OrganizationID.String(), + DisplayName: "Custom Role", + SitePermissions: nil, + OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), + UserPermissions: nil, + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "organization", "members", "edit-roles", user.Username, codersdk.RoleOrganizationAdmin, customRole.Name) + // nolint:gocritic // you cannot change your own roles + clitest.SetupConfig(t, ownerClient, root) + + buf := new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, buf.String(), must(rbac.RoleByName(rbac.ScopedRoleOrgAdmin(owner.OrganizationID))).DisplayName) + require.Contains(t, buf.String(), customRole.DisplayName) + }) +} + +func must[V any](v V, err error) V { + if err != nil { + panic(err) + } + return v +} From 3d30c8dc6858538378663efc77e8a96cb544d792 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 13 Jun 2024 10:12:37 -1000 Subject: [PATCH 079/168] chore: protect reserved builtin rolenames (#13571) Conflicting built-in and database role names makes it hard to disambiguate --- coderd/rbac/roles.go | 7 +++++++ enterprise/coderd/roles.go | 11 +++++++++++ enterprise/coderd/roles_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 14d18e2dd4e0e..ccac26679e81d 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -195,6 +195,13 @@ type RoleOptions struct { NoOwnerWorkspaceExec bool } +// ReservedRoleName exists because the database should only allow unique role +// names, but some roles are built in. So these names are reserved +func ReservedRoleName(name string) bool { + _, ok := builtInRoles[name] + return ok +} + // ReloadBuiltinRoles loads the static roles into the builtInRoles map. // This can be called again with a different config to change the behavior. // diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 3a162a1b5ea80..b080f01df2d4f 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -11,6 +11,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" ) @@ -41,6 +42,16 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, ) defer commitAudit() + // This check is not ideal, but we cannot enforce a unique role name in the db against + // the built-in role names. + if rbac.ReservedRoleName(role.Name) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Reserved role name", + Detail: fmt.Sprintf("%q is a reserved role name, and not allowed to be used", role.Name), + }) + return codersdk.Role{}, false + } + if err := httpapi.NameValid(role.Name); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid role name", diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 239a055540075..0c0f56eb57ba2 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -210,6 +210,34 @@ func TestCustomOrganizationRole(t *testing.T) { require.ErrorContains(t, err, "Validation") }) + t.Run("ReservedName", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + //nolint:gocritic // owner is required for this + _, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, codersdk.Role{ + Name: "owner", // Reserved + DisplayName: "Testing Purposes", + SitePermissions: nil, + OrganizationPermissions: nil, + UserPermissions: nil, + }) + require.ErrorContains(t, err, "Reserved") + }) + t.Run("MismatchedOrganizations", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) From d04959cea8f24da12b7936f289ab0b268880a706 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 13 Jun 2024 10:59:06 -1000 Subject: [PATCH 080/168] chore: implement custom role assignment for organization admins (#13570) * chore: static role assignment mapping Until a dynamic approach is created in the database, only org-admins can assign custom organization roles. --- coderd/database/dbauthz/customroles_test.go | 2 +- coderd/database/dbauthz/dbauthz.go | 26 ++++++--- coderd/database/dbauthz/dbauthz_test.go | 8 +-- coderd/members.go | 4 ++ coderd/rbac/object_gen.go | 1 + coderd/rbac/policy/policy.go | 1 + coderd/rbac/roles.go | 61 +++++++++++++-------- coderd/rbac/roles_test.go | 13 ++++- coderd/roles.go | 7 ++- codersdk/rbacresources_gen.go | 2 +- enterprise/coderd/roles_test.go | 1 + enterprise/coderd/users_test.go | 59 ++++++++++++++++++++ 12 files changed, 147 insertions(+), 38 deletions(-) diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index 1a9049044e0ce..4a544989c599e 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -157,7 +157,7 @@ func TestUpsertCustomRoles(t *testing.T) { org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), - errorContains: "not allowed to grant this permission", + errorContains: "forbidden", }, { name: "user-escalation", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 85659751a9107..8aec14eb7bf47 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -239,10 +239,10 @@ var ( rbac.ResourceApiKey.Type: rbac.ResourceApiKey.AvailableActions(), rbac.ResourceGroup.Type: {policy.ActionCreate, policy.ActionUpdate}, rbac.ResourceAssignRole.Type: rbac.ResourceAssignRole.AvailableActions(), + rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(), rbac.ResourceSystem.Type: {policy.WildcardSymbol}, rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead}, rbac.ResourceOrganizationMember.Type: {policy.ActionCreate}, - rbac.ResourceAssignOrgRole.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionDelete}, rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionUpdate}, rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(), rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop}, @@ -622,7 +622,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r roleAssign := rbac.ResourceAssignRole shouldBeOrgRoles := false if orgID != nil { - roleAssign = roleAssign.InOrg(*orgID) + roleAssign = rbac.ResourceAssignOrgRole.InOrg(*orgID) shouldBeOrgRoles = true } @@ -697,8 +697,14 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r for _, roleName := range grantedRoles { if _, isCustom := customRolesMap[roleName]; isCustom { - // For now, use a constant name so our static assign map still works. - roleName = rbac.CustomSiteRole() + // To support a dynamic mapping of what roles can assign what, we need + // to store this in the database. For now, just use a static role so + // owners and org admins can assign roles. + if roleName.IsOrgRole() { + roleName = rbac.CustomOrganizationRole(roleName.OrganizationID) + } else { + roleName = rbac.CustomSiteRole() + } } if !rbac.CanAssignRole(actor.Roles, roleName) { @@ -3476,9 +3482,15 @@ func (q *querier) UpsertCustomRole(ctx context.Context, arg database.UpsertCusto return database.CustomRole{}, NoActorError } - // TODO: If this is an org role, check the org assign role type. - if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignRole); err != nil { - return database.CustomRole{}, err + // Org and site role upsert share the same query. So switch the assertion based on the org uuid. + if arg.OrganizationID.UUID != uuid.Nil { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil { + return database.CustomRole{}, err + } + } else { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignRole); err != nil { + return database.CustomRole{}, err + } } if arg.OrganizationID.UUID == uuid.Nil && len(arg.OrgPermissions) > 0 { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 44d45118ce1ea..96b0e35874186 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -625,7 +625,7 @@ func (s *MethodTestSuite) TestOrganization() { UserID: u.ID, Roles: []string{codersdk.RoleOrganizationAdmin}, }).Asserts( - rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionAssign, + rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate) })) s.Run("UpdateOrganization", s.Subtest(func(db database.Store, check *expects) { @@ -681,8 +681,8 @@ func (s *MethodTestSuite) TestOrganization() { WithCancelled(sql.ErrNoRows.Error()). Asserts( mem, policy.ActionRead, - rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionAssign, // org-mem - rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionDelete, // org-admin + rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, // org-mem + rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionDelete, // org-admin ).Returns(out) })) } @@ -1257,7 +1257,7 @@ func (s *MethodTestSuite) TestUser() { }), convertSDKPerm), }).Asserts( // First check - rbac.ResourceAssignRole, policy.ActionCreate, + rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionCreate, // Escalation checks rbac.ResourceTemplate.InOrg(orgID), policy.ActionCreate, rbac.ResourceTemplate.InOrg(orgID), policy.ActionRead, diff --git a/coderd/members.go b/coderd/members.go index eaa14ada67d8e..bd41dfa10741a 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -87,6 +87,10 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) { UserID: member.UserID, OrgID: organization.ID, }) + if httpapi.Is404Error(err) { + httpapi.Forbidden(rw) + return + } if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: err.Error(), diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 9ab848d795b1c..5b39b846195dd 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -28,6 +28,7 @@ var ( // ResourceAssignOrgRole // Valid Actions // - "ActionAssign" :: ability to assign org scoped roles + // - "ActionCreate" :: ability to create/delete/edit custom roles within an organization // - "ActionDelete" :: ability to delete org scoped roles // - "ActionRead" :: view what roles are assignable ResourceAssignOrgRole = Object{ diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 2d3213264a514..eec8865d09317 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -218,6 +218,7 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionAssign: actDef("ability to assign org scoped roles"), ActionRead: actDef("view what roles are assignable"), ActionDelete: actDef("ability to delete org scoped roles"), + ActionCreate: actDef("ability to create/delete/edit custom roles within an organization"), }, }, "oauth2_app": { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index ccac26679e81d..4804cdce2eae1 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -24,7 +24,8 @@ const ( // customSiteRole is a placeholder for all custom site roles. // This is used for what roles can assign other roles. // TODO: Make this more dynamic to allow other roles to grant. - customSiteRole string = "custom-site-role" + customSiteRole string = "custom-site-role" + customOrganizationRole string = "custom-organization-role" orgAdmin string = "organization-admin" orgMember string = "organization-member" @@ -125,8 +126,11 @@ func (r *RoleIdentifier) UnmarshalJSON(data []byte) error { // Once we have a database implementation, the "default" roles can be defined on the // site and orgs, and these functions can be removed. -func RoleOwner() RoleIdentifier { return RoleIdentifier{Name: owner} } -func CustomSiteRole() RoleIdentifier { return RoleIdentifier{Name: customSiteRole} } +func RoleOwner() RoleIdentifier { return RoleIdentifier{Name: owner} } +func CustomSiteRole() RoleIdentifier { return RoleIdentifier{Name: customSiteRole} } +func CustomOrganizationRole(orgID uuid.UUID) RoleIdentifier { + return RoleIdentifier{Name: customOrganizationRole, OrganizationID: orgID} +} func RoleTemplateAdmin() RoleIdentifier { return RoleIdentifier{Name: templateAdmin} } func RoleUserAdmin() RoleIdentifier { return RoleIdentifier{Name: userAdmin} } func RoleMember() RoleIdentifier { return RoleIdentifier{Name: member} } @@ -314,6 +318,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) { DisplayName: "User Admin", Site: Permissions(map[string][]policy.Action{ ResourceAssignRole.Type: {policy.ActionAssign, policy.ActionDelete, policy.ActionRead}, + // Need organization assign as well to create users. At present, creating a user + // will always assign them to some organization. + ResourceAssignOrgRole.Type: {policy.ActionAssign, policy.ActionDelete, policy.ActionRead}, ResourceUser.Type: { policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionUpdatePersonal, policy.ActionReadPersonal, @@ -361,7 +368,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: []Permission{}, Org: map[string][]Permission{ // Org admins should not have workspace exec perms. - organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant), Permissions(map[string][]policy.Action{ + organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourceAssignRole), Permissions(map[string][]policy.Action{ ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop}, ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH), })...), @@ -409,32 +416,35 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // map[actor_role][assign_role] var assignRoles = map[string]map[string]bool{ "system": { - owner: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, - templateAdmin: true, - userAdmin: true, - customSiteRole: true, + owner: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + templateAdmin: true, + userAdmin: true, + customSiteRole: true, + customOrganizationRole: true, }, owner: { - owner: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, - templateAdmin: true, - userAdmin: true, - customSiteRole: true, + owner: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + templateAdmin: true, + userAdmin: true, + customSiteRole: true, + customOrganizationRole: true, }, userAdmin: { member: true, orgMember: true, }, orgAdmin: { - orgAdmin: true, - orgMember: true, + orgAdmin: true, + orgMember: true, + customOrganizationRole: true, }, } @@ -596,6 +606,13 @@ func RoleByName(name RoleIdentifier) (Role, error) { return Role{}, xerrors.Errorf("expect a org id for role %q", name.String()) } + // This can happen if a custom role shares the same name as a built-in role. + // You could make an org role called "owner", and we should not return the + // owner role itself. + if name.OrganizationID != role.Identifier.OrganizationID { + return Role{}, xerrors.Errorf("role %q not found", name.String()) + } + return role, nil } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index a1f607ac756c8..c49f161760235 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -279,6 +279,15 @@ func TestRolePermissions(t *testing.T) { Name: "OrgRoleAssignment", Actions: []policy.Action{policy.ActionAssign, policy.ActionDelete}, Resource: rbac.ResourceAssignOrgRole.InOrg(orgID), + AuthorizeMap: map[bool][]authSubject{ + true: {owner, orgAdmin, userAdmin}, + false: {orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe, templateAdmin}, + }, + }, + { + Name: "CreateOrgRoleAssignment", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceAssignOrgRole.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ true: {owner, orgAdmin}, false: {orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe, templateAdmin, userAdmin}, @@ -289,8 +298,8 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceAssignOrgRole.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ - true: {owner, orgAdmin, orgMemberMe}, - false: {otherOrgAdmin, otherOrgMember, memberMe, templateAdmin, userAdmin}, + true: {owner, orgAdmin, orgMemberMe, userAdmin, userAdmin}, + false: {otherOrgAdmin, otherOrgMember, memberMe, templateAdmin}, }, }, { diff --git a/coderd/roles.go b/coderd/roles.go index f4e66b7a56a50..8c066f5fecbb3 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -144,9 +144,14 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customR } for _, role := range customRoles { + canAssign := rbac.CanAssignRole(actorRoles, rbac.CustomSiteRole()) + if role.RoleIdentifier().IsOrgRole() { + canAssign = rbac.CanAssignRole(actorRoles, rbac.CustomOrganizationRole(role.OrganizationID.UUID)) + } + assignable = append(assignable, codersdk.AssignableRoles{ Role: db2sdk.Role(role), - Assignable: rbac.CanAssignRole(actorRoles, role.RoleIdentifier()), + Assignable: canAssign, BuiltIn: false, }) } diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 2c524e356553e..73d784b449535 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -54,7 +54,7 @@ const ( var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceWildcard: {}, ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceAssignOrgRole: {ActionAssign, ActionDelete, ActionRead}, + ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead}, ResourceAssignRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead}, ResourceAuditLog: {ActionCreate, ActionRead}, ResourceDebugInfo: {ActionRead}, diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 0c0f56eb57ba2..8006f7a68b175 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -203,6 +203,7 @@ func TestCustomOrganizationRole(t *testing.T) { _, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, codersdk.Role{ Name: "Bad_Name", // No underscores allowed DisplayName: "Testing Purposes", + OrganizationID: first.OrganizationID.String(), SitePermissions: nil, OrganizationPermissions: nil, UserPermissions: nil, diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index ede99551ef0ec..4f55859cd9e4d 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -237,3 +238,61 @@ func TestCreateFirstUser_Entitlements_Trial(t *testing.T) { require.NoError(t, err) require.True(t, entitlements.Trial, "Trial license should be immediately active.") } + +// TestAssignCustomOrgRoles verifies an organization admin (not just an owner) can create +// a custom role and assign it to an organization user. +func TestAssignCustomOrgRoles(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + tv := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, tv.ID) + + ctx := testutil.Context(t, testutil.WaitShort) + // Create a custom role as an organization admin that allows making templates. + auditorRole, err := client.PatchOrganizationRole(ctx, owner.OrganizationID, codersdk.Role{ + Name: "org-template-admin", + OrganizationID: owner.OrganizationID.String(), + DisplayName: "Template Admin", + SitePermissions: nil, + OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: codersdk.RBACResourceActions[codersdk.ResourceTemplate], // All template perms + }), + UserPermissions: nil, + }) + require.NoError(t, err) + + createTemplateReq := codersdk.CreateTemplateRequest{ + Name: "name", + DisplayName: "Template", + VersionID: tv.ID, + } + memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + // Check the member cannot create a template + _, err = memberClient.CreateTemplate(ctx, owner.OrganizationID, createTemplateReq) + require.Error(t, err) + + // Assign new role to the member as the org admin + _, err = client.UpdateOrganizationMemberRoles(ctx, owner.OrganizationID, member.ID.String(), codersdk.UpdateRoles{ + Roles: []string{auditorRole.Name}, + }) + require.NoError(t, err) + + // Now the member can create the template + _, err = memberClient.CreateTemplate(ctx, owner.OrganizationID, createTemplateReq) + require.NoError(t, err) +} From fe240add86f6c7fa6ce26a2325fef71d17fc16a9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 14 Jun 2024 09:29:07 +0100 Subject: [PATCH 081/168] fix(coderd): userOIDC: ignore leading @ of EmailDomain (#13568) --- coderd/userauth.go | 2 ++ coderd/userauth_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/coderd/userauth.go b/coderd/userauth.go index b9d163a6afdac..bb7f0ee64c293 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -960,6 +960,8 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } userEmailDomain := emailSp[len(emailSp)-1] for _, domain := range api.OIDCConfig.EmailDomain { + // Folks sometimes enter EmailDomain with a leading '@'. + domain = strings.TrimPrefix(domain, "@") if strings.EqualFold(userEmailDomain, domain) { ok = true break diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index ef62005b9e1f4..bc556fe604ebe 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -941,6 +941,30 @@ func TestUserOIDC(t *testing.T) { }, StatusCode: http.StatusForbidden, }, + { + Name: "EmailDomainWithLeadingAt", + IDTokenClaims: jwt.MapClaims{ + "email": "cian@coder.com", + "email_verified": true, + }, + AllowSignups: true, + EmailDomain: []string{ + "@coder.com", + }, + StatusCode: http.StatusOK, + }, + { + Name: "EmailDomainForbiddenWithLeadingAt", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + AllowSignups: true, + EmailDomain: []string{ + "@coder.com", + }, + StatusCode: http.StatusForbidden, + }, { Name: "EmailDomainCaseInsensitive", IDTokenClaims: jwt.MapClaims{ From c01d6fdf466ae4cd7daed58e07a0ab17aca03e66 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 14 Jun 2024 15:06:33 +0400 Subject: [PATCH 082/168] chore: refactor apphealth and tests to use clock testing library (#13576) Refactors the apphealth subsystem and unit tests to use `clock.Clock`. Also slightly simplifies the implementation, which wrapped a function that never returned an error in a `retry.Retry`. The retry is entirely superfluous in that case, so removed. UTs used to take a few seconds to run, and now run in milliseconds or better. No sleeps, `Eventually`, or polling. Dropped the "no spamming" test since we can directly assert the number of handler calls on the mainline test case. --- agent/apphealth.go | 114 +++++++++--------- agent/apphealth_test.go | 258 +++++++++++++++++++++++----------------- 2 files changed, 203 insertions(+), 169 deletions(-) diff --git a/agent/apphealth.go b/agent/apphealth.go index 1badc0f361376..0b7e87e57df68 100644 --- a/agent/apphealth.go +++ b/agent/apphealth.go @@ -10,14 +10,11 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/clock" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" - "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 @@ -26,15 +23,26 @@ 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, clock.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 clock.Clock, +) WorkspaceAppHealthReporter { logger = logger.Named("apphealth") - runHealthcheckLoop := func(ctx context.Context) error { + return func(ctx context.Context) { ctx, cancel := context.WithCancel(ctx) defer cancel() // no need to run this loop if no apps for this workspace. if len(apps) == 0 { - return nil + return } hasHealthchecksEnabled := false @@ -49,7 +57,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace // no need to run this loop if no health checks are configured. if !hasHealthchecksEnabled { - return nil + return } // run a ticker for each app health check. @@ -61,25 +69,29 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace } app := nextApp go func() { - 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, - } + _ = 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() + err := func() error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, app.Healthcheck.URL, nil) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, app.Healthcheck.URL, nil) if err != nil { return err } - res, err := client.Do(req) + res, err := http.DefaultClient.Do(req) if err != nil { return err } @@ -118,54 +130,36 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace mu.Unlock() logger.Debug(ctx, "workspace app healthy", slog.F("id", app.ID.String()), slog.F("slug", app.Slug)) } - - t.Reset(time.Duration(app.Healthcheck.Interval) * time.Second) - } + return nil + }, "healthcheck", app.Slug) }() } mu.Lock() lastHealth := copyHealth(health) mu.Unlock() - 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(): + reportTicker := clk.TickerFunc(ctx, time.Second, func() error { + mu.RLock() + changed := healthChanged(lastHealth, health) + mu.RUnlock() + if !changed { 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)) - } } - } - } - 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 + 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)) } - logger.Error(ctx, "failed running workspace app reporter", slog.Error(err)) - } + return nil + }, "report") + _ = reportTicker.Wait() // only possible error is context done } } diff --git a/agent/apphealth_test.go b/agent/apphealth_test.go index b8be5c1fa227f..dbcb40b7e69e9 100644 --- a/agent/apphealth_test.go +++ b/agent/apphealth_test.go @@ -4,14 +4,12 @@ import ( "context" "net/http" "net/http/httptest" + "slices" "strings" - "sync" - "sync/atomic" "testing" "time" "github.com/google/uuid" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog" @@ -19,6 +17,7 @@ import ( "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/clock" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -27,15 +26,17 @@ import ( func TestAppHealth_Healthy(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) 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 @@ -46,6 +47,7 @@ func TestAppHealth_Healthy(t *testing.T) { Health: codersdk.WorkspaceAppHealthInitializing, }, { + ID: uuid.UUID{3}, Slug: "app3", Healthcheck: codersdk.Healthcheck{ Interval: 2, @@ -54,36 +56,70 @@ func TestAppHealth_Healthy(t *testing.T) { Health: codersdk.WorkspaceAppHealthInitializing, }, } + checks := make(map[string]int) handlers := []http.Handler{ nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checks["app2"]++ httpapi.Write(r.Context(), w, http.StatusOK, nil) }), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checks["app3"]++ httpapi.Write(r.Context(), w, http.StatusOK, nil) }), } - getApps, closeFn := setupAppReporter(ctx, t, apps, handlers) + mClock := clock.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) defer closeFn() - apps, err := getApps(ctx) - require.NoError(t, err) - require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, apps[0].Health) - require.Eventually(t, func() bool { - apps, err := getApps(ctx) - if err != nil { - return false - } + 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) + + // 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 - return apps[1].Health == codersdk.WorkspaceAppHealthHealthy && apps[2].Health == codersdk.WorkspaceAppHealthHealthy - }, testutil.WaitLong, testutil.IntervalSlow) + 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, checks["app2"]) + require.Equal(t, 1, checks["app3"]) } func TestAppHealth_500(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) 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 @@ -99,24 +135,40 @@ func TestAppHealth_500(t *testing.T) { httpapi.Write(r.Context(), w, http.StatusInternalServerError, nil) }), } - getApps, closeFn := setupAppReporter(ctx, t, apps, handlers) + + mClock := clock.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) defer closeFn() - require.Eventually(t, func() bool { - apps, err := getApps(ctx) - if err != nil { - return false - } + 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() - return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy - }, testutil.WaitLong, testutil.IntervalSlow) + 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) } func TestAppHealth_Timeout(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) 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 @@ -127,63 +179,66 @@ func TestAppHealth_Timeout(t *testing.T) { 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) + http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + // allow the request to time out + <-r.Context().Done() }), } - getApps, closeFn := setupAppReporter(ctx, t, apps, handlers) - defer closeFn() - require.Eventually(t, func() bool { - apps, err := getApps(ctx) - if err != nil { - return false - } + mClock := clock.NewMock(t) + start := mClock.Now() - 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{ - { - 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, - }, + // 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() - counter := new(int32) - handlers := []http.Handler{ - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(counter, 1) - }), - } - _, closeFn := setupAppReporter(ctx, t, apps, handlers) + fakeAPI, closeFn := setupAppReporter(ctx, t, apps, handlers, mClock) defer closeFn() - // 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)) + 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) } -func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) { +func setupAppReporter( + ctx context.Context, t *testing.T, + apps []codersdk.WorkspaceApp, + handlers []http.Handler, + clk clock.Clock, +) (*agenttest.FakeAgentAPI, func()) { closers := []func(){} - for i, app := range apps { - if app.ID == uuid.Nil { - app.ID = uuid.New() - apps[i] = app - } + 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 { @@ -196,14 +251,6 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa closers = append(closers, ts.Close) } - var mu sync.Mutex - workspaceAgentApps := func(context.Context) ([]codersdk.WorkspaceApp, error) { - mu.Lock() - defer mu.Unlock() - var newApps []codersdk.WorkspaceApp - return append(newApps, apps...), nil - } - // 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. // @@ -212,38 +259,31 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa // post function. fakeAAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil) - // Process events from the channel and update the health of the apps. - go func() { - appHealthCh := fakeAAPI.AppHealthCh() - for { - select { - case <-ctx.Done(): - return - case req := <-appHealthCh: - mu.Lock() - for _, update := range req.Updates { - updateID, err := uuid.FromBytes(update.Id) - assert.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 - } - } - mu.Unlock() - } - } - }() - - go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), apps, agentsdk.AppHealthPoster(fakeAAPI))(ctx) + go agent.NewAppHealthReporterWithClock( + slogtest.Make(t, nil).Leveled(slog.LevelDebug), + apps, agentsdk.AppHealthPoster(fakeAAPI), clk, + )(ctx) - return workspaceAgentApps, func() { + return fakeAAPI, 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 + } + } +} From 87820a29d7f670769bcfa60264063d58e4ab22a5 Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Fri, 14 Jun 2024 09:30:04 -0400 Subject: [PATCH 083/168] docs: reorganize scaling docs (#13574) * refactor scaling docs * manifest * make fmt * fix 404s * fix 404s pt 2 * fix manifest --- docs/admin/architectures/validated-arch.md | 4 +- docs/admin/provisioners.md | 6 +-- .../scale-testing.md | 30 ++++++----- .../{scale.md => scaling/scale-utility.md} | 10 ++-- docs/manifest.json | 52 ++++++++++--------- docs/platforms/aws.md | 2 +- docs/platforms/gcp.md | 2 +- 7 files changed, 56 insertions(+), 50 deletions(-) rename docs/admin/{architectures => scaling}/scale-testing.md (92%) rename docs/admin/{scale.md => scaling/scale-utility.md} (97%) diff --git a/docs/admin/architectures/validated-arch.md b/docs/admin/architectures/validated-arch.md index 0595b29cf3019..ffb5a1e919ad7 100644 --- a/docs/admin/architectures/validated-arch.md +++ b/docs/admin/architectures/validated-arch.md @@ -2,8 +2,8 @@ Many customers operate Coder in complex organizational environments, consisting of multiple business units, agencies, and/or subsidiaries. This can lead to -numerous Coder deployments, caused by discrepancies in regulatory compliance, -data sovereignty, and level of funding across groups. The Coder Validated +numerous Coder deployments, due to discrepancies in regulatory compliance, data +sovereignty, and level of funding across groups. The Coder Validated Architecture (CVA) prescribes a Kubernetes-based deployment approach, enabling your organization to deploy a stable Coder instance that is easier to maintain and troubleshoot. diff --git a/docs/admin/provisioners.md b/docs/admin/provisioners.md index 22f1eccdf1a88..422aa9b29d94c 100644 --- a/docs/admin/provisioners.md +++ b/docs/admin/provisioners.md @@ -18,11 +18,11 @@ sometimes benefits to running external provisioner daemons: - **Reduce server load**: External provisioners reduce load and build queue times from the Coder server. See - [Scaling Coder](./scale.md#concurrent-workspace-builds) for more details. + [Scaling Coder](scaling/scale-utility.md#recent-scale-tests) for more details. Each provisioner can run a single -[concurrent workspace build](./scale.md#concurrent-workspace-builds). For -example, running 30 provisioner containers will allow 30 users to start +[concurrent workspace build](scaling/scale-testing.md#control-plane-provisionerd). +For example, running 30 provisioner containers will allow 30 users to start workspaces at the same time. Provisioners are started with the diff --git a/docs/admin/architectures/scale-testing.md b/docs/admin/scaling/scale-testing.md similarity index 92% rename from docs/admin/architectures/scale-testing.md rename to docs/admin/scaling/scale-testing.md index 38e27b63be1ca..761f22bfcd0e6 100644 --- a/docs/admin/architectures/scale-testing.md +++ b/docs/admin/scaling/scale-testing.md @@ -1,18 +1,20 @@ -## Scale Testing +# Scale Testing Scaling Coder involves planning and testing to ensure it can handle more load without compromising service. This process encompasses infrastructure setup, traffic projections, and aggressive testing to identify and mitigate potential bottlenecks. -A dedicated Kubernetes cluster for Coder is Kubernetes cluster specifically -configured to host and manage Coder workloads. Kubernetes provides container -orchestration capabilities, allowing Coder to efficiently deploy, scale, and -manage workspaces across a distributed infrastructure. This ensures high -availability, fault tolerance, and scalability for Coder deployments. Coder is -deployed on this cluster using the +A dedicated Kubernetes cluster for Coder is recommended to configure, host and +manage Coder workloads. Kubernetes provides container orchestration +capabilities, allowing Coder to efficiently deploy, scale, and manage workspaces +across a distributed infrastructure. This ensures high availability, fault +tolerance, and scalability for Coder deployments. Coder is deployed on this +cluster using the [Helm chart](../../install/kubernetes.md#install-coder-with-helm). +## Methodology + Our scale tests include the following stages: 1. Prepare environment: create expected users and provision workspaces. @@ -33,7 +35,7 @@ Our scale tests include the following stages: 6. Cleanup: delete workspaces and users created in step 1. -### Infrastructure and setup requirements +## Infrastructure and setup requirements The scale tests runner can distribute the workload to overlap single scenarios based on the workflow configuration: @@ -60,7 +62,7 @@ The test is deemed successful if users did not experience interruptions in their workflows, `coderd` did not crash or require restarts, and no other internal errors were observed. -### Traffic Projections +## Traffic Projections In our scale tests, we simulate activity from 2000 users, 2000 workspaces, and 2000 agents, with two items of workspace agent metadata being sent every 10 @@ -88,11 +90,11 @@ Database: ## Available reference architectures -[Up to 1,000 users](1k-users.md) +[Up to 1,000 users](../architectures/1k-users.md) -[Up to 2,000 users](2k-users.md) +[Up to 2,000 users](../architectures/2k-users.md) -[Up to 3,000 users](3k-users.md) +[Up to 3,000 users](../architectures/3k-users.md) ## Hardware recommendation @@ -151,8 +153,8 @@ with a deployment of Coder [workspace proxies](../workspace-proxies.md). **Node Autoscaling** We recommend disabling the autoscaling for `coderd` nodes. Autoscaling can cause -interruptions for user connections, see [Autoscaling](../scale.md#autoscaling) -for more details. +interruptions for user connections, see +[Autoscaling](scale-utility.md#autoscaling) for more details. ### Control plane: Workspace Proxies diff --git a/docs/admin/scale.md b/docs/admin/scaling/scale-utility.md similarity index 97% rename from docs/admin/scale.md rename to docs/admin/scaling/scale-utility.md index d8569fb8dffef..b841c52f6ee48 100644 --- a/docs/admin/scale.md +++ b/docs/admin/scaling/scale-utility.md @@ -1,17 +1,19 @@ +# Scale Tests and Utilities + We scale-test Coder with [a built-in utility](#scale-testing-utility) that can be used in your environment for insights into how Coder scales with your infrastructure. For scale-testing Kubernetes clusters we recommend to install and use the dedicated Coder template, [scaletest-runner](https://github.com/coder/coder/tree/main/scaletest/templates/scaletest-runner). -Learn more about [Coder’s architecture](architectures/architecture.md) and our -[scale-testing methodology](architectures/scale-testing.md). +Learn more about [Coder’s architecture](../architectures/architecture.md) and +our [scale-testing methodology](scale-testing.md). ## Recent scale tests > Note: the below information is for reference purposes only, and are not > intended to be used as guidelines for infrastructure sizing. Review the -> [Reference Architectures](architectures/validated-arch.md#node-sizing) for +> [Reference Architectures](../architectures/validated-arch.md#node-sizing) for > hardware sizing recommendations. | Environment | Coder CPU | Coder RAM | Coder Replicas | Database | Users | Concurrent builds | Concurrent connections (Terminal/SSH) | Coder Version | Last tested | @@ -247,6 +249,6 @@ an annotation on the coderd deployment. ## Troubleshooting If a load test fails or if you are experiencing performance issues during -day-to-day use, you can leverage Coder's [Prometheus metrics](./prometheus.md) +day-to-day use, you can leverage Coder's [Prometheus metrics](../prometheus.md) to identify bottlenecks during scale tests. Additionally, you can use your existing cloud monitoring stack to measure load, view server logs, etc. diff --git a/docs/manifest.json b/docs/manifest.json index 8acd4ad517313..3db60229405c5 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -336,6 +336,30 @@ "path": "./admin/README.md", "icon_path": "./images/icons/wrench.svg", "children": [ + { + "title": "Architecture", + "description": "Learn about validated and reference architectures for Coder", + "path": "./admin/architectures/architecture.md", + "icon_path": "./images/icons/container.svg", + "children": [ + { + "title": "Validated Architecture", + "path": "./admin/architectures/validated-arch.md" + }, + { + "title": "Up to 1,000 users", + "path": "./admin/architectures/1k-users.md" + }, + { + "title": "Up to 2,000 users", + "path": "./admin/architectures/2k-users.md" + }, + { + "title": "Up to 3,000 users", + "path": "./admin/architectures/3k-users.md" + } + ] + }, { "title": "Authentication", "description": "Learn how to set up authentication using GitHub or OpenID Connect", @@ -389,34 +413,12 @@ { "title": "Scaling Coder", "description": "Learn how to use load testing tools", - "path": "./admin/scale.md", - "icon_path": "./images/icons/scale.svg" - }, - { - "title": "Architecture", - "description": "Learn about validated and reference architectures for Coder", - "path": "./admin/architectures/architecture.md", + "path": "./admin/scaling/scale-testing.md", "icon_path": "./images/icons/scale.svg", "children": [ { - "title": "Validated Architecture", - "path": "./admin/architectures/validated-arch.md" - }, - { - "title": "Scale Testing", - "path": "./admin/architectures/scale-testing.md" - }, - { - "title": "Up to 1,000 users", - "path": "./admin/architectures/1k-users.md" - }, - { - "title": "Up to 2,000 users", - "path": "./admin/architectures/2k-users.md" - }, - { - "title": "Up to 3,000 users", - "path": "./admin/architectures/3k-users.md" + "title": "Scaling Utility", + "path": "./admin/scaling/scale-utility.md" } ] }, diff --git a/docs/platforms/aws.md b/docs/platforms/aws.md index b5114d720feac..83e0c6c2aa642 100644 --- a/docs/platforms/aws.md +++ b/docs/platforms/aws.md @@ -27,7 +27,7 @@ We recommend keeping the default instance type (`t2.xlarge`, 4 cores and 16 GB memory) if you plan on provisioning Docker containers as workspaces on this EC2 instance. Keep in mind this platforms is intended for proof-of-concept deployments and you should adjust your infrastructure when preparing for -production use. See: [Scaling Coder](../admin/scale.md) +production use. See: [Scaling Coder](../admin/scaling/scale-testing.md) Be sure to add a keypair so that you can connect over SSH to further [configure Coder](../admin/configure.md). diff --git a/docs/platforms/gcp.md b/docs/platforms/gcp.md index 630897fc79d6e..c8c4203314c77 100644 --- a/docs/platforms/gcp.md +++ b/docs/platforms/gcp.md @@ -23,7 +23,7 @@ We recommend keeping the default instance type (`e2-standard-4`, 4 cores and 16 GB memory) if you plan on provisioning Docker containers as workspaces on this VM instance. Keep in mind this platforms is intended for proof-of-concept deployments and you should adjust your infrastructure when preparing for -production use. See: [Scaling Coder](../admin/scale.md) +production use. See: [Scaling Coder](../admin/scaling/scale-testing.md)
BP$gEtkkaIX}C=Xx-O0opM4Ac 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 literal 0 HcmV?d00001 diff --git a/docs/images/screenshots/settings.png b/docs/images/screenshots/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..cf3f19116fb1317db8c892416784cd400e01e14b GIT binary patch literal 131744 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 literal 0 HcmV?d00001 diff --git a/docs/images/screenshots/starter_templates.png b/docs/images/screenshots/starter_templates.png new file mode 100644 index 0000000000000000000000000000000000000000..1eab19f2901cd32b0703eec2b32fc04e14eab4ec GIT binary patch literal 112764 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 QA>c<@LE~AqoMq_$0%g>9+5i9m literal 0 HcmV?d00001 diff --git a/docs/images/screenshots/templates_insights.png b/docs/images/screenshots/templates_insights.png new file mode 100644 index 0000000000000000000000000000000000000000..8375661da26038dc4646e1ac5e7553a1c4556158 GIT binary patch literal 165651 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ℑKSjbPyrZ8k89|UC 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

@8E(-I#SY!oT+aKT?5}d8> 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


J@955uuYoeIqU9oqFXMs+pn_aP_Sde){pMy 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�AK*vT#%Uq3?Cp%n z0g!DFEt9xLFs1=&d6}|L?s_c&16aMx*c|ID=x`W{uoo_L%%88K{QwXNQz2SAL zn|xxNAtH2vz_4-uag~e#aFKi$*<);Gu&xOQq=I8sRc)v=g%8_87z2;?Y7%LCvzRHqY*b8a1vzPzm75A|GTh@G+ z-=fwMMU8XG^*2gE;W^ueHaM4@8B=Q%|3i(3V|q`WO=~{v=75R_dp?zb9=)I~AdA2a zW}qIGI}9%yVQon-IWH%;J(MN<2$^Xnb=wGB{ZJ_2iPuwsGnlffmLbDdXRN7PyugVE zQ)<$a;`*z2rH$Bad4VC9bezR0u7Rt**W2L%=x#wxH?x;UkR1O>Ps4@Bv6|o5^kXd) zmM6`v2Lrwcxm+E9q$1?vIF;K|<`ULJzB1!}*NNhNuaZmFx#w{MRwQJzrqHqv5_yE~ za9#S{$Jb}k;U9^$PO9Rw%`tM`kzRnZ2d7Y!T++xkxf)#QTxm3dd&N>8d$oji4 zoB5VyMN`NMWv?&@2IpD61hHnp83_mYG! zrH;&t@}|YEb*(y|DmHQu!4Gn-F4j`Egd(I~zG(6@Bf9_!M%~0=&AbSq$fG+7IA0{N z;&mhdY|<2_#@X~E`-X=tn~~`D;VE;ob`c_N@cm?y$n|i+{e^(NE#vRdRjioWhDP9+ zN4U79Y(wE8i$k$FD?KqVro$nrn=j@40#nuJFoBXUNr2Z|32i%+d#GZm(7Cv7m+~m1 z`TQU!j$*P_#Ze<4%Ul9I<7c{oK6n2o^&u2&&ofN7xiCQj?Kk`Ac!q&#*tds1y)zgf zEb;V$p4tsG=t_drhV&qy*m?25RN<2B&=kf|>P}a4_}+loiaX$Q>A_)aOp6#NK^|D^ zD>*l;#Ay8Vq4V0Y@?-%A*Fi4Ek-_>`T6i+o$#fPzdG(?N+^(`^zp)ZymIpuP{alTy34FU-k;3674Q! zuQAZF)|a5~E)Zs2d<_xH_aoU1nGi2Hqq2H9gbhbB9d5@}Yd_3*ZZps_leIV{et^E; zG*ubB2AD`;&v}fyDwAim!8zH!c0OjC6ZB6+9~OBmb*3<( zVd9TUhxJv_v=+ymKQuCXk1CZ*Y+q7qFjGWwPtalWK7sFWSwovT6}zsd`q`CAp%_CO zXF@gEUM;-s+p|V8RN%78M!Ia!74>*k)v
    Vx=T5`Gd^LA?;e9YFeCeV-~Wq!D)g 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 K>}HjoJ?4Ktfo-J# literal 0 HcmV?d00001 diff --git a/docs/images/screenshots/terraform.png b/docs/images/screenshots/terraform.png new file mode 100644 index 0000000000000000000000000000000000000000..d8780d650ea1fcd5a8d79bfc0cce6349d9f6bf40 GIT binary patch literal 166705 zcmaI81yo$i(l(4HNN`PXPjGh)4ncwp?hqij`w#*IcXxLm7~FzeaCdii=bzkr&w1~; z-#PzUJ!_ghd+ps_T~%H6)YAkh%1fdky+eY6ftYLf_s93f~i4x1-ar%DRlw) zf_6}n{0LP(Ot=U6L(D{7`iq<#6fLBW00kXj4h8r77Rcuv4y_Gtdt)xyk?I9h9T9i0ZhwoYz#g&;Rrk$aM)A!_D5K7KwNK(NKty zf7^tyNJ%At*iXsrVVju$G4%x9eo~9!~6(fPEK} zkjEa%c9ONfogS{Go&3V~1goRL^>*AL z3MVPNro~Q5!{yaoud;|fCw6#|G!4(t=tkzI#=g##DzUw%vPrcCCG+?-UK){*^Vr$; zjlD=nc)Homux;*9gyiMTYYWQf{&?N0Q=_l=aN)!#ovUOyv}vK1dnUXJnVhb+9w%<; z{1pH1();jItlG;Vd_Ugz+1kh&8%FaTF(KQ%oWoCj zwr5g$acMRVL8|BuAgQ|q1QsqM>uA=i@KTpO22&KH5Mw_Dvu)x0jebn>LPzj5wmTS; zhPO81Bw>62FMG%AD+YZV3+aq*8V4=G}lzKmz_2EA% z>+|Ykd&dCoboj6>+L14yrMDx;%X`)XUcdWt^cAb9yzhT3Kbac3>J;lI(_;+Le0G_6 zIp|uAYQ2yd*(;OjP0`#`?QO}^+bT&+Gk&yP2N)gLQT82YU& zgv;}_j=yzXTlngk3)$JG0in({j*@&pRwXG(kYuguw0P2%g5VvcCituB7Y&^+uU^-6 zdp}=5EqmP%d0k#!mY&;^k^QW{S@sw#JK5Oql_YknUp!C7t#52x-z&)A^jdP;N!wj) zkd2pm|Nc-^2&?h3VAB@R-rjCsvgCEsQix(YwS}6A zln%Uq8;Z{Y%BKC*S}GZl9_e9VYC3bMfewW@NezuSPbTYF4gd*E3a2VimIHGR{5^wkUuLYZ@p! zI5-FhAa*bO*MyszHu`By$=|<62LfC1Ilf}Y(`$qlq`Mc(=*-W~8gBHU*g80HO|@;s z7wyhf2kbRc(*OVf88Zu?p`p-Z_6mD~IZZ?6%`8Rv0cn?IH$2UnLanng{XR(_%*G@c zEcB?8G06-?!~2Iw50SA7c8cvtVL8OWxD2tqFkPja_$xN+MgngyFjNXq(+4g6S%{lg zjhH(t|DX~7$Sg*2)kRP9(*4FHv8-68vvxI{g@8hr^>%ljMi-2(HfBCvmLd4l(@f*gPZYTd?{3&zx zMx6^d&4_wL&TvMVlWISQ64IKt&K#O(El9O8kFu)M!2UHp3-~yHOl(on1u{y?Ty<|~ zXz0pX#$&mznHe>KNS?&~ySuv_i<$Dg{Uq8#EH|j(v`mh+Ed{l!9QI0b+xOPjVXE|FD5nEdtCUiBE~HJ;Zb_{h z_ox}P1<$%Rx=X42gKU{eWc?a4Goc*!#>E94foYH)C{Nu8+S_4bVzk_qaT z1|t%(p5BY0mz2j|FMmJx_ImP=_%m8usp;nK-X6-`i}9z9CeZ-Yd=JvQj=W9N&w>Tp zFL(oAxt#Xd!99R^_vu-o2#B?SMcbWRNuyoF^%P^5?L>Q3c#{f_-LYpB5<~HXyk4WV z-SIas2~=%PG{n_fsNksBq8WVnbU^O*#ylkQeqf8$Qjj`PrQ1#K2q%nF2Jpf zXVm$g##guD{qlhG8jV=E4fyoQ>6e0a(-9{m4#3Xvx=wQAT=u-MdVIVX8Tei^{Q2Nf zWyizAW20^bII)9_`-p}n^q6UmFh}0({i0i{UiGFoBEabObpI)9eSJMVHdaO1J1&j? ztSe^Ve3i-b`A$v7@(Guz^hgp6^C%b;n9nXmXBxUxX0j{bEo(tyY|gfS%V? z*uCddvDiwsFgLkv>+SmBK3Y?r%;uwFL%jyVU9;@{Vf9@1vwqA-Nhk#%lf1+0`7S}d za@EE2a;#Uq*)<8%yJ^q)T&~A`GphG=kg2V3WY;+DWiv*hXk^L0`EHtTKfSbTYDT-@ zg2Z)|3?hn8Ue=G-OD;l*f&^ROhpmLW;DZe*85w5IjVL&&%y@*F-Lu~}5JAjIEFWVV#UCKSerA=7U#K_B| zlbX;2ar2hoko%gzqUYV+%G`LSsFZ0l`-Zs{4|n~IzA}kD>(^1K=9ftV*Nv!z)e}5p z+G>E10!b1lY_*;zNu7>Uh&zj^5p9!m$CP`jq>Tqux^CI0J5>RU_>P)#r3HeA>=QgL z5x90_xpgFy%UzcFyk5SOW?urEW|5XYIuLCP9&b-V0Bd-2WfUIYc@rfgqizF5gF;Io z?FxLztRl(@F+!9_HKvy*8FniD8v8gYuX)i2oFJG!Uz zj4hW+RL#HCY%~{&>avceU`ybwTbzoIUDi7M6ZspY3L`@;Dq3(;xNIbhjdL;0C1?*S z%%&JsT9S@(yP}2S;8%=Hh9zsagYf~ZTU%SSSzA=8f?hIszfx|hF-cyOaE*y-Y}Z%y zBOu{RoiW>!;?qWi!)5aVn66k$i9LnSr5uyuB>>_l4!}Hod__mRJ9V2fPYX5~<)vl3 z>);vPPGly2y|1a~tYWFyA~rA2u=6sX@dsdm35+^%=(EUbI!;?OzbxvNhD1?FTAbN#4@t(Mdnhgq#%a>C2kVnJq7dEuyIx0S9iFP%+R1714B&mxy>C_6J<|9~0ySOMecOu{ z;?1%{M0uOzUPf}nSws@CfV-J&e;l0}U{=1AB?%JT?*I5E`i+fMLG1}X1~uYS3&D|ZNLP63A${{%cT=Alu2I^8-}?F5P8s}@*8Tbq*$^NXA2+wb z)}h{5mKB0C;bO?^f@S}g;rI7m;|*ST^chcB_LC>`F`rGDZ+?w1f9W$5crH_0_H)T_ zMGT+_|JW7z%i)(8yx-A3Pd$|gGvgs?7{g54*^Bt>kATa-E zx~!>+&#MLm`aEI|>qKOCM6Ly7k^q&5iTr{W+B*rNNgvh2xniI#J#;W7|064?XND1x zxjaxeL-e?E=3bDsqCh$d_Pj#3NlErQUU??rzqanKA~+TW$O;KWlJOClKHtG>*wHXr zu6k>|+_Z_Km7VKTEpSwWK$D#+d^)@?#|i_q7C+pX&xH*Qv*)LI5%qv~d5jN7b^Zgv zR#~;L4kijBaFInnejiL#Mh5@7*d5Es2SLN$(^YM>i))4U>`dIs&h^?dc9`x?F5LqJV!eXhg;>MslFQ0Z^_%;H5PG^U)=#zh zx%v6DgWrZ{s|#vI%@5V+p-h&e#T=iGn{2|wMT|JhlfjR%2n{$%_3`iLL(w3 zuDU-u>`yLSy2-VcsaI*=VbTj;XNri{z^{hBz1ip58?P9$dAh$E_-OO}8Fn{XR^ojY z>)R%m6MBx)Nt{91i$?M!^>gd=!s}kL5!rEH1+T)Qka`|nvhcl zL(w?A<8a3YevZ$H?iyEV?Hb5=-yS0V;Q*zft;cG%Kp~^+4OM(_B@N&Qxx^sA3WLAS zX6475Zq;e9^U+FjccarmWK2ws-`V*B73Jh2Qh$w9Is_bwm2RJ(!*!G9F^N}=)gbz* zaPM|-fEJaU{v)q?`zwa^*b3e0{R{~iN8b9&iZYE(K#b{#eh;1a^t>!*X~!&d!fufO z+Y_1RX(!&T?Xc;+`$a2}t%}eIWsB{K5k89-5_wLrZX@{evO`C0DfH#xT)=F}A=FJ4I5xv51Q5Vau+D5nlr_*y*t_jLh^#qa|6YL7 zkti2oFoT60RbO>K@@YL>EInXz=VG#`?mhBZMX# z>OGNLCH0bOLzsF(NL*{6Cx}s60z+}&oszwilSr%kURqk3&QLO_nFo^QnOS(>d3|V8 zrYcm01dx-cp%hyjV|VL@qUoL2$4e#R5{Q-5f-o^LF-{GEb)Y6HHa1zI3F)V5(-DAM zh^@b`T23WEOz7f_mQ{qKqSxUMywGQ!3>_L)u=0~QoqAS;jSY9bTf3_G-qH2c%g?P% zk*;fku$Y_*kt|Gjuh zsomjfN{voro8*Yz?dSLGqv96#D}do3AFslG(5Oj`A}tTg-2FnHtf}&Ke@p?h$Mw#= zCjA`*R=Wa(7Z$#c;U@Q&3@01|UWl!-DV#H#Ub2`DacLqCfCImS!ZW1em(E@ ztP0{gwzj-vrFm@9sCICyq(96Bl#~P{U))_&4a(|16xB_X0C2oJNk~YvK$>wCTO!TI zteQh_--ru_hHjBF3qBlHYbM4VHIPTfCB)O;VT}mfG#8Zz+L+{tdq2LSk$nC1dWf!~ zIluPZOHu9Y@OXyET>kTEXN1`xHwNEUH4W}J^24BdYVXUw7S8;>KDPU^mn61=Q%HbM zM>nGd?k;X#jklV|z*w6Hsn*&=kO=9*XUsthZCxnPV^KNnPl~^GI=i^=^#pEJtJo7L z%FD;waWfDKzC4EPE;S|8V#k(kMTfq)06qwINI$z`l=$Q6d5R>2*mMQY@5ww$z)&il z!0CG5iVJMBk)(P@sp|Yx;AO@?KkZ)}xl+q0_c~ksHE}lL(v~wb)k&wVToJxhX>x+9 zoVJ_zen>;*vZ+GrLEd#IKqDVi=25%vpvI*DSDaxKBKWwhvYq8wRX*RWs~56*b3BxA zI+^zJ^Q0tUZ6l*~^p51|lB3n*3YPHMfev8#NMHkeFwe`*HUH)=h!Hi?Qg@!WFr`Lf zL4F_Pq$VNp!#&-0lF50JQRT_g*fU5uhAJp;4g!v_n97eK%iS%RA4}v>djY^8N@8nNs1l^V8?Of0G1fFgECq3zY=IK^bftEAvZ9y4_N~`{ z`CaWF&|NWB?#UtpSz+AO&v={p zxEhwKCU6)-#wn{mPzW5I8scBmW9)UI%|n9evQ(9o2%+a&(FECc6s?8qBf99bgNm04 zt@mDDUa>D#IQ!7tIx`+uQwecUAZ76af*k)w2;?9U#HVloie7O(UJTN*ZV-RajIknM zVPUy_x>*5>$KHaY8XB5B%)GhDf%xJ{@@c$Z?MBQnOb@x}=|9zRR9MvP#_$H9kpP~5 zMSs$=2s>ijd=G?@9TvTB+Ke`shY^0}M*pc0t)4Muf(iZpL(#5C4ef^52Ln1%(n@vJ z9B_*90H=jpwe=^CxeivZdnUj z2RIk;bX|vygVgO|ykA=0ol|*|ct0ZY-_PdcGYk!?mA0#!tbJJc&S`nuYi#lXBd5D} z!>Ueh;svcs=`7v5?jZh9uUy<1587=!ckohs>tzFyV9Kd>^IkQbw5m-5MZw?*vHEfu zKIPZ^WYCy}sxUt;wP))W$e6M&2T?bKBbI;}f;ejx)Za4#adTcrbj+6>fNiTT+EG7w zxWAUKSPlr!N{ggn%9|WyF|xN=`^qoge6O>*rCZw`m8mU!>l%BVkr0(0Y)KU!+IAwb zS)B3G6Pu!3BT;p#1p@NiyBrkM60YRb!h45*K$GvT!jfd(+?Jh6ZCvd+Q!`vpkRAJ? z&C~)vH!Eo(`5Pf&m1ageJ905xMlF5!XdM=SiR|qLO(CsI-j7V})_S|0V)J#epxz}i zp|FP%CcF7lX@xQNvvKne(%ZK)42BuGLql(#vgIDB)i@wI%iarD&;8sSPdCi)+Y4FZ zE#2UdcXO!p(xtcI!~$RTWz)k#`&}8C+z1+OS8J0bRAQ%s{3v_+=*cp1d}UXR4W z57#r&2muXxNrgK6^=ON*- zuStH{xv!>KxBmE%pYV_A7)z+$K{F5f$18vE^4)qneKj!Yn*#gOAc2EA&0?X04 zMO1X^H})<0&9)l>pXY&y3^>%gt(KF~@jP-V{$TOFoI27(qm1F4DfXjrij5W39L5JGtgt5BO_w%)5B!*njJC6yjvRiE zB*_#Uy*g-URSPYH-l_N4RTX52KoFl{-FXb1o`HenC0nr_&nwX=lU9B%ndlvc3%rSF z8a~w_7ui6L^m?Nr`k?OgTUG7qFJbdjAhg!j)~^Y|XLz}BSD#6?z|{Bs#vdN1dKotG z|5N)^O#N0pjzgSw7-sOquOAl^^4+1o(qbbhXM<6^{9ZJ3Dm@HA=bVD~*AtX(KrOC~ zwm)6fZVc3`3~xa#E1}RXQ8WRw;B?yWD-aZ-{3(M-&{OJRcL8kSQl%>nrP1OJu5FEZ zO=09ND^A=33D|2Qt0BN#3(l-MVEPP#myY2A$lvr&hn%#%=vw8?2Jg|@m$EF`c7|!b z;df{Mbf-QSc6;ke1@(&^7mp8uFf#BKr;D{NRT-t=qW_``%n&#Wqy8A<85Ge1@wa2m zGG(c$V!8rW+o;?n{1+%Xt$-s9Nc&lP0pB)LOUFm)BnfOx)qWO@n&4WeZepeZkfEqMy)xcl0XHql8b5P3NJt(YQ!-u< z5fOp=TD+Z}uK(J=4Ox5UCu-M6c|xfWu%Sl3na&r+k8!FMAb!0SQZO+c&TN($ot??m zufgtYVRB@okelTPMM;(C=LZ`()x$UwE!D)7cPeMOaw(iL>dzvQJ#kmwFHeM5nM(~S z7H1Zef|td~#suZJr$vm)YoyP>frqx$!$a+|k&s8B$QL2Qqxat2DpdVDfNh3s&wj@v z@XUuc;6~hgo)+V%WpX{;CZ)I}5g1C_pTBGQqsM7$+^3tGnVHAp(57IgnSlXKt<|{H zW_32{+@h&;4YktR)H2xGP2f7Hs8>g=+wd` zmc*Hg%f)dRofSeKqlgO8=d*F4hoJ?9L}rbaPj^fFCCkGmk{#LNJ9>?qg?OOz?{>@0 zZueB_pD5oKf`Lgi)>6hPg}XGE=%sQg`rwqGfHt_;gfx)W$M5X@)~~CS5-y(~PCg`H z&zMJjUF%qJT&>#ZrJ!|0$s&k9D-+~VHK9k$1*BmU5|*Y@_)djXk_+{lfaC1l!qGn; zU+zut@`BWLf>kqP;o;zR6Z)+dULEkVuvG0+T>!lp+bm4#29i0v>1H@_H4Ed~iOwJN z(qVa>4>ON_W6J6Hq)-XDGxhAz-mbb-yINR3TRoiU3;fVNad+GY zm^PFx169tEBX0>E&-{9vwdf=#72&s!UCAq!IA*Z9HVFc5O|?Wm_Q%j{4A51Kqm7@m zKGYt|)M_Qfg|-q7(psRpksHdVd*;o{cq10q#X4f}!ms>X#hudXkXE>?8F18l9C~KXv z8od9{9`?KHjlOH5timIC*Eh(nKREWOx$Zr2!Nx_Ll^(P_?O-eOAPf>vRdPPpB0pDq zmIOh9`y5w$IdR4WGlUm$xypQk+GOtrY+ks{c$_30%Z=gvk%-S(>9em;_Nv-<6ej6a zL(IVEXHs4-&R0dRHzMCg*|QXkoAo-sarPha(XKc@GjI%voq*}7u=b)%eH3!J@{Y#?ddUdw`S%r@?H3qbmUlT zEIfTfRl=>HyxQZHMb?} z0(i(m7_wN;g0KdC5E74~01Wn1ac>CtZ}_=t*^7NIU-4%C03v40Yg%pBpwnt7Y~Pz5I% zccp6jo3f*Dpw@Cnxr0Y< zt6Zo4USxnSC0D||4jKXpdp%RhFe+m}ui0eS8hiW!HvQMD3(27|swiQ~R0EN< z173Gnn4uHGq#7JqBD1Ug6b&MPX2|scHx1sggt3bFob&e!+2nI3@sxFMVMwa?<0+Qc+Ppa+&}S z&y-QGEv5ez+aG&EGK)`SWOyYi8d~S|5kU&eE5lSa7p^+Z`8d#PEEcz zwu8i*zNhP%Q@xR)V!W|b(E)~xEUVt=zRbiK{q0-TFRg8DLZyTB2GoDUIQ#c#RX&nZ zGh5R1#pa|UwLCULS9G0R{MZZ5=R+wQl1js3$!#?S-h_z~~k@w+m6e&k2TpM?(! z0tSYM_cS`uF}q)y(d#^Qw*fq92DKAv5^B!$TLsUxo*;UzCX~v9^!EZ~*M#BWX^@hY z6&343+}z$??+QX_iTCq^^UB51>udsJKfPNPSPT7!0f}JDHaG;Nj}(5USlit6Lp|cl zwp!5N+}c{ZKd9(^yt@ETt?W;gj1@_GV;5Sr4>OjZe%%VVoW!Q2=_02PKykiC%{xa% zLb7u`seJ`r{=u090Rn3@l|hwIIX8e1e7cyw=f|>9M2Z$yecB4u2dDed-cR`yqnpJGO6TVuB(|SE6)>ShbVPGS;Z!Za^m9)<`y}`!~A^ zM87i;F%r4k-^td!t27ymH!R`xx)0rRA4wO;zX+^+%6GVknRVDXNZGk)zD8trw>e@n z`OWwFn>Lz@ko%?ZDf?4cFqGFlNAW^;|KIwtNZ}`}T~15h#d)#fzLp@fl~y7U2=o%R z+BCd5Xk+M2K))2P_7bxZXo3k@N^0CMfBzIHM0`Z4$>qaCODXIXNN}ldW%Uc8Re~Gk zQfjQiXBl5UQWa18*du#?C!QeL;E1SdE<9`vEa(QVthq|;pz40 zODg!d$gf4t!14C(2R}D2_*+?8OZfJ}I2U?D15bWmD^cT>gy)b3(a{W3Wm!?^n3u)% z2grcgtS!a=PCUXezruyZ$2{PFxWVZ);Es)SbYSAf$kP zjEwbyE>jXxLYkWwy)S|X@R!u2075L0l2=Jp5ZvbPRD@j?h9Om}y|?%E-5opv3W|SA z3sGunDzo`G<*&|(31yP*afQ-{X0Jy({h2SW;LFZ2UysoSQ*SSU=i(G6fe%=p6}o;3 zXw7V_9y3JtwFTT*Ph7pfoq9BqkZnt@Hj@1t{|0fzW&k)tLnDJeE;DZ+5cuqVWNvOg zFrybP$W?dgV0WPs?;(EoxuD~Ub7e_uIw>6TR2#tIzC#r*?JTQ!+dbuQ6~xZo;rNVv z@haVUm`c#&Dt6N=?UxV}(=N^8Vf^bWx$1AoyGIe43zFkE1m| z#tN_}HaQjQE}9YmjE=@K3?aILZ<|7dwmvDtDm{$-dre42^_hHQ!rL=xhJcMbK3*}l zNG}h$b#)yBQXXMiUJWzb+;9D0DJHw2@)y&|oWo?+ytU$voUwe%KJBkHK&(go_X2{* z=)LqeTp(2td%A@M%Z<^$p;XA`l}gl%zB`6eStBgd!;>nEkfgope*-tZ#}DDknUBBi~Chs>+~0Mk?MGP_V840IhY%_tl5r z_fwo!Azf1){a@JiK9Ix!x$Qnz=fP;KK<_`M4;Cin=7vKPxFpFfC|En3b2z=YDDl^| zSoY8J$I)yEc;CrWlI)_S!0^=4NG`2$Q#Qv{k}gp?$Na++S*o(~itoeqR4A!d)PL)- z@DgSwT3h3m7DjZhQ$a{j))zHW9c843hfLBtpPKbdkWE2VPh?KjoL zx-6&e!kvlnr;ZqJ-JHd~I3qbrJ_h-k0`^$dXoR$5jqkdSnI~9*#PcbmFFU(W0L#jB^kH9 zH%AWTp-`?!zRpyidr@|4F=8q;7%J*QrT7kBal((52a)gdHqoEGSvfEIp4INJa?@Dyj8nVOyoLOlAG0%jwK}#9?q``+4_Cu3OSyG^ z;g@!nh+DY6x0CN-S0ICAnx-3pZ8zRo-o(8hevauNh6?!7d>yNxy@-7dPg_FJBA_u* zb#20=t;@`lWp1n?HW5_u-&sD6H`_Dt3Agh3DG+mZ^sx@lDHs&+2NmoSmEdAadry52?iG-RnbuStvy9PcXWS zWpROkKw#EPV*c9xq>R>K66=tBc>02|v+Dh&;4^p*pMIi*7cOz`C? zkw`D`d$)(CdWU=x89);|L*l>ajJ73A%i@gD9)QTHVp0XQJ9fZOA`R2*bIvjYm7k4O z?r<8P!Q<^&eg))E0#XwSB4W?ONm~TO3Q5xVTz*E9iP~QL;xg*+Lu_1v;1OXqZBM8W z{zOlvi0|6T$!i6L5u^uWvfpo<GzF~j)e$9$KNicwbj6d%hIXK8|{{&`@4 z!CLyglU3=V-9cYOY(`s^t(_&Jq!u1Hrp{fcxs2W_N-a{^(o7VHT3Tiqvh|jUHl?6S z*C~SJN#$Es9dFjtRvB})9(6h4)TwWdic46j!e3mAoDU>O<$P{3V<7XipwRKU!p6xp z3N0#ugs|AA!>KGf3H)!_zR-7cc7jQS=Q`;2GQ2t854K0Cw|epnr-cAMuIe+~A1${a z*DX1F^fS83!N|pbyFF!F8DFT-8^axk@b;3Yo1G2-?06*J`QlvdF|kAVTMOi{d>tcv z`B*P*@Gq|(3&TFRAQ2YTLS_6sp+yK}aZ-iRcTy^ze?@pWQK60{bR!@67!q|piQGB) z#zI|ddYmaMqzd6brw0BATjdh}HIo!AivB8bw}r?;NiBI|E@ca2v9k zshyI&yKU)QJ&X;17Hd9ms1#h7?EG&`K01q_UDH+@PK4Y$5LaRg0hi8Yt-K^DyK z`5lX)9}@PtK&{P+&1dWU&%F$Sa*qT)~&D$^{ErEmbo&n?C(VwoHQ2`-xFA!6l zo`Gz|wAY7o%B7{{OkH1XZq9`4E>2xkE{n-~!4@~(VU>kWyvZk^G!lKs2oJFcWf4X~ z^fbpe9`cM+6X%ZBVHN?c*~1~V8_R-UYI&K&zJB2cYQ7Yfvt`Y6noSgQmC}a*DIv>S zei3FAs6WH9iup;+if!t8Rhd@lH2hS^b}AE3g>qR9J;weG7pjM>_}$f5c1|9q+)3K0 z@3GNcd4ywTcAZt0;!KCmF-nuFM0+cBC;$UHtwd;((&EyWKJ8>VHBDvo|-H8T-5z5 z5+ug7v%LYl!DkVfq7#Ago+XQ8BR7qizpK&%m&{J*SjF zaBg4au<Z^JoIUZTK*!{b>m3Ag)Y;ypV4>i+)x z>%8&^Bp2l+|T>E#ws9tdWSq<;qXmPMwd1;uuKOrzPr-XuWBBZ4=2h<)=8 zP30N`au^RHNj@My4BHp}O!9};d{}pGZf^bV;I`N+y{Hk8g3uFoMa%U&NOAOKH5=P_ z?v*8diKW56B4VEq5mE}iEct+69LCc!FMD|X5gB)zcu!UVYR@sDFCMv2=5L+x-2z(N zSpN6eEhDQB!)x5Vx1W+&;ge)GQ^u{4EG;dWy`LYI*sxfRyT)L7y)Spi)PGlax2u(` zlVRq6(0*n@To!zm={kV1&?3mCq%;c=$_Z2}?v!EWBvy$>_!Ng(h%;8~OixfyP)bg2iiF%q((s8!50aP#hJ=WwvtAjK`)tbye|Kh%5>_T> zc9DkOW-QW}z02?NvG}VA{E%NA&FVxzwMM3)p&<$dgYKx`i$J(R@UTSjex=1UenHrQI{>)93)BI{Ng>Tse(<)M+4sfjOULf_YS6dqxEdQ(*;PYkW9 zt3frV>U(}Ew*Ifk?l}xvwcq`A-lPz?UCUkgn)Kg!_Mv3=5Z`OUcWZDG7ld z#+R0eAY}KFcEY%m4)(fBuc2R$Cv)hCeDhfp8}!JkLUK)SK8zXew}iN-s?1# zPG*v6b>2JMhQMb^Hp>_Jm<*vaTN1pl1HMzy2wnE%e=~5*kdb z7<~&1ND!$KO2h_$hi8Wzp(8@ZW2B!NSR7|!;Lre9cczyegiIkX)aA^A@5CpIY`x4c zZ|ZCogtoiyA6Ls6^7)Mf`rpq@s}v=rLuvu}TjYTXhE5r;GCG=BZ5uvn8OpjBJK=^!hF$E9H6<5F`bYL-g}L6oIz2n*!? z5+?jP4JSF_@2-J)8UFB*(b3ryw~b96nW`lK$oWkyLqo&MYdvIDgR}K+=DJIZJrV>0 z3j*U0Bwhq+3_Z6W>Q+@;6VK7qTbTsUHfF#h`rKgKF|BXidKs5X<3b2kUl%G&W%an5 z>J7U)NXBet)pLvb+NU{d-*x(=r7&Bc%k@bqOl&yqPD@@Hls!FfZEd%Aj4R!kTaqjm z@mZ>B?Rh+b{PB6t-lc}{o9`<;zTs`E-wW)t+ZkTJU2EC(?&rqF#x8Wb-Q{sq@sUPC zM!rgI*1RJV;DGN(ta3+n{whN7R{f9v1z=GfYLc!8arT7ZG@g1L(?F_R7L)gM1BJ@H zYx}yJSZAA6qCMH~h_-;AFH10ZIsc?n{okeo*?@fV723`A67pb-Hn-lYz^$-Bvg(;J zPY7!|tM;z3*s9IEufKncd5Gc3a3pQ)vxn6Yp`%79<`L34N?=I1(nsC7*4tb|YiDZy z*ur>x2l*gFiOS;O zccHSFQmmE30Pz=gjK6fu&new7phU;2(>!ezGa0c;5DGdxCJtkgG3x|5*~iAFtZkcEJ0RYpLDi@4NOh0Apu?Hu5J7bv>%O4Q-7Kxg!c=~Auj|Ox zT6_B`T(50h8{BxnE}L;||N1Y9ySu@G0Wq;zU<^3jMqi6669+NCDi$YI{eWl|tf4h+ z0;#x6E=VFVzxygb621W4Y*A+!Ppl5H}+(m2G}iEU!}d@en1ynbs2 z8b082d{Y{WlNsnDsp_a-HEXd)@XeTt_RE)Cix9pXr))7a=ZjOZ6z8;>z}@E)?Z(S@ znK^G{bM=#A#y}M(qhr(|ZN5!)yRBZZ^Sf6ymnf45oG+#LUKq(cQ$0KkPhWk>AAakA zkAxU0dnQ`*()Y@{b{i+b1O1ofN~Jda2=Vxn!n$D^J80Nh^@X+Q^cka+4<%VVn94hae4+FkO^21_xA zx}q8MG^T_6H8tX`wltdO__)p6<1`L}-m9rc+5`{N8EMcOD3o3s3;uZYWLTu4>DqhN zyoblWDw*$(*O+6Z8}sR&A2nN}nf5e<5WSo+8PucI5BnRtiWTWmlriIqQe{-DoXtj=xL75{Kuy}YiA2}z(d%%qAXI45dm#ptv7=3@2hw=rV_jO%^SMu4iEyGPm(Z2AKxx^A&FrS-KUQ1Cu` zM7Cbq`oF?-4l;Cf^lw#0y~2;8ViFQXdbDNChF!LsC0yK+5x7rK71C{%%3`Y_E5rf5 zEy`)gKsleAy+l#h^B5K>nGo~I!G0$CZ((^1&fP~MKD_^T+~!}}RE){(!l)^EDB!ru zJYQ?&yRn85x!bbNR$gj+xtDH^O@I6cDvLiM^3SUruoeSZ18(Fvdp}x9lQ$@ys2Ze|_Bv zHu}=hTg{NYp|F~uYJ*!Hez0Ipp8v)h!P!}Uw%Hp4WIrAYpN4|_v;ds%nz6mSM4gD& z!IQM%cfR0q-8v{Sc;ELMJk)KzaGBr_mptl5lg(h$nVI63gH<^b+qUb)v03#lS?Rtq z%J3^FVY(*N3E`2|8$Br8=Wl}7`(7d;2xD_2oEj3+K2owQEH-H^5E9j5;}fAqF!XSs zI#D`*f8C`@LF~6kBE9i?Iq2t7IeVOU%kGENa2~V9_?YOMT=lHRN&xwSlTn7m32M${ z9K7?7vaUzfmxAT+?Y&~IFmDW^@Avx`L;-56eS22;V-K%xJ?c8O9P-I2_}&s}q4jX& z5c^lSASg|&+ud#grg{k=xY6iQ@jU}M2h8hrwDojr7sKgRrsDa~tN-?UkiudRY-yEa z2{av`l=(9PGM^i1b^!IKp3`WF<7c%OM zXK0CvjWuqzWbf^obdb?@hixl+LI=v!W3YT+&%|>k(Z~~P(QzlzE%!;ZJEBpqvk**w zb|joWdWJ_^W=1|{cyw$C2oXANUYGMrTMm!#PV!-631BHaz_E3@=CnJtzwoX0P1)0Y z{L<%&8Lg}2Gv7mZ5m`YYLwIS=u)i>yS-w4|VMS3ck4fSV&A^vwajNKf)vK&t^UUUm zNbENl@N0BQ<^SXBE2HXIwygt!1b24`t^tB;(BKK~Zo%E%U4py2yL)hVcXxODI!Eq3 zIq$u1jQrSpj2*hWx@y&`Ip>;7FV#)`*zz{|HoEU74r$a(aIT-mVXKEuq8WJ6o$aM@ z#)MO>b*(AAA87BNVig*Agw#)HOEe8sP)vsu+lrl~txAC52XPbq>H65aI6eCbtmYs9 zyO-Pe2=vf?bXMmlX^LN2q5bBZK}0fIaOk6(aB9Yv{Fc*$+X@~RuGA!3Om6Ah6(Q45 zjD<}j8T@Isd#hCz41CU9b_vC}e?VC2n8;s-%ME*q%(2Asax^CE!&Si{KYa%UJSMzj zta2AHTpt1j<>v|{EUN9NZ^@}Ra8F-3TjrjzI5e?-P8fHsyzW22I=c&o+kN<0IU1^1 zf1?Jo?}mybGm=PsDVe%hBv7H%{g6N4JH#&?Atik0P)$3NO*lNsr_@a&{c?E$!^Vp3 z4t6upXlWQuap$`HxX@X`kgD^r9JFa*r_T!g{D?fj5Gd7`c(14uUBCYv5zUT zH(>{HBV0Ud+vpF|D@CQjNFNC(6x(s{2l~zXW#_pptt@)m&Ehr-q+!7TN1ZYF1C{zu{)ho-!uqwEzuRG0XY&IfB{*MZ9Ek@@MD_IW z;Dy2Cgm8bZL7JbR4|O{R2xh99ckXoCo1GD%BK)s9l-BY@8D$FPtv6QCV>6Dqe6~Mg zk}}@v#_vw=+;=DEnFynGjK;&^tOii|EyF|C2sY@hMONyNXMeHM)PsMitm@<4^StPJy1VnmF(5Q3#n_ z2MW6XmInO#kPeCb)!prv`A};IaJ~S1goy4Wl3DQpHwl1e4(w^g;d-44nSe7fHNmM4 zc%OXhk3}CNN;tFX3}?wS+T2nzU@4)x zsJxhjVa8L9!dQAQpmDp;3g;YNN<~7RNEv4-J!YfY+ltFR^qpJ(<(!j+Wx}^k_9Jfur73rgTuhHNlTgkTnt{(Vb`^V5RS(ADB-@Hj`& zL2)mCC?_p(z>dYPFw{|APZG)IC#NrY2Ngf0-p-8s&Nj(t_o+s?hJEoe)2uAY(JC%I zWS;nJUM770OJz*_4D|A1=+D!`V9>&NN?9vl&;uno3yL3(%5^7qbK7wza_(JAbRpD| zli~{SN_Y0@`Y{$y?P`u6b18$}c65k~+6m-I<`UyBlYbl9AxMF4Tp71=nwY^9i@Snl zksku(&?j@S^9cz7f$XsS>Uf#c6{e4+<<=GgPF9(%X1Eg|BBC(S7ui0n_0CR*Pd;>|$(22X32l0zf_X-tAtRrq@_{lpP$z`_u=kQS|7_ttrM^ z1lK3OXk+{X@A#IWb^euKBC!(oA1FO9BXRk5JCVu4D~A)j5j^^<$uB$xurcBy>b#*E zY^9m2vgLw1T7W^kki_BgxE@5)p53Y0n=h&2-U)Cft?B)gZkoR!C-cS}-~9@g{oPw( zwL+TbFubL##|Y-&UDuq)A8whFzB;qHZMN>s(;NyJhbMPL$faycI2kj$Vym%ASF7*CO|@Db+-Y~Ira`IK}G zXeSM&(j0acuLWYU<|KzC?*bJ`wI4plT_3HR5nLD7`0eJTG01<{A8? zxH$JgUI{@|cKsk1Ar67HfTq`Yq`1UPX6B#pqTvo094>pv+FGrJl@*_^u5QjJBqSso z+zP0v8JGVJxXO7h@AO43$6zsYS@(mLt4psxBbr7l}g+0C@r%AX^U4v-HXTjm6NRQkLU(;#JQ;@W-N`ZIXSS+s?UgV#SUmQ!z!OI zouyZZQ;wFar8=*Qe_j;ICZ4Wuyhnr$RcWO%{hGY%-L_(>CPPGc=whT%@K_RKt{(l!tC*T`6||ip}ZtgTbzx zYG-tGuW57+K71Dv3>c?HK=rj)!m<2PnFw(r{B#8A!ly@SJUS6ErROi{rGfIU6 zTm`ToH+-yp8{x!wzJ#kDuM&xQN<&j7wUSkO8c`g?XO`A&%hN$sb zeWwnBMT~!-Z~Vs_R^&UcQVTu8X`We7S6U~dQq*q~jKV$cuBu8~PUevRSoplIh+iaw z$KBu#jhh!R$N>G>W9Mwl{PcW%rY21TWxbHB$t2v8G^q#sNHlpXe6eG8n6TFEuV?-H zpF|{YnL1Zwd_0(nib{4)j`mS}MIYTq@JD4?UYMC7hJ#KD`K*qEU~hefN(ZwY&}Rr<6V!CsZ2HT3A4qAm@{)+ zP+>BIWyws?JYtf3!wy=bLyJs`J>dcorb(C}x8bWE>v`aoLX+AjB&x8NK^dxKWKkB< zsFLk%IxU;epRZA8w0=*-0EHD|Kmcn7%~KdYyfEM&QnQARiyI0+vV+M!4LKT0k#1^S z%{Eu7F5Vys``<03(cB#yDEItD11snkC_hRmp*(r5VyoSmDO3=%6_WH4xm$&Y&o zdW5iH7i59%?&uPV+`~q{ArS8dM8;Gj-U|O2vwwYfMT#K6)7~fZ)Rd8y*3{9-s(p0& z|Dk|-p>Z(@o}oo(n+M++Ef}6YPRPiNO|bAP?KMy$(xD;s*JVMS(QDo*9`BB-gTo@? zyCY%FwMmdeU0m8OgLL(30f3;KI)n?TS2;kn=wY(HNi9)sXDNrZGg0(af9u~hF#zT0 z=-4+pC>QBhm$_PA!A_uCn7&VqJwL) z0PRO+Ou+sozHD*pNikX*fd2``9|irE0Q@J9Ohf|7%F&RQk42h*q4YY%yt}_YG;Ce0 zJN>%oqBdQ-`e|!xYfmpp<)J&3BP;7>fLTpM>(QQ1kVf`-%V>O1G|-yJO@#6*cfOz@ z`od+hPy^+Zcf`+^9xFCFa+nFQP#@Y5S7vi2VT|4)D>7 zC}BH2-VsuLP}>cz$si7tr0!2|E6P&N3-GXtCf=SXyP}W}4Kq5dBGz$r{T956(Nc$| zRY`?pI0OcKyjm^_-D|<+s1;V68=pvF^#AwBV1a;Cv|qv+%1}>~-i>pVTStYB4Zgv# zbw@TvYOx0tspTk_;{QMGRlA`l*^JRj$y3axY%Qo|mwNC&9rFvR*)*m&WY*>;O0n*E*n7*zgkBAw`Al$FY&Jr>7P7@l0UI7 zux)P5yHZ-2b8oTJ*N|J8iwb?-e>e2~BN&z%0X!L*0q5lqC!(I{vQXj2;OVRBPf*uY*_a8h$-(S)L3ff}z7Ujp=!2xmq z7VoS=1qguPe7+TG4vIE5I884j5iJ!si3w;ylB+XL>tLTK&8lLQMnbArtAD7=TSD6y zDH{)T15__UfLTvW-z|(}GOOp>nkFunko*BGvI(a(6UWU;Mv3fOEd;(_Pm0F zp&1w$D$G~8aX20F&#BW9hisPWEh(d$nXroX44k{E`jYJ1h}0m0LwtmIlVyGb@;rOQ z2zYoj4(WCxo%EP?#+_6i-gVB*puLk*Aleg+UKqOiM03<{8xmA)+&4IhXx~kiF0eh^ zKk^yi5P$Jho4%883}{cD4#cmpnU9dJ4yMt8YGCyen&s(fR6va(zn<{{5s>bPUwg6v zm2r(PiUob!Nzw1#)ym^{vZ}Rh=_=Qn6m*H^Wuy4I(khJ@1L*Ao83CULuunwyq5JLa zRh@my7z0Y^3f(%k*7wGBu6YQ<<~~BAU}z>dfX0d#VC_7yQvHK(22_-MV9hNpT6%hr zEG#U#dU`s8u{2J$si~hw1&dhtGMY9in{ZhZr?;s%r-h#!R3&U@3Q^m9r}&Ei>3g@+Qa z6H0ftw?hGYxc9BCZVzwKP*DT@@!b>yp&D`?v;a*G?=uoWJ9c}4LrZv*5))?{jGkE7 z+p}yl)gixs|Ni>&h#m%q_09M1`wK2FfZ&JHZ#`DUtOIZ|@&pXx$Orpc94?3JB+Qqy zH-J&I80E6>7#{}#^l0QW^0WY9dOpVg+CdTrBcp$ZcK3K%bFh2c`w;`Cu{310%0SBX z1&|Et+@37g9~2gi0j`Wo?yoR?u0^BKO&c_d8f9*|qp7lKT~^e;k9;f`U}kr6f5$ zZ7(QhG@^Ifn#~?ib{=%15J5JKR#yR`%J%k8Wry6!{?_s))N^yTmu$P7qsybWA;-z9 z*1qN*+TCbws#bjtR9kbOfkJWGkc$HY6U0-)se;>_HA}MRW zU|5!QlwT%T7jaku<{w4aLCYF8-SgvZujkwM76(%lSv(I$9nz`5vN*e0G>@AAHCbuK zYk_GPc6|x!N=&9e13qqM`vMpRr}&|V90wtP7fLA|6SZb9XIZDqEleqPgc$ zIV*FOX{L01hual&Kll{<=g*%LaVja5E^Flsi0`8eoO7m!L99Swcw09;6v7h-|`Kj^nPWBe-=#)N%f7aIi!S~U62O4FEK(ebN z^Vd*rpi@&~wVLFbaiWWVqqC0>!C?Df}ORsK$>yIx~i|X?`?kSuK=V z_%DfO$lj;ws^e%P!4GEB-54Ll{dSbf|+$j@_75u0E=0)AcH!`@)s z)Ox5GmfvoleRdPEgE2%4a6uP;26XH^+nT*H>VxLqPYm4o~G z`rf_wk4m&CS8ZAAeZyK?&>EkZNCC~|Dh^v}ps)xCi=`{GNJXyzH>}SV7UgaMPTOD< z$$j2pmpc;up>-a`;AMWIA9FaqqoEjhmpnMoc3GXRWr#w$aWa#Tpsu=9{j^9te>MNzT#_wS!UY=>n(s&#P#^`&&*w@Esi-eyCgwZO9Z{0#lOec3m1fST6CIL2ep_mD#KN>YBXX z=1uN*`fjKGf?sR&p9^;SGXYaqHhXR)o7o#rEac}xM&r|K3$3_i!2cQL?K_yS^!Ygb zc`hr>@D%z}Wj3w88`@m_8b5~5`^oSl2USoSVD+V&sTU8GM0lx4ygU5AC z6Uj)K#ejF?#>RN58i#GM`-Jt*WiUEj(H+jJ`>S_sz4swogRm_L>&DSwvSUm2k68J8 zVggd7XxkQ#sF3(r=A0?W|AwMJ9`h?M*pQGmuR;p_XKhhp9Ez#1z(7npu-7m|F6weK z4RzaMB5PSjkb&WVUk~qC7L$5%u|0_L(XDpiXb_UCxTJ(4cGd1^p+;=D+sqq(Vw>!}ENllQSC$0t~1u4~Qf}M zPUqwaaLu-f{~F-Kpb zW3p&#M=vd{Br%fRSG4&hLe3`!k}ZggqgI*o?YL;fIH=px$vk)^ah|-9qKD|@WS{#> zp2CAq3wK@|>iix3*B_-A?a;^A`#k^s*5!bjrl@!39QMhaZP<3qv}>2iOD0S}imVm0 z8wY9R_r{msryr0Z1p$UfN{m!fkxI+tV;{)g^o`JBTBOpvxxBn2XO`2)oi8!rhgiY@ zvg7vvcCpE#f397$Ag!NA$p20zxlw4~AY^kOh1C*tvG$mJrb=ImOcFWg=gt-@-?vW{zr1M@5Bfz1!*! zlp=gL28LZGTFbcEY8V!ht>outyLDHP%CO&u$FLj>^zGaJ$$W^?Qj>iH zzEzMJax|d0;~h3(_5R#jrG$M-4!YY->Q++H+<@>vFoO*VDGB*``*8dPZ#%We_Gmto z`Gv&~B!r(U&(t^b23A+Yn;S!P2-S5UoNlsD&1Mg57^}&?=EhZu8c{a*iy#8KrX->! zVk+afva+%#IIpB=hP`o#40cKPaCyz@TX;?gAUO?Mj+5IE0uOdCYh@31m~Zdzhy3q%}u(*`!Esj^PLe5m7SMEu2a#QY~G9ER|kdVyDC!GWk~uJ+XOu}@6P!LLcHEF#(GG1#fRtoS6#sw#T=xcR2mK&b=O#{PB$qAMmnak!l{zK{-eKx4@2(vcQa{hUo+Fd;+#|sq zEoD=`j3h(&VLH->HkwoNQ5=@xD!ofgQt_y|u590%tPgzPj^|fT(nqOk4rq>M=$|8` zV&umRNM^4rQ@1J}mI&TcNUGMO`SX_YnL&4o)Ms{n@aSVfvHdH8GVu}6(uQe!H<6cR zE-k5=!nSCE23zD3LwRZUGhLYbEho)%F0$}m2M3Ok;BZzuLqkzu;;TGa_7fVnPxjgp z1{U{ypol~&5t7khfu|#myo;0Se`9T384;NZS0)rHf)FA@+mn$OwnrMVYX#YNn{9Re zCF}PY`_DV_Zg~qJkTC`k4GC=B&}kk(U10%&fVe-+vtst9qFV#oyy0?2Qsu&?;@S*K z?f1WOuzE+QC}Z>5pu@OijBpPX<)V6b>XQbi`}Uw8C06UK9%OK*AJ)gxehOG8GW!2) zlf8TQDNmTu>W*G6){{a6@)tv5B%S&5o$OXAcO)2<;J-H7e+K1f2?A1PG+<`~w2DHX zwYatSlY=vIG}P5ggSLBPT`7q7DyCCg!)R_>p>qUskg`ZFW_=Wh4+v=3+x zNZnCHj}Ffg4$ptj&*pd$-9yUCs>lJCHRsYT#%B`!P0l3R5^Z5E{kZV?r@GeZ!Ry_s?aHV$wl0dqH0BRcw$ZBW~7h-jJ{=UxNckl@Tv;dP7 zc1zT}bnsI~orFMJ(jiw&5wPyoqtY7H%B zbI9?tzQS*0!R`cH!TuC0QH{m47teLPVb%jnt*cGR_+#4v?Q!FkUse@1pa>l=Y3bpA4Z+oCY= z_u)9A10gLVMqAjn;F&eld6iP3kwzB~H;*`e7D@1*w_Pra3#_yc?@BNK&s zNr$U61C*SGVr+ ziTMp+Tovedm=(UW3GjN|z)_OkYD*1hr{iT591!r;S}K_pB!%6!Q$fQu6o7gPYunqo zfAw`(+ujb`?3L0y-zWo&urW;~;o+s4pDv(*K0vrYc39zZZv|-U)0Can*viYRd-5Og z1qW+WVg%R5-!@fPUYjr6P*ay!uqZ*)8qEs22C&&#&?yx>r=R>d;DE(-o-n$+`PD!zv)pmJvDPa zopn1a^E`jPb@R3`Jhgi+xLJ4&tJqtyN1&#ru{@djR{Wp`X;sBoa}#UYq&XM=xVE$I z8{cP9toA|!>94R_b))vExwrSIDaga`e`7M)gNb*Q$dgSzs+a7qCaxN4HLU=RmRM2RGtlJ3+Gw{4+dZKlNR+p|a7 zu$p!<4E^rADfZ>s-B8U}6Gi1!Ro!pl#d`ohKEcdHNs~mTL%I5f2Cb1~drdvPP=EYX zul>oip1d^LuRh89;Wc@Ad5APTZ9SjjQVOA=7QzABS&|D_j4^y*4#7-J0uHD=uLPV= zl?Ri}j@HfBGf74xfOj9&>@iSU`!xM zk`@GpR19}IU)UG|@MWy*4I8&PX}{=+x2MhMA5L6(A@!FstWQDqxHK{BOzxn`;En|k z!bK_= z3Ti3nLvE5uGNVdG5pREsLRf?r7h4pcjAvwBPvGI<=_-UkA)Qp`g39z3CBFoP=Ois) z9{AC#oLExtv?RUK(o0errFsOoF^ww91JPxT9Rh$sqQJ7hkf1RcZCT?o_95_to}8Q< zgkJ$~I`d@0auFz9uBHW`7R`(kV10K6BKOL1_0BmQ*C^dx4EhfEXdJ5cd^RzGx$E#s zN?X;fyn0j(wD()?4R1roW;Xe`P;)4I4WKaPZqM(A6C-@pX-z!)NK&<(pKcZzLaw>MFl8tz`yLT}@9(3!_lb$ROrB<$=HVS!ET66x zD730H>U3dUVcPhUKB%!e^VMwcY_$*8(YHG6g4?Pu$mFFyTbwUZAi>InBs#<5gt;p` zdzDA%6uRC280~W!Q$CzD7=s8Jw#QPLd6t?M^ws!^zvC3C*&iFK_N7^zDDoU0iL^mL zl~wo{ce>)G;PlvDiwiP(SL)EM_Jv5SBaTPW2hEf{A|F%S3>WgCL*4^VJ*z^0-o835 z>tX$Z^4I43cYbK`7P8vV61v-%SuT7-=uI;XGtOG)`3w7PqlBy7r;tf>+j}pg$)=ma zkqcQWASe`}4pjyK|2u}T{Uqhde|tBrtWi{6zxj$r#G|`@GEXXs>X`3nu?~NCG)))~ z2W@QF@F`a54@btvmI8=OS1Olt%zbN_`3Kc{i;S)8?I~h1vN(jN`ldGbH%jJaOjwv` zXoV@OR$|sDk=wH$!a0klnaqyFHlJ!Pjd7nIjE7&B96nv}>kBnXo;6hx+KEl0n~i1? z6F7B*A4=i#h$nc^?1kmAC^h+Zbx)@hgNCYUSy$SY&Md7qS-zTLszgW+xFu(olhFx zGa8JvKc0?$5No>L8Zj(*!)!p{dmrfvzS%fX$h{h$3VTl zj!4XYP`nOvILNMl2pG!tA`L&VdXr$4W_c~!vN0+t`7W|KRA}{Qu{sMzmkj+_GHI}A zj@odAdAuCub~j}B8OghGJVKrlldYw}J@Q40Mm;CR`R)^t3>G2b)rhjMamB|c7KVKH zyY}lvLyHAmHkga~@bt78>&m5VIfZE<)Adm8c$l+ZyPjIH)dycvlKLsnTmrR}wr2j3 zkp4Ff`8{KbN)cYAQ%1*&#s^R}lv{)eV)0unAR|ITZv5Px*OjUYjANQkWE zn>cEv!s~Ue@;R09PP69u`5q$Fa0zko!abDPqqr}YJ0{0CUp_KUB3Z{LDyNqRfJq=7 z)TanC8?ZWmh-nLtN!$xa`e?2jgMACcf&WEEDrfJ#~c`n#j)`<~FGvFh(KSI3JADOCh_J=kTN8UDHXmqDB~ z%GDT`yY8|Z=bPa?^12Sb4;D`?7*^xkJ}9b+PuRArA23{IS#3v-BjZLsL{v^&p=lUn zb2K`$MN=PpG0o0T_QH?jj;b39bmF7G>X)n^6$blv?h4Gi7n zM0CHMiEN{JcSlm%wH#Qt!s_bK&G4<77{=C$TU7zBrn%oKe7TwxGpQwt5z>9$A_I_u zoMn&Jf()y4)b-OGFjht-H9>vvN@J{hefGBkQNKK7F_LsF!~@+}QQ^YTLjxvEVdR^p zPgv+FO$Pl=o9b5b%%0DW@aYDlsdwxCr_p1*`uFS6j)zfF+>`Hy6ByBxMeMH+Vr+Vc zLj>BZqK`IBOH!hYs5M$u^=9?N8UfT#M!cQ1#I@7sJf8iU$B0_R$#kLmbB*arGdKVX z7KPOT=MkXMmg>B4V`EjF&A#+P)CkunCMLO(`%Y-OHGtOl1bW=DssKHa2CHok20C6_ zpS=X?_xHxl%NxTCkse;uE#(wdC6AM*Z(!iRd)i8AN|%&~dPo=D>56oKF>< zQW>}FyuX#cS;cT=>;@ChdV&7<@S3Z=_0xY^ZG14A7*HTGDW}D)Xna9n5Xue?v~ zjPDOGs+BSrouYY+;XxvkPZ`PoVCn!43Cpv*{E{+O z!*Guc3Ux}-*Dnx&WOTD>b87%V=F!X*DCiYQrK2~TX~c!<@1%QrVyjW$aCR0DR+mp@ z7)OlDZ{Y$?+yv(@MrSFXFxhPs$464OE@WVHTp!^K$E6KGq-SglMw2@MjDOIm6#beT zrTM8<1U{lW1e^Qb22U?;s=;i!XNOH@q@>R7=S)c>-Q;YOzTVFF;#E9zSXMgI7!hnYp03vQqBlV{!3bczg`6!s4GVpVHrY7uh1`^;#jpmeiVko%e^-M4^6R-u59G^UVky7tqmV23RNu_ z1dPteDA7X-q$g)x3~GrTO=lJdkg|6ani@Ovzv;v@EpC*x=F4@2+XDye(9dt*Yb+1h z;>qc!sw`IX=Y^@28Rry+w3LGDMF8P}#7Bk(J&$l~T~{H45V?cKfN%1b=1`lf3AEcO+C z;7{07XDfOoXVib{2w$AOvjXEe>6#_*!jFph0}T2zAN73TCjf+jwemLr))8I&1l7J9 zSit_)BRXV5UHN*q(d;J0inUGI^0WlLBiKYj#j0)k|96*xKT=%soQj?-;nACv6y6rF)}NXDT~y zEZa*Z)tp;XLo)Su5L)z8z@Gn)b>Vm2IA=|O2z0Hgd+ z00dAXiD$SnXGrwop=1ihwZye}OUoe1U4#)yr_F74eRMX*+DA|BW=ZONvcSNBr^B}` zEwj(r!lQCmCRTDSy0ez0%G5x;RXngj8_Sh){#+yB?DWp=bj35K=p}E1@_Cm>Dzh7~ z(0J1Wve)*aPpA|v@b&Coh5jKm@C)g;(h74Opc!Ia^!JK#f#rE?gR?uG$?6u4ee%{G-@#?hBOe0wY1=9V+OAvep+qWSrgB zOT=1}c@Om#&$p2-1av7$U}O?G-YmXyT%>(yhX;uFm!%<@%a^N3dsZA5UgKH(CFfJ( zYLjuSG}=2}4ZW)Y&u0zCdm57L1!QOS%~ki=0&Z$nN!O$AtnBRHhA51ElG%N8R%xjf zI6oz-O8)j<{`19?T_U}hy48>VnKH3ucDX2WU}G?li}fW9mjtO0`Z`Eph9QaE@($HW9~KI{gb=6=Jp(|pOUl$Jw@hD^3Hdfst9F*r(jTQQ#1 zt99`Vzut9!nr{bm!6{a&LwXN?BD0I*U+$nbTe>WQA>K2TAVm?}6o^a)^nh%tuo%y~ zTl21qxvNPKXiiU8QO%h}s5OZb)>0_MoyelsMMX+Hl@q*I3ZqvRhtY`>GM4zx^&um&CcD;= zUGwv0*S&TBGA)$r!&TSMK=>a&Udxc!@ZGi9!*F2b`3Rt6N4YPN{9s!7Et!+~|A}r9 z{NoJuAmX88l@wVEc+HBdd(F=^*_SmUbRD&*J;1bgs3mFj3Vmj-MTuial~7(PL~?M5 zxK4YE`R$&`)H5Jh>q|hWG<@pTRfV?^ZRuvF`!L=w zJai_47RD)gPaqdu}QSWyPcc;n=7zaec1 zA&EPVsXeWObFFNB;u2<;QhoPy z)u-~38^YdR4X0T=$l73lyEO+zJ!&^Bs5#N|M|W9vwgA!zmN6e@(<~NR*V@%m8vaAQ z=h5j%_tel&i!XsXF`iY5?|y;}6wb96!%xq83yiL5rYM)du<;i+SAA4wwdMSbH< zU>~GXN>I?bJLk5xw${?pnkTF``P&QNoqb);muzDu>W9OR#ZkYGEv0F^a#gSDuLBIy z|AUZ9|FFhOTaP0ja!DL7qdg6YOsY9wsTa-PE@8VC?}nBgiSk3goekaPZQ?hiI)}Nr zg#tVLw%oq{0iT+bw*W6vQ2cUqoDvN*Jn>+!ii)-J3WF09P<~kT|WMqt3 zp6UVUwye-M{XxLu6zf`(kS_LVz2WD0QQoecF@I@Q)~jkVxsMooHlyy9V z9wi!$dinv?B?>00MdCGfpUM>d)*XeyClW}e?IXOA87KO0h+4QL6)tuH6mlaIGDEq8p z7|-pHjanzXYnpKj+SYDVlW8ha#oUiFjW&>NxOFDsP^>bBi^RwUE~g<@;z$Gx{)+3u zEDZIkb(cJHW{t_*;da_#)wvYMc8?ASZY#xApw(O&A|mH zm~LZ(?Rsr9>(S2R(PF5Tsm<+!mj-O>GwBwm@+tK*Ylm-1NlRs+PTX85_DhG1Zh@W0 z#x$T`nAKH&V9e!YMLivPjsPoh85mLV0NN{NNMp>Fs)QCqgVZa8bEC|Tq6mlKcwM62 zu1%0Cw?3hB3dCBS4keWN@X0s5KCTX^4976D%wnonX{9M5EzT8MKGi?|>gJmnp_*7{ zvK#>D{PN9kx<;3~kQw!xvnx@u`oLCS&q9$NV0PDu$u-w@EVzzXk7Ba*bb#DDycT6U73w!*aQkfzSGurF9IF=sJb(WjL|f_yXQbF zBXr{oD6YeKZ%;f~T^}5t7ad?v95#NQ-yPR&%PD8r9>(TdiF3<{p@=_7A7_zs;F2z) zbyB~G$J~&3|AS@u>p@Tn$LcmQCOC@aW`9P1VO?nW(GmV%?NFK}oOjR=g)60SJD~6g zsis8dD+La?*P|0<^=rNHFx}i-%qR9Ozw&5#AE#HF=j2(ZVS~E0r%J{(bz>K$;~X{X z^VWg_iteJ25voDpA+Tt8$+q=gX;u_QeA{avLNMrYZcxZ)%7Iq5iPYw{N@cKz<_|gp zv1-){1{JDPR&T~`c>txQFHQYEWBRm`)XpzfbLB?3aMMjoq)C+rPU@PNfGVH}>=QQ#|49@d<*)B##GtywNp4(*Jpl+ijZeSybKzKA^no}y++$2% zdNnbZegn2V#mDJeF!pg4kAqF5)d%Zo(lOK?+qVk|cJF=Cebi-Nt%`B;502J6i7>T3PA z)UybAyzE{2Ks0bY*-(#$hK5FL-S6MSfcCMSUN4%-T+VQTfwSmYRYpw(99hsD;h)DN zV?Ig+7jh*1Ncrtx4tyCSfB@7l?L$K-BO@bQkujlwM@QkIowc=0naQu& zl(2Va#l8mo42YK^6L9L6p>(M}@;0 zK{q=mGotuepm|cJC`AGB*~q`D7&r^^l0cLf+?A?1TLSFwgHGn)J1(QCWx#@oL%!Is zJ~QpU0k;o+8Meq0KZ}3@=boe0tJ^b;>a%>~zh^tJ*@t_)ZCF!QQX*nvLfz4=VbSUY zqgJVf@r!i>MEV1IapDhatIbT^cY|N$-J6NX#E)m}11cYaq1w`mUx_3+wCcS+zCL+_yOWO^Z;u?#I5YT*3J@yuX(KxB=}g3q2Ze?r+-I| zdaYm9%C`13>-)!@>LHxn-gae+ATu#q z+S|id8%;x@)2UPI2z2E$uU>KuC0+5l@s{~nc38#yzH)dWmMj1ri!K9c&I%$qC6ZX1 z(`|0QpD!;zfyMrWo{x`@=2Cq{UmkC{$u&9_R?u>jH&*9%oMR}dBW54R8HYIg@>|s@ zy#dOqs!#u(?*L@z?J@raX3wZZUsOEMCo6@jdy}y%Y;Bc3(;s~1c(5o;PB#pTKy!q< zRr-%fXdBuN@Jki$uOM?W<#Sh8pGaP&Yq(Y-p0j&9e`f8&Dk-C({%h%{uOs#iiFntg za7d=E!g64vpE*jF(xS)!y2q}!(&43Z$-~dtf85yt5lHQYn51N3uu9Q17ixk^hwhGK zO|GCi^TmrJx_(XLTP1)k|7#8W1!wq)u6^x$XS)x?!D!FAH>IUl&p&@+k#%|iH2X(~ zGfk6)YDhqLl5Wzne+Fv^CniRfol8s<1;ZNj#et!&T;R_@xVE<8SDg(ux4_|5^^CXD zgo~%nt;|&i@b@~aH}2!s?G`9ww|oGb0r2^}mCy1|d0&wnWs_sBP<}n^(nnCzd$#uX z%Qe>|#$|qdaQ|Mf`6Qs=;NX3OgTl&KN)Ov(_R&<`ADzqc!tte%Hbu6oc-75lfo?@) zRJaY`_o8pAD{DJ)${8(gjj|V_HgsfjH8?KLCT1^<0rj7S-K9@VC`~4vZ-$(|*)J{N z^N$N<5KyGaGSGAOOQ+2LX_`_=JSc`FTtL>Yr;;KsQ(#9+t?^5^L*`Nc_M} zij*4q6oG&Zi+0a2JRFe{<2#o>c+io8teq8Nk;-lNHRbT|x@__Fg@0O%RP%!>DmQ(Z zh15Pd*uNk4?}ai(0Qp;x;{GE@QB!*WE{EDgw2^umfER*^rbr-WH;sZ+k)4Kwu|1oT z#J^tff8Ta`E20Gfx+)cwnAJH+of@OHc>KrDYF-&RX~^B1Rp}0H=epYl-%Z7OW6#cR z1nXM)5m4ZZv|p#|jLXU%0sp|(R_jXhe{ZFKKfn}{XZ)OE&~{w#-ku=}%Fc(_ zkp>LqhwUhWKFd2K9UTCeg+!K-(YuaIL@CpLK5tNbo}R!K8VqnYKdy+4`TNiOK0$%K zvg~iYJ7B;R;B>YlmCBi;#t-Q1?OY5;<&PCN&c2hI&3RJfoyGhEsQd4O|D{$70^C{< z*7I4T9<0npC|q*>eVso7(_gCs=}-N&v0O?#5~MJPLVSF@mhjmr#&{R#;mvjo1`(>Q zw@3Z$Oz7=rJHlUYFdxMeXoxvH7NU|JtG3vNI+AQASeiG?Z8Oz9mEVP z;=3dEZyp`q?{xsPbxJC8d3|=^klOWw;Vu^lCyZR7Jt!SZok;ry{T7y&9&h#TB*a8L zN?JY)4GkgK?GSS|yHq=w32PD7mT3g0zV*u@;KARCMf)Q1C4!hB#^IDTStC^c6qoyu zD!vv|F_1>B>Ch(-S%lXZShV&wrz^-BZVd=+&o>-b6q#jQ{P$dnSH{zYw)ZKN<4!<# z=5)c}SmKM8AfT{xa@3SNr*1@n@zeJSSusnsK#H6q1Ve;BbbvrP$olp+;9@6mW{hC& zWUE2^+`0`9{VUn*B4%l6REW6$eG>C}69QRVdgX0yP7WdM$pNhG|D)@zqpDoD_F+K; zky2Vxqyz*6rBmtdE@`Ea?vhUF2I=l@=?0PRjz#B!MSZt>pR>>2`}@6PFdXY2iRZav z&TC!)u%Oo`*}z?oh75Pcpi0+5*SFo$T~mX3*q% zRe+NGD1S0644qcV4k!U4;^PJT*{pVpYa=Hl>t-Kn&{+E=9UYB`?J&$COpY*mZU$l% zQtfL+`1px_98LG?9UAHavq^&0jkvnaA3pxe}@6PF3ZyXp%l~CJ&;`{F#Jt@J{-ycbB^MB_$K4q+0@m! zynMHg@9k5wc1Nw5_$Tu zgs)72)>&oUE>6=W1jXdx`)~DcOczmnb8B7&^CgkVyjVLu##z)9;u=o9rMiQe5`)<4 z@D6_r??-h&k@Wy@4Ta%o`j3x~9e1+v-hCS$DP>ypxEH8vY>Y}b4t|JqK_}6)TH*tQ z7}<#5csDi!P^CPFPq`Os11`{mpeMrP_|Lot=Q-aG_ug_2mVJC7{~W znRmZ_WA_{m4lbVKlmm#yfq>ZP<6yjtxaOiK+{0~@D~bNIiN3z)t#>diT!tIVKzDgE z1Pgn3U}5@cjN$=MSU%_}(f@usU;l>u_}0kyqoW%y?;?=#sfq6a-{BY-PkTw7b9v}> z7%>+j;3~H}EV;?(&HvW@JtK9$gd=gklrt|-tJL;QNg>XW@xWzZ7hAB5w!4K0oA-I?UUAyc5i)+Cu2E|NGskb{Js*FUNysG}nw=cKzV*VgM=#z!SG&_#{@-r7d6peSG|B@rjUPnq;?>^m>B}q?h+r z)~Q$*eu5TO&SlxE>!2^A$l!dfT2S12zWUa2w_ivV!Pak~YGZ=oraW+GrqZ}~zqT0j zR@?QM67#g^&6oAZ%sfLs#`D8hGQ)X)TpmLUQU;Tt$ZE-ET^{$15iqr1yDuw@5<)Cn zt|0x=?{x|u_KI)V0H9Y8*?qq46kcfk3%jJ-aWbnpXV-3$FK`?e4b}f<1(wC}VJjzH zviV%ix5N|)Z(o*0C*v?kM~2Slu91=OmX9OytK`8Ep@eKy1M!wtfD=2r8#d-An)0;n zGjbV@nz;BAKh);|Sy={{+Y5?jt<|RY%HESwE-Qwb(*e)OpJHK2c+XuIAZegNArl>W zY{Bk@Dy2U_&SE|c*v^g})7tLOq5$;qRt-B}UhN+#T%!^-k%6^4Qq>YSH*bdGqOe~N| z$(h2qMv*#fke9E0Xm{+5@Ur}=2Vc1f3va`EzX z1P5jUbkC0~jawgCT9PXDSopujRgVg!jhmiUl{!+uiE)JdqK@bcL4gGGpoD3H=*j>R z9_PSq738q7^UW8_tE&jWgc)$W9I%D@q^mG|E}3R@c^ifY?5BV7NE^68{V- z1|_NI8U{1jJlE{;2mtv_!A3)guYQ)QRc@}2CFOv%FTEov9poNU`S&|1M9>pGJ*xhc#q9;=-!X{o#h~}4pR>=PAc>`wCUr48wwAVM zoo`C{mYVzu*F(j6vR|$`(0#96LpxvWNB9(AKJ&QP)4>gQbVO-djF`^p?t+`^6YOM8 zvwpa{v-6X=Yg?>0Aj{RDzSdTq){mP5nF9ppXc(=MxbH{`8nu&O@z%WDy1Rp0XPtip z7~$n|(DWB;j0?h{UtS_u$iw5};!=nTeF9R+&6&#YpepK>a_-vDiKR3w5SOBN5)k4G zn&a5T>6v)*8t6l8sfX2-j)^%smTdfPQNw2ltcH_095-U(Xi%n}23^<6nbuTPymVMV z-A~3qC*cn0?G?@B_{;kPEw!`nybFgl5-%~o{Y-Sv+VD)Pm-?3<8gW5=SyLJzc7D`k&Yr7WW9vx0n! z`jE){_{vL_jae=uCGVR3MBEz?0wj3cnixqr_>386NYTXRbaQCi|2S{-cqMs1(8wN?wO^SSBive#O@b~?Zwus?PX z*A>unlbtzQV()TioQFE0CsN^W|?Q2l4}sx`qbYT}|BWlw_ivdlA#~``Se< zNft9lQ1(Nh0{3^f6KE(ZDtMTeA;l|zX`i(k3VgL+>4xjyY;11+9Qw**f~2eao!#RO zp2T_kz1=XwkL13%4nq9crY3H?29pUBJ91yFfFqYS!=c3Ly3^rj*fxsP8OOXWxAw?t zG!F9n6UUv_@LMDIdE+7=)GIsom8}Wtz@oy(hVx4C(5T^m!!h_z-w+1EX&owrqt~MH zFNNukmi}U&&=tf$@d^8)8uvIpLRq6Y)*TkCe|(r`T6Yb()*jhS=CJ9zHDlC&Fz$<%9w@+=8$IrMZ%zhE z)f?|TTL)`C{}MJVbX^VXd1JYV;A>+F9tp~GjpkQHArA@lj&hI(?l5B7Z` zqa^$O!|3`P3eA+2j(e`+D*-N3)9$g-cCNgYu-BN;)Y#FKh}xzadwR+W-lU}|5lQHN0JyW<>FMNjMf+JtdU(Qd7j}r38@A{3Cq$+zy{PU~Uw>xe zn_OhOj=N3=HyZSXR`UthLx)mfNX&BsLxc9=#rK6<)?&v@zY#K==RATsPXr3l7s?z? zm(I~<(3U{bI&|@M86nS zbJE{mXjB>cktzs;up`~W(rR!G5g+3clOMCc(zE5P3GiK(MJpjzW-t4K`9}T-EM<$p2 zsH7(NWs?8eGJowKFPL($CKbIhq{X(;`zWFO{enBIwy zw)EG8`bR`s^DB^pz=5ri?{(EAJ$q5|K>9dVYIN1V_s_TZKV4-W6hW`IA)Nj;BQsM@ zHXft7I_@*hyDVi3Qb(Swrl_A#}8 zJsmGZnU8`pHbp4?J%D^X?&For?Iga23G&p53Dr-*JXoPH&WBKd|Mjf}s)L%+zy9}M zU9(*(zlon%%2K9T83i1c^PC_MtqT30qSev#|7t=B-uc5^k6*$jwj5+G82)(@{qM07 zym*1@&hnrdH}~~#q+Y#~UgA$GTHBCd+xR@u!u3K4L%vvW%_r>9`R$WgTL>JR3~6SA zpYvGVXNfnNvkU?UsR6=wL;aeQK=6t1&QbyF%Xj0G*TdHr=3h`A_0|XiWV6J$kooVc zX9M#oO6!;7@nB(_i{n2*#5@Uv28YA29I2$}X?bmJlDiul!$9Z!r|@jvp4BmE2MOFb z*A8a#p@J@mcugxF`|&j3+CxOIxdxL}!oS zOAuiZdXxB)jSUGTS-g7vx;>x#`pqBHji@?L!;dB9CafpFgba(yDGl^~Lo56n$jN*j z&t_&g0s^vd7o2b z>2%}!??*uER?6!N3>7|G+1ht(_l18$F`>)K&Hgx*Q~h|hMqA*`<`3%6^I}Acl}Zyc z9Gq)Ew-!6MY;^c_-CMperrvZ0t9#u3I2Ldz|2)JjnaKHYZn9J!HwY^2L@=Y4aA`Ut z|ITy)Zl4p{2GMDi6PRX!4qIcibu>QH%Y0d8e+5mn(~N6-zs+Wl1&PDQlQRE9d$~S5 zhe)V=>lcxUbT(j8x_7~Sw*tx(XO7`iRORyxtwMhp{dqRK99H)}YRf2`rTtG6C0L<+ z;-@z`)jJqKN`=-+ua?fIRJz}I@niHS^hc2UB*y-X(RdDDz4_&*vu!p(;{-mxYY05IdR^F;HD0zbU zk(>|A%^yqr-V3rW2p9WE1_TAkzg+T! zyFK2k9C^XRLwtX8iqQ@{(0KuEt_MW2v7*thKP)K|Dr_HP|wDN7J|g9(ArO3AQN<_K4dMkutJM|wQx-z%|IU4M=wrBR*9H8>-DFbOycot6!l*WSI9b3o*dxZuRd z5b>#ik>?siKn4?wt@xI7HS;A1@Db&{JR7(?S%^E+vO0{W-tj4>k9FlHv=5W6+q%ma zeNnE!*knSylU@aFZg4DteJ-yVHw6}zVH^LuW1qrL2z^{Q011o^;m^IdKhZJc_Ig%U zax6sXgn~)MudP#*?MWl{qDph6Rw@ha0GSp9LK|w;>#dIQO3J|^GizENWZngyh_slX zy0y8ZPOR{?lMO3oK~X{A#}^S3BXugG4+1e9&wh$blv~i_sY~D$`1Tw*pxtSA2qV*} zlzd3zbpy$3Pwu26wG`M` zTYDiV^c1UABGzKm6Wd?Z00%wagnSOo1C>J_r+9>e8~SdyufNj#{tVd3_Gsx07UYcFEQt4Dm+?Xf7uhRc?YvT{R)_TIDq0M~WDf{w8L{}T#>HGRiE*j-PGr-;LtE5ZX91!~S34d_sbnEI= z7es2IIKe+J1!V&Yp!mch)SgmYCdjv<(B5$c+i~;K(@RxvL}q_?M`sle2oG1;s;Su* zeI1fR*j^8r=;_gzICVT2u>n3y2~C49$2DC;=W($JQFMZZ#ZlK!RXMk+AsWxXw76Nn zLWgX$?RTX@XIh%(4y*Rkayr*$2)2Hm%%I)$^!j2ym(ObJ88-G;fCN>`0u}B~xM~B{ zrS%42Nlc`?(OD9%vs&?nsQku#S^^Kc`Byrtc9qf_nu?b`QwdoqiW0i``_eL|M zkc#evwi1gDw?G?kYAgehnJ|@Bjul`3K}}V~=uq*2+h(cAZ^u=!b zV2EwXvO_jr<2yXYjpoCNuFA{AFMq3?y&zO?GIBX4A8i07VKZe8I{KvVCk|34?5!Q) zPimC(?RCC}Z)+fQ#hMLSr|7iB=C`myGK&u%Y)L+54|UiO@w$p8Wn3o1%31M!Mp4`C?(~f30>TsIS0kkh4m?@}5Z)2T>^O)&QJWCW$MpLAD+kc}bwAD3 z&>z%qkqjntJUndW3xB;o`74D>0>^5~3a-Vb8`qkZmDO(ohm&EK0l!p1Ha+;6>Z~A< zN`x;%2PO&0`;4_O9R;++GO4b7oVMGt5F&6zYm*gr%Ol0f&-fMHxZhvcgpkFlhf%A< z`f4NA1IABmtg`q6mcO*T2FG_BDGmoL4~W(*R<0|fJ#!BUWfFWXDQopepVSS;`d^?+ zW4qQPcVXHJX;vLzC1z(+PFD0g$dqeo)w$?6>mDP z_6um~UUg1xR&+Hg=Xq0T(ev;ql*(i6!EN*}49NC|I4vE62g4wLLRgEP7p>=bO;60B zE_nOCvs&Eg(8cb!X_6#M>u)WT5@kf9uKWaz!3`SKi;N{S_lE{fV2AfpwSC|(RHmn) znr}69h>U%L)%&tA2jt1-lW>0;%L>GQYgC1K3((IW3~goy!<&bioVK5GCHFZN5%Fpk zPtVR|dtlV8{-u&Y0{6Yn3kY{#zkbc~ot`xHj zsZy;ABXz}c*7LnsPDsoB^4Qg{e&U*{>fovJyjZD{>7@GxTF1mh^@yh~ET_*K*Et(C z-DD(bK7Bd<-C@nMxIb~%y!_suVKhx5BF2&YN-dzav{f8)b$Q)e<5i$Pai9l?dn_=; z?Anz`pLP|#PKSQQ@>R^Ss=y4>cRZf|%zb>KS&@e_8R@FHu1?pW9JhE|?!LiD*KZp@sWOOiTfUEaVrukLGjzrwx70aTil!QJQe#}g!wXo z_Tsg+7I!Fv;NN91vJVmLNm^m`m54cUG^Spje^Ep7f)Qq`8*!c+aM&JaFkGl<&Fj0( zD1hzr%Bj1DcC^@iBw5kt>WeO(#&G(;jKj=q7*=iw2~VQrm5=DxUuxgMB<z`4ZY?3**awGKpUomJ=f$qHVxLf$gBQ2FBEQ?o%Zlw&V ze%G=8P-lZ8g#gY*z#(Of6HFcfKDjLX?9);UvA2(%HMYe#iAAg(^pMXJTnMZFq%8^` zNE`9}{QVVjG_~4MTZTDoHof2OlxR0ct|ljZd+dk{=4QoY%UJ}Vty7t?<2X9_F5pi? z^)Y#p7eJ*}Mw%o8xQa=v=CbBQe3rmyZ{isE$tw9VEbxvgwPs7ijiuUJ#3d64Pr0u6 z;vLP~jI;p?1(AS0kXg)fo}Vv=lj7>Zl1`O2XZG)J01g(rCu7DgWmQ6mQoR~{dc$BO413{nmszYLMCcV-SRK5>v`^vx*F>yt1IH6 z)4?{1tSqTF8yL8>Y)9)^2(GZr%31A3D4{n99yZz-+(w6s?DiOOw`|85P8nlA4&&B=H0#*b0oQy}PzWzRG9Q!z{g_az|x_uCGOFfEoTRp5LMsv7~6Cd9k{ry6Aiw z^CTOJyE;(MsKBbF#acfGQ;#&v1VJ9t{pCgNZoouAqE3MDqSnDyjxVVm`|!wF-2AvH zwYd-fZ-w9+uV-#KtNgvKpY~?FzqSafs!iYVxop9a8pbr=Q#V6N9+N$F#`>n`ZTtQ` z!uztj*B*2YvDJqSg@vXXQ`N;!?5*u^wB&YzJions@zj|*A|irQjEW~g?1T^6{hawT zMa0+WEqC51sGDW$5;6tHX|m1o#z8KcC-qx&GV<%Mh!dHIF9IJPkXc8hY(SN; zxw|c-wNJ6-IN#b$iCw(!W5^4F`6%YAU}AejT^vz}v-XaXzok zy{n#N_rDN}QG9qh*5=$swGfx{P_YOK%k!Af>;7!9W>xhh&nH_`b!{Jtn=26~8#iZ7 zyF6J%v0r-O^SV+jy1TTzvTm{XqL~kil@)H~b%RY#&96JhkX5RbF6ymcSF+kGE5-|H z=}(@5c}n)0F^kjKTU6O6#1h=!-`X0!{ZDj}?PK`sRkk%7G3E9T|H~!${p-CL=7{;E zovq_0JEF9pVzWpo(b5B%$gOnZz238NTpRLOo7Au5ymF0`sw4;6oLk&qb~Esu##TF6(RnY;j=@RZhm1$*_p zysIaahgCsQ*+G#LeqRv=oGL!yS7EOGJ(dZC8UFdjZwFu5!xvICZ;-NFRFOeseH;u4 zU8!YsN718}io^e@7FjfeL~CJJ^3@ka&PPsOxmQWpluA6qB!w z?nEHImgzz~d&VVEGOg`;^UzKMq_cnMAh$ZXlrwd0!_i2PtAn}N=3Lw7o)zx$k8q#9 zgTHzB#%lS+LJ_JJFefL7Wzg~hu3)7Gem9ta#WeZ-*)y%nzWXbpf!N};9OpzW40gewO?cP<%d!DfeiPZHgW6PyFvzqX3ja;ViN{Yi`mxg7l z4;W||XGX*mjCqxdVaTVO3kt@3 zO8FO7J;V;HfJ--7>WgwuE-YfXa9=ZL5%=ISeES-xcYCk!#8{;rZe91Qr&dDQ7RR7B z|5_~n4YaX)CfdiHu?9_Huh!5}B8^Gzps>!75FC$Ajm!#l{rn{q!XvwsG(<%a{7w4# zy`r0T*yFG)kCMkpilla>r~w5sW92^1yaP?|$+TqUENW`<-9P6_t6$@z&_QOYb_rgL z7BSsIG(@_tDm!7O787ael}$}VKrMz_y95^yz7*C&;-We3#1PNxR$#D17h@Lbu%dJC zj0X04aV_xND%I`qORKgX8X8h6L9x0$XA2@L?bp$=lQ(H(TfAiBg>4JCLKhsIlM_`C zBI|BWXkbYjmkh^HVx_ z(#AlXUVk)=-is!!S=?1zIRW0g%QeNT8QE2k6!q5<{_6u+%Slj%pW)@@<@ns}*hv(& zOPzsOXXTcaIjZYH^4gg*~0naiaDZi7t8|b;X2tlzGeX5j? zZi?=BwGr3J?ns&H46%ff0$_;C#D3+!2iLzI%lOOmJz8XBrAwR3|W-Uk#d5=&6;Ek*R%N(&9BSARoMA-0Nwgf0G2Olfn(0|~I{IwcsJzh;V{>rRyvIK6%QR*)Ww zw+E)H(89ZH7icdeT&hJzTpYW4k&63r*-eVXB?k`tjL16g%yPS3Tu{+A!%&S0I_&=u zZ1JCDaj#*C7p1ZlQ599D@Wrc(g{xN!fOiVB>!Z{kejeX{lM!k1%3%T2+~QwCRXVkx z^jSH|hGklfSfKtm5vqg3yqt#DbUfw#SuUUc@#=kG`C%zOVBJf<9*ei$6Zn6~mdCHD`yh;0kA#i&9)~K!kQ|ocW_Bfq8q>~Wg z6b^@te$uLE2f>Uc;gunl{pag_QH~cg##VrvvdzP&R)MM(S>U|Z9qODI%+TPiZg#LOsMmS54u zyrIpmdGBr_CC(cbT+sL(4Mvog1YFeLR2cKw?vS8?s4&fU&hjp^oKW$9Je&P6enZNS zY`{jiAB~W;s&R5alrR`yupCPiSehguw@*0~N#1srfa1r%jb zarO|L`)iCZXwO4aK181m$8$n)1)+prp9EUESV8S?j`p`WNi6iKkI^1?pP5<+)ft#7 z5Y^mN5OA>;S3l=n>u!{lR}2U>ja>`G+gf9UYMTBUM*7=k@v0H}cya0fEhY(->x!rH z&(gzoa`8z$6x&7oqbU26&czIO=saGlkjIOX+3`cyAG*(aCHWpfYT3q4;>kn z^cRO8v+RBRmAPj)^uJyJ_B&Lp3G%9toG>d)I;HF|^647U$m0*^p=Fv79S*6{7e|*>hjuUH&Cr5ku~{YL!-}-gsiBz zx+}BzBaXC*hL#g=-u7_qWI%q+qiM-%BvZD3pp@4cYsu}7#QQ@B({4^9VKY6O1LOSN z=r2)hpEYXB7SA*lkk}cOv(k=#=VJa&(EfjsOkS*_FDOqROK!?1|1K#eaAST*&!~$B z1x2JrBIw)Wj;#|+xV(_mYg0Ifs5SR@F0QU_qF0`&=qu{5EHcZ4;iD8zJx8HyWA`SI zsdVG0ZDma~Crl3by)UG(fF>v|WL#)=I@ct|)cMn=$G{R_#$i<}>hWMI58=0O3lsvE zrjynW5zl&n3oPZaGU=$DIPq=UL#XaxyadI}H{!|S>*s)GZMlntPW!I?5uRrHb%{nT zI-uhJ&IzJuF^QJOjE;-*4`v|nb2)4lnS_*dyfy=9g=N!mPE(pv{|2E&9Z4LJo7iCh zVM+2UG7Pn}38|?Ptz%%)m!Z?zqIby>Fnc1wbjMJBAxB26T%wt+UkKIZg+L9?f`5e;QoB8a&{jhn6L8r)*7j&8r$M$g{e`2@D`t+LgP-1yw15w zGjWmeo~=RN$8n@_FW$1$e_v_XkL>%k5!WDi^Rk;)X-tOlbJw!cy@N4*b=pB2+4g(RDn8rp~FH`veddFqH;se$xw#T9FLqd_K;8uS}=+)U7 z_41SDVkzz>kAoUTvjWpHHX+%Q0dC)@WCa`JB~q z8{Dls4OHH+^BQpMjxN_cV)3&M>UMGnt-EkkmO&vtx!c)wo3W&Degu>$J9|3~D^Nz- zkRODWO*v@46E8fOB^RBxi1jXqdB} z1Jh)}2G%Sl)7a*~q5y&6*=bA!c75an2^bW~YhWg(=YT8FtdK z#r@!8$9_~lqnw>4k3h?k9_5&bjkEq^rb`M6Xb0+B4gg26OYbXqqWFZrk;<{6N-cd$ zSN|O>1!-F|5@cKJ!b74m0rh`sez zhy@c9Uhv5wn%m*pT5w+!wQ1bk-kzCOuAwSqbgob?Y8@Y&GGjknC$A$y*p&Hst zMWFo)z=v@kU2WY@h)XF!2j6+<+%}De#-iI>yCYV`T~%T=}ysI)7`U+|HhWR zq`8t-ZQ?FPx3?L$-j;gy4(&@WfM;xX6vFd1xooATui>W#S9Z|5oR#Px%M%;Ty3 z1aPJfe{LjKjIl`6g#}Td@Tg*Ypor59s1F&h_!OP>D%v$|}VyN!$eWOgYt4GnY7#C_sLmDP!PRLn{<}TB zBCScs%q4B)#GAo~Y{4sKY~9lJPwt`qJ!MBjTc*WwD!xIbdd8NyNi~+5bhORzZj2Y4 z@e&fd1oDN_BV-Swe^|}=RkJtUpE26gzIluC&n~U)ii|&V>1t(I2|PrC7wOE4Il0FC z^;re-s__&S-uCK`YMf3djg<6R%0khn&VI?59*s#bZrg;C@=1pj5Eciq#q2a`GS@0I zilgOL>=x|xu>{MbO)7yvG(H--57iUCxy?1D5GD|(^u}O zX=>62QfZn&3i#eBr8t1Jw2w#2KPGa^7WuB$bqMztO^FqIq-sf;%Z;x~^CCKB?yoP+ z^9-J!l6b(G#@%E1I@MPTqqe-lRiH#E?QdyR(z{{F&XY8u6+L-&g9GnMM)Q0F&XQ`JlHH?Ki=XlG@oBjW!9%Mp-xY%kYy(3kjTvbe%m%UlQD~1M zB1;CRAnCv7{BV;Qk!<~Rt9T_Ke_bCebSVMBTfeg7yR?Sn5x16Jd{h1N`B4iPfks10U`-{yY zgvQb3meXY@l!RuiXzp8=RQD$X&P?q?f})~j_CKm637%*N6KoJh} zVzze{S6=-YRnn%+f%o^F%YRMzF>nW1YB<(Otg^R8K8;Qgf4WBM1Bi*Q)vH8F8LqyX zzehZAOip+xO!m{+?&HIkBmD5hA#KAcjVo(w+qv+wz>wEwb%l;`eja_0+ORtItkTjx+kzmhCWVp zcKhDn>=m7bb?N$pt{UcRyW@9cA(tVJTFC^ z3UbO8!qUoq7u68Lx%-HIX0m=|*x%a$ZI`Q+E5ewaE_oPb5 z6b!cn)IOJ6sCYz;=L#zVuPh>yVtug>@=I#763v)~vr@o+;7f-e+P>k)JT-_hMR|tL z%dH&wCV9Ab{KLSz&+6wsZF6%P<8EDgH>S5=A53X;;pw%S$S}OpEx2Z>6dIR;@k%TfiBOu1&XQVL@Y4kB&Qp&0^{vOm(K$u_9xm`j}X2q z&M%^%nDn4j?KL-`lxCN}COUqppaOvFQi<|3Z_%ip?-vmSk13{DrHk3%?{&jAJ_19E zEzQlt8(?#fXn3i>RCjxG(`jX#+x&^ZrXM2Cr*gb5Z_=`R=!|gh2F2-=Or{cU9YoJ8 zF+~l}x!9SchZhqImAS~>RPja?InU?)W1m$C!PB-R$}xLaR55rpH8t62%ieylD%lv% zSJ>hd%Ed3`{W(}7@I&SFC^p`WWtV;g?KvvF+weR8FO47n@!P2)j8T%*sEs>{Z{U$E z3Xw8^$lef-P&^|=6&MYk*12vu^G$xC{1x%^xkiuoq_Fhbr3-2|YXz}|&%YC(K_mG4 zE4}#X_Rq}5S2sk@h=_b%TG!is`O?1H6>Rr~AE(9m?&gv4ya_+Q=K&TL)!tw7xL5X;H-h%LSc z+*|*F&i?+v%Z4m{-s$>$H}LY(k=U7rZ7Sfq0An<}#|Ai-r2rd`6E{08Q6e8X5J^Z96CohV_PukwSX)%A?t|PKX9ABwt^G|sN6z$8ll_l z?f~NJ1XBc0wMwHM3Rm&-T)jIS9K825N71x0zA!mRGfm8k!57G$zwPmG+U(~n1VDUdb*gk<*5a9#~xZt}gPjXti-k@u# z=m^gwo^v@IR1I4@>P)zu1d>m3E>@w!(cY^2(HT?z{ayU^*~=bao1-NQ2PuT5FZnY` zTe1GLRQ_BrfBV<$vDq|TIaGC)1C>XchNriF zA^f$e&EI0&pM*&dTUpzP_))COLV4Zc-fZH_TCnaS5G#nV>vky@Add8~Az(QrV?Z>c z5JZdkXiStas?` z2hQ(BRrs-AiM!i_YOK?x>w={X&yu*H1eceWt6&6Md|I5!yul0{@ALrnVOLYDXvok1 zw4_dt05}R{WydZzQH+*626csAcL*K8r7bTv;?6$_{3biYql_IB-1OIu{|9f@mB!A3K=&`6m{2LpQZp1&x8qvNF@jwM5$Ffg9U+(q- z4_dvUL>6^8RW&uQ^s>j7{$I)1=&*$4?n8;TLgDq%lePYKxnsZ!w9CElYu6M&yy&tl zpLDstT)X^s8vvOgb?P=-U}WKVhO@DuOsV@`yX363?0xlj$l5AI*|az)NWAz%6-oNT z#XKwbA)~Y1XOIaBLFCU@QjjI0l%dTp@j}|_Itk1hUzjfU&FnIDtgPNxH0&4=+(KOR zRhM>t$IqbTN@wf!ggLrh?ClY`0p_kPH49A14J8^0>ia8TSN3=~Cgs4z8;R<#RsF{Y zc0PolpddMIZ8#((7b3n?&lhrMh6V=HX5Am(Y#V1kKz)qvlaj)d0Yl}tcc$&{9|De` zqb6;=+bu_EkNx@-{VEQu0IS(N*s={d9S(0{56z?PSy5Dq3Wv z#sW)G^E}uz7yG#SK*Tqb%KNa(bioAhx7w6Bc&aUWc4Ld|xRhj@PL+UY;8@FOc?1Lm z)z%eoul@FvS&C3{IX)?17T(<2TJtAW-mx5mm^KD=vAufrfLex1rL?9#JRsof`<8Ox zuc*}}Ry4V|s6E!>IVxLju(g?ym8I&SgvgWmGhl3NOkIH9IB%lu3Fh`Xg5*hBOKE91 z+9MAS9T4a=Gj3PoAN0Lawtkn3ebeEFR5oS5dBX{GZhu=e#h6DYXWky3t&YMuWSpGp zdxVBSuGV(8^Q#;f@i&_r#>okF!E9n$=b{rWkB(ILSGjD*RnPX=@TtRyI>V%PZTYhI z3Jb@JMnrkk3N?NoKn;oexcb@xZbd#^Kcp(3-(u5M_L7ssbFZz_rwyF=bR?vufY7rO;nmU5bhx?7;cmhV3ZD$jjK-tp&@>im2Vf_ zw}@ZgZJP4rI2u$@6&XoHQ?gl1{_=Bg%N*RkToIRL}T~&N%pxaK2YlD+-9p%DPukH zg*9q$DYe!rvmOig2>{nooN@}rAJH^%CTrRt{T<6VAL2ftRQa1!ujzLFS~A7?dCS$N zN8P9R&VE?)tNSzRR7XsY>p2DIl-<%ax+yv)=QGM!2G%J+G+Umt#M`Y0Gh2Obz>T>4 zd~TxYeuaH=y=3`uc*4fcW8ir#qfXcWLsG^T36oydkf!SkuVHC^v976-P=Qu=H|w>~ zsZ7hImW;h2L22nNRx1t?EsWE0DXWB2+P%^=j8o72lv@qoE$V-6<4?NqJkGXL$heFx z?84lXKAlH+VRd!v$62;*O->HG6ZGP(V48atgxX) z?@k82ajw<|khF4X8mc>(kqs@6d?sC~wjN5mQkwisV^w}o#L;%rYkDFxvK0J@U{vx; z=?<0MM6}C~m+7kx<)0i+{l=a(1oK=;O8l5H0n&iul9;_EfBN?OyYn&@lFX;_xr%i1 z+bCmIT3TA6(VyRrU?cFzs!-e|%`P`5nulD8K;mIt$Kod6sj#!u+ zCvS1(X$coA6S|X6WSk?ONsi{+3FBs7jWPQ?>(|}P81dXv@-i&xnM@Tcwu*;}8Ld;u zOA2tNoP?fA8`oNq$*<4O%(2|(o*pVQVr3Z8nS3D;l@y{U(_CZSB*B|kjuk`dMoOEe zQt|#a@Nz4sKso!w2B#pp36%NA3cz;2Cf7>$=u%ft|U(r;0 z6ss)LT$oFD0DNw93|u)3ZA65#{kk*OvXo@w8n@EctWH_xSmXIhUf$yD?uNw3z&dt5 z$040`GMdY&wxaYwz#q9=rn>Ydl@jbvs54HN_2rhG;SmvSNJ`?1S8PcS<4S#hQW-6}E?oZ(KM@E2oRFJ$vuX+T|QgR(JOg8@AMJqzlu()Zxw zGsh0cy$T%<$fJpcniwps$T`@9e@7lk2Bx5np6x0-e#js1fr{y^t59V;&o=XEua%uI zq3rDJWV7B#B3b>6^v?SQ@WVl|0E3)qQ5)d?4lYcx&YMk`h_BikUM-J;{Iptl~F&8l_^nSBXtT^8M7@LIzp?&NB&vL`-+ofVDtbGzX7x$FJ4 zzhb=>X}Q@o9B)C6FpSCW!F8Z8b>7l8t?C6Ht{)xmVJjE_I` zlv`Cztw^(l9ykxA>@fi+EvP3?G%t^`E1k#O0xRuL27TJhFOk7uSJ**e2?W%%(^kw( zeGT0KNu~acAeKAnBh4r*>Ze9Jifm6#7im95=C@>TZf-tsy|*g~eB00H zstr1rf=Of3frX4K%opFcq;nLtjz$gjKM6P=vskj#fMVYo8Nzl=dI7&BFTOLZBIs`E3}Wp?*xY}Td*=TP6fbrcC&Txpj4X6Q zzt{Kw;;7(8HLlf(oGg-wVj=(|)r$zvBvGzlts8A?7f%6oCk^hKe^HMf$!hkvHxso= z>I=>{VKfbPV(HSGy}*ni_U2P)H4A!|_@-rnG9k{5aFGJhtndF{FHdFa_b~jdRP_|( zPmnT+{`Ofk2i_=OK}yPiSy&lisL6CHeo!LK%;m5RgJWX4arqB6(Et6DZ<<(AR+dJy zlZpf@i(A%mC@Bf?(?gv*g3KsJ`3TazrPR?(Hu16YQ$=}GbW)8c!hX+@ux2spA2+@F z-UVQN&y#SHXfd+X&N2t*s?R#;Xe-1*0dAnVnJth$VpyS)5S7A?j6mN$OA4XWlf}-q z-+37R=>2?O#{N#oN#9PeA?SkaK=jXU_}8-?K!5uALoUz#BUcI}d28#}!Xn)Zi;HfX z!x{ePI&zl7k9_wt{QOSWx7N}}ojgcQZx)?789-GDkg`FU#rn}ku3vaFL-q?zo=BsV76X+I z$=}DPzxUx2f6Y@W&p3$K*aQPy1k8WUQ;@K+{q2wU+HWqOYe-$SwYCCZaeNd(1R+~n zcz5SnBn%86fNUhWo*0AdlBu2_?Kv)!-p09i24x9WAgb3vJXY*rF&B2A?mV~90`d1F z_Fr$n+6kg7zCyhr0Z7q=EG!r-tpXozc;91%AWzTbmtO=nGp?x6lCtT;6rFxu@&i#52VAeJJ}bp=7Xe+z zi%U;J1;%X%I@oQ7M_!lq6F1YqaVsXXBRimmf|0n`jHFA=N%QB2H<>5Xb&eQL?$wPe z8qF733fnt%bor)b#W(1}T0zxYobOoXI2KtYKcuKbnosaQ?9JXc8gmPrNPB2xl-u1! zTNl9-aqWuey-g8Tf2{seF1Mp+CGtD=4h~~A(csfAokhfUODakxU7zjnVzPfXK$g#! z)|-}#7-iaX_!&oMy7!*brnqmX&PHceF2CJo)Xv+u_O$-7&Ff4_m)BRLkG(fd! zwzQxC0@9#TBGAHnfyA?BHZ8D-KRKFb@JL&Di$#_L#5rU!l_~r)pn5fE67ZG7Q%= z!VO7o+M(-9L5x=Lston}qA0MpHlsWNInS`pG-?&v%xThZM~*ghX8) zZ6iO+v}*z=mDM6HU5{5XCeHfqZy6{zQ%xWfmBSnNeYsjYe+{yfL-|x^=;89$c6Za5 zc(TMzP4mAIX7e-huNo+5I6A^GJSyicn;BSW`bpib$JOv;-k#;#zc%8pby7}~EBn{T`UOz3#;|f=zENnm z&qG2c-{vc6Lnd=AbD8D59`W*UG2g4wY=Y}k)TiZ&5!{RMvvE0;|8|PK=6t}xaVg0G z7l};)qOGmX+`?kew03*!HaA0NGA^8jwzg*+nnF^Vk9*P? z+1NOm$9Z8Vzi+Y?8c_qbSF>N9EF#MpH`}<2^**pwkQUZwE+^w~{Z_yJjzGT`}7S@CH-&ERF%mvum+dZ1TH zf0LA#_C!7$>FVwrYOhy)M@H4v`zj)GgD1Oz3RD)(t5IWwd}-?2=4({oj2MN@lyKdV zr6Q!KF_1*IbkIL)$MFd07I+_J8Yqtqz&I4+(eEa0V zZviK&vC<++W1-P)tj^ohbK?_k#J7(dpd1{etEHCDZBC04Z;fV%`sun91j<%Kpo2ES z9mX@OXS-m=tJ(nTY-%~SgFEc++lG&v^r3Y55lICs7=xEPP!Lg3-2(z%V849g@rIXL zK%LJ#+V(hbi|NtiW)=lXo4UCCL-kU|Fu+Qns8tqaprcWp3Drv$CjVT%fIf;p=&uRg zxTcH}mTZGC(pL&-!j=8@;vucs{1I_|X5ta){h)wwIDV!bPN}k~m!~c1U75$-PBLdt|&%n)CCK zg3xIv`akwnDFVbt@Ey8-;RQ1}L|oj!p&`c4Ul~lUPBs;*t+ROe0mM?Dt>~I>YWj5^ z$ET@9+v4-QH~^QPz)0Psk>Gi?HKs2;8j~{|(@Rar9i_c&MKV%5we6l6Yv(Y{J}*eA zYLR2kaWusGd`3ykgUnD5uJ_!O3$ zLHhPpDpc8(1&m<@imcb=@iJpYVMy<;79%GW7UnY4k#shya&R4O_WfMB4q^B`eYD2Z zf)_s-&dP6dls8j>10G&{p-4SpXMud>@Q)*#gUSt$yV~EVfY{wbyA&fbEDQx5#2669 zXfc@0%|`ROJ_h$A81jIucUm3f%0UQS}7 zdOK{$y}r=oaT13cp=T5?($;Tw7VYv*jlS*C`XHOul$%bP*$7AXqmH5pJ=2N2g0Up@ z%uGaHGo37+SjK(toQ1We00mwS+|nb7^O=C1I4&+Qi++6(4<{S+cX;mK2$#MUR5)T+B#}8gw(pp)2$zZ+Vg}h#X^Ym%QsSB+z@2Eo7BRS{qtxGane4`cl>|pkrcl$!? zM5cdq-Pnp?YSKHrK2JdFiha7Ksk85Ch5gNQY>|exH^)yrr_WnR`>ZtHcCusLHoHTH zM1F@elR&`bXXIp+&^|vf_wZMYrR|SAf2#uxQZ0bAr_pz1xCzS+PqfjOm3N4WR|pU! zH{ss}Aw72CLFUYbU~jKhdsbJ+^(s^1^~|iQ#-xb(7yKuuyI#TQ&cUxQ1$F-Gha_-2 z6V=#yFO)l>)!r3BeVX&51Q<%ou!4DBo_s}WYIdcQVM2^R+tb{ShCB*p%g)13U*`;& z`D~I`PCKUfYn#=D@%g~^m^a`a3s-ul>Fb-*PwrsE(1 z*Y!{Cukp?`^{z+t)8=f1Pg|axztcBItp9jLjOS2aE`4|xyQZ2_Hbmnt- z$b0Wc_oL}z8`4f=%5$)CsoW>llH}}vpkyopKml;`P6>ct*K0GkzDKwhxf}EsMH?+^ zaphyG@MmY3E~~WB&f_W3=dMnb{0uC{&QmtM9M7eads8#ZZ4?N-C)C>?4mZ zUUwQi;k@u_&v6U?^~ESr`!i(y2B`X*qnxuE`@Oi|s!g!H`rI15>(Z?-dw-a?eZjo# zpvSksTW0G{(NpLBM((ui+&tci(Dsi?pqs-&Kth9@3f+5PB8 z>Q3otRdVgynxpK_f_f($ysG2-g^dl*)D;;?i~Ah`QbmPMdYbYqIvV-OPf!T|IDmgu zWY&xj!&>W2iXlOW_Y@?0IH*N(>^HS<3mvxKo?tUVh||$!hJ3%_4jA2(zv=iU=yWXaDQX@j*P%e%95~_MjxIqlRmj;lRq~$Yxv; zINO22P{4?8J5cy{XZ}eqmG~7KYMU4I&;IUF=TS^;O|R<0QliHUS2m){a%rjCwDhbg zsj0$fAaf6sEI6=%$_N@?^AW;b5+T5K@sM+X|d z8V_-EK2*v58%;K+5u0rNTlxf*mE@JFAM&t5z zA_Lk1t-ZQ!F?Fp1d&qXSes1~z*?@+qxl2s^n`48Ouc3X$ESlqY@AS%|zRR?KNMGPn z3~*7;Q_NtxAiJ%8fze-!)@gvH$4n0s%BM#LrV+kCPxJN7EF^F=Z3e=46Z45_P6C!Q zEr7%>0@enw^+^$PG~za%gpBL59_p?;^nC0;enCtf<=rs0)Nk{jMgCXE*GKP`oUDZ; zC)e22gx3*FPC!8cg@qMZ;sHoRb@n=Z&tc(F4#WSAJMw2nghjZ0TYAecc$Y!0QtnZBXC9m`cp(SB52UG*mGGQVLP>E?V^ z;nl-H$ji2<{l>^VS$u9HPX+4RH*pr<=AWU&Qg-tGWMWopee2x|8Mf&+opSQ?RyHkp z1_v@$e`!8K_=cZSoX&wXfy3i|iz8nt`@E;_q<1ZVLS6Lvuma>8=8n4XP~DyCv)3!H zig?Ule(7ia;~WD@wSUZwM5V^#e*h{Dns3s(th4Y%3ANE z3tF?`;FHfFgA#J{B#rgq%YS2Ofa;ykk>nSThm!F7l9Z}yz>F%fH+-F>qX{Vqj@iNc z2Nut}EJ(Tg2NsX4hhb3lnml0=&98c=>)np15p!jz_|!%w1n$3HU38(!POWGm3#SKD z-FHM(L9}MPZ1J)2pO>*4JYQp58&)i<*%-NRKmh;e$`A9@WrrM16uE?J>U$>+k$~z5YK5`n)Fl{sdgjEOio<#rh@cSE z`Ul7c{G6hghi_!;-_l}N@a_ihUT@B+@KTolF?h0m4Ivzu@i9Ad!bPpdw#}@;WLTJ6 zC*b((EHt-9cQ!rzOQg$|q14qXIlgCADq_OoAO*ATCeO{#5!A-q^ie<+1bBv=}yEjWzo7n{B)#2vKCk10~kGnP+bh;cnZpvPZX)omG`#vpAIn5c0^ z5frP$L0hw&_^?4HBt#w#Mh1uyprqK83HW#AAgrTlq{B;vV5Yxk&yN4FhlpPh zmDlZ7%AH+zH9xB@9>|azTO#`V_J8<&K>i=}qaniYBX{9HOa}x~G<*8q1`(pP5o*2{ z`SA8rKTjqFD=~cr)jKoUeK*)=o9^K#LZL2HST2v>m|I(Zi{3@9QTu3!Cl}y_cGX4S zEAoZmHYSQ(?|-oMY$Y^~=SZ(!{7(uImJD+6mLuqy%fx3zB4`y z70jhwz6TtTDZJY)FD{@PqqRdUs?5V~6FzuFQ$Oj>m*U=*Bc#$!_s>zuyOoudRSt|` z@iEax5M3E;pF=bSOZE5L=GQ-`kRNX3@F<1fZe*;$spKY&f!pw8dIC1l8HZh@QN2gS zfE`Qb*BF=k!s3vY|C6=)#S^HOPgL(dNFfagFzd5?FLfvcD9L0%vb&QMhHj&*eEPMV z-?T+O<_5nvMSe?L^{hyMhn_T{E=YOX$YeOpMjYihgNH9WlNm0Yq(EH%AtNW+a9sE* z>ObngKll#UP_ddI7tIx}$?EJ3(`nFO{{I)X0BjD7@sC1KFda$nRn6i_kU9VZ^>2vDme>CWGtrQo zPxv-EKB#NTT(A{o^0R#_QGUA&;Orv{%BQBuD69h;BE~XxW%-6#H;`%f2UErH#`yPn=Y)8uK9mQn1ZYngK(rkNAW$#t?U|N@<*U^!4T+F1 zR?L?Zva<`gz?xY0Z5rsD0KR1WBWyUwEKnW{1!;97ekc`xJnkF7NM1A=b z^_>j{Oxr~PI2st^`64)M)|(-XMDLdcHG5Ae{Ew<_j0|l+iaL_|kxHRXx9-kdU5>nC zuOH`roDVSR`EikeF>1`Qd^~H{-x5wKQ)sbFR(n2I1M~_Rhf`qcMN0Z~opB!vLl*{l z;}H}<)eCKRm2VR-seInJ93QmhA zXp9lVoSdZ~PE#O=Q&1Cw8xs&ZZx>^o4**JE=tQTZr&LrsNvW9;5oiuGlYP?*$;KpZ z@caaHdl)7MMjP+ZJrdJ`uNJ?Bg=yQbMEqmFCZWMrR#p-a6GJ|*sf$vcD#vFm-B2aA?7SKq~0Y_++57 z+xdpW>dC`iyQEwP(3@$t9;b(SWmAa zW){GSB$5bjfCq($V#NSjHR>m4-y9r@V*dH{k-}zTI)V?Xb#36K?xp0TCezixgyug~ zklwW~x;!z*1*P1!0lv|{yfo65?&6J<$_zj5=GeR7^m?{_9_-rG{;`k9swV*m02 zc$}m4CCeLF0U7WfJw+kY+8XJ$#%bi-tL)`yw`6j;;pe(JAU<+pd-=^9fQMn*}h}RP#y`X($nuSqGYF^)`eY`42Y@ zA`AvY{D3vP2(*Z~CG^zbTQq)3;V}<}0S6_RSEC3o(SOI7z!mN`MykOW;d{`DjiL4$ zXKrrJ;jrHMdU2tN3Hug|uMa|*Lew^WPZ$-feZS9E!)&zXl=%%7R9&L`8SPfvY-@aY zw6L}o1g0pOgUJQPkyo1;_=iyqT$JB6b_NRO9~qROkg9u%B?83)*Ktn?(1822@j4fn zVsmo{1_0Z#goGcnju9%0Kb101QR#EpskRiV&%@M9(Qn;Xv$gM-H$?08ow5O zh<^jVKb=?e{UDO?gt^3EED|vEODvdW@$%=?1NCtF_uyFvXXx`2BkD^S;V+S zTK-Dd*EE9l?U~ov`-T7!zGC1_6aFb>{62rK=Ng@CfJ(3-lC93wl9TJ&VE|SH!5>rwl z0b`$LNQ4=^;fgE-u<6j=Y2)SB#|(|87i-@KiGfsQ5#W)b`jMK7aA?AIFCm>B2lWl7x6`6a*!7F4dd_EKj)o<4pQjZ_wxcg$=N) zhEgdbuvtynVh?w<0{n2%@#f^*#nSz^3Q9X2&yJtJ1p$X$U4F4#3qtQ7L+TE=^FN6r$mkGK^>#P4m#rqja zwYakM44AEjN1(P$j=}{6iT`B&)EFJ>F+ew4x}KI2R+4{yaNMrN;2JWCW5NGJ8}jQZ zByl6Y!jzHJr4s~SKf{GwqXt*na$!y#wf1b1zhH{=3pYTRc@+^sba^3l0S#rK{n0`* zl!V2`C#;0pvLwvf+2tc$mzZ38U9vj_w@*yB;%APcYzJKf9thmIjd7<;w!hl&e{M)? z_`j4XF!8T6YcUVF{xMEgdh^grav}#ePmilp-#9_KKj7T?p2Ek^UwHdOu|5l6H%Hge zxLIR6!Fv6oMT`6|qt-GT}yF(j`mE8Y!P8hx?(SMaZY5o`&omcndbzUZh)e|JjKDsx1dz{-2_R zkg$8l;^s4iyZc)bPi61kp}*j2Xbk{Ylc<&n0kYjsaR~vXDlB#-EDB1xhdz?RBT2Yg z8I1I;4|`twUrAJ5Js{Stxql(nka1bLqUSQ%&8i-swS^0-9}w$3()K1RDeP?K9a2=4 z-a1T1X_LP3kNEm0Mg{vg=YS+Kumlx%L#*NiNB57)cirO)C1;UTx5Eb-z`XNqii-5! z*w6=+MpH`*B)AMahSKDGg~9CbM3rhRF()=w{umAq&%fo`R%nwdgLlnx^7N7Bj2W1s;-WEz5>v?cCon5#ebV${`(o4 z#Pd~WUWFG+WZh)Tx~~50?-$Zt+r)t#jc^h8Hp~ZRr`QG6ct|a=(vONuc^>3yiR=ph zqa(F_lOzhxq(|c`>!K}_X|!)l6$ovf|cLNi3_Od zLt~9v2Yz4($2C}zkl>6@7gOBIs;5E7p6Vd`OMvO;eDxYE99-EM)7L=>ZiU2Sr}&DL zTq8Z039*>hX_m|FaJZ|YTrf{V8kLYS8Hmt*;CH#Z2TdF&fJ4V371np)=027hm>uoX z;uz#n5|)D(r4Z-O?He2WTFA;8{&3fCW58`J>n!0$Y-maWx~oi{Ww|$kflQT$O5Nq{ zqif`u!?6mvb+dL-HniW{R4{WhPmgx_r5-%CpCgS@fU36f=aZJ+z3_$`#9}ri$viRo zia7l+s}BGXM@q9Ppu$`ZYHZ?b{_$=h9bei2o2)cS%ZS&)&2Da}zKjmkj z`}9Kpwmy;qktYhrM+EKKO_(DeMYsf#wz+_}-YZBzL-PVizD5tR*Ltd|$7%uZ?_w>s zZOj2}e$oB;6QEV1z*? zDemy19u=V});W}=ZRQqqO`J8JM44}lh&xrRXq#NbG91aHqONP&EfqM-G2CYOHtjPy zp`X9>6Mpu}xm7G0#Sw$&`qE|=Z+f4R->=EMDO*qKqe1(Ql$}O%64r(3HhJLOcp65J z;5;41g4%atVpc5YmAm5S1rg4>?wzVr8}_mt0^4yG`=g{AvtF*<7Ny7ewIUd*-PletjAl)a{9X|@l?m4^Q4V!a(`|Jnl3A(cWPz^!d zgbfV(9*>Qk)VSKTV|iIFv_N5!HO=cy+#%S<$V2b<>l&=;7vWKc$h;62oML7i z8(dti-Z9>@lae#RRk2S#WV)9H!qMdAg@%QSJF7}YC;G^cBWSk25;5i>LH9H{{Ax~w zfOI?MUYbh?BBV-SdlGpu@bLD+H?w4w*3i(=UmM{qzmby4_;BF}2Rb!9>}_Xjy|b@!lGncGx!9v2w(yHHS4y)~5PqM|^Rd4}sxiN|T-+FDu^$5LEXFA$82HrBVtu=MCWDVro>c^MBW+#!W>M9`bK;%`Huoj)20eozX% zVw4^5^(aGmVvCg(LE6 zEG+H$o{W%e^w4T(8hC|R3`>T@1`-)zzbttUoX7KZNHHyLl77r}PQn%G5e<@NvL0EM zgfnYeYT;y4QAZ?2$Hspc^J4!brLSU?aFq+kBuzgu*^r&B>>KSJTNNbUI<(K>z_2Rs z*?20Ll$;C!4SfkxZlE5ZMh(7ubbl8R0{r0JsG+0dg}{3w;UB6Lt@0zcIQ`lft8J?U zIQv+|4%4&8i)Xm7k5(v#Nbu6c-VybYcoD|)h%Fm8iP7!|-<(zJAy25=) zwUVupn3leCpkGz*4cXDvHTJw2ysc+`M?0~4_L(@|8_>`FDDfq?SW)g#R|P2-hZUsv z0_X6zO_4rglZ-+7&&{9tZrzwiyO`Jz&i~DNp5P%;r;-kqjf1VZ zcNUJ4k`l~yQx8OR3M(o)a+OMcVjFo>3)$J(*{mqKvXqyzxoBWqA>gLXIC%I33sii( zwYU>S4cZ;{QCPZgwTbB79{z>`)OTF2o6bUNyIe_`uP|olmoD61E^lwErwrRzOWoI0 zZ*P!+bp^g1b*Kv&{P;dq=ORtannuI%+F>vz26G+CLJ}r(IvyU}fhsFK5|WWS7tEq z6pH1luzr)5NrMm15(VrhtX~@HGo7ydFre3F>&-z^;Iu?4xA{>OiwAS)#d&dMqBM1+HZ8pzZuGm0 zpW}l_LqAU@FvOI|OjmGzswMDZApm-G&FlsNB<~0o8B8pUqe>UhO zH!rtSaE}ja?K)Gv5r(})05IwHagaY2b1F3vNgMpMRRGKyJJ3H zNshEK9n82H`J*xyQQi2yLinl)#ay+qrh`|^v<@DXI0Z8{$VF_@B_lhaUc$LtQs+*M zZ_=eoQAsh3+N8>lF#3Jb^_=2)YWDulC`!fL39QcW5lO`~mz>?n3|%qajc`2c>U7b4 zLVHg}&GOsy$}JYJ*pt5n-#u*4ab&lnT(+=sPQ5;3^9dps^|m?bn>*v(sWMDvL)0r^ zaErimfrv9~27Hr&hb;*(S*KV_it6-t<>kkOAN6#1%WN)`U8UpgS8t2^ z!LWUSYKW2x;FIYn*%%LRmUpQlRXS}SC@C*>EAyf~@+;Yn8=LHdyKG};{^mY1S>z@x zf()(1)k8bdF`}PjR3|2tBc43I3(2+_iTe9`A%M!4fk^jr0sWRx!N)p^u`7!Q$cdaB zqkN^=$ixI%Pfza#xGhqBSOQbOSAa6j#x^1IRuJ3oR?XXLy{|d`n}hYnf|^lKT%7SK zPvEuAw~eUC$ZW3KQuYFwo!)9%q}v!Tu_qt_FbPRNI61MrA`)-XGGx|=o$|emu#7-M zde3aab-rSZ&!GpSta#AvqUq-)wWzaNwaGs&9z=*E~}`AiNqCjtw#RYai@}@)?or=m($Mq@A5KcZk1ccKz^Y^7?`xX@~;FqD%hi_+1Rj zt!dq7PTBUTcW-Y>B}JL znIud`SkAr(kg$Lq`ThW1Xms>fAj2W@7!tvbIxH3UfyX`QWmU&k!0ub`Re8U?8uoo# zzROU?N<2@-+8Wh%uNHMjJ;!X*Vr#@;g*7n%I8>KgQ(^s&*xH#dP-mV|^IzUswKEK8i*OxQU|^v9o`d7QO$pvAwe5nI^K|0XirOIt5a9516G%y~L)w+v~#_ zE7x>P1-Wec0w(9H*EsB!Pq4+4Tl=czP$oM6-m72ENOJns4Q(Twp zU5wEVU__C-D<3*K_sQdPM-%BoXuQMvD<&p|?>gnilkNt?886$h5I*0Yj#KjKgk~34 z*fnM(-bL{@tRHkyQ@@z)qV6ivAAFmuc!=;0H?c5UQAtiIC5S#?J$F=Jc5!J*P~P2( z>fK7 zo_PJwJ_)bD2;44e;ScAO???>@=fY18#^0$YjepUk+D!I%;1@beg+%$s^}OcC;r*(q zozADH32zk-EiB@vDL(0XTwG$lwL-{Dm!x7h%F9a+vznHc?ec#$wzrsl{L8Ir!(@DV z={L#hXQ8KcMVbtHI*NzTacg*hQ{Zxy8FJ*kG? zSg*Y4v@sRO4T*?KX6c3aIG%9(p!^(3{VW?EMeG%%R(+m!%y@ALO>|}r)g|mNr6~C& zwX($9Vl4H$`TfldPHQ}8+LqOGF(xvj#MY_#d}X_NmsREDK_{Gdw#&I>@rL8Sr z0C@ll_IU5jy4u=TTCMsmHd0cLtEv-TsrE-RK!Wr9ovp36rAIuo5>ryu^!LoA@|x z?7Wd};P$?JVsP7niD8Ea98RLS9a+Y5F9qSW$ESen3+V{l%h#3dvcMCQz@l(O5(CX9 z<|-XKKTyKqWJCP8Ps*SWIO_P<>{t1yeqa9)KC(ZWqoCdF$uLoE^L(u@J{VLL8gsSw z;Y%F2Ou91x80t<>eGqV262$xdEEs>Sz{i9djBcg5#|s7QR=U*jDYdnT6#+4xz>S#S zUV`XZmsbDaBs9oTDw`zNzdIK|QgR`M?eKo^a9e$zDAdtN$08 z8v{}Xh9-h5_5lwztyb9D`#Z<%OMtDmfoOuU%#Dutb8F znV3dHX>UQ^vyZXm=Bl*3vODsXA3Iuh{_6B7DR=_&UI7p!Ll`wDhFbVFSC)&!HRt82 zAcj4=VHHnG%ZLBw=15pw~!$yAj%r95p{w<~Mx7L*m zS>qO<#v!GVxZX%Wp?dA4JS-f-(12s z>rag_FVd+f28Codke2r>T_RvhV>T4tOd8tgrq4p3o8%qMIqp9su{34NJ71$jZ*<A%CezZ!3Vd)L#Or(vM<0sRX%gXIcE z;YJ6#lT8GGtvSq@L0wW@-05)sNLulI6MQn~{c{c6V{UWz#bup~TJVD)mo_BmX>0 z6@ni%m_3JsOL2nGrc7+6D?{oX>Mz+>lvSK2Od-G?6HoX_*Z0ohf@ib>R4FTEiup8; zf!WYk5O!|PZ7y$HRix-1hI#I}tU|nYP0ABg|?s8r3ZNP9w%4i`jXL%@rLSSktP+194 z*e!ti=B0!Io5cIC#0*5JXJ@t`rTNu}x=XXw%P26XTP*6h15SHB3qhQF1dB)8WGYWh zXDZ!h!fHY{Dv={d(i3=`m6hJUEFfNgn^cE{$ze@V)eMuBIGdY`bFwoTuX<|sBhhUX zm6ItL>gtiOFca=WLMdis2ZetblQ#msAl=#AC#9M)F*9p!j!n36qetVvmi}+zJ;DYwYg0Wgkh)hLV%0mrQgpJhEAV-rC=n zV25Qy!r_9o>D7*w6jzRa^g;Pm>}vzVQ@{{XcDDRpXaF_A7@CI^tMa)PNNg4ZTBSYt ztYBYYv-t5ZUo9)M==EK-Z&0*Dc6hxipHitl;Xx}1D^a%F98&TS@{mKM3mh$pNg#^8??Ok(@n@u)m+Lg# zQ!s8+#loxfd4O%u>^)SY_vup>u+fFmf#A_T4K(6>E?G_6@7jLU7t{l$`OL&m<1!C?KFY#+O{W09X12 zNyGYjp00?B+}ya@k{P)^DEP@%i%Iq%bwF<_i=}P}PIOTgvPThY?(GdcLb~u+uCX$< z3p!+VJTqPu?D2h9ZM~V>UISS476t~JRIW`x2}^;rExO7sFDD1h$;sJr;kZ{7+uDh& z?_69`GT7kC8^?tu{3GQ|>V4Maf_vi^tVhi9{n!R6k z(_3pa-WU~dTU@Xj8(Q}q)K*RqE-sHzjTWWdW7qmeN<+ccPEmiU)@fo_LP#7AV3D={ zL=QlY0~Y(cC&A9iR~=ITit_dK6;nxya7gs8XbrPl*I1h%3c!;Ml#5+u3X^|JJq)Tg zf`^CyuOh=74siid?E7~m;Z~5&lM%g*O9AtiZIz5771L#%a!nngFM(U=gZ%Qs!ot^^ z8!q!^1oZX3IQHj&)sKpb8X~DDVW%Rt3r&^oDB8m=Z2l6~$jFGa+=8nyJKON|E@T<9 z7&ba8N(S~%yu==+P z9ApEL47aZXJYW!-8r^OSO=lg7kGe;)fqxi<@Z6#_u?xQ5aXc7qJW$!delOQuLFZUF zh{2h;PsR=sEZ?1aLSgyWy8PFp`Rh4VK7-QrRMa?bT3aWme5O*r7inxN%IiQ)E%0zd z51c`&*U^0hZ#)mX{o8Q&O>0@r=vPcwjL3F`d-h}JHWVecABhF z2kG~30L*0I+-l#J*p^^Sz8{AD>Z&~a`GXnwKgw>8R4z{UX8oZWPP=YmmO1skmM<-w z8bU%e0kD@6{oFu!2b=5jfPl<)j8+oMo-iK!=;d{e>fzL~PUR`qrQWe{ab(q>)g>5s z<<<(y^l&;G*MYirwK!H{uRKURNU_G27MRaS2x6ycoGaSKo}Qh_vo@M^M{>XaAY>I@ zL)&3aPebum$^xlsvzn?xEoe1E2Xc>D1Czt{X{(p#abY@tFtmNu!R`cfr| zr$nO(!uge4-%j^q1=qXF|3}wVKvlJ^Ye0}L=|)l-q+1C=8fod0?vj>PIwYmLJEcKd zy1Tm@H}$6HzI)EOQpD?Hg~cC~e|mrzYrzpJ5k*O30V z2+5~N;#1X?PJLu%w1sVxpx5%&{=ARD&MYO*$H@M@fr?WAh|OP;pCl9V5UWYyC-MvK zN5a{T2xw7$xmR9QrQ%CA+$BEn^rt~8b=T!UpG*)a_;<}Sh)TT^C^YrZaenG{LFW5p zzD`RJV|u3lgHbvi+kI&To@^}xY$>)+5$1SVg*zMjcoff0HcW9`Y%JH*eQ+JwT`m;z z!^UTSWz73h9!rgX#;$*@KXO=XpRFGkL_z|v2D`NAkzipS#zD~@^bQU-6yCb;S(C*5 z?9JVH&L`G2^LfQZMHf{=>)%HHdqvAUZ__UnyI>FadI93)l|br~W0pejH`!+9HlFC; zx5<@m8dl2*9^$!1EWuk8Wkj@rfqpw0dSXGKL}~VxjlLH(J|rLq=ck_B={=IZp&@cg z0Pj#rxbixEpLqOwT@{A$_R1#!yC}b<)9p#-4G?+~M9$41I@tb}L|bb?Au`SH$IbZ; z;RG^uS66wSU6b{;^`gFYX#}#avU>j7W8!H42R;&z<4xi(MVxv`OG{&rxdRgGLrSC1 zU68)THk5^?razf*sP&?IMrGx*?P2jOn-c}2+?+8tzV|(3YUihXhYg>1 zrc-rE{9EsTn*1GCfIIfmJ zn75;+HLH+?o}L`D1g48>gqN?8oQ3uqtT6jVj2fPw z*~b%=$k2auB`Z2q?3irtz}6CO7?@eZ(cSj&se)$&q>`Ls2A5 z)(WMrSQ#;nNjvFrM4H-fA2~gKwf4q+cTpHW1;$UZsmhO2V=Fd3maa%pr!}2CzT2(P zUC%+3R8vDk+#r|wyW_qOqZC!gEy5nK!U^Ze=VdG@?jtXco*VnRV|J`pHaDA8-i%wD zjV;}J1=;Iu`PV`IwMcAZ5l!Q~lHhdR%!4aW3>kQkD-XttuXxT&KY#}E2V}w8m7>yo ze}aadN;nd{+URvJtsV#4f?|3JYGbG=T{Q*<;o4$`=L{mLK_zevrtT{asS(o?jOeb6 z#{o>&FWJZ1MM%o5^otZ~|ZpI4v{BZr8u39%y_uEkxQ?3}xdS|JvTReMTLY({J-AdBfNO$)Vu1{q++ z{`E4+VF@W&^UnugoHK~H8uJ!5Gpw$zMT91c;p9bV>tL-Lj7+nmeM8?}KR@+I;v_ct z+1#`eQ5c*H(-6sOgscSU)#d-=6%(~c=&rVULA^b7o8SICX@d)rr!rTX75i~ z5mgo`bDS>yOvTUMA4Yyshtzc9iPuv7>y7?-*FIOTR$kq>)r5hR z>Ny7oiu07JZN`ij7xwgUUjgCxU9SHAR7nCTDN(nKD1Ji@4v{VgE|I@+WWNheWzd}~ zBD3y|S3AN0AY1CZ-?RM2s7<} zc?KiSus|S`uG5OTS(qU(EloHxQ?7k(?kQgiT6?=6!0n=`si{%X=A{=G%UCh8vn!Ek z^zKhtKxKADbaJPM6!Lr1JTs;iFR!f3`~`#Q&L;xfs_MlH%D~3yFMm6OhhhEB*uJ}< zcK|NN+mnIU!>NmGCPPf>HRhaWXJ;$xDC~fdidkA8)7y^ryxtdT5l7-b4kN-#Dqj>?xvjOYO`@R zVC)jJbH7f;(AM!mdSEQ)y0g8#B4@lBauXSei7)IWQZ&qfh2+zX;P3BGPDl4vaO=^aYJc*Jl;E{ z7}WKT$HvAvRN)(hdwYEPs^Q^7**|F~Cs9D-#J=dK2x8(Ks9CB-w*fxWj_Y~5%ksUR zdZ4!tu%kn!e1npe)(9;rGlA?2*s_5_|0UzVfU%PJ#C{H{YE&ud{?&0vCKMY9Iw=yAw)oQ2tH6e)eEBWGnqHWzg=W=N>f?O($vA$wgb4c`2b7od?T0- zANT}*ZNz*IMd=h)Z9=g*M*9dfuwP`f*#gOHQS${qbmq>E0OYsH4Aj?R3{Afq6RLL~ z5~?a<-p1sP5X!H9p(X7pP~yescP9QAF-g^D|LLbnQA-CrSra<=b60)^|1*Uhl;Z}I z0HhBzP8ro%(T^@VPr8X7b72u$E}p1|iap~XbH9+U5tDDp`(0lL!=wx}8(Ch*qEb&) z)2ykg>IB7W@$-hF;)13t@Xyg1E6GL-Q(PZFLP+<16jHCVZiXbXr&7Iq>FaexF~M&B z#<>qvq4}MUEQK?mL$B$#|6_mk*-wEoIb>ofBhLzjeYtQ#*~#E8H8fKWm-b<|r;qph z)j?2nCPg+)mlAQSefCHca?j0>a`nb?U%yWb>DCRxv~afADs z+(}*SYeBm;D(Xzop8xhv2!7>!_)&Vwb#$h}kO*`W>iYY;+G*1 zX+R+p(n8e7q7jgqvhVu1m1qKFM?F8@oeHn&Kf}{N(QJ0(*-#&oWad3u$bxf1s$X-DK z&77-lP<_pgcA5yv`TjlF{|e*MZ0y(h_WnN2<$t^j=;yX@hzZ}m#Y|`C1@D@enf*5U zaJ;zA^AA~!#q0r!yXGaO8j!FAR@in*NMXGfV4eabS7G$p5MdI}m9FRMBTC9fwj#{; zH||=7Bpn?WFa$RIUz81NQ9y$<`+Eo`jr`JvcxEfWmIS$$T9cGG8GB<9oap zSYZTimwTd|A2w(fMn}AU_0`+0)9rRK$7+3Av>Q%~psZk#XrQdTJwRr;%ju4four=r z@!M7WHwY&$)j+z#mh*CFl+rBx+^8=;cOm8%{9bQVmekjEG(eZ>@<+r{)}?*<1r-gg z8*;fn>11xs7k1i;?=XS_tTEk~+9UXLv$K6meyNvl2m)VgboHt&B2IX3e!DyWb{1~{ z_%)(aFJPoaCD{-|sD^>AJ=q5W(SqLlyCyUdURys=b>roSPwxQuL1MU7XQib{yY$~{ z2OyAI>Bq`1E~XMKa2#WmBQSvEyfe)O>SD1IS*0XkcM5cTQ#0^VHg>|t7)dlp348VNWBqfQWl-9dq)25-rNJC^>O!J+_Bv^YH@c5kGc5J@ z=Xu8U7!0c2(um;#*Wgn^Buz(=<;4rui|vnrIUD@L)&roPYw--IMq+j4JN^J|2UWxO zxJ?=f)BSpt0*BLOEMMk{-V*IsE2QEXeX~RMyA|h7LLI;d-&~yJq`d?zqX_#` zV>{dPt-(OAqrIkWNKO2j7U>s?5D*@$qQLR?GY{( zmb)%aF5ZTvI;{kn3}-7Jy#UI=A416KCKK`@JSYc}x zwG|Z5rRLb405x~jX%DSx(~97f9vlzrgO-6=6CQOcWUqtZfr`gaX&o<8P{B3I2P!PI z)T0SyT6PUDSmUY=%=k`!}PGLy$x4fj&N_ z27S2N^qtlC788i`X?%TQDvSqM0^Q_TJM8Z2%bg7M^)U;MocEFp;fE5~25cYN;U9eh z0&p(-vdG#{W@hIKxqi|7mkIL69%cx`E^={Y0D!c;Sf@~455G)aQq5&_q<}Q6w5%+d zCnI7S&+SEJ3-deg00adgnd&b)(@D49VBYt_IV03Y7VQIANX2^Pt0=}Fi*EZR8{Lgz zuvO@Eh+S$_ko(fwHnu&`*cEePIGwLO#OJq8evPq#mds%Yx%jpV{SaHcGeyJ*TXSCq zfe^iS@R1T|b2_jFQbb^Wtjp&=s`7aiufdwDex9oj3@5%GUS5uKU zib~refax?;oMq`TD?&)Fw6*l(ZqB1mV>x!^bN~Cg+;NSz*;%;RS%R9mpFbVx zEtq?r8mX-fgd`?5oN2ZSsHWD{vA|WzlsGgGY_o38O-{!AXn=_opQ#rvYqegKSFc!8 zAr`0~u78_SC)F>+btB<*n`piw33t znEAgG;KOgALn+Z8mpqa!3j{1zl0tU#7FRcY(^Shy*xAQx#@|bXd#lB-^pMQf#8*8` zsj2fcYMrdN6cpg?5sei?4i4sSTX%EW5(He4>>bYcu-)RYy4eEab@%`=iHx-0|@0c#?1O6X@BO|Oc8k+50T{v8+skZio-5jlEUw0fCJ`5(o z0SInQgF>p$GXN`n3LGAOfUenyMH?$DI+Lu3+~M&_aN~P==nhz7IPlt;*sr_NW&ObiRPJK8W{u zW*n2^hVzPoqe5G(l?Eo}=zm?beiY&6#!*4lqoJYUw-v?r``%t9R0CTV7qvsn-MsO{ z_N8`29g@4g4i>F0*y_p(KTzNuQ;JzR*vepp=^870oh@rV!C6rEJ7a!@u&@H_2*ipA z>ha@nrw+r#@~oRzrg;_a$B19Lz58}hCVygE@9Jn8Q)3jhg*h^sS0HMWRQ%KhZSTpl z++vbI)VCmRH#Gdh?n@$pXJ|vEvZ+~uCEHvOt zr6&E(#23E<1T3i31D0K6N@C#eRGf<3a<|7Rb;%2;z+p%WE43$$42M22Vo3{Qy?ToW zruh4`w#esxqkvcY629&Y&_xG6a-XxakplyWiHX^)7i3Sec`yS40uYdqL1Lm9cva+N zU}fgVZ_@PHFwa}1-BNZ@Lap_6g1`au-FA;~B42jj{nK!KzvxVnjSnUULsr-8y{XGd z0uoB_G6@Y(t&f$Y8U%E*B=q+z&k|g&MG=JUp++%#N)fP#EHr;MtV0to-7`FmAP#9t zw$%E}Kb>8}=ZxBMVmFTAbjC_EtOIrl&rsim1De56QoiM;9cvug+;Z|fLCc;EK}RY! z@3mra1mW7&ZhU!N@1I&8Wx;+`R-JX6b21GpyxiW8aKy=4tVSEK$!LNG{pIgHA5ME5 z1uk6Vm6fCeMKm>vB9#9+uBU|n+1>;oW3rU;pcr+U@Il3Vrp7|ek307t(erH`*=w4v zi=rap`PQmZ^8$~UCODFJ|317J=LD&Gp3y0#~;PFRAnBrfCR zK}R&du&~1)aeaPaZg{RA`g+g}N02`_gC_u^S)mHx=!D`SFpUw z>CKM@!JEyIc%HOkbUF9w3-9!Ak5Kh|uALnVK=*u65C8gV4?Z#_O=jyfPF-ME1LaVV z1P5o6hxDF^>-ZMk9(fGaQ*-^tSpU;1n^v;LTUdsWwh_wPlX=KwU;$G}u^qAo!qpBx zf=Vn70>9g*;!iEgAFki$(K*=kL@KT;zS9alBx)TmM3r#HNp|n?+nFeVM>=z;aG9qe zUbw8v@SJ65(6h40E3GTOM?wo9!pDD^Bm)^Z-E1r)gm5}`INV98%C6@Q`{=&nrlL*m zsY`1XJ%Vyu^>ZCk|A+@HFT@okcXUh6)Z&VFcZ9o<0_8 z@(eHES_`dC@%gOTfz!5GBS`6Y&yPOg9r6M+=eAsc{`hs862{)M8gtPy_b2m(qZ_;9 zn8?Sj_@p{Qp9S5KR(sH=Ok2(REl~*Bh7_(BO&)`gsi4fezM5$5&lC1|d#wdtKfd?Z zh^THYhcB`7ei`)VluoB@OLV=z=g;(iI4VeQ9SuYwC~TQ3)z)O87#u5Wnt%p08yfT{ zCLcfEjxBFB^gicaMu70=u>?d^xHx7C*A%@NN<)KpvF^GdKz>0WNk{#w{AnYcbaJa3 zCijfqQjNumG$P_Ds&n)kup^dw|Arh;Rp->~hE}3*MVcRTi|6UN z&tqnBCce;-Prb_&lE?W#(M;td7Odk=7^bI9`_c&6M6=Om-iqS_=Eg=|`+N;n%N&H; zKZdnew?Ko93QEPm@}y*E*^04c=0c4)cqTOJ-rA?ik6SLKY`1@UCd7g7;sqi`#tR0f z_AA-t>AjW&e|mm*36I;|M+zD~kuNf!S}yrkpbWwc>CCcqI#;HcX~<`NR^9#15>>aq z)JdcUKBkw|Y)j-|z6I_;YG;`mVbL9(s%`>HQa1R>ePDN`i5mIpoksD)1<8+|)aTQk z?vkMcBJb^elPD>ffK3Y)b{eCKMlNARE`X4aAD38{t#S#HGppGbH%wE*X0;-x@vd6{KFFyQVF}CIlkkuy`7?^T|b=EL& zoUv2hk_+0IqN7Ff5R%vtZ@+!DAy|z&Y#$tziCCT2E6Ba&I9`JEYboMrvGnF}xdLvM zApW5wKmUc2_vfytNN$4;N1XMpG9@*!05S;}!6&m+NoXhpeu(VAPLM@7+hm*H@nqlM zj(uxw#M=3FSs*f;6{)ZxDH8J8WL0cRtX$@lIs|(*^@=EW%Ie^kath%ut`B>}5bmzh zC6m00=|vfnaEVlJ*=8MKk6)w>^Q2Um<>-PD%#zven`0yUOTAYpgXlu%=-7C;dh0G} z1>5>RZp_MSE4rW|PZE$Hqln3{N>bzXvuNoRS%VVHz^Y5Wtt1~F=6EEswQPOZmiU%M z*Hg|u`4MDb2ZGkpN6i$)7#Rp4`i!{2-e7$LSbZg|55~rZi{GVjZIiE>)6*lpqIlK- z;;{n&hB{+4Xnh#YeR|yE+}_zh`O+S%caiDcanN;Hwa% zYOwkvuiSZ?DA4J>+uFuK;L?+zwHlMSm~xTVb<9v{kH<*C$GLUYYga9Pf$N>qk5X&m z)$hYg#&IKSMsX|o3@wWEo()sT5mP|FCF5tj6cGpvPt}`wpXSp!@oVc?NKGRASMBXI zD*js$g_$nh3D9cI=fZ7ny1zR;5>8c}mc4gQ5Uaky_YCa;kZ*yTgJrsm&vljkhR@e; z+82#nb6lS5S#<6qAN?2>_R5LV4|(-q5W+5h+%QwV5S#MEdp_YvJ9DacfuKn>?N2P0(erN)vJKSCX;1!4E?W6oSc zL06hn-GG@8-}P=A;m)+jU(bErF1cAZg`w(79^>%id=lpsXzHG4^)L*)RZ1NuMCP&` z9jO|h*+1G?gCTl`${U{Z@T>oa%j3Opr#WLG_r>?~W=>T;_)8k(R9zer3t4PXXb8X_@e@g&(+s38~lHlD0g!)jWGe&v0pD5E2}W zK}wpz_)twfFksQzJ~{XPc4ut|)k0d+Kcx{nxwgLgNM0@Q>N^25TIxumW+a?CPBO8; z*Bz6+RAMV_w)|Top1DTfX{U5U3tai-@E4i#{h?U+rkT)XorhgtVa2fS>W?@y%r!<> z9B_%pVzByl(CsO7*={U?92Ur(=+jLrcfumbWo(Dbpq*qkQ|y(wZ*EM3jN? z!43vXZH~5a&0fE~9;;z;E~dOk)yt!}2pfB2>e*dA8M20D-_H87#utH7(p1*G>8W~U zUB^qNypJ>o!EHHBP1svMe$F#N#m1`O3poeteU&i}oY-51xG3tS#NHSxIhqy5LnQ~LE1HnqxR#^jFWi1YyB}QeO1|Rix-Hf4DMefg zuIGl^-JCSFwZK z9309G5PMZ!XV1k7!@Q)QhX~3+_ZiU7Zzb|wJWc)E3LC}AS(WxM)uu>ob=$$p+<1!s6SB6q%!@>s9z*`H#^;Vb)>ODeD4U7YdtCCI0D}6w3`tdl$+crIp*y@q|r8DYCasYd^j9K*^?f=&WSKP?k`tX&Ml<4}_ zSlxzIVchL|n3(8tPUvbshYF}{GnvPEU7vdnrPo1&u>&!>6{!*6p@I2+{Sf}>U?_1Avx zrd~`%C!hGPk14{{r!cK)^>V6IvF?e*Y4E*V9hQ++-7hC9(ap1ds-|7aQR`*M(DF&4 zekjHCCZ=H?{b(7dj*Bzi^~IGBPL}|u9NO53p!Q*Hvah*YO5wK~yPnCVn&Q7$d>pY* z$nMO{Ly~fG&kaLgLip=~m(X|iaPB^O8A`Y2meSs9%B64V&M>9PfB2(9#37~kaQBOz z4qo$gM^Xl>fOhO_O5>&8DU+j!(YtPTlg0=?q3Z#}1N5vT1?-+w7^2PSv zPF5V+Zq_iQ@GvW#C!X{h?5q6 zn~Wy>Cb1^4Gu~gA8Cu)DFuiflwuA>dbD@Ev$*5<9f~w{{;rrGpo-0Js z(&9Fr@P(V*^GI1=-IvtgRyurqctwj;d5G+kkU}z(Q)b%V9zV0x*AHM`bmulkqW(&g z8qxlhR~lvgO(eeSb>}caX-y1-v0^SduQr~+Nx*Bx>iKvsW_k?dMbq@E>%Fv;NkDWvd)qmBL;k|LD{Qq^B9*gi3 zVU);3OgyCnxU8L=4-3agOrf34%)y1P_gr4aV$oh?Xo;YUPfd&Om61bNfflyv@nYjP z67zE_7#;Cqi zeA3fJ*0x;4e}-IzLE0Emj4RE`yTRPlU`}Tc9bTZFl&Cm09B`cB-qTKxDyY_SF=`on z8~-_6r`h)Sr<$I{x+GggaE1Obe7#wlhvU_uU(XWplDW(7A<-*}g)AeS?R>_Ical4| z5I=7sF+usX@D-HSWDLCuGc z7L@Vi{agIkL-1cU8BB;aWm=M(Rjbt9u4>TfR{ffv;m&Hul?XI6-JiugMHW>VZL4c- z`bAKy_%ceLTQ;NFjo!!TlGdi^XO}>+pSMZuC;#bH|L4gK&Iralm$VgU$MOq)Q8Cut zUp3=K1d|xzjfd6-s1J6$`fqMq_{b#RO4BHHFCp36ZK*pDwcZmu9Dl?}{Q7DYS?bf- z%D6W?EdrvjGnBX6u2r$-WraPQ*O4ySdJOmD8#W9qQyY@<9dvL1Z}D|WF-+9F4lj|_ zyK|OTq7q(SbMf%)E`ATY9>o<1yBNzdl!=w z9k9V$C;Uny7Cx)`D_o`d-PyRgDRh$OlkaOjc{<%wHASrUYbe2#yCpASynihT)9nT? z`BqhIkAH3EzWO5+*xb8-bJ2skckk<9)pYm}IZ|hLX%aX+IWgUwaev0nc3ip{tn|T+ zo=1+tB*W{LT5$Mx@kg?MXCPo!b&S0~ty&^gKxBA^pGI)q+?K%;`IZfSoXk<&Aj#;+ ziaPsc*#8HIKF;ELj=ItZh&GrhD>!DaLZAH*!iO0HYFGo9P$uwO1++1e(aO<*oJcx;ktDBDS?VL~w zR5sUE!kRil-KO(7(-U#fTuQ?!iwi2bx=OdxlfzTt zi-k@vGP9o#GO0zII9yL8+W|aSxr-wgg6kY{($4i*mJ5BIu7ulD&IjZY_Mtxz@2&2aq+pY%{PC6v^aLcf1A`F&2NWZkla zi7A)n2=r68&MZxbiblH;=?NS0!l7pr(jckB;@5&tEs!WYUHkeZdjcoZri!gMe#a#? zrV&l*o@I>%w?-B3L&`K9&JU0GZqo$qY*k1#hE?^C?Mwu&-rvq=9Z3ESddE!xO1RcP z)U=N@e*f=?93rT8hy&Y~tu&jtJlVa`0fpfS>vrDZY|A6c3A^AL6-B+pm8}!r8E+8zA zHaWSFZP13W)_U=`hoxf2E6`g?2h1spsp9tEX1!MPTo;6_D9lHsAQ50Wv#%^){U*=4 zKXpq-phI==Vybb-H~L_gvHZFYb!Vvg zmzc!Bu0%%pt?RpW^HXxZJ075HlwW&pLP7Vz1@numDu*t)fxJXMFg($JT)!tWHg#;z-S+$>FkTlb zQ;zgaYda;T`xQ2leU+vsNut8v*1kyk{j-&yO%D$wiXsHR%@=&wPD(#`szQhX+Dwj@n*UG1lBIt*K z-tkA~VX5~|3D)jU6Phf)T&uBom`5gfp^ipzfiaij`#cwYx(>9vJ3X%#fmiX?yIPuM zLW|uO`TMfmVcnVH$bh+(PiQs0CjR(vUvAK~T-H@Gkh=d|&(JD_*F_-xJv``@0p`i% zc-88jtBbJzk>^v+g@P#6fAqgte1js)vQ5sD_(2zKrl4warLQW!ZJ?*s6|3*XKOXu& z){c)w+;0FM6O+=ES8~@h5Md9aJ{w`Y=V2!6xmZ{CEO>iL@1=`G(qLL~3s9;(C+h~p zj}c{6WA7&8f7}`p=HgXcu&2acU<0IaEKon6LTzbjnQ46_2Oav6EC~$IcmASMq)N%c zf}Wa63R?G-5Lh~HZVBVh<~ccKa~wlM%sXS^X-N--RwB68@&DiUY7%#s4v%Qx9~Kl# zbF5vk$^^j>Qu+c>AEF(PH%+GaZFKv;%%y2`Xa@%ex3aAQ0|;QuQ3wfObloxtl}*y}t@#&92ah9rd9(w7%=#k^&Q_t&dtD&TL`bFg z;VIzNkW-j}QLRW}0HQwejWk17SHN#LI_Qs1upqOPq479uF`|(Pg#r*;$K<5czS|&4 zn){Ik5I$2wOq`mUy4hp!G)uHrgfd#s~ce*^&aF5=2UD|VYCITU7?TVcx;SOId|HU2GG`?%D&=fAU z!$@t%x>D^gud`@Ya^F@dlQbS!L#`Y`lQ?3uzPPVWI~8H#xWRJECWgJH>?SxmXdHe( z$CHGbKssJMl6VNUB5LXUNqwDOT@QQfV%g=BqFK#cUQwfl8{*Rp$tteKkHv{yakY2& z;W-WUR_10L3>9P~=czTa-`wIT+%yruIqljt|m1R&UTw{0X z!wu83hj-=rgWi(~4HGOEy+X`)T(JIkN~NfwvSyAgmV5&Q)HVl9JfI?PMJ{fe>h zg`}-^4kz3@m&%oSNT*b`&PWgaY|ZAoPln-|nGFm;-;F|$y3e-ckb#>a#vsKg8m<^i zpm{r5@`m>kaWV<$V?fpXAc<9p<(xyoyEp+1C0=U6)n*Wk#I#cnM1>$rxg~iWR zrsDI}Rp!SRQ4qVJUlD%s3Tyzd(Z4-8z%gt)z17l6j%|OVp^*srmK5X80r)U8W5
    cmujro=NuSvRoNB04%Ys&IrsB9V<1&=1lY^p9!i#1>0h4;Q`eORMUIsk~` z-Lv(T+0}Pz$(EHgMn3NtNF8+~XD9w0tVqN9hZL16IsKk;jL)ACygozYR|AM(YqSsi zL-S^ks*NC#XKSHU+kK=&lN|2Tq0h)jQk=|SG9-zp)@ff=N+%QV9*j^@)iaIbBRV5g zS z6}%BRvin==EK+;={|~?ah-Pgmv3eP(>Hv_A3dH-ReetqfOt7U^FtxQ@^iTbr@l-bA zdSTLYHp6b^$AF@zukipWP#G3oJvi_OkV}h?6H#zN>tn!UTFNcjru<=`S2B;y-J%{{ z^Zompq)*08S2bQk1X<%_V|z_uQ;x@QUGD_MODpfX0;<-8?(T|a&N}1s)3=+Ozm7#4 z*y2_*O3^DNq=g&{dq*(dU!2m+50Dy@{ma3o7SWb^a#mDVU%Ws8!+pemfU6(#`{=9o zhcZbtMy>`>)6USfu*R10-?SZB4-F^|b^GQ>dzS#j0i*w`S6ii)CV)&o(;3!0V3mD0u| zaee*QqSDK@RzzHuQ1{;AA$~w}UHuBXYxNf8?e=hCjR(IIHTS%|)RCg1;o(&ES`k~t z7d$KzCNWD#s*Z>Q2^_@OF1QjC71K4`?Eg?cQ~UJkvzHoFp$r|+SnY)UE#!cR)m_9` z%?PPLsPw#L_PbQMK6;(N=Oh*OmelKG;&+`oYr(bkOGE^7N7HRQ;sFY}T^O%l#tb}J z^zp!pBE)xvhRC@ExNT4$RA?S;1{jijudmk|OtM+o-w&FZ!57`qIlBF|t-F$FlG;62 zBOJM-maXN*%ljF@D?Y~Z+rICDCGl+IlRCi@fBQ~A*cW5_QYKt_hhYhAs)cq0AyC>{ z3goS0QJK`2xb~}pj!1T7&t0e#LtNV0=)NC&EG-@#!UNwE9<`oV;3h2Qo&xqnH;N*!RhBi@Hs zo>6>NiyKd?{1bQBtKe7WLZa*U57i5G*xEXYcFVEsf=T!5BQu-Z==-d21O#q@@+aJ$xR(Mvl#H&B)%T zHuIpx8ie-+qY{lP#l6g zow4aSLNb(BTEnx|hF_sF`p-@rIe5K|RIE+MOfSk_h=at`Ll7p`<@1nC5Hf+u&mhL%kPvoejMn3UJ_vc7SG>cb#K8BL0tjgpp@h>ny+ zudP1C%y5T4?P8&)(is9z2}D zvFK+f%XtI^eR@xT*zo0PrOn247Tq;-rmm9d+dparfD^`QGDI9o%==7VUw@{_Rk{R6 zY6nkSMnPE z8h1ieS}QNBni;D&h{7kvIO^7ZFn#u=_>2Hk86|k}C~$nKbsWII>YF}%>*GG;x_$07 z{E!OtQ$A&7QGREYih(Eo+28+c@Jlge>*hu+&5=lUuXJc50^m|gZYAn_Gp+9BAXBtr zFXD*5A%?;F$-=_mlPseFVAc2<{BKEh%-^gGk?6?^4`SHSQMMtl!Z<~=1>*?A+!slI z`7*sykhfL9u;uY4T@_(uS{bta1AIp))F0pY$A_s1kjWM`J>A^g1X?98y~pubx=qb} zOTQ$G^O`{j2soiFi14TO=+G7_)m%SJ_+Y#DlD|RYGDZI|<=D0RM<&#Esrl~Ft z!`|V){lb4-h13J~^GTx{Cp3_+^&b{`KOILU+xlZ*x<+Nw{t0E9&^-#u3zwdMd$@lM zz0L14l=#-=sTmTsX*tj+2FeyR58PIB+0Ngib9Idan5uV2;4BXg+PfB!kzh4k zyq5cZACEglie*pijOoPz_%R!*5P`E>HAS89*FrK~&R2tu6;$5dxgT)P@>}SpOFw%& zD274Bcoo{MYW?rWi0{*gfKNo^S5#DVvTG7-r1kFIGmgtspHH8rBGb~)fnrlxU~Kj- z9#=X8Y(PzrO?+vg>h@WH1BHB48^6n4`n%@L4uJ}$Nfr7H)Ea~xQhRYLJgwie{*TEl z;DLajnDDA@$*=3jw>l))vJXsq#IU-LP^MFvRw)tVGw?guW5{=N)iwv?AN4l4+1UKb zp()$?Cf9uum1JqwHrCLfQ@(%6B^e3r>t3p$S+phk=TKH!o-1f*AQBJ|*q$1MeqNIn zx*xMy@&}|Q%n8w0J@67iaz?yZq`{z|;yzSF0#m%J#(!7O3D&^`1~8&(T*1rW(zCWk z0}XcDyBj;nw++va!ze_YKqcVd@G!l+9D{-~i=2i=RcDAHwC(;)TgSq)?!jbaT2Lin z7KDg+S)(n~jnnV~l zjCRP6y*;hFWT*OuKAGPOpIIU;fZxS>;GAfzf2Y8KoRb<;RVTQVMs?=X>W;@8(v{7! zdP(tfBa8xm`+mlT7yd3AUnnu$A5dOB>6q{8M9%%GL$%Qq#sZbdt+e{1T|4rttp;;r z^;&Q)UsmqWQw9s7nMSAPui9k;f#u8JAa%Y+_kpi1H{Rk4I1}Do-Xgu|9Vk#E{>b{z zCz{TM0227uZf<;z$D}Y_UDO>-b=BF}j7&^$nE8b^Z^wt{x{<;U4aekh-Z&-pWb0~Y z4tTP|gnp8OeP3`4uXN*>b;FfrX-!_jprZN+b#{y9SqrZ2w0K7$G{7GEb?QNy@K$dh? zueqKsrLT~FEn^1zXF*ZtFQ*`|_MC#4u783bFvs{D5dja0e`ja+98^sZEiEl!cjJ?j zQCjcLzI~n&bJ}^cy1g9~mFYo9y?cE*t=rN1c=V}2DbF`1CQDtnW)E*dj}ZeSVFmd- zQ+&81{q>Ws!Sj`N(Q>y-7K0__)sni0Cy9xPtd=thJf0AgWMOa84})$fASOivre%IR zeqJv?_+0dUE@xTW?m*}M~mE0O}<2jrL}M2|IF z?!ags*U8Tv?bDLqn~+N&iax=i1t$m}=DSrg*KyQ3En@lAT30h97>WF-v$+? zLtQuCy51v%LULbpc1#P4lg754e5?DMy)VvvzQ29iWOdEC<{aZ4FD}GC)2xk5_+|?{SG5tu)|0eD`4%R! zlk0^Bj)!~&na?diP?+vH30O|)WYVWRk5)Goyr(>Q-dD(EXQQK|OSmY4c4b&`iHY|L z;56I~erDnQNRX#M2(;v{T%d>rFfmfAd_620xvv6%YZ`8p8*e0U!`%tK((r!aXK0tt}9A^a{U*k%ir z3~hJIsN^3<3^#iMrGCo?op)$6j_~vI+qpsMdAYH~d5l7UJODDYE~;#Le+)XWzT}$~ zKjaL7R}{^b8_y<}Q(V5c2YUK<@Zs(y8FCfTfzl3>bl!U!K+ySPWD}Fo19N0_R5eL$ zoSwD~LUvCca|utw`-iGrN9&kkIz7W~nsNu}=Kk3(T=bYHA>!GKc8o27B3;Z-uEK8} zaw$sE=AvJp$%i_&OE$&(CVXP!i7&6(p^ARxdge?_vQ!(+HO9oIfxb%01$++I3$FUD zO&^l-kRk|794s>P?QcI1$N5fMiXnU1%L?%qMHxJq?ScMOcWm!@wy zjwguS)$YeyZsRENME)Y=pOU@nz}Z380#e-^jM{X1D??*h#)K-Sjh|h+szwC|jHh6q z5Qu_Jm%(C6))=2~!o|wt)1h7-4p)Yj)_egnT~~>DbT1|wcZIo3dls}!(>q8sOenNm zv+UJ)#(f+Y75ejh5MaYR|b7{8tU7l2gwx@D zWl)^(eF(Y9VKsW_fl##OemWSO{hk7TfAnn*LMU$2wu*2M2+*O%4aBc2>n+x?mCMV zC1fGDrO!g|1rP^E0))~vYBI~t%7V*DVmlG30vf9M&R9qav!>6gR#16+goUSg>Y)@U z`oS)?frJOV3Z$n=L}S@CyFb{7knYhabGB$bcg*A z1U)I(CD*?6c^i_m{j-gz?+NGOBpF>=3L<$&=r|PoptVm41kr91*U=#PF zhyRn3yv)y9%T`*>!hrFGsxyF0k~SXIByOcjUl3l$2&rJs46s^cYhP*nvZDmUGeR*g zAOpC38JL=eEz3#>`6_^dfd#_U&BoM04%4Se3&Nl*IjncQ3cWk0(vFM-sNU6IFKkx(@n=kcV}SrOwjHaZ-j@LLmn*VS{+?STzV;2kp0B{LyoQs=W0-|}MQMZ~^4n`^3{%4NPBUGRDCd>G-DjPWW6oF|kXAetJ}&Q^_B zPSIbVVQ^RZWq|M>;cI{3=R+io z#co5?nB>!DeRjFO6DKNd%ynNUQAV>TVBugF7g>Otj`69KX61+*Rt#F8^o*zd*ZPm$ zxNB{(Rf@qMlZuKipSKK+I{)_$cM=1{BjD?_f<|={A+RN|2|-x%$+I0dH-mGh)^+FNR_e1x`+P!{fU0hy1qChie zNKHuz6QT-3cz^tgX8|=pYkJ8;%IvrYf3R4SFGHV1@22wgS}&1cb$o1eTm(+i_`+-% zzdq{hky*vc*unyG_dxp-OClMLjgbZ8&j|a@q_x}U5=}`hPLbl_0oQxpwaG%Ks|$fy z*mAE9A;r-_z4P^gGHMp}$`m!ifEz8D*Cp$RJ3_2Nq^;(zou``)T- z27gDyjU)1m81%yoF*7-yVa$~7hgo9f!w$jh-Z9x&c&qW6y_A@Z*n2?iAX3;^Kv1GfMy9irP3s;7Pt!*FkXG8FIS+e9Q{-M z3o#&9*VQ4D{4QiXD2##@Qkij2CfC>32mG{{Pu9IZY;9FasSyqRu~+et^YDH!3``+< zygy!Ptor3haRNw(H-=)#?0}F7H(ud_gM;i(zX30w+>j|w+Y#u*vY85*A$5iYXI`Xn z2e?VApKhZZG6nj~PpZ7pAdn%e04EwbAi}5`=ITpwD&sXoa(24EFr5}1LTSmkcv0_T z^AgY^)&UW`5CW4X>gBV!S$ODUWE3=nkdf)63q1Kks1#;t9p~A%SHGotD=dVd$;fBX z7V z13_XQ^@c<-qnH+qh1!UYF18=wkR__Jnlt&^AhbL5mqftvs;X{3Ec}U~;TH-i<*mDx zdpyu%buXIrb99eBBCu3y%IQ8nd%NU^;+HHlSi&FyxX>H2aW0kFBi?Gy+G(${)0*uC z)(_FqV&W_o^_Afn+)JGHM2|F)`4fD&S6&)v=(hhX<~@pUc^f)_&_ZZ^Z>|eSh~Q!t z`4Nwos?o<0nxsKpE71~dbTvtftmp-*q2ycp*N4o^28#Zr!E%AICu0nnT@w^+1@5%r zcp<)D5&gjrx)T_j7$&U$C}B6If9I^DN6762>?2U@HR34f>E*QM+?pNt8FMxW)YX&n z5FUkfTJJck8ycpXqk{{#<}0)TgLVbgHx1RPq%s;xF~T^a#!7yuyoTg4)mFI5=&YwE z9m^BBK4C*fS<1A8w0bi-sh*E0PP63>Qgs`j`oPd zR$Gm#AJBSB2#@%Q(eBbF2JxtOU>=dj<&>KGFDad@ z-W(cs=>W3VR->D>-~NL_sA-~T!s7ZfvXf&g1HV2wSsEEZdR45@APD28qN5KipJkn@f;;M^k&wE8^KbmoH^@&&>;{vIpcO4B`WQm~gtcqr z6!TjhE9iSe<;MILA|wVrq$Hzi&%so_;c8ROR=dg3Y^lf#KpGDFvhs-pJKz*0XTK{( zK$N&{ojF$UDV8eNGntUIiuXz5jN7bk=zj3)AdO=dT1Q-I#!;i$3XC9naEQa(6oRk2 z1$)cfzSdvh*DS8@pQuS6kk?$Y<+i@t8}SZ`7ppG(bh^-RfuhW7**g>KC*0I}C#^ zN73J{Psidkm(M-G2Yz0yGa)1vUWSb}x zF29%`WDhgRj$q%;q{_iA%MvD;1@+8wBZ@qU+v54`af`jGat7ZHyQ zB;X_^F81>F^_3P|(2-EWMi2Byrw;5T3l?%T@wYRp(bvEq6AmCd6y4rOe-c#dk*X7V@Cj!O%=;}DwF3*^*w zudG1n!u0!aw`}L^*XHCHIISgs0E1N^S~aL+d9;rW$iV7jrMwFG7F}ES6Z6&@KSNC5 z(VTLD*woZ?@vCfGus-r*;ln319D8Uv=4^UwhsiIng+R+&vqQ9oY{qIfIms&-g+E`x zv0wy_?F)tJ8xxadcW47X`IJU(p3B}HBLV@zeovy7{1XzqZU8TJ?4_D9uAo8Uixg!ZGH*p_w$q$e$HMxZ%xFp4MMR+dhC)`BfAek$#$CHqUu(BZ}jWr%ve5A)N|s^(p*fa6xn zU!OQ|{rHfe5RZ~7EC2CGAmDX!^cG3;D!38G{eFXzCMEbCs$f5Q`#_}7SjrZK`4{Xu zLZn=TFQ`93H&UnnMK61^Pp*O8{DUx?AQfH+mxpG2m92`DOiSg$ji{(7cTBKei2WA; zpJk>7Ee|Y$r-EQFwazOUl6#oL6z^@Ix(Z|KoTa$(EH2F;t)K2J5;I=4?TOx*5fAc! zcgUlOjTVv*ts^|>s+A9i>VP1U=38*MztGZN zja^c9DETOTS<}N1Rj`}vAa9D0KboSmPY7!D+Z_o`&-m}3h3(5DL<}X*PhCCipG99` zWx$#?rcAyZ*_m_5QXC8l^7|aA-)2vm^8EBBV9r1-6D}UUiHWpn^6AM%n6Z+`9OF6&_D}#cfMMZJYJsWmlvF$oe4_J9UZ1X zw0y$BRXdCR8(a8~NtZ7aJczL|*#$67qB!^-NSv7_QX`KJmv_NE_Fn)e|{tTKW%2w)%^;Una7dqOYU+dk%;z+a zhyZ*H%o0!g;_4O)*I%9)+!~FHOiI#mqpvA^lBIxCERzd77nqX>>mjPDtHUyg8l#~B zM&o`veRKvkrXV(1zmYqPKY}}-!!<Xx2&Dh`W)R{qBX(gSU;}(uHsEEJ6(lq8_QtT@;gxA!iU2`< z-wVyA4&J78%LB)>m~lq3v9$#c9EkU<-y;8W>-h%bgSbO|CB8aRx8YcA_Jm?pJZAdH z4{;Ks)0&tLRw@mnXh(-6CqE9b9-(cDC`BQjK-p^R(04N1lul@(Dvz1x?vTl7Xx509MT9AVLO zz`#+HCHqflw?QICOkO~x(}Mnr{7dIG0Sr`2)avBRErx~W*5M(H^$KJcz|*@u>mC}y z{(b`uMoLZt80Nwa<=R-s1FlSf9joSVd4M3P(`3-ZBzF}>l!HO`lD0@uF8K6u31M6t zLIfBm{+Z}4z-Q{Lb|b*tFAl{6@E1~3m_FKJI0e<;bFzQ$^_*N%p=HM>iyi{OCcG_U z(p(iOrdcOhbaFiF=nPiiPbNf@v%ogSrobG=TZ4G+pDB|6KHM*Ib91})+P@1ABm)1j zE=6dk(`5)y&}E@k}#81BjR(JAx}p?0d}?RU7f~*hyZz%r2bpy+mmIr_d5ue%Snod zH-7{j=3WxB&7O|%lk?#e*45>YqK*Q6m3Q??0(rEM8@zu{1}R?&2mC|#{{W_cpQJ)$ zB0hdj8IjOfsPPJdtlNdwSBH{XPGuufmQl(q4HV--(+BwpAzI&d&@NsMjpZI-#NhA! zlcwaJa1cM8g#!E6Njyh9920wM4Nf|LTSUBBK3$y|2>Ez;y|0hSj;39ax`BXj7MZ5O zoSwD;;^VPy&eq_1dy{*2N9|h>uZ8B1Cv(BtUhjDA_QpQ@|6=0h#ry4UTY7MP+PFTw z$a&fcaKX&VdjtP|;D0C;7X~EEE>Aj!gz1qsxjl`xs%biBx*AHHDnBgqrN7K>us*%# zCpesjY3gy&g+tSGJPd9?R~Kb>lk6e_iz9>wdU<&%^sY?H$QS@*Lj7U-6TgN$036YH z)rbzr+L|^pE*_qdYuuSIAP)>+-lASyUIq;fNkJnCYRbRlz<~f53~PXsZ7>|SklWNGX4QGoBWrhU$^-a}b<goT z3WEGdroS?1%HjvsIoCaYRMPAN!`gpd%U^dx7no1iUw4SWkjcI%tKC^LoQ4{W?0UVrTNR&;6z}|0fZ<-iN z0`mhvWZz{RJv_u}J`p#+4MpVP)3?NFcQA>PJJ)2jG{|f`VC*-^3*>m|w0~Dq^#(Xw zhHEVdF)_qs@{Usl3DdxI^Fzf%Iww&*HNWBIR`Mi~A-$LwKGtqh66R!iY83!`>*(s{ z=Hv)1&o;Gr@#6@*zc?y=1^(tA;<b#71ylro0mfmn|L%*qnY*`xp*u z+a2c?FMc;LS0=4Mg@u&ElzXtLIcw=BfOEcDnc#}yw#7UtSg7z`E0r}t_rG40kf>24-6r)+-8 zbL?QX`6HY45g1e3g0G`g*=0ze}y(((LEntyXLl)qf?G+`EyN~~g_0JTI(O3VjDZ25(iSy^PCvVtLiPPGJB%-T5twvuXG zRfs4C(^ZJwyOn>wFMiN3Qa<$h9iX#14RZ0h&K`!Fb5-C z87#<_i8-Q<2XmW6sa|CO-qk_Shg2m~6O+8?*51iHaaqFwS*7oVVp`S^*dRwIOO6Lf z6Hu>n&Q?wZM5XP?$;q-R%FPAP0}v2D&D50v8l=K8`@&K!Q7~{Zz?2^xrvvv{ls1X; zpezJ9@QH&E@VclDH8-}H45#y?-~YB$^BiC3tPc@#!HeD$^nQahrT>Pr*LfvyvT{d- zzPGP#+r|CA_cxfNPY6F0lzZ-^qPjX|`PYcZ$XQe`HUe?^PDp`vs84)%hxc8l%2~){ zlU&He?+(huuyGF@Ph4c6le4Nt9#%%A+Ng(A6w@ErI`QUpM=arGNjHYt7J!xaH${hw zEqs)|ipMknGP}xk9^(@z>TUpsj;{e;TR?UZ(s|ppe)I5Ye)KOf{m|~Kx1n=F&(PY2 zhS+rJ8H6W8IXAb*13Hw~lTxW5)^mP7G^v7*OG+?R!9;HlZ+NTxSSig4YYgF5?lP?m$I*Y>cO|!>Lzj zMPAbc>j5h0asRQT^>5qkiyfX&ao!74+?8Y!=dY*&k2`3&&Bdyp`ypeC3TO@v4#;K3 z$HthK?Y|bL?f6H(fJWhbgar)3kbs6K#GUu%vAEN=`Q=4h#*?!F;VL!6`!*}(;)m;y zo6!HPLcoY)^cVX+wA*3bgZWbWLn5?0?hI`{vv5QY1D7`f^uOA4Wv6y2G4kI462-z= z1zcZJ`R4~y+a;J@!VwBZrNF=H^TE;+`eH)r4(L?azyws8uB83J!RsU(u71{i-rnu< z_1~D&z%qX3_4}4T)Cgps^&Yg{=RJJNeuI{{^+dN>qZP8|qeE%h0o=q^!G)%z>b-<6}Z{iinRaB;L z$T~gCP{ki8K$!&v^;16lC{syx3nG1$l3?w2c6Akf$11fD=U`>+q1M#%!!9pPElp47 zO{*;WLEsPD*6yhoh3CgLs~HOioM-{{ZJe zT(W;3o|2-T5-bgly%CLd{E%BV7959f2zr7Tb&>rz= zrl$M)`;%!lwfJSO5n%7}!+XY^`v7b(|C$<+j9LT`L2!23<(#~W;{lazyrRDAb>9S@& z7afdTYh_s&kkrS}m81-z(eCb!xdXM^IzRpY)iM3EI6@GhFfbY{i~1Dk7#PME!j@99 zvi<;Z@J8PTTv)i9RFT-axTq*M`!YQ*7>K>AA@{3uS!?g-NK7_*9SRZ6)BP-sLayKLcN{49fWpN?K7TUIJ? z8^$6p58PHX+v|%~V{pNPCNq1j58$yfG~5=N<}G)12w#~L=6c3_SPVhR_#fNxpLgnm zQr*}X3>e-E*P3=PJ0GbU{>CFDtVF?&eY{NEdtPYN1cgUz>~;Ml;z z98N+$qHDHCLT+NPkvk*b#D%SW7w!z=1aNq#l;;29i1g$ zO9&}ThsP}W(Xb3}v6*glWHgW*etL5u484;|*l8Tzthsmz%3qINFyiP$AJGFk^}sh4~*At!RaZ{KaN^)JGom!*%%1F*-`o zGgs%qq^)o4x*Al)-a=51s|D9e_V5}CM1>B#P^lm7`+1h<87izung%~u&o${FfJ#Ud z84*ohYUKwoCH7MCm0h)nzdFMnLU>s(|2WlJ;m&EuC4@MNQs8fy)KW{n*0rRvpalqp zy(yAe;4Mt-1!7GNNQg7q?HYmXOzh@*-u(Vf1(>J%e7z z%R?S#*NE2LZtW5NPu6LJwdicoa1P(CFYFvgs&ll?Eo5g`sobUaLyeeYKIfCzWTY5`--7on!8$8JI zc%jI{mqSjwauoR#YA*$4^(sfh^26yOQaSK`;jb0T-a_U53iu31{Ga3e8%p<$@c1d~a$Y|1@*C^vMa zO+R$#_+eEdYU=ixCWEUE%5=;fErS8Ywhe7TJR9-ki~ZvubDl9>w2wOU9% z5A59r^Yn&){}vaXZb9{v=YaV-JGhksfX=gg;~U!je4ZbeWu@NxP}&VbH>7E@3}GA znH4U5|K5$04f1domV01DMMVZ{vv@%Im3W1GI>)dj5dF=eoAA@?n`_!6Pd7EsPe1qn(5pcc*r(uGo1yCB|ILw(i4F*9nJxcx`^Gte4u18oA#=cAWmPi(0swHUcQ2UPp{UewvudOu?t0 zqR)sJMf$B8uel9?z~?*RtIQhN&mZcuE{LiX`tjDzgJ0#Jloup`BSv?n?2%Aly#F!g z`)k?@nF4Ny)cGO+UR}*zU#v;WDvs_Ey6M$>8ZR=oB|^c}?Bn^D@C+|fSbfM4n12x9 zQ)E*&7X%PfZL-7S(Z@;{db*dGle0BdYmA?h7XT%ETmcUfLPRSaV6JV9y6UUbIf|~H z141N4Jw1@&;e|`jU)mlAf^N!>U=2IH(WIBkE$E(*f?Rl_xDv;^s_)Qj5o8^2`{QK5 z5Tnaej2MvgALz^Um-MVPJ5p5(>%)f?WCLi>pO6y}XJkO;Myh7(Br7N=_-#9q!ffOS z0njlb6M9uIagD~oM(2s3=;Tv(zb3hHzOXNMIHvr6|3n!_s?(Y1y zwv3?Rt(elLBj20%-xSIxQdkIpG;c;{7$7o$ux~&w&UOYi^@UOIg|*Ha?*jl}SG$~Q zJv^VgYh~$szf!zBpCgj^|IKR$YGK8reJ>j+ag<@R^rG-hO0a_7?wb#SCcg$oPM9a`pkth~6>luojlf$2R zLNB97vt0Xgz02k?5sW;;R}EKc1_qGz*0j%!SB@LS6K`|_n0sGmvI96y{Wm$%95Vz7 zKvv#gAl7B+#G5&|-H`~u3J=gIU)Ce99%EOd89g9?UzIMD#@Is6N<|=GNZFLTbWri4 z6B5mhwDP7y@iMOXT#UsOxK`r4VEe#zac2!ux`1Z*I-xH_nANCE>Ua?wPXwlc#MG3U z2D&m|r9N%M3e;vbAF~4HeT5a3RiTxNqk6I!EDQH0-G?-x5s#_Xe_vc{e0w1uYLqr+ zEoeMRGdH$`iYL_f>2NezCi&6vU12ECx>M!r^3JS_)i8u|jR=$eH42@5TQ2v+M_BLiL;#uT+( zuTBCd^PJs>+T27ph;fge{RbhM@d5KX*B|u-Zj;n4fF|vgTE(2cIcpKTj2Z8v2rhVZ8uveJ_9HFDDDwog+~^tTvoy zBP0|i)aj}?H8avUm)MB*J~FZ^-M6uM?agd8|B09I97*kQwDqK9N73>GA!rrSeBU%M zXK3~MrTe1q5f#JFJDG8aD^*E+@YLwJja$Fq#uGI7>lv#nrN;HbtLOTNkPu{1h16?& zt=5x2GsrDBh_Q0ty5o;Cwm#a|F33jvL6mI}J?B|lAIDzfo(&1Gh@`s~*E@Fs?(WC! zr)olQK3fSPkuE&7v)ZGj41YP-wbjXJ&1hy})J!h>^8qO)0x?#UP1k1^h&qcoSb(8T z7pl!9i-q-7m}kzV`n=;=J)nV#l!=xW7XYB5)9loVOS|JuS2grKVLD%aLMAahs~TMH zrOO)Z|5}qW!GH~JqSJVT2Eujx3S|X-z@U0a07yI|BV(f{FkFOhI!mYF#M_Lv71itI zl4>@>Dd=-8Vc>ZByM#(3Z%leR6blPhF1U0w5LZb|LPci(DTB+`QwMn$NTd(BG|o%uM9{YaA#^`cBI%99bKy4K3 ze}-K^tLj=#)Xzy3!%uS%hzgMBKotj)Uh--E2^IG{9ykC&Luem53UcEVdb6O#^_L-i z+951izL8Hz=G|-0dEB%7*YHR2V@H((y5Lx@NKQ>w2*PX6LRzdA!5eTFw*j(?`vesZ zsHG1Nc?%FY5=dSRcTrork6fsTJGB(ornp;nm_$Prmk@N?8^S9Zj9G)1ANyyE_-dq( zgKiwqqnGi3lfhSxmx5I-Jwrl5f*E-{De5F2Ym+x`f)GGs;o0FP2n4Ac>R)4QtH0{K zE4J0yzU<<$aq~too%r_b(Bj-96&j)u^;>HLRFvA7T5%!wo6-dYdC@O>F9or8JK?t7 z0)3C?>l(0ejw5Ij2H8-TU)nWqSTKem>74Z1y>^phe8}lgm;jsm5cfQ4rlAS{f`C!b zT4eIX!+tJKUeSap1r&*SSrMZ|W~&7n@Zp`#*b9E*hL0+?A;vl@UJI1PjDtx!#29o1 zmjDY|mCOAEnTv~e8*YhNSPx}t5^lWKMn|@5o9{D_)#~ISqZ|M6f)Gq{gZq)nboj1H zm6E}o)YpmT$*LZUtyE_%E=uDHMqSemE;R8r#$OX^`|_)yVdQ?hT)f%pP4fZLxNJ&d zrod1A*?WsUD6yo&w7S##vGhY^*GqgoPLWv2W-x-5piLKaLO6RVMtz7$hjC(kX-B

    I`nm)+k)7SG$k$2-8B|9|W&3Y$BslwZt9IMsq$%TQ4-@q27)xkkgVG9ZOuPk)&67 z)8#mdD5a|cwn{%sd_fb z&Mh#u%U&cQ`T6t%X#-TRaj5SK(+D&8xxs=oE_jHlrRp$#K=6r8uQ&|QO00Jy@DWNE zIb~4AY9%DclfD-!at9hsN&wD~X zNc*&VE&t$v- zbp`UYP$k$E=tw1&iT+}s9z=vkkfD?F9s+|~buD%^Qy+IWK6qz~B}`q?dGL`HRW1#! zIgU8*QcDN^DXX=3hd5DoR7u6O7sdk-5UnNsOh`JqYgI0MZc+;Eqw#F(UjXS!#<;1_ z@SA8AC&h-cp1Ibw-(DH6EofnypP|I*&hzpLd+j%26J9R(SLA%Lw^myE~HA zN`tP65-%yjFk9NRyvOGPZzw3Jgs+qqPo5A|3bcZggMED=Efw19sIqE;CQ+?kuaHVW zW=*h(l`J-kQPaWk7|znD^?HY=()I-UaL3GC3*bgP;7RM=R9fDqTj4R8v>VwRVTssqt|eMPrqL$^x(nn(!=9%2%-t3`e?Lp zt6zn!i|Bi3l3oGbdDgZv4ud)VQ?Y6&k-XH3Vk~m5hxM~=dPWPrHd|CtfZJa5UPUM_=R_ zA+crB?eO4)RDh?%LI`&t&Z_^LXK9)fH&j`JQau5lo0i$sUM`aqsmK9enGgdvDbvt? zYQy#nM13$o!%B_^4YY5*=>5sLD|+Z4KjqR9{_A2{BF)Q0Mtbn;%BQtHcE|UILtG%XjX}%_* zMpjm&%Q4){o;w8xohVWg@e02!FOx4smyua8&702G->9VbMKHrz~B#|EVtRl@4OS|pep96n0z?Je?eCezY(1bcg7tgc9^`1M?`b|b-W!*PHfHxrx>Y znJPm^i&gJ0sRx{0(5|d_d3o7(ttlHq%UhieQPYmSpb8M&ZjaPZ9s=qP3wDlI;6r=C zri(Nw$xGf^Qo@Uh)L;Y)gK#;>rc)t+OS^CB1SO zhNwkKR)Hm!0|y8gC-2W!S4joEn+*nftSwQ6Ii}5N(OO1_rtj?i!F3|so08TcePkaA zztfBfMKbpn$b4dD3Fos14^?BE6P=!D{VbdN85^&(oI7_>Vb1xxbbi&HLOzGgYQL1n zMJJ-h1xa!p8ivZDu0z&U$O{>o?)M7{Qa`ymvrf7*4Y~-#<0G1tDmD$)V3dIMwNDg& z3ic5#dO(dv62;(fAkPH3vzjSK6&jEumgfAqL~SMp)f|m3&Q~4Ch4gQYN*wA}uQrV@eRylnNcH7x6e$PaL__ zY!o`>@kCq0lTsO*DQ+enr~dG%-aQhMI~1J6nkV!k|57$QF+2GtQCiItaB|~pgN!%* zvBTr{)D*GsEHqnPSP-7ET$!Dh-ztdiA%wQMKwJGW#=EENUJfW$bkEYGO4eNNG@J6c z{6b<`>oU)k`OQm@TF4X4wJmtp+4-5wkF^&Z`~6lPW73bHT-J$O9XmMJ~aDo zD158mKB_o0;{g;%fFCRwZ&Xob89WHN36xECf|GxTPc$Q%#z#mTy8t*C zmFFy_6I)x#_V`jOYRN~0DP{BV4PSp{_)`8ND3mYUjiWeNLD+ZdMD=7zL?t+9;|!$t zP?QSpEnO+3)k?mBsD&qTf=`5a zyo^%KL7CucxDJ5MW9ZpL$ICmUKnXFhHLR=5-TSG|q0zpwt{g{oR1M^ot`D%U=kIL^ z*N@XqL6M-)C-kvI;i`o_-@>NR$)>rnX@u%1t{C#8o5jx{530LBS`cmfGeblYuRGlA zX0t&Z;8sah&-FUp68c3+3ZIaeOHgi+115Hbj>&9QQe^VU1?OJLc`S5y6{F2S??_>` zcSsFQeN`-bui+Wjz!DNtta|G)_Z7aBF?A;v#@YCjn;Goe+;sBfy^-LB#j=i>8mhU_ zLTp5<4Xy=njRwK!;$fhfYscY%V7V$>`kh4LvI^3>YuAK%WUg|>Abs{!d5?{sT0b_( zMMwqIjKAPmNfr{&cggPwgSc$iA|<~ppGm4#N--e@+RIco1(W6GrQ zi;!flG_O^UlT$Ks6ypc4Sxf2`S0@o!|LhLMhGnG_u{AG^a>vg3!8~3#vYTUhqO&{< zvP)WMa&sfu&pSyH4)Q8JLdju2?O3LigXSz~9qLk;nq~R20ZgMLv z^7v>vEwszD(`yQA$dA#N)n9T*%!_G9s5eEODVojJkVB;fMRSHc|EMJNpn>3B=y8rx zly>^zAu_a=9^(kFH=WprW2U!4p1KUqbLrVh*yMC)+9dYb+lUBw!{4$~R)sluH(ezu zQgF=N+&G>~ujCV$NSUzYP!I!)ZI4Ws3W41e%q6Bmt|4f0HcBSGwLY2wVHh+ z_Er@`mPV4_kxD1zQR6QuJ@UUQVKm=Y`Ve;4O>(jQ^@ylx?pAo2jfxrdg*mI$(M#@PTONMAVdV>1?cr;xR9V(}U4qE1Yzh*-wPq z^=sz<*orhuZx+^ZE8t=MrhD!5!F8f2Y-YNp(mF!tsypLqKzplF>WF}sR08oyX>d6B|MB&eQE@e0x(S+~A-EH~Lx2E5 zf(LhZXx!bMCJ7ci0fM``yGwA_#@$^Sw|nw_^W9l9cjo@=wYpE&IaO!ZuDu_zp3Xkf z1@gb>Mfso1e4=shLPlTBk6ahBPMbEpOFjV-Iz!v+qoD12n0#<)0V*IV^|W*oDt4}- zqIP59_w?kbm=ykiUMQ7#)l_(QnbP~q{ebvvJ~HnCLHYiv9vMi5^bx8lKRT?7q?=d6 zazZ8eL(VNjBTmZnE)uoEm9|lzqI7ne}jf#Zs;*iReig7%Z&;_9M98c(Ma=>ixcZOISclmjE25 z0=3MNDBkbqr~9%_fmD^=H_{=;3(X!*lx>ke1bZEdfpWWx`O}vHa{eXt{BqD_;AW@BL3SeEw znD6Z$hmU?@tx^S-fof8o1;eNlzahZiD$$&m)rYuzGkh+YcM%aVYHZ?5uZ4a4p4m2^ zZuM4*me%xp@mpf3#Yzs3`@1voeyG5UVAnaH#M+va6O~?t z{x>^K+>E}GJt38jw~t~2$~u;fBMYlR(kP|d;pB4Jn*k8>!HUts(FmA_k_Vr~bi1;TYgx(O9++hQv6ve@1GpSW7$o!}$rbvP zA2iNy9&zG&#SqY*Z~YBS{({g1QQ<@mf70s3F?HM+E{ZIEko1$EXe}(YpnJK?!b|ipu52kpjT(Ux7ZN(bUoj%Yfa>w=QbsM!j-eUTV1^ zkn>FtHB^YD@#Os%*sr-)7D$k*xi}?!d%)Y*e5rAIi5syqaztL>(f`tfdSI$R`D^A3 zQI;u=Pxn%jTb;Rd>rpI|k6y9w{SkBwV8(t?``J*gvIEp?{KAP%3NIWS)b)G9_P$rU zxVVT;9@=}HzLAUve)$(B53);3O~shZ8^!6q*bzXFrtR-TW3_rZtdjq^+kzg1K^j=M z0ueVyZ@oWOa=+ceU%TCTmPh#bPAK}+WA<$VVrf@?%YX1(-%%1MRkr~>$$d1$1aXqN zDs-}hdpJv)rMG|nu!q$b#ix3nwcX^b$RE04^VTjuIfm0exowo$laMOPr(IJbJ@0#ytul82gj4xE@8O4c5TcjWSnHQ;!HX*WkU+m=T;lq4S02 zc0{o`@9)YdAK)Zgy+YEYDE@(VkDn4GZP9l_I0m1kr9}A_7Q7St<6@Wcj!q}CS7v97 zpYnU53O_Lwoph^xtZ%hQ^n$?d$+sD0#@J8 zZzl8JYf@_p|2dORX};{X99I{)VOq0q0YFdyp_bNw-DnvVE>wl&;QqMywG-H8U<`Mh7eBLt^Ryb&hva+Q^^GUmXhzuE`7 z(NSmmt{k5B8PhaF^41bfESqus^A)EUIOQw!>qoSQsDSNfpjnLatOp25WyWtfj`nIVQ z^iXP}Tcrc>&Euz6Y)A3jem6W__(Zgq^{xRq!$}@IQM)o-Q&i@i$fL=+wY~iS zNM4ZqNN&|8gx`8{!k-`tS7E4O@JCZ+)3>{*=-!4P!RRl3fyGSSV`z_y42*~S7qiUohZv^o@v$wz7?rAxFYL`)@W?|jfr_}jr@K1^ zOKi!^2}uG5HesFxNe!Gih@!F#KtG=PK40_B&Sl&hU>l(krxoR>4u?t(GPAKsBe)fK zFk^~Id(o*IMCJ5fN8~0%HN+$&(A>!kj;oZ+n9Ofd52N#li=R0vPIdrn%y9wz016hX z3FOZPzDRBDzBl_i4X-t2?=l*eg?Nh+>D$isMhQ&Lj3^wZ`{z{6Zwt87B(W9Z$Jat* zxvRj542Yajq9~=oF*SBsvLkogwUz?dT`)nSy4rfZAA-ui`#u!C)*TET`|K+l{qCwM z*Xh@`pjN(8_FCdVfd$ZdlUV?*=NjkgMmy}@PzfY^J%@*2$i1k ze~tZ>f49zZT1mt3*A|wA$-;V5N3JX@%}W3xP7_)F61+Cs+T(RpiZ6Ic@CRB4qNV{u zvk+f(V_~;w+5aVK>m#83_?jJH+Rj95t-e{OK#V1x$OQuR6YWs=d{I^pmO-ly&nhk9 zzrDTPCwGSJi7SJ<1!)ua`Or-EUm<~a3P(_)&ER3$IOX7bod;zzVPVCmfbJr~I&LWNRBdK47y6*2ubjkA)fRP(c}4El&O=4D)zViJ>!+y9j+v{3w{Z z^r%9(Bbt7;a~-oai%E3zDkaGm_h2Ftn8|RcJt+V;d5keutf9VGpUl_Hm7Z`N&bRs8 zuQ0h0J#=Bl$Kd25NM4s?URt<4tuW$d1NEp(9*b4Mf=uNyipf8lb-k|{zMMO;PUM)@#G*6+woGN<>;I7dbO1oe2p1xYd z1PE;yh40uOiSBe3ZL*e~!_KdgNl=5#a0Ox`c7lLm{0fK^gwjbSxRHmAn zUi)UeCQR6$5>33HXeu{3l9G^?D-JzwkpE|EpXh`ftRh*zs-ppO?Fx=nO_< zYb+U2VPyWqAK}6YUgBet$3NV_-5hs8iO)XltHH1`Xv-hnMVBx-TdYLVX%it!O+l=C zd047U01t%{U~m_;RwclSB3|$OC;9SUfPFx($Q$M>{j#6y<>B1{CamQ>TUrabJIvA! zB`9m~q<4&W8;oEnboWcoo#SLn_{V=EFa2La0uc_}ich{54h)Ir2A&=b8xro8N5rl> zuIc#CZT;`uZiRpsR(AzL0T=)KSM(9|jYn@4)N~QHes58(M*K@t$>@)#Q3dJ$)l`~A zsDDSwgwy8-%*X_pf#&~wxo=26zBUvP#&ZZZXk<5vQ!p?iR$fd}oQdJYsK*FNi6v`D zEl_{aT{~*T_ZJ^S{l??=)#q+6fB@ij{ErtT6GG4-9a>eZVA;gQ5%90_t(n5Sf zf-q3j5Aq7yW$^;mEBuGjf&ZB&z}xpqLpwnjI&!RUXH@WM__%{f-=@0}B9+c-Obk#N zGAb+I{uKe4pCL&Z!$r{wu%d3DNIEA#Xb5?j$fW!c#)UvrpMBS_UbgD_4jTeTy5u{Xmv*e- zAp6e}*(doX_pXIPfEmA3H)O`@LmS7@nW+XG&^+9omaA}7xQ5t&z9YfG+k9`zdE

    DTW6OZjUkYPwzDL*sOVPK3&9(IjQ=W-$W?V3pY5s-U5iaiIW+BU6CZSS{TXLcGp%@|C&kwnEU6U&_m4W^@D8 zL1e(k4(mI(IsZ0cVs1`zib_@>ZJTjz{V*qj+?V@ac0cgwOWmB@}4||0Z z{PaZtI2pajaR1Oc0oE7*!*2ij3tLqRheMwWMxN350niP7g@&OzCkGw+rEK~wv?;H2 znpmb~>`6GIxw=|zi$xea(1C5|dqTiDzMU|JrP0Z&cQ@a?FT^U}HI>Y$?~LibA(^Su zg4nPExUlb8MLcMv0#zJKyHmA3aUTgjZ#kUy#8GvN&{7U-Rey^$M-jR&l*HZ=Cjrpq zigGSuGM;F>NgV3drIU^Sf|hINHKJ!kw2zo zBm1hVGWYzPd!71IO6XPgnDA@a?2r(gFN*jsHVpgw`=zBekN3(@7Z*1dXl3bcDHuz! zfjDotj9(?x-H2rw?UkXZs*+TvOnwjKl z{OLTY0HHzv9YJ* zZ!=X`Q&{vaR(v~pW>Tl9TZj=BV>VjPLNTYtXou_Ju=7Tbt>b-aM`9eVG^VTO z&rh3nifW7Oq9q(8UUYOO6l2@>=WKkSj6tcMRuXx=nx*l}skI0A86L$YMVN&bLnLvwEHYYWXSjRr@=RQGcX>-#&NhwU{|jY~^3$fBx$SaW5l zW1C4{7`FX86K4;P&wx;z!$vsg`|ITt9yiymb$4hwm_xNHFaUDzyh@ZKq~iA*!uNcqPf@(Vk3{d3+|$#Y+l43Op*u{VkkoPPzVPv*`0P z1sKKy?8s8J_jGhuauU!`_7727x|-*X{L=d8fowi9qmK@0T$_X9O24|6=y(%p-~c(A z<@-{B+v5!>#^HRXo}Fw@(3aa`jh}G==vD#~Ws|hYV7~I0dHpSdCIm6NrE~$? z!@{~w!Hjbz(mZ-4qrh;Ypg(|bXS{t@7dT!W_xTw{fbZUaYo zTktG>mHq7z0TyA-B4h*ONcDbUDqFefw4r~8KF$b0=16iy^t0_SsE4^do%!aMzE?j; zOd>jaoWFKB8g_H*-|c-|xt>LJzbK1@&mQqwEodv)=(bg1lLU58weMZy3-*&sz6w5$ zW+WG))9QTJ&*ZLoJ15aaNpx~iUK5S-Y8eGzcGiu<@<3TBycbeK@ogGD;&0_ix|xl4 zhh+FlR-ET-+Eibbv8!t!E3rInV~(zd$W%ui1qqx#F2lC zq>2kly`@_>!epSDy&67fw}o}>5!IfBM(*;?mh3xojN}jQ80{Yx8SLnn?U#BVj!s3E z9@(t;X3T$Nt84d@Z!4a;NON|)*SbE_zVom#+@6LSOqe+Ba+`-w(ixo{?YsTHrztao z-XjkBhF3T_ z^9OTDS&uc!B)H(_4!0mhM2tw#&`fbUx2dRUWfz90@guWsGb&9U^0yfrA;x%>FMgk#Xg(MSHN~u+#umtYuU4z)z{*UzMr0y z6sz@KYvtb~An}4z%LH@ob=i2&UF_x8?y-!nR9Sfest^8O-_-wkeWT1<+Nm~>APr#K z`1lkQ^cy>hsB}a7U#-hvGLyHS0p6g?;$ray#(ngn>t|G!*pQEd0}@~0hLJHy$6i`ZKkQ-mDro7X7gh{n5U07?J|O{YD&d>>vrT@iKqF)4 zjdhkjOyxd>3sl0o>Q1KAZP-|fJFggHP*19hGTlFiCxjm_p-j3kOUD_0I!YpEEn&GSN zUQz&c&dAg^9azMFo54!$P0d$aW>e9e$9ucxxkWV^g?0Rb*;@y*Kce zHE{8_7}PDawUMdQxO3+}_DpJsTYqd#B|~U?e(*V*ePHy4_hU&l93f@BAv=Hit;8$D z5#>;kg^z0eFx#+a#oZHboX-Inu3sBLp7#4JK<}v5EYV0-9$M|nr}0qKWDjd+y5wv1 zoMZg)wCCl5Hd}A+K8k@GSy1J`O2rSwjvzoj0u>3_Kxy_cvg2E}eY_P*Hj zRn=b9{UR63-TV1zk`$88`G!H(09K=^s=DGTtDoWF z{m46n8yZ4i&bGW0MJmFlTdyjwd(2NIUKVe;KH+daZeQQTLPBr06p zFQfFFov*YFKPF?)$u|5uEV>?_vNSw)2&~a@+ufLuUu?v=-tTfIQa86a0%1{(|2l4L z5DgOC(RQtKLvYIO)P+x8=s@t@c;nNvoEwf)lwR}MAQt4ah4~_QFjVzm^`(&phE=@jI=*fyg(k zPHutDHd*eTX!hGK0`INvs@az~8;#bAvCKaV_NMr1AgbrlF8U~cG{k23l~U$4XXfeE zm{{CnHsdYt?jE^!9Sc4Z-@j%A3ceZTdHB~PI~A$NP$6MqEUYhWgLLw)vu6O0x1TE> zPW}3wv+XVY1}t83avD|CtWj-q^6{D;P^_cv;%h74Ss{bWdC)utR=iAX$rclnqs1)1 zu4cj+u_b%oQ>n&jI3&`D8JguPu5c5p->5#dH0s8c{<87AYHUspSa}W!0YNYZ0uTE- zSPt0b5pW>{`ui(?_jB=d6LDSJtQ$`bNkwJIiQ3E1mYwu7KFDmKjv(PAjLi<2*sm?^ zMIksEKAt&gRS3a^j7{NammJh_`hE@LPehge3|c+*c<|U_(wrKQm|m_OVLE=_(J37X z5O9&A5WP?DPzUmJr_JDJA)XROTX=CU&>9L%Ib;KQ!ef@z(QIsUH~unJ#YTQ@ z`nw1A8M=#O347BMICs*wn;Cv##(E{2>XOq+>Y(T7>_&KdHY5uH%*e$7g!)4(x zWC_}sy)WY{TWP)gL+$y|wxsg_J@r9QSqAe@GIv`%0tQc7vc z;r6)7?QDVWDJ0xVfY+*7MtSh_i|g6o$^7FM%jc<1*Qaupf$6RyX{n<12VU-2KYFHg zOhe4W8Dvejj3n+>tj$bBr&5gF)IY#Q1W6xxO>vB zS5^UDWE5rpatGG7@mK`SZ?hL5Bf9ZcM-~n_*S2ln;>4|}M1mFDD2gM`ZE?=MRQ{*J z(zP@YjN*Z+z`MrtL8znWSSKDH@`JbX1i$4x61PrW&zAvmbBEn!>iKCH$#5H6sNXP3 zj!f0&tZN`LJC~(~07$haB0x}iqI}jj_EE(A0#x+<7~}^>#1qq~ghngZ60C{mc2l({ zF3c45=xUm-Ij6!crXm*ER@O~9(GHdaAH7&tt3pJ0t^$wB$JrV8a2{(b9mu6*@wgpH za>ra6YZ@2rn@Z)}u6)eYVZP5>ERSw3D7VK<*O_hOO_%Jv&s zD-ceaAF3e)JrHRaE>FTo*OVREaIgS-95kZzB3EbOCjQS@=yxhJJq3Hmk}TNL)Ku5M zvMkT3LVRv)3B){_>yYd?*N3Le@GR^Y8@2ExUO!$8xu};V)!p1iP#U8hI$~Ph{?m>D zRYny=y3BR_G%W*{T}-{Q8NE?W$>%>SYM^eH^T4tW= zE1Ad5dzsNMsXqvggX&i9QMTCe^7AR+EvxwW@807g(p=_p&|ESp!6nz!wYI(_B-{fG z2-OL_0rvLn-tzu$NKCaKc9{c*Zp2Y9vqueDYjR3mJw;?QH{9;E^fjv<`Ez9Dqvd7D zxij-fP++7GA|Hc27+G9=+84LQ-nc70CKln(E;K4===Dp$7t%9DkfosTrX*JFMyxC< z=1mKDA5CXyqJOxrXE}8_zacuKIH$0<3SzYDv#+^47Z`Ar-zCNgqf6uQ1RkwsbQ>7mW3o!T$= z4A88)lUFvkJcyDQ?m-$JK`8>?7_>0t{0I}a{iH@tglXn%l*NUw76NlQg_VXyA5@|i z7$BK($c%!W`R25SKu;P_ch{Ng_~6Mkkq~yZ;7Xy%@wpu7m93HaZKtBd5sTr~UYEJ@ zZbIDKj%ajG9Ss+4;ohc(;7uwRUr~+)>R{|jS1Sn}+m@9W6Q728KzK&t7ZvU8-CchQ zM|rpOAU(C)GqW|&Wpd~-ci-YPkn5;hrg)Z+c&}pWT=Po2q`~pqJ#25?`fU7_RM%*0hGq({;H?{fQzKh@Ek)wi@fcIM3hNYxpKt-9pT9M}-Anfw;@~`+QbR?6b zA;-i3pVF@*n-&!lQ&dxvu-3+^X#G>z-p=}V_UYqJ0H&7y<~@jMUK%CUR$f+5wZJRz zE_uf-===@1DvZ&bR;_4)v^S+)}A>$QDh zO}&?Y>M6`gTH0G0*qn??%wKQ6SRWC>-YvX=%PX3180Q8xJz9V4IM_aaOhURt=^R#i^a4@9WOm}FlaNPz}M zXAN%;ij0`KhD0&4pDhP>Y8z)SCr?)QrN5vttC+|nEdinDcCOuzHE8R~7bZKqg!{*i#-O|>PFN^%`TCNpwv5P1;wL#bmMbJqT zl8cKv`PO_LA5C;OX8};qZj+%U3y@6B$hnoJ4d{E-Iw+6Gn=aoie&ncK*0AG}Q=V6b zo~Z#DV56;tiP6CJQmN4ma5SDg!sHhXH03lIE28zQHetwe2Q{#_opqA;t2*I5AYm%L z7j(scJYg99<-nKHY6re&w6I(4DZMzdnVhS<0s?n3&pruVmWrFSdYSZ5c=Wv-gr-EPZL{I$W z>8Fed1)#t##mVX4jCwM)1^N?kC>4SRHbQ#00MQ0dzck4t=^W|67in=or<5K9 z3Mea!4B6X}TEX>Y@YGC>o@7F9nTRyJV?PTO0X>R+Eqj*E48hWd4|vgq9}Exw;2i{^g(^6dyBoajmCV+YfOMtWKa>}mLDUHIZ2k<0yULE~ zth#%sg9~bEH6%~FIE!jDAIr{HpxYB70g-MpqU!z5vr}o*H)yY| zoJ$JQ@KMaz+gGxUp7!>$NS1@04p}?(lSWj%h{JImb~him@ze_X?G_l&JB*^v@dAb< z?|L~hIznwSz;)MQc2 zr`%&vjBa#rn^7NSIPQ=MAF-8-+jZf@-GFnPL7*YvbDyAfBw!pLa-TJQR3g9KxkJa!)`jlY)rxWt_N;23U+X zNK@g6z)JL%(MYk;Kx-=s99&2`4nK&pj63Eult>_!ENyVzJcn}2nS1%v)mgsb;zdfpFAv;0U|p zXcav$eF=MP9ArGvxv#O>lldFq>@D8JH<^F#rWsVKu$jG42amUKR+jjMxdo63l;cGu zTEg{iP^w0?WTEmEquEXV>FWbY)%>a0HC5hLag`kz;oTt`Bt8{Bd6a)lF?0;GsNicl z+l69Y(WBlg(FU zEm>A%x1XVsWEuwABXT%9x@xE)IIOX7DYbjKN*m)Hr!K?Z2qZ+|i4G zS!%=QIi6+>LWa38NRlE^k?U;BoSlbPoftwF_dgpTn4N8%^lfTh?0FGn`GUrDy#fbi z2M4-J=kY(J*`*UGCsbynP!uz&_ zV}TFh-waz$zAP;_InA4qjNJf*YlA1QJ!1`rwzDf{?b0(PB1UHsiw>1Y?o9HQXn=;E z`a3H$X#AYivbF9BC`_8+d!S0Ah8$SrH9EbMTi*1v(AZ{deGOIU1VKQ50t8x`ANRg! z?hMTRYn2CX>>XEwcI1Xy!SBUBIIVW}e;|V#v%C1P9M&-}B+e4d=F`>x|^ngyMDgxT_5%eKQq5l5SRJZ8u)JMp4 z%jj}Iz^d2!Y{v@W`$3OlI= zX;rXl{V;l(?^mGQO%nNdDT@hqXfdBxP?LSquvtbEy_3CpZ2RlmIX z0!!^prGqb&lfC`3vP^lV&sOkCn~W_&x{vdU^mppHj5N38Ya@G+#9^SgyKp(v*4k^qpkU>Xh&QD9X_jzZ_Ma=mkJaqKL z2lT8kzJ~{^Y=4H6(~R0Gs~)X8j8nJG{Z`Y&W*HX}#tRhg!zrj>fo>6$=@bqL!<9Q%f z^EBS}6jVxAlim6#WD?Jy8 zJ+$H=3bbQpry~%alGe2Ai6zpXclUl!-WT5|**enoImTiyjgGPQui{v~{)0O3wa0cZ2^2_7$FM&< zF?XE;cDp`)2-f}HA`(nC0Gjl`GLd~k2&MO9_A0Arv||Qg#VvUg)jL=)cuD9=8bcxuRoqGVeRw%k!^oliKtu{@dX6BWd|k>D;jB z`!)B@WQ35a$J%2(VsW!HZ=d18k^PSmg}{wgTttr+*?|}?S?^Mkr25TnczP;)KMR+s z-J~APC#kQWwUuVx=U8ejhLN{vX04=I#4JJh34agoaSt%{&evQJ(16#yD3nncs)V}4 z(_>1D3P7Oz52Yf)3?^BiBXbw-CV{E_zMXcOa{Wpgj$mb!^R@X$|x^CKiNLOI(u$}!>va&~wm)TEZTuG&iZq32## zDZ$~51w?ryK$U&)Ax4tivZBNFkX46yLu6mnEkm05JOQhm+ zhOD6`((W0STE4oS_2ZnyS{}&8pknkFopEDO{PMH_UgVAzO!!cPg|v0#X)s`7G}F z`jV`{ir6&Bu0<9|v~+t{;2GgP4{SVAHz1AUCze=2gS+kd6H6;^O9YflFSM3Yy@`0- zZsNI`8e9&p!NltFWzxklPaAs6>G%G13#t=VkBgV5RWPL!V(L^`igd=d@Tot^9`!BM za9X5KpMYbDE95*Bb3~~uK=Y%uc2aG&dl%q3M*1{ke%W~8G0cu?KUh*{IK5!E5tkys zu;J}C&$8Gv`P9W1jU?MgN06Sd5TaDq*nZv^)E3e1En2x3a8$Fs_y|^EeCP2Z& zUl9Mb#;=U-Kr)rzP0MpOqT zqK(sZ;*I0TM2~$>d5(YC%R&gBsz;tOXUGaxknA;`I42Gqo@$oThT|?C`imBO$ z-mauTEc>Rfjs^IOre>QTiAM!ydVVegu>$I1db+_eM`3Yzom+MAy3U{&*5hGNjHzpP zxVUCCG3nIYv_noj-VAz)+b z^stSoWqQ#)C>gFMEdtXQxJ;Op*O-^^UT=w9THTLD@Q$LXJn^(7(8J~9B74yk;^n?hkGegsS=e(dFyhEj zv!B)P5$hSU?~J5`1HR{L)9QKvK&+dl_PVl3NMHZ$Zs~-47zF;peK0>junhZ6mY#Mt zANw65LQtW*u#I*sjnL{kMSI0t=)<)he(y-1?mq(n`knx6MetR%^lIW>OhI!i%4PWF z`0|Ng)wu5Y*KX1NW}PDDR+f2*#m2rMchwr*)& zJzNYcdMN7z^v5gA$0;>5;wQP)2TFDQ03mG>r=5{*z;h6bg#!gBU}vpw%&4eRQn+(L zG_GIM%A~37Umg$eVReFX_23{&#rrP@?`9)%dL)f2gKgGDq_e9_xWq6gtt5ri zB-GYF(wCxnkDQqYVq6B99IBqYvq3}cvUO{ z(vE%o{T+aR2+xw6?4H}@dBZHMTg_*wv)THwS_Ve?lI8A_RNF%HpCe<(8+%vRo~^ji z_Ujcpb2k}46Fy$x8RfC@&mR#I0f>+QGxmQUxz0So>}p(86q>fSHf~j9UN|oBFYa!| z6@`_pn6G|}mtm$$MFT$~paUq62>wAqiu!8AIQVh;fCNq7(9ncky?%$Py|eSg=*1u4 z^|OcV=G+?rIuV!l_V(?7jmPZwP!Jmg(t6WH4q3?zm8CU_3i3V^UIfhipt7r98T6Il(K9Hkn(}Yhf93D*-#$8u>2-A|Y+*sz*%_$p zc^#n9;)y;mFrXsc+S>XfVp+Igxd_G>7qdIFhp%IZJv6ke8`oc0=hl*+|H}7v5Vdg5 zEX^mkE~(9Yp~~qZpnLAn5dTPSAmX25{C`d)WxJ-=!2$8lU%%u=ot~tA0(`U%xjA#G zw)OA4swXOo1{y!dBRB3O_q5?mO@Tq)#a2`^0fXd0h>D78f38*ngM%G5dZH>v742X~ zh!n^nC1ubYPv|~I^=Oi9qaq;}oor(Ma$w8tw(n`L0DtDyQNvEEvOqEvTo<-(h_IIS z{16AZ{;QAgeKlvs<$n5R+4mW&p)BR)>np(eUCn4yB84*2SCHIu_u7(}x$KMk!!F6RnVAvbUT|~T6*<1Rm~9tL*Aqz|x$^w<1&!zvAYdoDVdr}<9!DT97SVMAxqbmy zNdJm;MJj81LMmX6tliyEiVto+VT{+FYhUidM|WYj#~{gYZ7MB()64IUcBL!)=Bz01 zlHPE|3x{7Zbd|;XZHOFsXF|zIm)ZH-SuZ3?k1pan&n(E}+X=T4QHyhfnxUaZBH^+I zOkJwnqT7dhy`Rk%xQR*omkWLWMA!d}gS(h;Uf27Td&~AvVXo6bwar65|FK`czSh8# zq<|HFCW&5b0HT*)3kOhs=eP~JSxt>ORym9DA5{tTF!=l!mmhfvyo@7MFI_=^WM&P$jJGeX3dA;oXgp0nx^_2=>Y*WfLlXR;mmXj z+YkPmRd}FA*~53eC@vVo6+ko^$Tl|$ky<9bKOKq?!KP7g?l>xnib)=|^wfn{+=sJpLs%CpPV5Ox8n zGe}8%Uoo(AK8k$(ZRNJ&6SrTIpHHv?Y0_P(Dk&jNOicV}x6B)YuZZq?Y0|aowqlcB z?@M^+32#ElaYdk{tn6?HvoM*Jk(A7MQC}d%*efPrBwEDgv@OHxuLZi)JFuB0PzQsH zVto4hC4p0eKSNpaO6P9x`SJ7P2^iNo?ssf*v)@voEc;!Br2`g;v(B1OW*V$oj?(A@tehdzk={jV2(F`@lu4yTwzD0G0o#ifO09$>M8a#*M%Z^TvUM)w!S zCVJaYeUd@n*RL}8U8qxK2|yV#u!Lqsqt@CzMBSkX@)JsXkb5IzcI?;b%~XXD5C;Uu z4R-{`Ki*Y5lpkY1wz|0qEqXOc%hh57h+=Vs8=)4daI9j))53ua5v7?KU0>QS(xA^-y%JVKh=AnZRzMMHgtw9eXlvJUALz zX|C3Wr$NB(1o{r2WMOXWi3ABTD`A9mCBJ*8mYprAA&0#I5mU@${_TDRw97soWKCn~CIhYy6H)PzlcopGBVZ}ba)J}uOcgSvA5C%UsO znQkG!-KbGnL7@;8AF%`ZB81OF;jC5T*GJKX$f-ZkQ*yWg(-xHQnN&w*DOz#Tmp8Zl zVNVWgSD~mEzkfx66B~ZE%i(f3pI@Zmwf#hg=%AoFT4*LFGBGfzq|rqltBWbB$yypI zGI-tq`4=AoeG)3ro%4b7?ie0rZXZc$dh?c5#^eHUKU2kDudg>^Br6&ahUOcRwipxYRM0-!DeUKX)Tcqub+hq>kI}?w>QZ14G}CY1=V`yqSk)? zc4fJT^uRnqr^q-yO?3sR_!kq95W5f-+$B%l%`~MY+F^(tj z4tg{7#=>7;!QN)+aklr<0_hVh>}yI&iBgzFNqKT2`d2lz9VSVVlgQr=S`}4QlSNO} zCisJ9l9I1!W=<_Dc&x+NYT}c$_2(*caEeYwJ)tYwb}gbbM<1=TT#|~>J}@wF_OJFH z*)Mhj;wsJe$6j})nzDVpwiLJ{1SxTOPRQnbw#rLh+IG;C+s1*nvs_-avr}O)-dY7r zGI_@DX8L9;8b#LuFTe4{Pv!Ae_ta_sCBuVHd%>`!@w%6WYO7lJb??PlFGfe;qLt?B zUfXt^eM_sDL8hWzjZ3KD@(1f54F89&tBk5E&6a`S9-QFr?hqunYjC&V?(PuW-GjTk zy99T42<{g2o%Br4bkEHD$y(fV&;4}os@k=wr}`}_D`wj#8>(vu4}q56M?8@qiYVWy zVGUY!I^7{`X0zwVQv|?!m4)uSHdnRCDoz)JUj+*%=-CMZ)bXot6`CTsWA=4*_R zZ%^F)TL7>?#a$U3ESe_sV`Su;@XDSAixGHcTAGZEwWX!>QuJW`Ql^30Xc{|)MuUYg zf0r(eW)srWU54b%)K`CH*!jv0pSSzB%)Wt$wBd&&8FAHB3itEl`1oD4d z)qorC%6K=0)g_L@Cg=L@9+WA}j!j;U$G}`7YP?8)(XQoRni{NuSQHatUD4EM{3D3* z908%{@kSR=S>a8HE$H}Fr3P`2e;aFoY*t@VYUNn72~go(iPGXNI3)+ow}&lzDo)F% zT}9jFywJD8EKcqiF-DN)=Qh)f$5~Wov!GW@svQ@#)h(W7su%J>wD{@7E>1P+6~}*M z>yzYQfRjlmr~TJbxEhO_9aa04hSS;7%JFn@6_M_}AC-#7s<(q7oYy5NM@6U5Z zfZha3BJ18S$7uOhpFAGE*-crlt^GD@D9rNusL}YErwmkPE3j>9%-)FNdj`0lYZcdA z&MSt80XECHQGBlySi9N-#8J=kb6Xst6%`c^&({+ay_I~VDB|pPu-8EABhvMUw&h6L8RZEb$~kjnjv3Of7WFV^Ipsg|Z}-5(qO^mjoZA8lT0Wu{_GLHJ z@i%rabr#OQ;BGDOg;2_RBkKM$9k#5bbOgAyqRJK z{d&ExwS3efX6{ZJAk5!se%^@E#h^rF&lre}$uOAP9w(#G1GX%Qt;Y{JobtreB zQH{deLvw#>CM^!mDW!#+TI=xDqJNk$ej|^|d!8BF#MO^3Psak-zhxW{=swpR>UTi~ zwo)o1z+UNZZ=~+Zs{kI{E}4C0;glPxZX~E94lQcg!~#Qo>85{SATa>vr5S8+ahr!f z&YzG{Y~R0smvzWo`&OWszyXAs0H*V3S+j2Tb_bFiE6sn)`b)bBL4Sd{{BqZ z2p^y!Cr968Jf%DpW3Ipddoy#hZgC9R^hwe?6P~ZM%D<{U2v*&BZ`5nkUVBRMhkJR# zsitCp{(Z0gnV)p=lXnI2sHk8V8vm3VJ?9F2zb?f1I?B!RaQSK`(b*(e2=RD^ICL2{ zWzCPrhE?~M@&9*T3H<2C%r3v-#Kopc-|dXX%Kc*tFh<6fvKI14AW3JI@vI+jtNfVg z^tbu@+vg$Jd}}0XecRtBT^#fuI78!mEI9&?hh42W_+{C`6NE~q=6z7*fp8-eW1 z^1Z1EEIp>NtqezITt>Cg3fILG-srNvrUnq%0>xpOYQ7J`IjaB^edU#G&&qsbs6JfRl`ZukZQ6(pF|K9+*`SJq=Z#6Y`NI0LsfYSP*u`&Ow zEJUD_DiRhaUK@Y|{1)xit?JP7*=%MXsr^{j(2OqJ{~;-9OLfLz^iuyDiK>@+{VV9= z8|Kp`t)ixThN=Y@_I{A&a$H3kqg#8J?r-Dx#~)I}RnF(yN>kI_@cx2>F{zb!K)Nl$ zZ$kkD!(z88eg$8d(>dcJ$zZu>H4ICnI^&GB0;j)&UKHl%qtS>#t17Db*2EG4sarM! z{Yo8(Sh>}2Ftc%LI0#sGHvwIJAt*?Aco5jw*rXpe8m;k}m}{3=IK4bb+G(%y@e^=w zAc{-I9y_$F&M~7Ctn*u3PeTOCR*oY5kQtwES)EOF{vMw~`ND?r0~s3DWvaYpawUXv zd=jh*J0jC&3Z)hj;gAOfJBMq=9i1$0{V1Uh2~sxI2lsMjF@y=gzIsiiFU%+I6O8c!(9tTblf`{O%j z#x9Ayy)&n_cjE?^qxnMlU)#!8GLpaNG8z3zQ%Nnfsn>m(hTBW15boJ^hO2(uhFku= zzH9C$v;NJoec$x&psyU7gp}0x%{0HI1$k#6A}}{Mcl}_nTle_rsLJSva{03Bi^txS zNZHy+sDS*hjD;P7jQ_R7O#%I-@kjDvQUI^PN77}@YHj6S3}ojfz|M^S(+hw**su3! z)g2c%gTYV?X};nG#$X^k2yl#+$&wNI>?t8Cr;g>pzWykL4@w|yW95i3MGvi zCD`9KYe1K<_$DBj=vj8w=>7f zh@wtzFczn{x?jIW!i2sUe?C(LGP+%4i;C?RPL3&-a_%N`*nOQ=&;+!-5v8W$>JRg5 z4JA>_sb=+=E0vX{q=+<+5rLf zV9*mF1|Xtw+%|86UM57`8*RghnCK?Hsp_zcOUb!KQaGMZ$C@!-e;)xKB#t+*#YyGp zwES?F^z7V%773op%ON|ana!iKobCGr#qW^-Ab4Tyg)rA>&+ll2#dFVEjOm>iKS|8Z zlK>ZxXkulid<=7Kzqxr*g6z3#WHiZkSJwtwKP##vjOD`9IB{ANR)t&ce2WuM*B4in zo$MEJGpQ_yXNX~LYilc?Q{7VI{rU<5EF>6)V&I2=1%0u+*})Gd7LAPcJ7>|VvIJm( zFX|Wk!?;?aXx)o9md4;+ZmKiPwI_!7=VddRrU*HF11;D z_*%A3A#iwYN06uZ*W2v(NjeVe8~u%u$~M?P4mO?AcW^lQ#SBNgrK1*j~Z^C=xtN@~2ky7Xr*|LLj{^|=HAd(Vz} zB0Hxe^NXMHkg95a4;LE7Zubg3KW!~7RY2r+KWfF%A>VlKm9{h{|IlUDT%DMJuEXSYM7)oes1Fae_q8+ z{LqF48Op@w8NNwL=ErcsnMoNHUo|SJ4++ZR6ZaOXI=1Q>GO_m1LT%hw-_Ii`eV?Ck6-?!nSJ#V)~X?J_+5OU`TC4Sfd8?=|+H`|N-UME=n+A$b-EVX_DRjOG>X zA}!7482B7nqosQO(E^Zyxtr+f+ z&9f6i@lP$V^1nfvKSQ8DVoKdEirka7vG`B9HEr?w=goQ>kz-AO$|lC` zk;LebHgIE87p)>h$#XF3L@~uvivodQLPJ$8HFS-$5c9sOG$yf**iHYS9IZEZ+oCCKcOY5^>!l|-f(9T z$8`MX+yI!PEWsUr-O-JdogdQ)*1-A>8jy*C+^qIZSm6{-_{BP0kSDdIQpKaQmb!|P zt4J*C-7E?<2=>wO*4b&;_f-ocDU0l6pSE!LFnB|Xy1Ydn zoTsRt`2gX(GaWWH;h?Rc_#Yt>$e)`jiNu#Nu7Efb&Z%rylGjnQ|0IoN2PVJoI)}p> zJuz`eOj^j&5v}b7vNYKn=Y3TOob#3eS;?c7HZK$$w5X#JDbtJ$wewa`5Ywz&n=>^N z^0Iu*U`Z%bK8&GYc5ZH%HrkM-bXKESKNt&hBSEh&uBs`}@-x!EwPEwBG%1IH9Hrq7 z0RU$Q?;>=lScw*PRQ<|GMx`XbSA$bI5>Z2P{;(mvHMTMZfwh-p9r#uq_r*g1TSLQh z<^8P>kauL`abh+8=Xw0+?tKjLDYyw+Y77hi8d0L6e3=rLTIsjy$aNWJ6pB;M`e4t{`0$zb5q ze$Pvbt4qx1YE7(<)DodLYC)$br(5TIaFm8a4!4+KKoeCaO=Y0N>U@Kl;k(xlxf}03 znJ^jtIpBZ3+@D9;4LOMPA<$w_Du~-SKXZqsqb!lT%{W-$x_bHSr}Qxr(kHlU^o=}J z&nZafQko8U_}eN16)m>1ODC#3d@NY4)zvAfjv~<_c;%~K5O5$1(&8K-$`I-+iIhsZ zi^-Q%6*_v_Y(YU00w`Fh=3Jc_T#SS4(#rplwh+P;UfSg<0u6azJ-lhq0A=9uF3G3t89Z* zhNOf^p4@B(hP#HUau5dQ=Ge2*M=?7%HPcfu%SOUP=j>6nz>zty$_>+j90CBZRk>L$>nY`}`k+w24fdP5E+F!#s?}CRu!2SJoE(y%?xPT&}49 z9%XQU`RD;SQ8;U^%P4}`wRL9eZH1Bzo*DS#FO=tc<1j}j_-Zb)HU`XkX)bOm>!=id z@s^g3C&|k}I7@%GTjm$FB7a+i&&FVv1=L#%jg82iVo`xDy)4LmFewt+wYAfty(5TX z<#32EnYe^#wv1F73hmEBsV@mq29}pzzH6C8F zy>?k-Sd8mod>P#zYpvnra#N9Aj%?>X6pFI_0Kne<9Yb3s&i=Di(&gn(wJN z=gJ$Lly6@%~H*z`@ z*o#^u|0LYIgGlE4`$-r7kc(-?RVdFfT8!#ad&03CSNw z=xjKEZ++j2Y+13Mg7)v6Sm(6ZXjn94bQ@39^WH$T1kQ8k6 zJFpn(1n?t5rB5V#At2l}z1*#c_21Lh)YNo|bf*G>&eub)PcDthWlhlJGU-B6qf>Y% z`4;~z`9T8ZA?!*qYBrJJqeaIb+~=-wHkM6IwB#lLP!q0q#9_Zzwhi>60*sxrvvW8; zeh8qYHoDlEA|oZO$8^xuB>)d_?77?_*Pu!I4`}g^$PNe-)72V6QQcFgqvl*SOQ@(H zoe8GX;{98gen_~RORl$35J3H z<1)Cx0*S01WISJlU-;o%HL_~HxKwNq3}AXkQnBpF?ayWEw>o8N z*PN8kOn?@IDRe@2CMX~*j9PUB2L)y2YX;4~F~)ywBi~oTF2-}Sl9DbpSI(7+c-Sl- ziWj}sc8s$acQYxvA3W=WUs~CXzq#!jQXvoN5J;RL{d;UEW$|Sf9>t`cVTR*r-Oma zSLo;mOgDBA+RE>)9_O)K)jpUQ7+20i21hEau(PriS+vg?;_QaE;SfhB*@|daOjz1e z0@U4`OX`YlAfY!p#{MNyJv{x+sjEi0H69`cpl#I9Olo9iW>*Q~8z&kyxV*YkNeOLU z`#Pd9`h)9O($U5DWovgNw*4NIn}P|4(53_G#`tbs!>SqU^74|y;Q*@NpFvPa2<+=b z2afrIS4i8ZHSly!8^5Rz#6%av8R zGm=i)e}=W)Py+#N612ry3!44@=s*DUPQp~Bui-MlG+5|e?1+d);R#niM>;ioF8wX% z_~##P!CgRpS4EJEU%HtGM*~Xhc?dDTXjBJ85pZ&#OG});dmV1_SEHyNs(g+;yBi6{ z_gY=o2b7EdTAlw2^*p6?yA35RRu_C87X3X}<5)ISe%Ixy80KpM!AUrBQ646O!he5E zV6%r$2GPSQ~tmI1-|OQ z9oXC}_u=`F|6h0nM*$kMrP4LCgxJ5=Tvgv7>M2Nj^znFdQ4Z1ip5VFeO;9L2LO@rH z0>-XZPF@+Nv%9;fv{V?{=#j_E5Pv{u%qJlM4M4%aKepj9#;e{FJ zoSke{jsfPBy=Bdhg4$x0UZQVYh5{xFk@3+{L~Q;)BvSu+$4z9C=#(TV`^UVW`~9>6vRid~l8Xw|%^ zQUzcKd>XASiu@DmX+7C|7E)@oOpRtpU4&X1evFL)gZGiK_&+nQeq3#Q?WmN`bOH{BsA$Hg{A-DmW9zCIB|0U=7wCda z%*~CxzmINWX4c2P;SmP3NbX)+!v!Qi*??$;laq51Xxp4fqvkgtf*7BaM9kp=1(d%E zzO)pdUR_=NcKIrK4VG;I2gx;?y}3D@Vf;F3k}f0FGn~f~5QVoPz*$rbL+>j%{}qEa z2m$xb!tF7IjrtD~I|&`c`;8xUx?k;gpzxoeFce6{JM5ojWn~G36=h{`#l^)o0GJij z;0mXo-2(#Hk~1pYWKjKj#Xp`(0@o!nbUW9pkNwE=q@Yv-M&8+7BdO3yLEKF=hxR}+m%n5A>X8|aM7PtNlaS82QfrL zBs@e29t$sR_`<}>^t08-eo1*abMZy~Z9|=w#55%wE*?mv|6qPgNoshow9Wm;fazHM zMO6*ze4;4dMr;aU{YUtCV$vPd|1ojDw+25SikPglG7lcN2ft@abgQKr&=Tc>E1KHs zee*PW(cZJEumq2!WCqZ^tR^{T#Wx*I{mMeser5P`LaP{1XBs-m$bGk)7=I*V!j{4fwG;*epmL)D0M#gM%ljR)Q?XZNPJCnAuQevqa#v^{M>ve@i zWaPSIPL$#R`EP6-<&T1l!tKYVB;Sp;=4LcH>{>YY?GE{+Qo1}I7?==#K?5EDx6@tS z{b6gfkjRU_4pc7=$;b-J`_e^QXE{^gI;8k;t~p32b7B7my9I-yzE~lsnTl(f4QAAo z!GCYe27E_!ZvsrkI6aZk^7}29ZEi6GKJ1Xm>u69W*PfA;Nt3GSBKykk6mCn|fznW} zWF%R?1Co9g)7JTf-@r_dANK6FqXtd2X%bkw)tK17(K_F8XsPff7{^;yC`{CnOkcaO zXoqVwy~n0r zOCwecW|;NUx+ap#gOmiTtMW<={*y?=V-R&PtsSyWTAQj@**vQ@= zyZDKKlWV1!xXb*(2d5RT#TxqI0i0;*a$9cc?9r5G8{rhh*y8wj8-)R^JBL`zG&N|p zZ>N18mzww`IAAj3q^nbSdvk$p{&vwZdCkxbK8A$^?9cmIQy)yBMt=^^K2Z}NU-Sy6 znk;I;O*LU<(j$5#`^SYsE|>NaaQHamJG>Q7(qcO1B%j4eV#I7>?6<=`LzwV8{X zn_1e;N?}Aj@tOJ!QBSaxh>YIHj>W4+HqVKVouDc*9k_Q+bR3y=uzY84P{m6v)o+hS zodhJhG^^?Dl;6r(Gh5x;y`EGS%7kn=3o9CXBChcU^FF93hJSOz5nuF+Xx$Jo74GP; zvX)X+ThSjL>m*dd0%SD%mO>`3+aUsUCB-{y%I}R__0>E) zgGKuClh^J$Ckyr9P^Jv-qlZI#27`4XB_I%o#Gd!^IlM-XJvImPdUS%{9*&5;-%($V z(p6Xf5?uWW4jkuMp1kf=ka(SydA{tRcIBk3i)euB%hk$x+LGMRdEo(;JPJh&cXZQ z3-0a3gtYS;$hu+M?zEgvF#FqMQ%65g!JBNmNA#$z(PMpqwZEqqcmls7ZlnVZB5Ha^VSPENm>>VfP!8{c4;V zVQWd1IR@u@JHc%yQrjJ95Jf_?`YBp8)PYoV|3^uRtk=qDMPdUw?o1*cg1jL)NHB7r=HEm9?xI z>gAkiXB_YCikW1~Fx)Rw$w=sbri{q~YyxM@BP0ZW$I?=uuc(!!Rrs@Q z1oyYtt4}Vi;9GHH-Xb_y5WjS+J{0Cy_l*we=t&Jpc{FXAp73>im=U253d?1eDL6Zv zuE-4K3CN49Hbg4!Zh+TmSh^Eo`eHk|^xHmgH(+OEsIS|?o=HJz4g1Uk$?8md1S%2L zI~-1(d28=9xNnEi%yV~EExFN#)5&@zpxn>_778zN^21Iv|FJ#u%)1f4f7<-9ny-7p z8!I4JUkt1aW2y*2BsGN$bt-sSy0Op}XDE;BaI32-xqD5NC4F&9�mCz^} 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 Hl+XkK;`wzb literal 0 HcmV?d00001 diff --git a/docs/images/screenshots/workspace_launch.png b/docs/images/screenshots/workspace_launch.png new file mode 100644 index 0000000000000000000000000000000000000000..ab2092e7f5d7d24d3a92cb2dbb1013ddf1006fac GIT binary patch literal 101241 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

    |N+$)Or`q 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

    (2pYW0U|k63g>FS^HqpD)umM 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 Pz@Pbn!^ZdakwX3r8V3QS literal 0 HcmV?d00001 diff --git a/docs/images/screenshots/workspaces_listing.png b/docs/images/screenshots/workspaces_listing.png new file mode 100644 index 0000000000000000000000000000000000000000..ee206c100f5baa847d7a6625503ef00f6dfa7b0d GIT binary patch literal 86975 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∾yBC$ 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 W8(c}RlCuWD%lN9f;j1ginEwMY%-7if literal 0 HcmV?d00001 diff --git a/docs/manifest.json b/docs/manifest.json index 3db60229405c5..bdfb26c4831ae 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -5,7 +5,14 @@ "title": "About", "description": "About Coder", "path": "./README.md", - "icon_path": "./images/icons/home.svg" + "icon_path": "./images/icons/home.svg", + "children": [ + { + "title": "Screenshots", + "description": "Browse screenshots of the Coder platform", + "path": "./about/screenshots.md" + } + ] }, { "title": "Installation", From eed97945160bc75958412216b897b81fe6e43897 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 09:03:26 -0400 Subject: [PATCH 086/168] ci: bump crate-ci/typos in the github-actions group (#13584) Bumps the github-actions group with 1 update: [crate-ci/typos](https://github.com/crate-ci/typos). Updates `crate-ci/typos` from 1.22.3 to 1.22.7 - [Release notes](https://github.com/crate-ci/typos/releases) - [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md) - [Commits](https://github.com/crate-ci/typos/compare/v1.22.3...v1.22.7) --- updated-dependencies: - dependency-name: crate-ci/typos dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 850c8f0c6d238..739948be55b25 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -170,7 +170,7 @@ jobs: # Check for any typos - name: Check for typos - uses: crate-ci/typos@v1.22.3 + uses: crate-ci/typos@v1.22.7 with: config: .github/workflows/typos.toml From 07cd9acb2c1e745e5024b860a50376ddacb47b5b Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Mon, 17 Jun 2024 10:24:30 -0600 Subject: [PATCH 087/168] fix: fix workspace actions options (#13572) --- .../WorkspaceActions/Buttons.tsx | 24 +++-- .../WorkspaceActions.stories.tsx | 102 ++++++++++++------ .../WorkspaceActions/WorkspaceActions.tsx | 44 +++----- .../WorkspaceActions/constants.ts | 66 ++++++------ site/src/testHelpers/entities.ts | 4 - 5 files changed, 138 insertions(+), 102 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index ecfde9e97aa9b..576e5fb84ead2 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -99,6 +99,21 @@ export const StartButton: FC = ({ ); }; +export const UpdateAndStartButton: FC = ({ + handleAction, +}) => { + return ( + + } + onClick={() => handleAction()} + > + Update and start… + + + ); +}; + export const StopButton: FC = ({ handleAction, loading, @@ -148,16 +163,13 @@ export const RestartButton: FC = ({ ); }; -export const UpdateAndStartButton: FC = ({ +export const UpdateAndRestartButton: FC = ({ handleAction, }) => { return ( - } - onClick={() => handleAction()} - > - Update and start… + } onClick={() => handleAction()}> + Update and restart… ); diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx index 3e663dafba1a9..e4188b7b88041 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx @@ -34,93 +34,128 @@ export const Running: Story = { }, }; -export const Stopping: Story = { +export const RunningUpdateAvailable: Story = { + name: "Running (Update available)", args: { - workspace: Mocks.MockStoppingWorkspace, + workspace: { + ...Mocks.MockWorkspace, + outdated: true, + }, }, }; -export const Stopped: Story = { +export const RunningRequireActiveVersion: Story = { + name: "Running (No required update)", args: { - workspace: Mocks.MockStoppedWorkspace, + workspace: { + ...Mocks.MockWorkspace, + template_require_active_version: true, + }, }, }; -export const Canceling: Story = { +export const RunningUpdateRequired: Story = { + name: "Running (Update Required)", args: { - workspace: Mocks.MockCancelingWorkspace, + workspace: { + ...Mocks.MockWorkspace, + template_require_active_version: true, + outdated: true, + }, }, }; -export const Canceled: Story = { +export const Stopping: Story = { args: { - workspace: Mocks.MockCanceledWorkspace, + workspace: Mocks.MockStoppingWorkspace, }, }; -export const Deleting: Story = { +export const Stopped: Story = { args: { - workspace: Mocks.MockDeletingWorkspace, + workspace: Mocks.MockStoppedWorkspace, }, }; -export const Deleted: Story = { +export const StoppedUpdateAvailable: Story = { + name: "Stopped (Update available)", args: { - workspace: Mocks.MockDeletedWorkspace, + workspace: { + ...Mocks.MockStoppedWorkspace, + outdated: true, + }, }, }; -export const Outdated: Story = { +export const StoppedRequireActiveVersion: Story = { + name: "Stopped (No required update)", + args: { + workspace: { + ...Mocks.MockStoppedWorkspace, + template_require_active_version: true, + }, + }, +}; + +export const StoppedUpdateRequired: Story = { + name: "Stopped (Update Required)", + args: { + workspace: { + ...Mocks.MockStoppedWorkspace, + template_require_active_version: true, + outdated: true, + }, + }, +}; + +export const Updating: Story = { args: { workspace: Mocks.MockOutdatedWorkspace, + isUpdating: true, }, }; -export const Failed: Story = { +export const Restarting: Story = { args: { - workspace: Mocks.MockFailedWorkspace, + workspace: Mocks.MockStoppingWorkspace, + isRestarting: true, }, }; -export const FailedWithDebug: Story = { +export const Canceling: Story = { args: { - workspace: Mocks.MockFailedWorkspace, - canDebug: true, + workspace: Mocks.MockCancelingWorkspace, }, }; -export const Updating: Story = { +export const Deleting: Story = { args: { - isUpdating: true, - workspace: Mocks.MockOutdatedWorkspace, + workspace: Mocks.MockDeletingWorkspace, }, }; -export const RequireActiveVersionStarted: Story = { +export const Deleted: Story = { args: { - workspace: Mocks.MockOutdatedRunningWorkspaceRequireActiveVersion, - canChangeVersions: false, + workspace: Mocks.MockDeletedWorkspace, }, }; -export const RequireActiveVersionStopped: Story = { +export const Outdated: Story = { args: { - workspace: Mocks.MockOutdatedStoppedWorkspaceRequireActiveVersion, - canChangeVersions: false, + workspace: Mocks.MockOutdatedWorkspace, }, }; -export const AlwaysUpdateStarted: Story = { +export const Failed: Story = { args: { - workspace: Mocks.MockOutdatedRunningWorkspaceAlwaysUpdate, - canChangeVersions: true, + workspace: Mocks.MockFailedWorkspace, }, }; -export const AlwaysUpdateStopped: Story = { +export const FailedWithDebug: Story = { args: { - workspace: Mocks.MockOutdatedStoppedWorkspaceAlwaysUpdate, - canChangeVersions: true, + workspace: Mocks.MockFailedWorkspace, + canDebug: true, }, }; @@ -133,6 +168,7 @@ export const CancelShownForOwner: Story = { isOwner: true, }, }; + export const CancelShownForUser: Story = { args: { workspace: Mocks.MockStartingWorkspace, diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 86a53d592243e..e58f8190e900f 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -26,6 +26,7 @@ import { ActivateButton, FavoriteButton, UpdateAndStartButton, + UpdateAndRestartButton, } from "./Buttons"; import { type ActionType, abilitiesByWorkspaceStatus } from "./constants"; import { DebugButton } from "./DebugButton"; @@ -89,12 +90,12 @@ export const WorkspaceActions: FC = ({ const mustUpdate = mustUpdateWorkspace(workspace, canChangeVersions); const tooltipText = getTooltipText(workspace, mustUpdate, canChangeVersions); - const canBeUpdated = workspace.outdated && canAcceptJobs; // A mapping of button type to the corresponding React component const buttonMapping: Record = { update: , updateAndStart: , + updateAndRestart: , updating: , start: ( = ({ enableBuildParameters={workspace.latest_build.transition === "start"} /> ), - toggleFavorite: ( - - ), }; return ( @@ -166,30 +160,22 @@ export const WorkspaceActions: FC = ({ css={{ display: "flex", alignItems: "center", gap: 8 }} data-testid="workspace-actions" > - {canBeUpdated && ( - <> - {isUpdating - ? buttonMapping.updating - : workspace.template_require_active_version - ? buttonMapping.updateAndStart - : buttonMapping.update} - - )} - - {!canBeUpdated && - !isUpdating && - workspace.template_require_active_version && - buttonMapping.start} - - {isRestarting - ? buttonMapping.restarting - : actions.map((action) => ( - {buttonMapping[action]} - ))} + {/* Restarting must be handled separately, because it otherwise would appear as stopping */} + {isUpdating + ? buttonMapping.updating + : isRestarting + ? buttonMapping.restarting + : actions.map((action) => ( + {buttonMapping[action]} + ))} {showCancel && } - {buttonMapping.toggleFavorite} + diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts index c2a85da8cb121..f6d9f8f1cfa20 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts +++ b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts @@ -6,16 +6,19 @@ import type { Workspace } from "api/typesGenerated"; export const actionTypes = [ "start", "starting", + // Replaces start when an update is required. + "updateAndStart", "stop", "stopping", "restart", "restarting", + // Replaces restart when an update is required. + "updateAndRestart", "deleting", "update", "updating", "activate", "activating", - "toggleFavorite", // There's no need for a retrying state because retrying starts a transition // into one of the starting, stopping, or deleting states (based on the @@ -23,10 +26,6 @@ export const actionTypes = [ "retry", "debug", - // When a template requires updates, we aim to display a distinct update - // button that clearly indicates a mandatory update. - "updateAndStart", - // These are buttons that should be used with disabled UI elements "canceling", "deleted", @@ -54,13 +53,6 @@ export const abilitiesByWorkspaceStatus = ( } const status = workspace.latest_build.status; - if (status === "failed" && canDebug) { - return { - actions: ["retry", "debug"], - canCancel: false, - canAcceptJobs: true, - }; - } switch (status) { case "starting": { @@ -73,10 +65,12 @@ export const abilitiesByWorkspaceStatus = ( case "running": { const actions: ActionType[] = ["stop"]; - // If the template requires the latest version, we prevent the user from - // restarting the workspace without updating it first. In the Buttons - // component, we display an UpdateAndStart component to facilitate this. - if (!workspace.template_require_active_version) { + if (workspace.template_require_active_version && workspace.outdated) { + actions.push("updateAndRestart"); + } else { + if (workspace.outdated) { + actions.unshift("update"); + } actions.push("restart"); } @@ -96,10 +90,12 @@ export const abilitiesByWorkspaceStatus = ( case "stopped": { const actions: ActionType[] = []; - // If the template requires the latest version, we prevent the user from - // starting the workspace without updating it first. In the Buttons - // component, we display an UpdateAndStart component to facilitate this. - if (!workspace.template_require_active_version) { + if (workspace.template_require_active_version && workspace.outdated) { + actions.push("updateAndStart"); + } else { + if (workspace.outdated) { + actions.unshift("update"); + } actions.push("start"); } @@ -117,14 +113,31 @@ export const abilitiesByWorkspaceStatus = ( }; } case "failed": { + const actions: ActionType[] = ["retry"]; + + if (canDebug) { + actions.push("debug"); + } + + if (workspace.outdated) { + actions.unshift("update"); + } + return { - actions: ["retry"], + actions, canCancel: false, canAcceptJobs: true, }; } // Disabled states + case "pending": { + return { + actions: ["pending"], + canCancel: false, + canAcceptJobs: false, + }; + } case "canceling": { return { actions: ["canceling"], @@ -146,15 +159,8 @@ export const abilitiesByWorkspaceStatus = ( canAcceptJobs: false, }; } - case "pending": { - return { - actions: ["pending"], - canCancel: false, - canAcceptJobs: false, - }; - } - default: { + + default: throw new Error(`Unknown workspace status: ${status}`); - } } }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bd7627f070fdf..055093570c7ab 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1175,10 +1175,6 @@ export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspac id: "test-outdated-workspace-require-active-version", outdated: true, template_require_active_version: true, - latest_build: { - ...MockWorkspaceBuild, - status: "running", - }, }; export const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { From 8c1bd32c33c630474d37d6f5bab3aabc1b9dec60 Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Mon, 17 Jun 2024 11:02:39 -0600 Subject: [PATCH 088/168] feat(site): add basic organization management ui (#13288) --- codersdk/organizations.go | 2 +- site/src/api/api.ts | 25 +++ site/src/api/queries/organizations.ts | 46 +++++ site/src/api/queries/users.ts | 6 +- site/src/api/typesGenerated.ts | 2 +- .../FormFooter/FormFooter.stories.tsx | 12 +- site/src/components/FormFooter/FormFooter.tsx | 22 +- site/src/components/Margins/Margins.tsx | 17 +- .../dashboard/Navbar/DeploymentDropdown.tsx | 23 ++- site/src/modules/dashboard/Navbar/Navbar.tsx | 5 +- .../dashboard/Navbar/NavbarView.test.tsx | 15 +- .../modules/dashboard/Navbar/NavbarView.tsx | 7 +- .../Navbar/UserDropdown/UserDropdown.tsx | 11 - .../UserDropdown/UserDropdownContent.tsx | 43 ---- .../CreateTemplateForm.stories.tsx | 2 + .../CreateTemplatePage/CreateTemplateForm.tsx | 4 +- .../CreateWorkspacePageView.stories.tsx | 2 + .../SettingsGroupPageView.stories.tsx | 13 +- .../OrganizationSettingsLayout.tsx | 74 +++++++ .../OrganizationSettingsPage.tsx | 192 ++++++++++++++++++ .../OrganizationSettingsPlaceholder.tsx | 37 ++++ .../OrganizationSettingsPage/Sidebar.tsx | 182 +++++++++++++++++ .../TemplateSettingsForm.tsx | 74 ++++--- .../TemplateSettingsPage.test.tsx | 10 +- .../TemplateSettingsPageView.stories.tsx | 2 + .../TemplateVariablesPageView.stories.tsx | 4 + .../AccountPage/AccountPage.tsx | 38 +--- .../WorkspaceParametersPage.stories.tsx | 2 + .../WorkspaceScheduleForm.stories.tsx | 2 + .../WorkspaceSettingsPageView.stories.tsx | 2 + site/src/router.tsx | 35 ++++ site/src/utils/formUtils.ts | 19 +- 32 files changed, 743 insertions(+), 187 deletions(-) create mode 100644 site/src/api/queries/organizations.ts create mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/Sidebar.tsx diff --git a/codersdk/organizations.go b/codersdk/organizations.go index aed035799c8c8..f65479f65715f 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -66,7 +66,7 @@ type OrganizationMemberWithName struct { type CreateOrganizationRequest struct { Name string `json:"name" validate:"required,organization_name"` // DisplayName will default to the same value as `Name` if not provided. - DisplayName string `json:"display_name" validate:"omitempty,organization_display_name"` + DisplayName string `json:"display_name,omitempty" validate:"omitempty,organization_display_name"` Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2a1057ef04b4a..50dbc32a1867d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -505,6 +505,31 @@ class ApiMethods { return response.data; }; + createOrganization = async (params: TypesGen.CreateOrganizationRequest) => { + const response = await this.axios.post( + "/api/v2/organizations", + params, + ); + return response.data; + }; + + updateOrganization = async ( + orgId: string, + params: TypesGen.UpdateOrganizationRequest, + ) => { + const response = await this.axios.patch( + `/api/v2/organizations/${orgId}`, + params, + ); + return response.data; + }; + + deleteOrganization = async (orgId: string) => { + await this.axios.delete( + `/api/v2/organizations/${orgId}`, + ); + }; + getOrganization = async ( organizationId: string, ): Promise => { diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts new file mode 100644 index 0000000000000..e9526e74ca3f2 --- /dev/null +++ b/site/src/api/queries/organizations.ts @@ -0,0 +1,46 @@ +import type { QueryClient } from "react-query"; +import { API } from "api/api"; +import type { + CreateOrganizationRequest, + UpdateOrganizationRequest, +} from "api/typesGenerated"; +import { meKey, myOrganizationsKey } from "./users"; + +export const createOrganization = (queryClient: QueryClient) => { + return { + mutationFn: (params: CreateOrganizationRequest) => + API.createOrganization(params), + + onSuccess: async () => { + await queryClient.invalidateQueries(meKey); + await queryClient.invalidateQueries(myOrganizationsKey); + }, + }; +}; + +interface UpdateOrganizationVariables { + orgId: string; + req: UpdateOrganizationRequest; +} + +export const updateOrganization = (queryClient: QueryClient) => { + return { + mutationFn: (variables: UpdateOrganizationVariables) => + API.updateOrganization(variables.orgId, variables.req), + + onSuccess: async () => { + await queryClient.invalidateQueries(myOrganizationsKey); + }, + }; +}; + +export const deleteOrganization = (queryClient: QueryClient) => { + return { + mutationFn: (orgId: string) => API.deleteOrganization(orgId), + + onSuccess: async () => { + await queryClient.invalidateQueries(meKey); + await queryClient.invalidateQueries(myOrganizationsKey); + }, + }; +}; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index cf70038e7ca23..db43fa46620f5 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -124,7 +124,7 @@ export const authMethods = () => { }; }; -const meKey = ["me"]; +export const meKey = ["me"]; export const me = (metadata: MetadataState) => { return cachedQuery({ @@ -250,9 +250,11 @@ export const updateAppearanceSettings = ( }; }; +export const myOrganizationsKey = ["organizations", "me"] as const; + export const myOrganizations = () => { return { - queryKey: ["organizations", "me"], + queryKey: myOrganizationsKey, queryFn: () => API.getOrganizations(), }; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 15a3e19aaf06f..c5b67b387b8b9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -226,7 +226,7 @@ export interface CreateGroupRequest { // From codersdk/organizations.go export interface CreateOrganizationRequest { readonly name: string; - readonly display_name: string; + readonly display_name?: string; readonly description?: string; readonly icon?: string; } diff --git a/site/src/components/FormFooter/FormFooter.stories.tsx b/site/src/components/FormFooter/FormFooter.stories.tsx index 41d44250d04e1..20af1c5b437e4 100644 --- a/site/src/components/FormFooter/FormFooter.stories.tsx +++ b/site/src/components/FormFooter/FormFooter.stories.tsx @@ -1,23 +1,31 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { FormFooter } from "./FormFooter"; const meta: Meta = { title: "components/FormFooter", component: FormFooter, + args: { + isLoading: false, + onCancel: action("onCancel"), + }, }; export default meta; type Story = StoryObj; export const Ready: Story = { + args: {}, +}; + +export const NoCancel: Story = { args: { - isLoading: false, + onCancel: undefined, }, }; export const Custom: Story = { args: { - isLoading: false, submitLabel: "Create", }, }; diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index 4c672cf8d8ee9..394268be48efe 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -14,7 +14,7 @@ export interface FormFooterStyles { } export interface FormFooterProps { - onCancel: () => void; + onCancel?: () => void; isLoading: boolean; styles?: FormFooterStyles; submitLabel?: string; @@ -45,15 +45,17 @@ export const FormFooter: FC = ({ > {submitLabel} - + {onCancel && ( + + )} {extraActions} ); diff --git a/site/src/components/Margins/Margins.tsx b/site/src/components/Margins/Margins.tsx index 9c03d2626174d..f5b120dded58d 100644 --- a/site/src/components/Margins/Margins.tsx +++ b/site/src/components/Margins/Margins.tsx @@ -13,8 +13,13 @@ const widthBySize: Record = { small: containerWidth / 3, }; -export const Margins: FC = ({ +type MarginsProps = JSX.IntrinsicElements["div"] & { + size?: Size; +}; + +export const Margins: FC = ({ size = "regular", + children, ...divProps }) => { const maxWidth = widthBySize[size]; @@ -22,11 +27,15 @@ export const Margins: FC = ({
      + > + {children} +
      ); }; diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index 23f0355ad3e9a..e54210d831d8e 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -13,22 +13,25 @@ import { import { USERS_LINK } from "modules/navigation"; interface DeploymentDropdownProps { - canViewAuditLog: boolean; canViewDeployment: boolean; + canViewOrganizations: boolean; canViewAllUsers: boolean; + canViewAuditLog: boolean; canViewHealth: boolean; } export const DeploymentDropdown: FC = ({ - canViewAuditLog, canViewDeployment, + canViewOrganizations, canViewAllUsers, + canViewAuditLog, canViewHealth, }) => { const theme = useTheme(); if ( !canViewAuditLog && + !canViewOrganizations && !canViewDeployment && !canViewAllUsers && !canViewHealth @@ -64,9 +67,10 @@ export const DeploymentDropdown: FC = ({ }} > @@ -75,9 +79,10 @@ export const DeploymentDropdown: FC = ({ }; const DeploymentDropdownContent: FC = ({ - canViewAuditLog, canViewDeployment, + canViewOrganizations, canViewAllUsers, + canViewAuditLog, canViewHealth, }) => { const popover = usePopover(); @@ -96,6 +101,16 @@ const DeploymentDropdownContent: FC = ({ Settings )} + {canViewOrganizations && ( + + Organizations + + )} {canViewAllUsers && ( { const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); - const { appearance } = useDashboard(); + const { appearance, experiments } = useDashboard(); const { user: me, permissions, signOut } = useAuthenticated(); const featureVisibility = useFeatureVisibility(); const canViewAuditLog = @@ -29,10 +29,11 @@ export const Navbar: FC = () => { buildInfo={buildInfoQuery.data} supportLinks={appearance.support_links} onSignOut={signOut} - canViewAuditLog={canViewAuditLog} canViewDeployment={canViewDeployment} + canViewOrganizations={experiments.includes("multi-organization")} canViewAllUsers={canViewAllUsers} canViewHealth={canViewHealth} + canViewAuditLog={canViewAuditLog} proxyContextValue={proxyContextValue} /> ); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx index a6541ea688486..02b40065905dc 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx @@ -28,10 +28,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const workspacesLink = await screen.findByText(navLanguage.workspaces); @@ -44,10 +45,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const templatesLink = await screen.findByText(navLanguage.templates); @@ -60,10 +62,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const deploymentMenu = await screen.findByText("Deployment"); @@ -78,10 +81,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const deploymentMenu = await screen.findByText("Deployment"); @@ -96,10 +100,11 @@ describe("NavbarView", () => { proxyContextValue={proxyContextValue} user={MockUser} onSignOut={noop} - canViewAuditLog canViewDeployment + canViewOrganizations canViewAllUsers canViewHealth + canViewAuditLog />, ); const deploymentMenu = await screen.findByText("Deployment"); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 06e847ef76a3a..77733bc63e920 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -19,9 +19,10 @@ export interface NavbarViewProps { buildInfo?: TypesGen.BuildInfoResponse; supportLinks?: readonly TypesGen.LinkConfig[]; onSignOut: () => void; - canViewAuditLog: boolean; canViewDeployment: boolean; + canViewOrganizations: boolean; canViewAllUsers: boolean; + canViewAuditLog: boolean; canViewHealth: boolean; proxyContextValue?: ProxyContextValue; } @@ -69,10 +70,11 @@ export const NavbarView: FC = ({ buildInfo, supportLinks, onSignOut, - canViewAuditLog, canViewDeployment, + canViewOrganizations, canViewAllUsers, canViewHealth, + canViewAuditLog, proxyContextValue, }) => { const theme = useTheme(); @@ -134,6 +136,7 @@ export const NavbarView: FC = ({ = ({ onSignOut, }) => { const theme = useTheme(); - const organizationsQuery = useQuery({ - ...myOrganizations(), - enabled: Boolean(localStorage.getItem("enableMultiOrganizationUi")), - }); - const { organizationId, setOrganizationId } = useDashboard(); return ( @@ -71,9 +63,6 @@ export const UserDropdown: FC = ({ user={user} buildInfo={buildInfo} supportLinks={supportLinks} - organizations={organizationsQuery.data} - organizationId={organizationId} - setOrganizationId={setOrganizationId} onSignOut={onSignOut} /> diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index c0ad5111ea9ae..b8766698d4ca7 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -29,9 +29,6 @@ export const Language = { export interface UserDropdownContentProps { user: TypesGen.User; - organizations?: TypesGen.Organization[]; - organizationId?: string; - setOrganizationId?: (id: string) => void; buildInfo?: TypesGen.BuildInfoResponse; supportLinks?: readonly TypesGen.LinkConfig[]; onSignOut: () => void; @@ -39,9 +36,6 @@ export interface UserDropdownContentProps { export const UserDropdownContent: FC = ({ user, - organizations, - organizationId, - setOrganizationId, buildInfo, supportLinks, onSignOut, @@ -79,43 +73,6 @@ export const UserDropdownContent: FC = ({ - {organizations && ( - <> -
      -
      - My teams -
      - {organizations.map((org) => ( - { - setOrganizationId?.(org.id); - popover.setIsOpen(false); - }} - > - {/* */} - - {org.name} - {organizationId === org.id && ( - Current - )} - - - ))} -
      - - - )} - diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx index e79da49a5337e..893de4d6bd688 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { MockTemplate, @@ -15,6 +16,7 @@ const meta: Meta = { component: CreateTemplateForm, args: { isSubmitting: false, + onCancel: action("onCancel"), }, }; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 8370be000e9c1..bbc7f45288385 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -25,7 +25,7 @@ import { nameValidator, getFormHelpers, onChangeTrimmed, - templateDisplayNameValidator, + displayNameValidator, } from "utils/formUtils"; import { sortedDays, @@ -57,7 +57,7 @@ export interface CreateTemplateData { const validationSchema = Yup.object({ name: nameValidator("Name"), - display_name: templateDisplayNameValidator("Display name"), + display_name: displayNameValidator("Display name"), description: Yup.string().max( MAX_DESCRIPTION_CHAR_LIMIT, "Please enter a description that is less than or equal to 128 characters.", diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 537c0280ba03d..a47d4b7b4c460 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { chromatic } from "testHelpers/chromatic"; import { @@ -26,6 +27,7 @@ const meta: Meta = { permissions: { createWorkspaceForUser: true, }, + onCancel: action("onCancel"), }, }; diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx index 48463eb1fc0a2..c715c82d74110 100644 --- a/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx +++ b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { MockGroup } from "testHelpers/entities"; import { SettingsGroupPageView } from "./SettingsGroupPageView"; @@ -5,16 +6,16 @@ import { SettingsGroupPageView } from "./SettingsGroupPageView"; const meta: Meta = { title: "pages/GroupsPage/SettingsGroupPageView", component: SettingsGroupPageView, -}; - -export default meta; -type Story = StoryObj; - -const Example: Story = { args: { + onCancel: action("onCancel"), group: MockGroup, isLoading: false, }, }; +export default meta; +type Story = StoryObj; + +const Example: Story = {}; + export { Example as SettingsGroupPageView }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx new file mode 100644 index 0000000000000..ae278b053428a --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsLayout.tsx @@ -0,0 +1,74 @@ +import { createContext, type FC, Suspense, useContext } from "react"; +import { useQuery } from "react-query"; +import { Outlet, useParams } from "react-router-dom"; +import { myOrganizations } from "api/queries/users"; +import type { Organization } from "api/typesGenerated"; +import { Loader } from "components/Loader/Loader"; +import { Margins } from "components/Margins/Margins"; +import { Stack } from "components/Stack/Stack"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { RequirePermission } from "contexts/auth/RequirePermission"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import NotFoundPage from "pages/404Page/404Page"; +import { Sidebar } from "./Sidebar"; + +type OrganizationSettingsContextValue = { + currentOrganizationId: string; + organizations: Organization[]; +}; + +const OrganizationSettingsContext = createContext< + OrganizationSettingsContextValue | undefined +>(undefined); + +export const useOrganizationSettings = (): OrganizationSettingsContextValue => { + const context = useContext(OrganizationSettingsContext); + if (!context) { + throw new Error( + "useOrganizationSettings should be used inside of OrganizationSettingsLayout", + ); + } + return context; +}; + +export const OrganizationSettingsLayout: FC = () => { + const { permissions, organizationIds } = useAuthenticated(); + const { experiments } = useDashboard(); + const { organization } = useParams() as { organization: string }; + const organizationsQuery = useQuery(myOrganizations()); + + const multiOrgExperimentEnabled = experiments.includes("multi-organization"); + + if (!multiOrgExperimentEnabled) { + return ; + } + + return ( + + + + {organizationsQuery.data ? ( + org.name === organization, + )?.id ?? organizationIds[0], + organizations: organizationsQuery.data, + }} + > + +
      + }> + + +
      +
      + ) : ( + + )} +
      +
      +
      + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx new file mode 100644 index 0000000000000..bc278b79c7e42 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -0,0 +1,192 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import { useFormik } from "formik"; +import { type FC, useState } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import * as Yup from "yup"; +import { + createOrganization, + updateOrganization, + deleteOrganization, +} from "api/queries/organizations"; +import type { UpdateOrganizationRequest } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { + FormFields, + FormSection, + HorizontalForm, + FormFooter, +} from "components/Form/Form"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { IconField } from "components/IconField/IconField"; +import { Margins } from "components/Margins/Margins"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { Stack } from "components/Stack/Stack"; +import { + getFormHelpers, + nameValidator, + displayNameValidator, + onChangeTrimmed, +} from "utils/formUtils"; +import { useOrganizationSettings } from "./OrganizationSettingsLayout"; + +const MAX_DESCRIPTION_CHAR_LIMIT = 128; +const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`; + +export const validationSchema = Yup.object({ + name: nameValidator("Name"), + display_name: displayNameValidator("Display name"), + description: Yup.string().max( + MAX_DESCRIPTION_CHAR_LIMIT, + MAX_DESCRIPTION_MESSAGE, + ), +}); + +const OrganizationSettingsPage: FC = () => { + const queryClient = useQueryClient(); + const addOrganizationMutation = useMutation(createOrganization(queryClient)); + const updateOrganizationMutation = useMutation( + updateOrganization(queryClient), + ); + const deleteOrganizationMutation = useMutation( + deleteOrganization(queryClient), + ); + + const { currentOrganizationId, organizations } = useOrganizationSettings(); + + const org = organizations.find((org) => org.id === currentOrganizationId)!; + + const error = + updateOrganizationMutation.error ?? + addOrganizationMutation.error ?? + deleteOrganizationMutation.error; + + const form = useFormik({ + initialValues: { + name: org.name, + display_name: org.display_name, + description: org.description, + icon: org.icon, + }, + validationSchema, + onSubmit: async (values) => { + await updateOrganizationMutation.mutateAsync({ + orgId: org.id, + req: values, + }); + displaySuccess("Organization settings updated."); + }, + enableReinitialize: true, + }); + const getFieldHelpers = getFormHelpers(form, error); + + const [newOrgName, setNewOrgName] = useState(""); + + return ( + + {Boolean(error) && } + + + Organization settings + + + + +
      + + + + + form.setFieldValue("icon", value)} + /> + +
      +
      + +
      + + {!org.is_default && ( + + )} + + + setNewOrgName(event.target.value)} + /> + + +
      + ); +}; + +export default OrganizationSettingsPage; + +const styles = { + dangerButton: (theme) => ({ + "&.MuiButton-contained": { + backgroundColor: theme.roles.danger.fill.solid, + borderColor: theme.roles.danger.fill.outline, + + "&:not(.MuiLoadingButton-loading)": { + color: theme.roles.danger.fill.text, + }, + + "&:hover:not(:disabled)": { + backgroundColor: theme.roles.danger.hover.fill.solid, + borderColor: theme.roles.danger.hover.fill.outline, + }, + + "&.Mui-disabled": { + backgroundColor: theme.roles.danger.disabled.background, + borderColor: theme.roles.danger.disabled.outline, + + "&:not(.MuiLoadingButton-loading)": { + color: theme.roles.danger.disabled.fill.text, + }, + }, + }, + }), +} satisfies Record>; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx new file mode 100644 index 0000000000000..d0b3d95bc894c --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder.tsx @@ -0,0 +1,37 @@ +import type { FC } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { + createOrganization, + deleteOrganization, +} from "api/queries/organizations"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Margins } from "components/Margins/Margins"; +import { useOrganizationSettings } from "./OrganizationSettingsLayout"; + +const OrganizationSettingsPage: FC = () => { + const queryClient = useQueryClient(); + const addOrganizationMutation = useMutation(createOrganization(queryClient)); + const deleteOrganizationMutation = useMutation( + deleteOrganization(queryClient), + ); + + const { currentOrganizationId, organizations } = useOrganizationSettings(); + + const org = organizations.find((org) => org.id === currentOrganizationId)!; + + const error = + addOrganizationMutation.error ?? deleteOrganizationMutation.error; + + return ( + + {Boolean(error) && } + +

      Organization settings

      + +

      Name: {org.name}

      +

      Display name: {org.display_name}

      +
      + ); +}; + +export default OrganizationSettingsPage; diff --git a/site/src/pages/OrganizationSettingsPage/Sidebar.tsx b/site/src/pages/OrganizationSettingsPage/Sidebar.tsx new file mode 100644 index 0000000000000..20b45d44de344 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/Sidebar.tsx @@ -0,0 +1,182 @@ +import { cx } from "@emotion/css"; +import type { FC, ReactNode } from "react"; +import { Link, NavLink } from "react-router-dom"; +import type { Organization } from "api/typesGenerated"; +import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; +import { Stack } from "components/Stack/Stack"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import { type ClassName, useClassName } from "hooks/useClassName"; +import { useOrganizationSettings } from "./OrganizationSettingsLayout"; + +export const Sidebar: FC = () => { + const { currentOrganizationId, organizations } = useOrganizationSettings(); + + // TODO: Do something nice to scroll to the active org. + + return ( + + {organizations.map((organization) => ( + + ))} + + ); +}; + +interface BloobProps { + organization: Organization; + active: boolean; +} + +function urlForSubpage(organizationName: string, subpage: string = ""): string { + return `/organizations/${organizationName}/${subpage}`; +} + +export const OrganizationSettingsNavigation: FC = ({ + organization, + active, +}) => { + return ( + <> + + } + > + {organization.display_name} + + {active && ( + + + Organization settings + + + External authentication + + + Members + + + Groups + + + Metrics + + + Auditing + + + )} + + ); +}; + +interface SidebarNavItemProps { + active?: boolean; + children?: ReactNode; + icon: ReactNode; + href: string; +} + +export const SidebarNavItem: FC = ({ + active, + children, + href, + icon, +}) => { + const link = useClassName(classNames.link, []); + const activeLink = useClassName(classNames.activeLink, []); + + return ( + + + {icon} + {children} + + + ); +}; + +interface SidebarNavSubItemProps { + children?: ReactNode; + href: string; +} + +export const SidebarNavSubItem: FC = ({ + children, + href, +}) => { + const link = useClassName(classNames.subLink, []); + const activeLink = useClassName(classNames.activeSubLink, []); + + return ( + cx([link, isActive && activeLink])} + > + {children} + + ); +}; + +const classNames = { + link: (css, theme) => css` + color: inherit; + display: block; + font-size: 14px; + text-decoration: none; + padding: 10px 12px 10px 16px; + border-radius: 4px; + transition: background-color 0.15s ease-in-out; + position: relative; + + &:hover { + background-color: ${theme.palette.action.hover}; + } + + border-left: 3px solid transparent; + `, + + activeLink: (css, theme) => css` + border-left-color: ${theme.palette.primary.main}; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + `, + + subLink: (css, theme) => css` + color: inherit; + text-decoration: none; + + display: block; + font-size: 13px; + margin-left: 42px; + padding: 4px 12px; + border-radius: 4px; + transition: background-color 0.15s ease-in-out; + margin-bottom: 1px; + position: relative; + + &:hover { + background-color: ${theme.palette.action.hover}; + } + `, + + activeSubLink: (css) => css` + font-weight: 600; + `, +} satisfies Record; diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 3e6cc138426ca..afada2f27a336 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -3,7 +3,7 @@ import FormControlLabel from "@mui/material/FormControlLabel"; import FormHelperText from "@mui/material/FormHelperText"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; -import { type FormikContextType, type FormikTouched, useFormik } from "formik"; +import { type FormikTouched, useFormik } from "formik"; import type { FC } from "react"; import * as Yup from "yup"; import { @@ -27,29 +27,27 @@ import { import { getFormHelpers, nameValidator, - templateDisplayNameValidator, + displayNameValidator, onChangeTrimmed, iconValidator, } from "utils/formUtils"; const MAX_DESCRIPTION_CHAR_LIMIT = 128; -const MAX_DESCRIPTION_MESSAGE = - "Please enter a description that is no longer than 128 characters."; +const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`; -export const getValidationSchema = (): Yup.AnyObjectSchema => - Yup.object({ - name: nameValidator("Name"), - display_name: templateDisplayNameValidator("Display name"), - description: Yup.string().max( - MAX_DESCRIPTION_CHAR_LIMIT, - MAX_DESCRIPTION_MESSAGE, - ), - allow_user_cancel_workspace_jobs: Yup.boolean(), - icon: iconValidator, - require_active_version: Yup.boolean(), - deprecation_message: Yup.string(), - max_port_sharing_level: Yup.string().oneOf(WorkspaceAppSharingLevels), - }); +export const validationSchema = Yup.object({ + name: nameValidator("Name"), + display_name: displayNameValidator("Display name"), + description: Yup.string().max( + MAX_DESCRIPTION_CHAR_LIMIT, + MAX_DESCRIPTION_MESSAGE, + ), + allow_user_cancel_workspace_jobs: Yup.boolean(), + icon: iconValidator, + require_active_version: Yup.boolean(), + deprecation_message: Yup.string(), + max_port_sharing_level: Yup.string().oneOf(WorkspaceAppSharingLevels), +}); export interface TemplateSettingsForm { template: Template; @@ -75,27 +73,25 @@ export const TemplateSettingsForm: FC = ({ advancedSchedulingEnabled, portSharingControlsEnabled, }) => { - const validationSchema = getValidationSchema(); - const form: FormikContextType = - useFormik({ - initialValues: { - name: template.name, - display_name: template.display_name, - description: template.description, - icon: template.icon, - allow_user_cancel_workspace_jobs: - template.allow_user_cancel_workspace_jobs, - update_workspace_last_used_at: false, - update_workspace_dormant_at: false, - require_active_version: template.require_active_version, - deprecation_message: template.deprecation_message, - disable_everyone_group_access: false, - max_port_share_level: template.max_port_share_level, - }, - validationSchema, - onSubmit, - initialTouched, - }); + const form = useFormik({ + initialValues: { + name: template.name, + display_name: template.display_name, + description: template.description, + icon: template.icon, + allow_user_cancel_workspace_jobs: + template.allow_user_cancel_workspace_jobs, + update_workspace_last_used_at: false, + update_workspace_dormant_at: false, + require_active_version: template.require_active_version, + deprecation_message: template.deprecation_message, + disable_everyone_group_access: false, + max_port_share_level: template.max_port_share_level, + }, + validationSchema, + onSubmit, + initialTouched, + }); const getFieldHelpers = getFormHelpers(form, error); return ( diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 716322f982288..7e7b44d8684d1 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -10,7 +10,7 @@ import { waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; -import { getValidationSchema } from "./TemplateSettingsForm"; +import { validationSchema } from "./TemplateSettingsForm"; import { TemplateSettingsPage } from "./TemplateSettingsPage"; type FormValues = Required< @@ -116,9 +116,9 @@ describe("TemplateSettingsPage", () => { const values: UpdateTemplateMeta = { ...validFormValues, description: - "Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port", + "The quick brown fox jumps over the lazy dog repeatedly, enjoying the weather of the bright, summer day in the lush, scenic park.", }; - const validate = () => getValidationSchema().validateSync(values); + const validate = () => validationSchema.validateSync(values); expect(validate).not.toThrowError(); }); @@ -126,9 +126,9 @@ describe("TemplateSettingsPage", () => { const values: UpdateTemplateMeta = { ...validFormValues, description: - "Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus molestie, port a", + "The quick brown fox jumps over the lazy dog multiple times, enjoying the warmth of the bright, sunny day in the lush, green park.", }; - const validate = () => getValidationSchema().validateSync(values); + const validate = () => validationSchema.validateSync(values); expect(validate).toThrowError(); }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx index 1d63e8ade1cc0..5b3078af46bb6 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { mockApiError, MockTemplate } from "testHelpers/entities"; import { TemplateSettingsPageView } from "./TemplateSettingsPageView"; @@ -9,6 +10,7 @@ const meta: Meta = { template: MockTemplate, accessControlEnabled: true, advancedSchedulingEnabled: true, + onCancel: action("onCancel"), }, }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx index ee03b8c3f3435..7cf1ba07a2ef6 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { mockApiError, @@ -13,6 +14,9 @@ import { TemplateVariablesPageView } from "./TemplateVariablesPageView"; const meta: Meta = { title: "pages/TemplateSettingsPage/TemplateVariablesPageView", component: TemplateVariablesPageView, + args: { + onCancel: action("onCancel"), + }, }; export default meta; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx index 3a299e37b20aa..55bebfb1b53ec 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx @@ -1,8 +1,6 @@ -import Button from "@mui/material/Button"; -import { type FC, useEffect, useState } from "react"; +import type { FC } from "react"; import { useQuery } from "react-query"; import { groupsForUser } from "api/queries/groups"; -import { DisabledBadge, EnabledBadge } from "components/Badges/Badges"; import { Stack } from "components/Stack/Stack"; import { useAuthContext } from "contexts/auth/AuthProvider"; import { useAuthenticated } from "contexts/auth/RequireAuth"; @@ -15,7 +13,7 @@ export const AccountPage: FC = () => { const { permissions, user: me } = useAuthenticated(); const { updateProfile, updateProfileError, isUpdatingProfile } = useAuthContext(); - const { entitlements, experiments, organizationId } = useDashboard(); + const { entitlements, organizationId } = useDashboard(); const hasGroupsFeature = entitlements.features.user_role_management.enabled; const groupsQuery = useQuery({ @@ -23,21 +21,6 @@ export const AccountPage: FC = () => { enabled: hasGroupsFeature, }); - const multiOrgExperimentEnabled = experiments.includes("multi-organization"); - const [multiOrgUiEnabled, setMultiOrgUiEnabled] = useState( - () => - multiOrgExperimentEnabled && - Boolean(localStorage.getItem("enableMultiOrganizationUi")), - ); - - useEffect(() => { - if (multiOrgUiEnabled) { - localStorage.setItem("enableMultiOrganizationUi", "true"); - } else { - localStorage.removeItem("enableMultiOrganizationUi"); - } - }, [multiOrgUiEnabled]); - return (
      @@ -58,23 +41,6 @@ export const AccountPage: FC = () => { error={groupsQuery.error} /> )} - - {multiOrgExperimentEnabled && ( -
      Danger: enabling will break things in the UI. - } - > - - {multiOrgUiEnabled ? : } - - -
      - )} ); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx index 0314fa177ace0..a7e29c61dcec9 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { MockWorkspaceBuildParameter1, @@ -19,6 +20,7 @@ const meta: Meta = { isSubmitting: false, workspace: MockWorkspace, canChangeVersions: true, + onCancel: action("onCancel"), data: { buildParameters: [ diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx index a67f17bb07c68..1a548db9bf88e 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; @@ -37,6 +38,7 @@ const meta: Meta = { component: WorkspaceScheduleForm, args: { template: mockTemplate, + onCancel: action("onCancel"), }, }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx index b45281c0f4a9b..fff7f647a4ce6 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { MockWorkspace } from "testHelpers/entities"; import { WorkspaceSettingsPageView } from "./WorkspaceSettingsPageView"; @@ -8,6 +9,7 @@ const meta: Meta = { args: { error: undefined, workspace: MockWorkspace, + onCancel: action("onCancel"), }, }; diff --git a/site/src/router.tsx b/site/src/router.tsx index de288d37d3941..e2685c29f69c8 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -13,6 +13,7 @@ import AuditPage from "./pages/AuditPage/AuditPage"; import { DeploySettingsLayout } from "./pages/DeploySettingsPage/DeploySettingsLayout"; import { HealthLayout } from "./pages/HealthPage/HealthLayout"; import LoginPage from "./pages/LoginPage/LoginPage"; +import { OrganizationSettingsLayout } from "./pages/OrganizationSettingsPage/OrganizationSettingsLayout"; import { SetupPage } from "./pages/SetupPage/SetupPage"; import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout"; import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout"; @@ -220,6 +221,13 @@ const AddNewLicensePage = lazy( () => import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"), ); +const OrganizationSettingsPage = lazy( + () => import("./pages/OrganizationSettingsPage/OrganizationSettingsPage"), +); +const OrganizationSettingsPlaceholder = lazy( + () => + import("./pages/OrganizationSettingsPage/OrganizationSettingsPlaceholder"), +); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), ); @@ -325,6 +333,33 @@ export const router = createBrowserRouter( } /> + } + > + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + + }> } /> } /> diff --git a/site/src/utils/formUtils.ts b/site/src/utils/formUtils.ts index c48eeb301383f..846414eecd95b 100644 --- a/site/src/utils/formUtils.ts +++ b/site/src/utils/formUtils.ts @@ -18,7 +18,7 @@ const Language = { nameTooLong: (name: string, len: number): string => { return `${name} cannot be longer than ${len} characters`; }, - templateDisplayNameInvalidChars: (name: string): string => { + displayNameInvalidChars: (name: string): string => { return `${name} must start and end with non-whitespace character`; }, }; @@ -114,9 +114,9 @@ export const onChangeTrimmed = // REMARK: Keep these consts in sync with coderd/httpapi/httpapi.go const maxLenName = 32; -const templateDisplayNameMaxLength = 64; +const displayNameMaxLength = 64; const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/; -const templateDisplayNameRE = /^[^\s](.*[^\s])?$/; +const displayNameRE = /^[^\s](.*[^\s])?$/; // REMARK: see #1756 for name/username semantics export const nameValidator = (name: string): Yup.StringSchema => @@ -125,17 +125,12 @@ export const nameValidator = (name: string): Yup.StringSchema => .matches(usernameRE, Language.nameInvalidChars(name)) .max(maxLenName, Language.nameTooLong(name, maxLenName)); -export const templateDisplayNameValidator = ( - displayName: string, -): Yup.StringSchema => +export const displayNameValidator = (displayName: string): Yup.StringSchema => Yup.string() - .matches( - templateDisplayNameRE, - Language.templateDisplayNameInvalidChars(displayName), - ) + .matches(displayNameRE, Language.displayNameInvalidChars(displayName)) .max( - templateDisplayNameMaxLength, - Language.nameTooLong(displayName, templateDisplayNameMaxLength), + displayNameMaxLength, + Language.nameTooLong(displayName, displayNameMaxLength), ) .optional(); From 1d3642d0beeb51cfe67a9f28937aefe594b0adb3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 17 Jun 2024 09:24:07 -1000 Subject: [PATCH 089/168] chore: fix link in v2.0.0 changelog to scale tests (#13591) --- docs/changelogs/v2.0.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelogs/v2.0.0.md b/docs/changelogs/v2.0.0.md index fb43de0e9581d..08636be8adb85 100644 --- a/docs/changelogs/v2.0.0.md +++ b/docs/changelogs/v2.0.0.md @@ -4,7 +4,7 @@ we have outgrown development (v0.x) releases: - 1600+ users develop on Coder every day - A single 4-core Coder server can - [happily support](https://coder.com/docs/v2/latest/admin/scale) 1000+ users + [happily support](https://coder.com/docs/admin/scaling/scale-utility#recent-scale-tests) 1000+ users and workspace connections - We have a full suite of [paid features](https://coder.com/docs/v2/latest/enterprise) and enterprise From 1de023a1218fabb67ea119f4786d8e4215304af8 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 18 Jun 2024 10:16:49 +0400 Subject: [PATCH 090/168] chore: add README to clock testing (#13583) Adds README with some draft content explaining why the library exists. Will be most relevant when we spin out into a standalone library. --- clock/README.md | 269 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 clock/README.md diff --git a/clock/README.md b/clock/README.md new file mode 100644 index 0000000000000..63bf31a36612b --- /dev/null +++ b/clock/README.md @@ -0,0 +1,269 @@ +# Quartz + +A Go time testing library for writing deterministic unit tests + +_Note: Quartz is the name I'm targeting for the standalone open source project when we spin this +out._ + +## Why another time testing library? + +Writing good unit tests for components and functions that use the `time` package is difficult, even +though several open source libraries exist. In building Quartz, we took some inspiration from + +- [github.com/benbjohnson/clock](https://github.com/benbjohnson/clock) +- Tailscale's [tstest.Clock](https://github.com/coder/tailscale/blob/main/tstest/clock.go) +- [github.com/aspenmesh/tock](https://github.com/aspenmesh/tock) + +Quartz shares the high level design of a `Clock` interface that closely resembles the functions in +the `time` standard library, and a "real" clock passes thru to the standard library in production, +while a mock clock gives precise control in testing. + +Our high level goal is to write unit tests that + +1. execute quickly +2. don't flake +3. are straightforward to write and understand + +For several reasons, this is a tall order when it comes to code that depends on time, and we found +the existing libraries insufficient for our goals. + +For tests to execute quickly without flakes, we want to focus on _determinism_: the test should run +the same each time, and it should be easy to force the system into a known state (no races) before +executing test assertions. `time.Sleep`, `runtime.Gosched()`, and +polling/[Eventually](https://pkg.go.dev/github.com/stretchr/testify/assert#Eventually) are all +symptoms of an inability to do this easily. + +### Preventing test flakes + +The following example comes from the README from benbjohnson/clock: + +```go +mock := clock.NewMock() +count := 0 + +// Kick off a timer to increment every 1 mock second. +go func() { + ticker := mock.Ticker(1 * time.Second) + for { + <-ticker.C + count++ + } +}() +runtime.Gosched() + +// Move the clock forward 10 seconds. +mock.Add(10 * time.Second) + +// This prints 10. +fmt.Println(count) +``` + +The first race condition is fairly obvious: moving the clock forward 10 seconds may generate 10 +ticks on the `ticker.C` channel, but there is no guarantee that `count++` executes before +`fmt.Println(count)`. + +The second race condition is more subtle, but `runtime.Gosched()` is the tell. Since the ticker +is started on a separate goroutine, there is no guarantee that `mock.Ticker()` executes before +`mock.Add()`. `runtime.Gosched()` is an attempt to get this to happen, but it makes no hard +promises. On a busy system, especially when running tests in parallel, this can flake, advance the +time 10 seconds first, then start the ticker and never generate a tick. + +Let's talk about how Quartz tackles these problems. + +In our experience, an extremely common use case is creating a ticker then doing a 2-arm `select` +with ticks in one and context expiring in another, i.e. + +```go +t := time.NewTicker(duration) +for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + err := do() + if err != nil { + return err + } + } +} +``` + +In Quartz, we refactor this to be more compact and testing friendly: + +```go +t := clock.TickerFunc(ctx, duration, do) +return t.Wait() +``` + +This affords the mock `Clock` the ability to explicitly know when processing of a tick is finished +because it's wrapped in the function passed to `TickerFunc` (`do()` in this example). + +In Quartz, when you advance the clock, you are returned an object you can `Wait()` on to ensure all +ticks and timers triggered are finished. This solves the first race condition in the example. + +(As an aside, we still support a traditional standard library-style `Ticker`. You may find it useful +if you want to keep your code as close as possible to the standard library, or if you need to use +the channel in a larger `select` block. In that case, you'll have to find some other mechanism to +sync tick processing to your test code.) + +To prevent race conditions related to the starting of the ticker, Quartz allows you to set "traps" +for calls that access the clock. + +```go +func TestTicker(t *testing.T) { + mClock := quartz.NewMock(t) + trap := mClock.Trap().TickerFunc() + defer trap.Close() // stop trapping at end + go runMyTicker(mClock) // async calls TickerFunc() + call := trap.Wait(context.Background()) // waits for a call and blocks its return + call.Release() // allow the TickerFunc() call to return + // optionally check the duration using call.Duration + // Move the clock forward 1 tick + mClock.Advance(time.Second).MustWait(context.Background()) + // assert results of the tick +} +``` + +Trapping and then releasing the call to `TickerFunc()` ensures the ticker is started at a +deterministic time, so our calls to `Advance()` will have a predictable effect. + +Take a look at `TestExampleTickerFunc` in `example_test.go` for a complete worked example. + +### Complex time dependence + +Another difficult issue to handle when unit testing is when some code under test makes multiple +calls that depend on the time, and you want to simulate some time passing between them. + +A very basic example is measuring how long something took: + +```go +var measurement time.Duration +go func(clock quartz.Clock) { + start := clock.Now() + doSomething() + measurement = clock.Since(start) +}(mClock) + +// how to get measurement to be, say, 5 seconds? +``` + +The two calls into the clock happen asynchronously, so we need to be able to advance the clock after +the first call to `Now()` but before the call to `Since()`. Doing this with the libraries we +mentioned above means that you have to be able to mock out or otherwise block the completion of +`doSomething()`. + +But, with the trap functionality we mentioned in the previous section, you can deterministically +control the time each call sees. + +```go +trap := mClock.Trap().Since() +var measurement time.Duration +go func(clock quartz.Clock) { + start := clock.Now() + doSomething() + measurement = clock.Since(start) +}(mClock) + +c := trap.Wait(ctx) +mClock.Advance(5*time.Second) +c.Release() +``` + +We wait until we trap the `clock.Since()` call, which implies that `clock.Now()` has completed, then +advance the mock clock 5 seconds. Finally, we release the `clock.Since()` call. Any changes to the +clock that happen _before_ we release the call will be included in the time used for the +`clock.Since()` call. + +As a more involved example, consider an inactivity timeout: we want something to happen if there is +no activity recorded for some period, say 10 minutes in the following example: + +```go +type InactivityTimer struct { + mu sync.Mutex + activity time.Time + clock quartz.Clock +} + +func (i *InactivityTimer) Start() { + i.mu.Lock() + defer i.mu.Unlock() + next := i.clock.Until(i.activity.Add(10*time.Minute)) + t := i.clock.AfterFunc(next, func() { + i.mu.Lock() + defer i.mu.Unlock() + next := i.clock.Until(i.activity.Add(10*time.Minute)) + if next == 0 { + i.timeoutLocked() + return + } + t.Reset(next) + }) +} +``` + +The actual contents of `timeoutLocked()` doesn't matter for this example, and assume there are other +functions that record the latest `activity`. + +We found that some time testing libraries hold a lock on the mock clock while calling the function +passed to `AfterFunc`, resulting in a deadlock if you made clock calls from within. + +Others allow this sort of thing, but don't have the flexibility to test edge cases. There is a +subtle bug in our `Start()` function. The timer may pop a little late, and/or some measurable real +time may elapse before `Until()` gets called inside the `AfterFunc`. If there hasn't been activity, +`next` might be negative. + +To test this in Quartz, we'll use a trap. We only want to trap the inner `Until()` call, not the +initial one, so to make testing easier we can "tag" the call we want. Like this: + +```go +func (i *InactivityTimer) Start() { + i.mu.Lock() + defer i.mu.Unlock() + next := i.clock.Until(i.activity.Add(10*time.Minute)) + t := i.clock.AfterFunc(next, func() { + i.mu.Lock() + defer i.mu.Unlock() + next := i.clock.Until(i.activity.Add(10*time.Minute), "inner") + if next == 0 { + i.timeoutLocked() + return + } + t.Reset(next) + }) +} +``` + +All Quartz `Clock` functions, and functions on returned timers and tickers support zero or more +string tags that allow traps to match on them. + +```go +func TestInactivityTimer_Late(t *testing.T) { + // set a timeout on the test itself, so that if Wait functions get blocked, we don't have to + // wait for the default test timeout of 10 minutes. + ctx, cancel := context.WithTimeout(10*time.Second) + defer cancel() + mClock := quartz.NewMock(t) + trap := mClock.Trap.Until("inner") + defer trap.Close() + + it := &InactivityTimer{ + activity: mClock.Now(), + clock: mClock, + } + it.Start() + + // Trigger the AfterFunc + w := mClock.Advance(10*time.Minute) + c := trap.Wait(ctx) + // Advance the clock a few ms to simulate a busy system + mClock.Advance(3*time.Millisecond) + c.Release() // Until() returns + w.MustWait(ctx) // Wait for the AfterFunc to wrap up + + // Assert that the timeoutLocked() function was called +} +``` + +This test case will fail with our bugged implementation, since the triggered AfterFunc won't call +`timeoutLocked()` and instead will reset the timer with a negative number. The fix is easy, use +`next <= 0` as the comparison. From d0b2f6196cdd3943ce2e925b8f2f7a66b0939724 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 18 Jun 2024 15:40:56 +0400 Subject: [PATCH 091/168] fix: allow mock clock Timers to accept negative duration (#13592) The standard library `NewTimer`, `AfterFunc` and `Reset` allow negative durations, so our mock clock library should as well. --- clock/mock.go | 18 ++++++---- clock/mock_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++ clock/timer.go | 9 ++--- 3 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 clock/mock_test.go diff --git a/clock/mock.go b/clock/mock.go index 97e7a16874851..3ec9779084328 100644 --- a/clock/mock.go +++ b/clock/mock.go @@ -52,9 +52,6 @@ func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, } func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { - if d < 0 { - panic("duration must be positive or zero") - } m.mu.Lock() defer m.mu.Unlock() c := newCall(clockFunctionNewTimer, tags, withDuration(d)) @@ -67,14 +64,17 @@ func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { nxt: m.cur.Add(d), mock: m, } + if d <= 0 { + // zero or negative duration timer means we should immediately fire + // it, rather than add it. + go t.fire(t.mock.cur) + return t + } m.addTimerLocked(t) return t } func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { - if d < 0 { - panic("duration must be positive or zero") - } m.mu.Lock() defer m.mu.Unlock() c := newCall(clockFunctionAfterFunc, tags, withDuration(d)) @@ -85,6 +85,12 @@ func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { fn: f, mock: m, } + if d <= 0 { + // zero or negative duration timer means we should immediately fire + // it, rather than add it. + go t.fire(t.mock.cur) + return t + } m.addTimerLocked(t) return t } diff --git a/clock/mock_test.go b/clock/mock_test.go new file mode 100644 index 0000000000000..61a55d4dacff8 --- /dev/null +++ b/clock/mock_test.go @@ -0,0 +1,82 @@ +package clock_test + +import ( + "context" + "testing" + "time" + + "github.com/coder/coder/v2/clock" +) + +func TestTimer_NegativeDuration(t *testing.T) { + t.Parallel() + // nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + mClock := clock.NewMock(t) + start := mClock.Now() + trap := mClock.Trap().NewTimer() + defer trap.Close() + + timers := make(chan *clock.Timer, 1) + go func() { + timers <- mClock.NewTimer(-time.Second) + }() + c := trap.MustWait(ctx) + c.Release() + // trap returns the actual passed value + if c.Duration != -time.Second { + t.Fatalf("expected -time.Second, got: %v", c.Duration) + } + + tmr := <-timers + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for timer") + case tme := <-tmr.C: + // the tick is the current time, not the past + if !tme.Equal(start) { + t.Fatalf("expected time %v, got %v", start, tme) + } + } + if tmr.Stop() { + t.Fatal("timer still running") + } +} + +func TestAfterFunc_NegativeDuration(t *testing.T) { + t.Parallel() + // nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + mClock := clock.NewMock(t) + trap := mClock.Trap().AfterFunc() + defer trap.Close() + + timers := make(chan *clock.Timer, 1) + done := make(chan struct{}) + go func() { + timers <- mClock.AfterFunc(-time.Second, func() { + close(done) + }) + }() + c := trap.MustWait(ctx) + c.Release() + // trap returns the actual passed value + if c.Duration != -time.Second { + t.Fatalf("expected -time.Second, got: %v", c.Duration) + } + + tmr := <-timers + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for timer") + case <-done: + // OK! + } + if tmr.Stop() { + t.Fatal("timer still running") + } +} diff --git a/clock/timer.go b/clock/timer.go index b2175c953f0d5..14efa9a04db41 100644 --- a/clock/timer.go +++ b/clock/timer.go @@ -44,9 +44,6 @@ func (t *Timer) Reset(d time.Duration, tags ...string) bool { if t.timer != nil { return t.timer.Reset(d) } - if d < 0 { - panic("duration must be positive or zero") - } t.mock.mu.Lock() defer t.mock.mu.Unlock() c := newCall(clockFunctionTimerReset, tags, withDuration(d)) @@ -57,9 +54,9 @@ func (t *Timer) Reset(d time.Duration, tags ...string) bool { case <-t.c: default: } - if d == 0 { - // zero duration timer means we should immediately re-fire it, rather - // than remove and re-add it. + if d <= 0 { + // zero or negative duration timer means we should immediately re-fire + // it, rather than remove and re-add it. t.stopped = false go t.fire(t.mock.cur) return result From 3a1fa0459090b9bbb92a482a0c73795b2e1b05e1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 18 Jun 2024 16:20:21 -0400 Subject: [PATCH 092/168] fix: write server config to telemetry (#13590) * fix: add external auth configs to telemetry * Refactor telemetry to send the entire config * gen * Fix linting --- cli/server.go | 33 ++--- coderd/apidoc/docs.go | 6 - coderd/apidoc/swagger.json | 6 - coderd/telemetry/telemetry.go | 130 ++++++------------ coderd/telemetry/telemetry_test.go | 11 -- codersdk/deployment.go | 2 +- docs/api/general.md | 1 - docs/api/schemas.md | 5 - site/src/api/typesGenerated.ts | 1 - .../ExternalAuthSettingsPageView.stories.tsx | 1 - 10 files changed, 54 insertions(+), 142 deletions(-) diff --git a/cli/server.go b/cli/server.go index 11c3ec50ba833..79d2b132ad6e3 100644 --- a/cli/server.go +++ b/cli/server.go @@ -796,31 +796,18 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):") if vals.Telemetry.Enable { - gitAuth := make([]telemetry.GitAuth, 0) - // TODO: - gitAuthConfigs := make([]codersdk.ExternalAuthConfig, 0) - for _, cfg := range gitAuthConfigs { - gitAuth = append(gitAuth, telemetry.GitAuth{ - Type: cfg.Type, - }) + vals, err := vals.WithoutSecrets() + if err != nil { + return xerrors.Errorf("remove secrets from deployment values: %w", err) } - options.Telemetry, err = telemetry.New(telemetry.Options{ - BuiltinPostgres: builtinPostgres, - DeploymentID: deploymentID, - Database: options.Database, - Logger: logger.Named("telemetry"), - URL: vals.Telemetry.URL.Value(), - Wildcard: vals.WildcardAccessURL.String() != "", - DERPServerRelayURL: vals.DERP.Server.RelayURL.String(), - GitAuth: gitAuth, - GitHubOAuth: vals.OAuth2.Github.ClientID != "", - OIDCAuth: vals.OIDC.ClientID != "", - OIDCIssuerURL: vals.OIDC.IssuerURL.String(), - Prometheus: vals.Prometheus.Enable.Value(), - STUN: len(vals.DERP.Server.STUNAddresses) != 0, - Tunnel: tunnel != nil, - Experiments: vals.Experiments.Value(), + BuiltinPostgres: builtinPostgres, + DeploymentID: deploymentID, + Database: options.Database, + Logger: logger.Named("telemetry"), + URL: vals.Telemetry.URL.Value(), + Tunnel: tunnel != nil, + DeploymentConfig: vals, ParseLicenseJWT: func(lic *telemetry.License) error { // This will be nil when running in AGPL-only mode. if options.ParseLicenseClaims == nil { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 43ab78ffc1eeb..508b11e83083c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9349,12 +9349,6 @@ const docTemplate = `{ "description": "DisplayName is shown in the UI to identify the auth config.", "type": "string" }, - "extra_token_keys": { - "type": "array", - "items": { - "type": "string" - } - }, "id": { "description": "ID is a unique identifier for the auth config.\nIt defaults to ` + "`" + `type` + "`" + ` when not provided.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8871bcf89e502..b114a85affced 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8379,12 +8379,6 @@ "description": "DisplayName is shown in the UI to identify the auth config.", "type": "string" }, - "extra_token_keys": { - "type": "array", - "items": { - "type": "string" - } - }, "id": { "description": "ID is a unique identifier for the auth config.\nIt defaults to `type` when not provided.", "type": "string" diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 36292179da478..1b9489db3af8f 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -41,20 +41,13 @@ type Options struct { // URL is an endpoint to direct telemetry towards! URL *url.URL - BuiltinPostgres bool - DeploymentID string - GitHubOAuth bool - OIDCAuth bool - OIDCIssuerURL string - Wildcard bool - DERPServerRelayURL string - GitAuth []GitAuth - Prometheus bool - STUN bool - SnapshotFrequency time.Duration - Tunnel bool - ParseLicenseJWT func(lic *License) error - Experiments []string + DeploymentID string + DeploymentConfig *codersdk.DeploymentValues + BuiltinPostgres bool + Tunnel bool + + SnapshotFrequency time.Duration + ParseLicenseJWT func(lic *License) error } // New constructs a reporter for telemetry data. @@ -242,31 +235,24 @@ func (r *remoteReporter) deployment() error { } data, err := json.Marshal(&Deployment{ - ID: r.options.DeploymentID, - Architecture: sysInfo.Architecture, - BuiltinPostgres: r.options.BuiltinPostgres, - Containerized: containerized, - Wildcard: r.options.Wildcard, - DERPServerRelayURL: r.options.DERPServerRelayURL, - GitAuth: r.options.GitAuth, - Kubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "", - GitHubOAuth: r.options.GitHubOAuth, - OIDCAuth: r.options.OIDCAuth, - OIDCIssuerURL: r.options.OIDCIssuerURL, - Prometheus: r.options.Prometheus, - InstallSource: installSource, - STUN: r.options.STUN, - Tunnel: r.options.Tunnel, - OSType: sysInfo.OS.Type, - OSFamily: sysInfo.OS.Family, - OSPlatform: sysInfo.OS.Platform, - OSName: sysInfo.OS.Name, - OSVersion: sysInfo.OS.Version, - CPUCores: runtime.NumCPU(), - MemoryTotal: mem.Total, - MachineID: sysInfo.UniqueID, - StartedAt: r.startedAt, - ShutdownAt: r.shutdownAt, + ID: r.options.DeploymentID, + Architecture: sysInfo.Architecture, + BuiltinPostgres: r.options.BuiltinPostgres, + Containerized: containerized, + Config: r.options.DeploymentConfig, + Kubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "", + InstallSource: installSource, + Tunnel: r.options.Tunnel, + OSType: sysInfo.OS.Type, + OSFamily: sysInfo.OS.Family, + OSPlatform: sysInfo.OS.Platform, + OSName: sysInfo.OS.Name, + OSVersion: sysInfo.OS.Version, + CPUCores: runtime.NumCPU(), + MemoryTotal: mem.Total, + MachineID: sysInfo.UniqueID, + StartedAt: r.startedAt, + ShutdownAt: r.shutdownAt, }) if err != nil { return xerrors.Errorf("marshal deployment: %w", err) @@ -481,10 +467,6 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } return nil }) - eg.Go(func() error { - snapshot.Experiments = ConvertExperiments(r.options.Experiments) - return nil - }) err := eg.Wait() if err != nil { @@ -745,16 +727,6 @@ func ConvertExternalProvisioner(id uuid.UUID, tags map[string]string, provisione } } -func ConvertExperiments(experiments []string) []Experiment { - var out []Experiment - - for _, exp := range experiments { - out = append(out, Experiment{Name: exp}) - } - - return out -} - // Snapshot represents a point-in-time anonymized database dump. // Data is aggregated by latest on the server-side, so partial data // can be sent without issue. @@ -777,40 +749,28 @@ type Snapshot struct { WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"` WorkspaceResources []WorkspaceResource `json:"workspace_resources"` Workspaces []Workspace `json:"workspaces"` - Experiments []Experiment `json:"experiments"` } // Deployment contains information about the host running Coder. type Deployment struct { - ID string `json:"id"` - Architecture string `json:"architecture"` - BuiltinPostgres bool `json:"builtin_postgres"` - Containerized bool `json:"containerized"` - Kubernetes bool `json:"kubernetes"` - Tunnel bool `json:"tunnel"` - Wildcard bool `json:"wildcard"` - DERPServerRelayURL string `json:"derp_server_relay_url"` - GitAuth []GitAuth `json:"git_auth"` - GitHubOAuth bool `json:"github_oauth"` - OIDCAuth bool `json:"oidc_auth"` - OIDCIssuerURL string `json:"oidc_issuer_url"` - Prometheus bool `json:"prometheus"` - InstallSource string `json:"install_source"` - STUN bool `json:"stun"` - OSType string `json:"os_type"` - OSFamily string `json:"os_family"` - OSPlatform string `json:"os_platform"` - OSName string `json:"os_name"` - OSVersion string `json:"os_version"` - CPUCores int `json:"cpu_cores"` - MemoryTotal uint64 `json:"memory_total"` - MachineID string `json:"machine_id"` - StartedAt time.Time `json:"started_at"` - ShutdownAt *time.Time `json:"shutdown_at"` -} - -type GitAuth struct { - Type string `json:"type"` + ID string `json:"id"` + Architecture string `json:"architecture"` + BuiltinPostgres bool `json:"builtin_postgres"` + Containerized bool `json:"containerized"` + Kubernetes bool `json:"kubernetes"` + Config *codersdk.DeploymentValues `json:"config"` + Tunnel bool `json:"tunnel"` + InstallSource string `json:"install_source"` + OSType string `json:"os_type"` + OSFamily string `json:"os_family"` + OSPlatform string `json:"os_platform"` + OSName string `json:"os_name"` + OSVersion string `json:"os_version"` + CPUCores int `json:"cpu_cores"` + MemoryTotal uint64 `json:"memory_total"` + MachineID string `json:"machine_id"` + StartedAt time.Time `json:"started_at"` + ShutdownAt *time.Time `json:"shutdown_at"` } type APIKey struct { @@ -985,10 +945,6 @@ type ExternalProvisioner struct { ShutdownAt *time.Time `json:"shutdown_at"` } -type Experiment struct { - Name string `json:"name"` -} - type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 4661a4f8f21bf..fa8650de3f3d5 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -114,17 +114,6 @@ func TestTelemetry(t *testing.T) { require.Len(t, snapshot.Users, 1) require.Equal(t, snapshot.Users[0].EmailHashed, "bb44bf07cf9a2db0554bba63a03d822c927deae77df101874496df5a6a3e896d@coder.com") }) - t.Run("Experiments", func(t *testing.T) { - t.Parallel() - - const expName = "my-experiment" - exps := []string{expName} - _, snapshot := collectSnapshot(t, dbmem.New(), func(opts telemetry.Options) telemetry.Options { - opts.Experiments = exps - return opts - }) - require.Equal(t, []telemetry.Experiment{{Name: expName}}, snapshot.Experiments) - }) } // nolint:paralleltest diff --git a/codersdk/deployment.go b/codersdk/deployment.go index b67964d6a985c..ff35d67bacbb4 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -393,7 +393,7 @@ type ExternalAuthConfig struct { AppInstallationsURL string `json:"app_installations_url" yaml:"app_installations_url"` NoRefresh bool `json:"no_refresh" yaml:"no_refresh"` Scopes []string `json:"scopes" yaml:"scopes"` - ExtraTokenKeys []string `json:"extra_token_keys" yaml:"extra_token_keys"` + ExtraTokenKeys []string `json:"-" yaml:"extra_token_keys"` DeviceFlow bool `json:"device_flow" yaml:"device_flow"` DeviceCodeURL string `json:"device_code_url" yaml:"device_code_url"` // Regex allows API requesters to match an auth config by diff --git a/docs/api/general.md b/docs/api/general.md index 84424331cf488..a92742ce0a707 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -227,7 +227,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index bc3d0440efb9c..cd691e1ad71fc 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1646,7 +1646,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", @@ -2020,7 +2019,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", @@ -2441,7 +2439,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", @@ -2464,7 +2461,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `device_flow` | boolean | false | | | | `display_icon` | string | false | | Display icon is a URL to an icon to display in the UI. | | `display_name` | string | false | | Display name is shown in the UI to identify the auth config. | -| `extra_token_keys` | array of string | false | | | | `id` | string | false | | ID is a unique identifier for the auth config. It defaults to `type` when not provided. | | `no_refresh` | boolean | false | | | | `regex` | string | false | | Regex allows API requesters to match an auth config by a string (e.g. coder.com) instead of by it's type. | @@ -8546,7 +8542,6 @@ _None_ "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c5b67b387b8b9..0d9147c912e9e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -509,7 +509,6 @@ export interface ExternalAuthConfig { readonly app_installations_url: string; readonly no_refresh: boolean; readonly scopes: readonly string[]; - readonly extra_token_keys: readonly string[]; readonly device_flow: boolean; readonly device_code_url: string; readonly regex: string; diff --git a/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx index 22a57140511f5..3ed1d8143738e 100644 --- a/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx +++ b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx @@ -19,7 +19,6 @@ const meta: Meta = { app_installations_url: "", no_refresh: false, scopes: [], - extra_token_keys: [], device_flow: true, device_code_url: "", display_icon: "", From e987ad1d897d2bb7d478fb72864e31a027e01c72 Mon Sep 17 00:00:00 2001 From: Kayla Washburn-Love Date: Tue, 18 Jun 2024 15:36:13 -0600 Subject: [PATCH 093/168] fix: don't allow "new" or "create" as url-friendly names (#13596) --- coderd/apidoc/docs.go | 5 +- coderd/apidoc/swagger.json | 10 +-- coderd/httpapi/httpapi.go | 4 +- coderd/httpapi/name.go | 4 ++ coderd/organizations_test.go | 54 +++++++-------- coderd/templates_test.go | 96 ++++++++++++--------------- codersdk/groups.go | 8 +-- codersdk/organizations.go | 4 +- docs/api/schemas.md | 6 +- docs/api/users.md | 4 +- enterprise/cli/templatecreate_test.go | 10 +-- enterprise/coderd/groups_test.go | 20 ++++++ 12 files changed, 117 insertions(+), 108 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 508b11e83083c..a6e2cc521dfda 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8396,6 +8396,9 @@ const docTemplate = `{ }, "codersdk.CreateGroupRequest": { "type": "object", + "required": [ + "name" + ], "properties": { "avatar_url": { "type": "string" @@ -10038,10 +10041,8 @@ const docTemplate = `{ "type": "object", "required": [ "created_at", - "display_name", "id", "is_default", - "name", "updated_at" ], "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b114a85affced..45b609b7d1317 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7472,6 +7472,7 @@ }, "codersdk.CreateGroupRequest": { "type": "object", + "required": ["name"], "properties": { "avatar_url": { "type": "string" @@ -9019,14 +9020,7 @@ }, "codersdk.Organization": { "type": "object", - "required": [ - "created_at", - "display_name", - "id", - "is_default", - "name", - "updated_at" - ], + "required": ["created_at", "id", "is_default", "updated_at"], "properties": { "created_at": { "type": "string", diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index e8229cf5477c1..c1267d1720e17 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -46,7 +46,7 @@ func init() { valid := NameValid(str) return valid == nil } - for _, tag := range []string{"username", "organization_name", "template_name", "workspace_name", "oauth2_app_name"} { + for _, tag := range []string{"username", "organization_name", "template_name", "group_name", "workspace_name", "oauth2_app_name"} { err := Validate.RegisterValidation(tag, nameValidator) if err != nil { panic(err) @@ -62,7 +62,7 @@ func init() { valid := DisplayNameValid(str) return valid == nil } - for _, displayNameTag := range []string{"organization_display_name", "template_display_name"} { + for _, displayNameTag := range []string{"organization_display_name", "template_display_name", "group_display_name"} { err := Validate.RegisterValidation(displayNameTag, displayNameValidator) if err != nil { panic(err) diff --git a/coderd/httpapi/name.go b/coderd/httpapi/name.go index 9431f574e5565..c9f926d4b3b42 100644 --- a/coderd/httpapi/name.go +++ b/coderd/httpapi/name.go @@ -46,6 +46,10 @@ func NameValid(str string) error { if len(str) < 1 { return xerrors.New("must be >= 1 character") } + // Avoid conflicts with routes like /templates/new and /groups/create. + if str == "new" || str == "create" { + return xerrors.Errorf("cannot use %q as a name", str) + } matched := UsernameValidRegex.MatchString(str) if !matched { return xerrors.New("must be alphanumeric with hyphens") diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index 0dafb53590814..347048ed67a5c 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -140,14 +140,14 @@ func TestPostOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", - DisplayName: "New", + Name: "new-org", + DisplayName: "New organization", Description: "A new organization to love and cherish forever.", Icon: "/emojis/1f48f-1f3ff.png", }) require.NoError(t, err) - require.Equal(t, "new", o.Name) - require.Equal(t, "New", o.DisplayName) + require.Equal(t, "new-org", o.Name) + require.Equal(t, "New organization", o.DisplayName) require.Equal(t, "A new organization to love and cherish forever.", o.Description) require.Equal(t, "/emojis/1f48f-1f3ff.png", o.Icon) }) @@ -159,11 +159,11 @@ func TestPostOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", + Name: "new-org", }) require.NoError(t, err) - require.Equal(t, "new", o.Name) - require.Equal(t, "new", o.DisplayName) // should match the given `Name` + require.Equal(t, "new-org", o.Name) + require.Equal(t, "new-org", o.DisplayName) // should match the given `Name` }) } @@ -238,16 +238,16 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", - DisplayName: "New", + Name: "new-org", + DisplayName: "New organization", }) require.NoError(t, err) o, err = client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{ - Name: "new-new", + Name: "new-new-org", }) require.NoError(t, err) - require.Equal(t, "new-new", o.Name) + require.Equal(t, "new-new-org", o.Name) }) t.Run("UpdateByName", func(t *testing.T) { @@ -257,17 +257,17 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", - DisplayName: "New", + Name: "new-org", + DisplayName: "New organization", }) require.NoError(t, err) o, err = client.UpdateOrganization(ctx, o.Name, codersdk.UpdateOrganizationRequest{ - Name: "new-new", + Name: "new-new-org", }) require.NoError(t, err) - require.Equal(t, "new-new", o.Name) - require.Equal(t, "New", o.DisplayName) // didn't change + require.Equal(t, "new-new-org", o.Name) + require.Equal(t, "New organization", o.DisplayName) // didn't change }) t.Run("UpdateDisplayName", func(t *testing.T) { @@ -277,8 +277,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", - DisplayName: "New", + Name: "new-org", + DisplayName: "New organization", }) require.NoError(t, err) @@ -286,7 +286,7 @@ func TestPatchOrganizationsByUser(t *testing.T) { DisplayName: "The Newest One", }) require.NoError(t, err) - require.Equal(t, "new", o.Name) // didn't change + require.Equal(t, "new-org", o.Name) // didn't change require.Equal(t, "The Newest One", o.DisplayName) }) @@ -297,8 +297,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", - DisplayName: "New", + Name: "new-org", + DisplayName: "New organization", }) require.NoError(t, err) @@ -307,8 +307,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { }) require.NoError(t, err) - require.Equal(t, "new", o.Name) // didn't change - require.Equal(t, "New", o.DisplayName) // didn't change + require.Equal(t, "new-org", o.Name) // didn't change + require.Equal(t, "New organization", o.DisplayName) // didn't change require.Equal(t, "wow, this organization description is so updated!", o.Description) }) @@ -319,8 +319,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ - Name: "new", - DisplayName: "New", + Name: "new-org", + DisplayName: "New organization", }) require.NoError(t, err) @@ -329,8 +329,8 @@ func TestPatchOrganizationsByUser(t *testing.T) { }) require.NoError(t, err) - require.Equal(t, "new", o.Name) // didn't change - require.Equal(t, "New", o.DisplayName) // didn't change + require.Equal(t, "new-org", o.Name) // didn't change + require.Equal(t, "New organization", o.DisplayName) // didn't change require.Equal(t, "/emojis/1f48f-1f3ff.png", o.Icon) }) } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 01b3462f603c3..7aebaf41b1e1b 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -37,8 +37,7 @@ func TestTemplate(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.Template(ctx, template.ID) require.NoError(t, err) @@ -63,8 +62,7 @@ func TestPostTemplateByOrganization(t *testing.T) { }) assert.Equal(t, (3 * time.Hour).Milliseconds(), expected.ActivityBumpMillis) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) got, err := user.Template(ctx, expected.ID) require.NoError(t, err) @@ -86,8 +84,7 @@ func TestPostTemplateByOrganization(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ Name: template.Name, @@ -98,15 +95,30 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Equal(t, http.StatusConflict, apiErr.StatusCode()) }) - t.Run("DefaultTTLTooLow", func(t *testing.T) { + t.Run("ReservedName", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "new", + VersionID: version.ID, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("DefaultTTLTooLow", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ Name: "testing", VersionID: version.ID, @@ -124,9 +136,7 @@ func TestPostTemplateByOrganization(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - + ctx := testutil.Context(t, testutil.WaitLong) got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ Name: "testing", VersionID: version.ID, @@ -143,15 +153,13 @@ func TestPostTemplateByOrganization(t *testing.T) { owner := coderdtest.CreateFirstUser(t, client) user, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) - expected := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(request *codersdk.CreateTemplateRequest) { request.DisableEveryoneGroupAccess = true }) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - + ctx := testutil.Context(t, testutil.WaitLong) _, err := user.Template(ctx, expected.ID) + var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) @@ -161,9 +169,7 @@ func TestPostTemplateByOrganization(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.CreateTemplate(ctx, uuid.New(), codersdk.CreateTemplateRequest{ Name: "test", VersionID: uuid.New(), @@ -241,8 +247,7 @@ func TestPostTemplateByOrganization(t *testing.T) { client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ Name: "test", @@ -398,8 +403,7 @@ func TestTemplatesByOrganization(t *testing.T) { client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) templates, err := client.TemplatesByOrganization(ctx, user.OrganizationID) require.NoError(t, err) @@ -414,8 +418,7 @@ func TestTemplatesByOrganization(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) templates, err := client.TemplatesByOrganization(ctx, user.OrganizationID) require.NoError(t, err) @@ -430,8 +433,7 @@ func TestTemplatesByOrganization(t *testing.T) { coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.CreateTemplate(t, client, user.OrganizationID, version2.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) templates, err := client.TemplatesByOrganization(ctx, user.OrganizationID) require.NoError(t, err) @@ -446,8 +448,7 @@ func TestTemplateByOrganizationAndName(t *testing.T) { client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.TemplateByName(ctx, user.OrganizationID, "something") var apiErr *codersdk.Error @@ -462,8 +463,7 @@ func TestTemplateByOrganizationAndName(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.TemplateByName(ctx, user.OrganizationID, template.Name) require.NoError(t, err) @@ -497,8 +497,7 @@ func TestPatchTemplateMeta(t *testing.T) { // updatedAt is too close together. time.Sleep(time.Millisecond * 5) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) require.NoError(t, err) @@ -542,8 +541,7 @@ func TestPatchTemplateMeta(t *testing.T) { DeprecationMessage: ptr.Ref("APGL cannot deprecate"), } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) require.NoError(t, err) @@ -566,8 +564,8 @@ func TestPatchTemplateMeta(t *testing.T) { // updatedAt is too close together. time.Sleep(time.Millisecond * 5) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) + // nolint:gocritic // Setting up unit test data err := db.UpdateTemplateAccessControlByID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(tplAdmin, user.OrganizationID)), database.UpdateTemplateAccessControlByIDParams{ ID: template.ID, @@ -607,8 +605,7 @@ func TestPatchTemplateMeta(t *testing.T) { MaxPortShareLevel: &level, } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.UpdateTemplateMeta(ctx, template.ID, req) // AGPL cannot change max port sharing level @@ -643,8 +640,7 @@ func TestPatchTemplateMeta(t *testing.T) { // We're too fast! Sleep so we can be sure that updatedAt is greater time.Sleep(time.Millisecond * 5) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.UpdateTemplateMeta(ctx, template.ID, req) require.NoError(t, err) @@ -675,8 +671,7 @@ func TestPatchTemplateMeta(t *testing.T) { DefaultTTLMillis: -1, } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.UpdateTemplateMeta(ctx, template.ID, req) require.ErrorContains(t, err, "default_ttl_ms: Must be a positive integer") @@ -886,8 +881,7 @@ func TestPatchTemplateMeta(t *testing.T) { ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) }) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.UpdateTemplateMeta{ Name: template.Name, @@ -921,8 +915,7 @@ func TestPatchTemplateMeta(t *testing.T) { ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) }) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.UpdateTemplateMeta{ DefaultTTLMillis: -int64(time.Hour), @@ -956,8 +949,7 @@ func TestPatchTemplateMeta(t *testing.T) { Icon: "", } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) require.NoError(t, err) @@ -1164,8 +1156,7 @@ func TestDeleteTemplate(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) err := client.DeleteTemplate(ctx, template.ID) require.NoError(t, err) @@ -1183,8 +1174,7 @@ func TestDeleteTemplate(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) err := client.DeleteTemplate(ctx, template.ID) var apiErr *codersdk.Error diff --git a/codersdk/groups.go b/codersdk/groups.go index eb76902b013b4..4b5b8f5a5f4e6 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -18,8 +18,8 @@ const ( ) type CreateGroupRequest struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` + Name string `json:"name" validate:"required,group_name"` + DisplayName string `json:"display_name" validate:"omitempty,group_display_name"` AvatarURL string `json:"avatar_url"` QuotaAllowance int `json:"quota_allowance"` } @@ -111,8 +111,8 @@ func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { type PatchGroupRequest struct { AddUsers []string `json:"add_users"` RemoveUsers []string `json:"remove_users"` - Name string `json:"name"` - DisplayName *string `json:"display_name"` + Name string `json:"name" validate:"omitempty,group_name"` + DisplayName *string `json:"display_name" validate:"omitempty,group_display_name"` AvatarURL *string `json:"avatar_url"` QuotaAllowance *int `json:"quota_allowance"` } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f65479f65715f..35bd1d64568e0 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -41,8 +41,8 @@ func ProvisionerTypeValid[T ProvisionerType | string](pt T) error { // Organization is the JSON representation of a Coder organization. type Organization struct { ID uuid.UUID `table:"id" json:"id" validate:"required" format:"uuid"` - Name string `table:"name,default_sort" json:"name" validate:"required,username"` - DisplayName string `table:"display_name" json:"display_name" validate:"required"` + Name string `table:"name,default_sort" json:"name"` + DisplayName string `table:"display_name" json:"display_name"` Description string `table:"description" json:"description"` CreatedAt time.Time `table:"created_at" json:"created_at" validate:"required" format:"date-time"` UpdatedAt time.Time `table:"updated_at" json:"updated_at" validate:"required" format:"date-time"` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index cd691e1ad71fc..a5c333b3d0bd6 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1020,7 +1020,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ----------------- | ------- | -------- | ------------ | ----------- | | `avatar_url` | string | false | | | | `display_name` | string | false | | | -| `name` | string | false | | | +| `name` | string | true | | | | `quota_allowance` | integer | false | | | ## codersdk.CreateOrganizationRequest @@ -3227,11 +3227,11 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | -------------- | ------- | -------- | ------------ | ----------- | | `created_at` | string | true | | | | `description` | string | false | | | -| `display_name` | string | true | | | +| `display_name` | string | false | | | | `icon` | string | false | | | | `id` | string | true | | | | `is_default` | boolean | true | | | -| `name` | string | true | | | +| `name` | string | false | | | | `updated_at` | string | true | | | ## codersdk.OrganizationMember diff --git a/docs/api/users.md b/docs/api/users.md index 1f6a37346e1f1..0fd67493def34 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -1024,11 +1024,11 @@ Status Code **200** | `[array item]` | array | false | | | | `» created_at` | string(date-time) | true | | | | `» description` | string | false | | | -| `» display_name` | string | true | | | +| `» display_name` | string | false | | | | `» icon` | string | false | | | | `» id` | string(uuid) | true | | | | `» is_default` | boolean | true | | | -| `» name` | string | true | | | +| `» name` | string | false | | | | `» updated_at` | string(date-time) | true | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/enterprise/cli/templatecreate_test.go b/enterprise/cli/templatecreate_test.go index 6803ad394033e..987cac0b93058 100644 --- a/enterprise/cli/templatecreate_test.go +++ b/enterprise/cli/templatecreate_test.go @@ -41,7 +41,7 @@ func TestTemplateCreate(t *testing.T) { }) inv, conf := newCLI(t, "templates", - "create", "new", + "create", "new-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--require-active-version", @@ -54,7 +54,7 @@ func TestTemplateCreate(t *testing.T) { require.NoError(t, err) ctx := testutil.Context(t, testutil.WaitMedium) - template, err := templateAdmin.TemplateByName(ctx, user.OrganizationID, "new") + template, err := templateAdmin.TemplateByName(ctx, user.OrganizationID, "new-template") require.NoError(t, err) require.True(t, template.RequireActiveVersion) }) @@ -86,7 +86,7 @@ func TestTemplateCreate(t *testing.T) { ) inv, conf := newCLI(t, "templates", - "create", "new", + "create", "new-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--failure-ttl="+expectedFailureTTL.String(), @@ -102,7 +102,7 @@ func TestTemplateCreate(t *testing.T) { require.NoError(t, err) ctx := testutil.Context(t, testutil.WaitMedium) - template, err := templateAdmin.TemplateByName(ctx, user.OrganizationID, "new") + template, err := templateAdmin.TemplateByName(ctx, user.OrganizationID, "new-template") require.NoError(t, err) require.Equal(t, expectedFailureTTL.Milliseconds(), template.FailureTTLMillis) require.Equal(t, expectedDormancyThreshold.Milliseconds(), template.TimeTilDormantMillis) @@ -123,7 +123,7 @@ func TestTemplateCreate(t *testing.T) { templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleTemplateAdmin()) inv, conf := newCLI(t, "templates", - "create", "new", + "create", "new-template", "--require-active-version", "-y", ) diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 5efb2e5361056..4d84a24601b1a 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -101,6 +101,26 @@ func TestCreateGroup(t *testing.T) { require.Equal(t, http.StatusConflict, cerr.StatusCode()) }) + t.Run("ReservedName", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) + ctx := testutil.Context(t, testutil.WaitLong) + _, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "new", + }) + + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + t.Run("allUsers", func(t *testing.T) { t.Parallel() From 84cdcac8ad6cea1ecd72f6edf8b21e9fa689d1d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 10:51:25 +0300 Subject: [PATCH 094/168] chore: bump ws from 8.14.2 to 8.17.1 in /site (#13595) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index d4c27a5cb92d0..378c8c5c515a1 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -3954,7 +3954,7 @@ packages: util: 0.12.5 util-deprecate: 1.0.2 watchpack: 2.4.0 - ws: 8.14.2 + ws: 8.17.1 transitivePeerDependencies: - bufferutil - encoding @@ -9423,7 +9423,7 @@ packages: whatwg-encoding: 2.0.0 whatwg-mimetype: 3.0.0 whatwg-url: 11.0.0 - ws: 8.14.2 + ws: 8.17.1 xml-name-validator: 4.0.0 transitivePeerDependencies: - bufferutil @@ -13185,8 +13185,8 @@ packages: signal-exit: 3.0.7 dev: true - /ws@8.14.2: - resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} + /ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 From 7049d7a8816c7d2d2eff7769d8a718ec8aca6e17 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 19 Jun 2024 12:02:51 -0400 Subject: [PATCH 095/168] fix: display trial errors in the dashboard (#13601) * fix: display trial errors in the dashboard The error was essentially being ignored before! * Remove day mention in product of trial * fmt --- cli/login.go | 2 +- enterprise/trialer/trialer.go | 16 +++++++++++++++ .../pages/SetupPage/SetupPageView.stories.tsx | 9 +++++++++ site/src/pages/SetupPage/SetupPageView.tsx | 20 ++++++++++++++++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cli/login.go b/cli/login.go index 65a94d8a4ec3e..87cfea103c271 100644 --- a/cli/login.go +++ b/cli/login.go @@ -239,7 +239,7 @@ func (r *RootCmd) login() *serpent.Command { if !inv.ParsedFlags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" { v, _ := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Start a 30-day trial of Enterprise?", + Text: "Start a trial of Enterprise?", IsConfirm: true, Default: "yes", }) diff --git a/enterprise/trialer/trialer.go b/enterprise/trialer/trialer.go index fd846df58db61..fa5d15a65b25a 100644 --- a/enterprise/trialer/trialer.go +++ b/enterprise/trialer/trialer.go @@ -39,6 +39,22 @@ func New(db database.Store, url string, keys map[string]ed25519.PublicKey) func( return xerrors.Errorf("perform license request: %w", err) } defer res.Body.Close() + if res.StatusCode > 300 { + body, err := io.ReadAll(res.Body) + if err != nil { + return xerrors.Errorf("read license response: %w", err) + } + // This is the format of the error response from + // the license server. + var msg struct { + Error string `json:"error"` + } + err = json.Unmarshal(body, &msg) + if err != nil { + return xerrors.Errorf("unmarshal error: %w", err) + } + return xerrors.New(msg.Error) + } raw, err := io.ReadAll(res.Body) if err != nil { return xerrors.Errorf("read license: %w", err) diff --git a/site/src/pages/SetupPage/SetupPageView.stories.tsx b/site/src/pages/SetupPage/SetupPageView.stories.tsx index 239fb10cab930..030115fbbddb9 100644 --- a/site/src/pages/SetupPage/SetupPageView.stories.tsx +++ b/site/src/pages/SetupPage/SetupPageView.stories.tsx @@ -22,6 +22,15 @@ export const FormError: Story = { }, }; +export const TrialError: Story = { + args: { + error: mockApiError({ + message: "Couldn't generate trial!", + detail: "It looks like your team is already trying Coder.", + }), + }, +}; + export const Loading: Story = { args: { isLoading: true, diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index af673acacc333..7f97deb973991 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -1,13 +1,16 @@ import LoadingButton from "@mui/lab/LoadingButton"; +import AlertTitle from "@mui/material/AlertTitle"; import Autocomplete from "@mui/material/Autocomplete"; import Checkbox from "@mui/material/Checkbox"; import Link from "@mui/material/Link"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; +import { isAxiosError } from "axios"; import { type FormikContextType, useFormik } from "formik"; import type { FC } from "react"; import * as Yup from "yup"; import type * as TypesGen from "api/typesGenerated"; +import { Alert, AlertDetail } from "components/Alert/Alert"; import { FormFields, VerticalForm } from "components/Form/Form"; import { CoderIcon } from "components/Icons/CoderIcon"; import { SignInLayout } from "components/SignInLayout/SignInLayout"; @@ -187,7 +190,7 @@ export const SetupPageView: FC = ({
      - Start a 30-day free trial of Enterprise + Start a free trial of Enterprise ({ @@ -316,6 +319,21 @@ export const SetupPageView: FC = ({ )} + {isAxiosError(error) && error.response?.data?.message && ( + + {error.response.data.message} + {error.response.data.detail && ( + + {error.response.data.detail} +
      + + Contact Sales + +
      + )} +
      + )} + Date: Thu, 20 Jun 2024 10:16:04 +0400 Subject: [PATCH 096/168] feat: add NewTicker to clock testing library (#13593) --- clock/clock.go | 9 ++++- clock/mock.go | 62 +++++++++++++++++++++++++-------- clock/mock_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++++++ clock/real.go | 5 +++ clock/ticker.go | 68 ++++++++++++++++++++++++++++++++++++ clock/timer.go | 2 +- 6 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 clock/ticker.go diff --git a/clock/clock.go b/clock/clock.go index 5f3b0de105911..ae550334844c2 100644 --- a/clock/clock.go +++ b/clock/clock.go @@ -10,9 +10,16 @@ import ( ) type Clock interface { + // NewTicker returns a new Ticker containing a channel that will send the current time on the + // channel after each tick. The period of the ticks is specified by the duration argument. The + // ticker will adjust the time interval or drop ticks to make up for slow receivers. The + // duration d must be greater than zero; if not, NewTicker will panic. Stop the ticker to + // release associated resources. + NewTicker(d time.Duration, tags ...string) *Ticker // TickerFunc is a convenience function that calls f on the interval d until either the given // context expires or f returns an error. Callers may call Wait() on the returned Waiter to - // wait until this happens and obtain the error. + // wait until this happens and obtain the error. The duration d must be greater than zero; if + // not, TickerFunc will panic. TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter // NewTimer creates a new Timer that will send the current time on its channel after at least // duration d. diff --git a/clock/mock.go b/clock/mock.go index 3ec9779084328..31c0079da9769 100644 --- a/clock/mock.go +++ b/clock/mock.go @@ -32,6 +32,9 @@ type event interface { } func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter { + if d <= 0 { + panic("TickerFunc called with negative or zero duration") + } m.mu.Lock() defer m.mu.Unlock() c := newCall(clockFunctionTickerFunc, tags, withDuration(d)) @@ -51,6 +54,28 @@ func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, return t } +func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker { + if d <= 0 { + panic("NewTicker called with negative or zero duration") + } + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionNewTicker, tags, withDuration(d)) + m.matchCallLocked(c) + defer close(c.complete) + // 1 element buffer follows standard library implementation + ticks := make(chan time.Time, 1) + t := &Ticker{ + C: ticks, + c: ticks, + d: d, + nxt: m.cur.Add(d), + mock: m, + } + m.addEventLocked(t) + return t +} + func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { m.mu.Lock() defer m.mu.Unlock() @@ -70,7 +95,7 @@ func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { go t.fire(t.mock.cur) return t } - m.addTimerLocked(t) + m.addEventLocked(t) return t } @@ -91,7 +116,7 @@ func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { go t.fire(t.mock.cur) return t } - m.addTimerLocked(t) + m.addEventLocked(t) return t } @@ -122,8 +147,8 @@ func (m *Mock) Until(t time.Time, tags ...string) time.Duration { return t.Sub(m.cur) } -func (m *Mock) addTimerLocked(t *Timer) { - m.all = append(m.all, t) +func (m *Mock) addEventLocked(e event) { + m.all = append(m.all, e) m.recomputeNextLocked() } @@ -152,20 +177,12 @@ func (m *Mock) removeTimer(t *Timer) { } func (m *Mock) removeTimerLocked(t *Timer) { - defer m.recomputeNextLocked() t.stopped = true - var e event = t - for i := range m.all { - if m.all[i] == e { - m.all = append(m.all[:i], m.all[i+1:]...) - return - } - } + m.removeEventLocked(t) } -func (m *Mock) removeTickerFuncLocked(ct *mockTickerFunc) { +func (m *Mock) removeEventLocked(e event) { defer m.recomputeNextLocked() - var e event = ct for i := range m.all { if m.all[i] == e { m.all = append(m.all[:i], m.all[i+1:]...) @@ -371,6 +388,18 @@ func (t Trapper) TickerFuncWait(tags ...string) *Trap { return t.mock.newTrap(clockFunctionTickerFuncWait, tags) } +func (t Trapper) NewTicker(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionNewTicker, tags) +} + +func (t Trapper) TickerStop(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTickerStop, tags) +} + +func (t Trapper) TickerReset(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTickerReset, tags) +} + func (t Trapper) Now(tags ...string) *Trap { return t.mock.newTrap(clockFunctionNow, tags) } @@ -459,7 +488,7 @@ func (m *mockTickerFunc) exitLocked(err error) { } m.done = true m.err = err - m.mock.removeTickerFuncLocked(m) + m.mock.removeEventLocked(m) m.cond.Broadcast() } @@ -493,6 +522,9 @@ const ( clockFunctionTimerReset clockFunctionTickerFunc clockFunctionTickerFuncWait + clockFunctionNewTicker + clockFunctionTickerReset + clockFunctionTickerStop clockFunctionNow clockFunctionSince clockFunctionUntil diff --git a/clock/mock_test.go b/clock/mock_test.go index 61a55d4dacff8..d50e88884b54c 100644 --- a/clock/mock_test.go +++ b/clock/mock_test.go @@ -80,3 +80,90 @@ func TestAfterFunc_NegativeDuration(t *testing.T) { t.Fatal("timer still running") } } + +func TestNewTicker(t *testing.T) { + t.Parallel() + // nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out + ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Second) + defer cancel() + + mClock := clock.NewMock(t) + start := mClock.Now() + trapNT := mClock.Trap().NewTicker("new") + defer trapNT.Close() + trapStop := mClock.Trap().TickerStop("stop") + defer trapStop.Close() + trapReset := mClock.Trap().TickerReset("reset") + defer trapReset.Close() + + tickers := make(chan *clock.Ticker, 1) + go func() { + tickers <- mClock.NewTicker(time.Hour, "new") + }() + c := trapNT.MustWait(ctx) + c.Release() + if c.Duration != time.Hour { + t.Fatalf("expected time.Hour, got: %v", c.Duration) + } + tkr := <-tickers + + for i := 0; i < 3; i++ { + mClock.Advance(time.Hour).MustWait(ctx) + } + + // should get first tick, rest dropped + tTime := start.Add(time.Hour) + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for ticker") + case tick := <-tkr.C: + if !tick.Equal(tTime) { + t.Fatalf("expected time %v, got %v", tTime, tick) + } + } + + go tkr.Reset(time.Minute, "reset") + c = trapReset.MustWait(ctx) + mClock.Advance(time.Second).MustWait(ctx) + c.Release() + if c.Duration != time.Minute { + t.Fatalf("expected time.Minute, got: %v", c.Duration) + } + mClock.Advance(time.Minute).MustWait(ctx) + + // tick should show present time, ensuring the 2 hour ticks got dropped when + // we didn't read from the channel. + tTime = mClock.Now() + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for ticker") + case tick := <-tkr.C: + if !tick.Equal(tTime) { + t.Fatalf("expected time %v, got %v", tTime, tick) + } + } + + go tkr.Stop("stop") + trapStop.MustWait(ctx).Release() + mClock.Advance(time.Hour).MustWait(ctx) + select { + case <-tkr.C: + t.Fatal("ticker still running") + default: + // OK + } + + // Resetting after stop + go tkr.Reset(time.Minute, "reset") + trapReset.MustWait(ctx).Release() + mClock.Advance(time.Minute).MustWait(ctx) + tTime = mClock.Now() + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for ticker") + case tick := <-tkr.C: + if !tick.Equal(tTime) { + t.Fatalf("expected time %v, got %v", tTime, tick) + } + } +} diff --git a/clock/real.go b/clock/real.go index 41019571e6aea..55800c87c58ba 100644 --- a/clock/real.go +++ b/clock/real.go @@ -11,6 +11,11 @@ func NewReal() Clock { return realClock{} } +func (realClock) NewTicker(d time.Duration, _ ...string) *Ticker { + tkr := time.NewTicker(d) + return &Ticker{ticker: tkr, C: tkr.C} +} + func (realClock) TickerFunc(ctx context.Context, d time.Duration, f func() error, _ ...string) Waiter { ct := &realContextTicker{ ctx: ctx, diff --git a/clock/ticker.go b/clock/ticker.go new file mode 100644 index 0000000000000..0ef68f91b5027 --- /dev/null +++ b/clock/ticker.go @@ -0,0 +1,68 @@ +package clock + +import "time" + +type Ticker struct { + C <-chan time.Time + //nolint: revive + c chan time.Time + ticker *time.Ticker // realtime impl, if set + d time.Duration // period, if set + nxt time.Time // next tick time + mock *Mock // mock clock, if set + stopped bool // true if the ticker is not running +} + +func (t *Ticker) fire(tt time.Time) { + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + if t.stopped { + return + } + for !t.nxt.After(t.mock.cur) { + t.nxt = t.nxt.Add(t.d) + } + t.mock.recomputeNextLocked() + select { + case t.c <- tt: + default: + } +} + +func (t *Ticker) next() time.Time { + return t.nxt +} + +func (t *Ticker) Stop(tags ...string) { + if t.ticker != nil { + t.ticker.Stop() + return + } + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + c := newCall(clockFunctionTickerStop, tags) + t.mock.matchCallLocked(c) + defer close(c.complete) + t.mock.removeEventLocked(t) + t.stopped = true +} + +func (t *Ticker) Reset(d time.Duration, tags ...string) { + if t.ticker != nil { + t.ticker.Reset(d) + return + } + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + c := newCall(clockFunctionTickerReset, tags, withDuration(d)) + t.mock.matchCallLocked(c) + defer close(c.complete) + t.nxt = t.mock.cur.Add(d) + t.d = d + if t.stopped { + t.stopped = false + t.mock.addEventLocked(t) + } else { + t.mock.recomputeNextLocked() + } +} diff --git a/clock/timer.go b/clock/timer.go index 14efa9a04db41..8735fc05b9a99 100644 --- a/clock/timer.go +++ b/clock/timer.go @@ -64,6 +64,6 @@ func (t *Timer) Reset(d time.Duration, tags ...string) bool { t.mock.removeTimerLocked(t) t.stopped = false t.nxt = t.mock.cur.Add(d) - t.mock.addTimerLocked(t) + t.mock.addEventLocked(t) return result } From 8923ce521657dd692340240f9f3f59a6065b6f32 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 20 Jun 2024 12:02:31 +0400 Subject: [PATCH 097/168] fix: fix flake in TestAppHealth_Healthy (#13607) --- agent/apphealth_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/agent/apphealth_test.go b/agent/apphealth_test.go index dbcb40b7e69e9..ff411433e3821 100644 --- a/agent/apphealth_test.go +++ b/agent/apphealth_test.go @@ -56,15 +56,16 @@ func TestAppHealth_Healthy(t *testing.T) { Health: codersdk.WorkspaceAppHealthInitializing, }, } - checks := make(map[string]int) + checks2 := 0 + checks3 := 0 handlers := []http.Handler{ nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - checks["app2"]++ + checks2++ httpapi.Write(r.Context(), w, http.StatusOK, nil) }), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - checks["app3"]++ + checks3++ httpapi.Write(r.Context(), w, http.StatusOK, nil) }), } @@ -109,8 +110,8 @@ func TestAppHealth_Healthy(t *testing.T) { require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[2].Health) // ensure we aren't spamming - require.Equal(t, 2, checks["app2"]) - require.Equal(t, 1, checks["app3"]) + require.Equal(t, 2, checks2) + require.Equal(t, 1, checks3) } func TestAppHealth_500(t *testing.T) { From 4699adee5e6e7d240019fa1dc4b3c92a627d1f35 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 21 Jun 2024 00:12:25 +1000 Subject: [PATCH 098/168] chore: update dogfood sydney server (#13610) --- dogfood/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dogfood/main.tf b/dogfood/main.tf index 9c6076aff3d9e..58c78fbec11c3 100644 --- a/dogfood/main.tf +++ b/dogfood/main.tf @@ -17,7 +17,7 @@ locals { "" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" "us-pittsburgh" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" "eu-helsinki" = "tcp://reinhard-hel-cdr-dev.tailscale.svc.cluster.local:2375" - "ap-sydney" = "tcp://hildegard-sydney-cdr-dev.tailscale.svc.cluster.local:2375" + "ap-sydney" = "tcp://wolfgang-syd-cdr-dev.tailscale.svc.cluster.local:2375" "sa-saopaulo" = "tcp://oberstein-sao-cdr-dev.tailscale.svc.cluster.local:2375" "za-jnb" = "tcp://greenhill-jnb-cdr-dev.tailscale.svc.cluster.local:2375" } From 8e06ad46d038648f3866eb9521b13f4a10cfe601 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 20 Jun 2024 04:19:24 -1000 Subject: [PATCH 099/168] chore: add organization member api + cli (#13577) --- cli/organization.go | 2 +- cli/organizationmembers.go | 32 ++++++++++++++++++ cli/organizationmembers_test.go | 38 +++++++++++++++++++++ coderd/apidoc/docs.go | 41 +++++++++++++++++++++++ coderd/apidoc/swagger.json | 37 +++++++++++++++++++++ coderd/coderd.go | 23 ++++++++++--- coderd/members.go | 47 ++++++++++++++++++++++++++ coderd/members_test.go | 59 +++++++++++++++++++++++++++++++++ codersdk/users.go | 14 ++++++++ docs/api/members.md | 48 +++++++++++++++++++++++++++ 10 files changed, 335 insertions(+), 6 deletions(-) diff --git a/cli/organization.go b/cli/organization.go index 36ea0737812b0..44f9c3308139e 100644 --- a/cli/organization.go +++ b/cli/organization.go @@ -29,8 +29,8 @@ func (r *RootCmd) organizations() *serpent.Command { r.currentOrganization(), r.switchOrganization(), r.createOrganization(), - r.organizationRoles(), r.organizationMembers(), + r.organizationRoles(), }, } diff --git a/cli/organizationmembers.go b/cli/organizationmembers.go index d81f08f333474..e5754fda7220b 100644 --- a/cli/organizationmembers.go +++ b/cli/organizationmembers.go @@ -19,6 +19,7 @@ func (r *RootCmd) organizationMembers() *serpent.Command { Children: []*serpent.Command{ r.listOrganizationMembers(), r.assignOrganizationRoles(), + r.addOrganizationMember(), }, Handler: func(inv *serpent.Invocation) error { return inv.Command.HelpHandler(inv) @@ -28,6 +29,37 @@ func (r *RootCmd) organizationMembers() *serpent.Command { return cmd } +func (r *RootCmd) addOrganizationMember() *serpent.Command { + client := new(codersdk.Client) + + cmd := &serpent.Command{ + Use: "add ", + Short: "Add a new member to the current organization", + Middleware: serpent.Chain( + r.InitClient(client), + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + organization, err := CurrentOrganization(r, inv, client) + if err != nil { + return err + } + user := inv.Args[0] + + _, err = client.PostOrganizationMember(ctx, organization.ID, user) + if err != nil { + return xerrors.Errorf("could not add member to organization: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stdout, "Organization member added") + return nil + }, + } + + return cmd +} + func (r *RootCmd) assignOrganizationRoles() *serpent.Command { client := new(codersdk.Client) diff --git a/cli/organizationmembers_test.go b/cli/organizationmembers_test.go index 6cd8b9d3ccd4a..89c10e8cf2e92 100644 --- a/cli/organizationmembers_test.go +++ b/cli/organizationmembers_test.go @@ -9,6 +9,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -34,3 +35,40 @@ func TestListOrganizationMembers(t *testing.T) { require.Contains(t, buf.String(), owner.UserID.String()) }) } + +func TestAddOrganizationMembers(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ownerClient := coderdtest.New(t, &coderdtest.Options{}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + _, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitMedium) + //nolint:gocritic // must be an owner, only owners can create orgs + otherOrg, err := ownerClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "Other", + DisplayName: "", + Description: "", + Icon: "", + }) + require.NoError(t, err, "create another organization") + + inv, root := clitest.New(t, "organization", "members", "add", "--organization", otherOrg.ID.String(), user.Username) + //nolint:gocritic // must be an owner + clitest.SetupConfig(t, ownerClient, root) + + buf := new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + //nolint:gocritic // must be an owner + members, err := ownerClient.OrganizationMembers(ctx, otherOrg.ID) + require.NoError(t, err) + + require.Len(t, members, 2) + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a6e2cc521dfda..3ee75d77e46c3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2356,6 +2356,47 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/members/{user}": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Add organization member", + "operationId": "add-organization-member", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationMember" + } + } + } + } + }, "/organizations/{organization}/members/{user}/roles": { "put": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 45b609b7d1317..9acd484da021f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2062,6 +2062,43 @@ } } }, + "/organizations/{organization}/members/{user}": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Add organization member", + "operationId": "add-organization-member", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationMember" + } + } + } + } + }, "/organizations/{organization}/members/{user}/roles": { "put": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index e8a698de0de34..7a697b58b7929 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -845,11 +845,24 @@ func New(options *Options) *API { }) r.Route("/{user}", func(r chi.Router) { - r.Use( - httpmw.ExtractOrganizationMemberParam(options.Database), - ) - r.Put("/roles", api.putMemberRoles) - r.Post("/workspaces", api.postWorkspacesByOrganization) + r.Group(func(r chi.Router) { + r.Use( + // Adding a member requires "read" permission + // on the site user. So limited to owners and user-admins. + // TODO: Allow org-admins to add users via some new permission? Or give them + // read on site users. + httpmw.ExtractUserParam(options.Database), + ) + r.Post("/", api.postOrganizationMember) + }) + + r.Group(func(r chi.Router) { + r.Use( + httpmw.ExtractOrganizationMemberParam(options.Database), + ) + r.Put("/roles", api.putMemberRoles) + r.Post("/workspaces", api.postWorkspacesByOrganization) + }) }) }) }) diff --git a/coderd/members.go b/coderd/members.go index bd41dfa10741a..d958f401bb9b3 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -9,12 +9,59 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) +// @Summary Add organization member +// @ID add-organization-member +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Param organization path string true "Organization ID" +// @Param user path string true "User ID, name, or me" +// @Success 200 {object} codersdk.OrganizationMember +// @Router /organizations/{organization}/members/{user} [post] +func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + user = httpmw.UserParam(r) + ) + + member, err := api.Database.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ + OrganizationID: organization.ID, + UserID: user.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Roles: []string{}, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{member}) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + if len(resp) == 0 { + httpapi.InternalServerError(rw, xerrors.Errorf("marshal member")) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, resp[0]) +} + // @Summary List organization members // @ID list-organization-members // @Security CoderSessionToken diff --git a/coderd/members_test.go b/coderd/members_test.go index 250a594a150f5..1f7e0ff56ae09 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -13,6 +13,65 @@ import ( "github.com/coder/coder/v2/testutil" ) +func TestAddMember(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + ctx := testutil.Context(t, testutil.WaitMedium) + org, err := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "other", + DisplayName: "", + Description: "", + Icon: "", + }) + require.NoError(t, err) + + // Make a user not in the second organization + _, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + + members, err := owner.OrganizationMembers(ctx, org.ID) + require.NoError(t, err) + require.Len(t, members, 1) // Verify just the 1 member + + // Add user to org + _, err = owner.PostOrganizationMember(ctx, org.ID, user.Username) + require.NoError(t, err) + + members, err = owner.OrganizationMembers(ctx, org.ID) + require.NoError(t, err) + // Owner + new member + require.Len(t, members, 2) + require.ElementsMatch(t, + []uuid.UUID{first.UserID, user.ID}, + db2sdk.List(members, onlyIDs)) + }) + + t.Run("UserNotExists", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, owner) + ctx := testutil.Context(t, testutil.WaitMedium) + + org, err := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "other", + DisplayName: "", + Description: "", + Icon: "", + }) + require.NoError(t, err) + + // Add user to org + _, err = owner.PostOrganizationMember(ctx, org.ID, uuid.NewString()) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Contains(t, apiErr.Message, "must be an existing") + }) +} + func TestListMembers(t *testing.T) { t.Parallel() diff --git a/codersdk/users.go b/codersdk/users.go index f16780aa2eb7c..5cf01405af0d4 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -379,6 +379,20 @@ func (c *Client) UpdateUserPassword(ctx context.Context, user string, req Update return nil } +// PostOrganizationMember adds a user to an organization +func (c *Client) PostOrganizationMember(ctx context.Context, organizationID uuid.UUID, user string) (OrganizationMember, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationID, user), nil) + if err != nil { + return OrganizationMember{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return OrganizationMember{}, ReadBodyAsError(res) + } + var member OrganizationMember + return member, json.NewDecoder(res.Body).Decode(&member) +} + // OrganizationMembers lists all members in an organization func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]OrganizationMemberWithName, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil) diff --git a/docs/api/members.md b/docs/api/members.md index 77ef260131e29..2f0c8a97a9892 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -315,6 +315,54 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Add organization member + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/members/{user} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /organizations/{organization}/members/{user}` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | -------------------- | +| `organization` | path | string | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMember](schemas.md#codersdkorganizationmember) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Assign role to organization member ### Code samples From a1ec8ad6e93d1a64319c262a5c32ce2888936720 Mon Sep 17 00:00:00 2001 From: Kelly Peilin Chan Date: Thu, 20 Jun 2024 23:05:21 +0800 Subject: [PATCH 100/168] Update docker-in-workspaces.md (#13606) --- docs/templates/docker-in-workspaces.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/templates/docker-in-workspaces.md b/docs/templates/docker-in-workspaces.md index d6a873c68f77a..ab697b34baca7 100644 --- a/docs/templates/docker-in-workspaces.md +++ b/docs/templates/docker-in-workspaces.md @@ -352,6 +352,7 @@ resource "kubernetes_pod" "main" { image = "docker:dind" security_context { privileged = true + run_as_user = 0 } command = ["dockerd", "-H", "tcp://127.0.0.1:2375"] } From a1db6d809e4e812298815748c9974d6655c904a5 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 20 Jun 2024 05:06:37 -1000 Subject: [PATCH 101/168] chore: implement delete organization member (#13589) Side effects of removing an organization member will orphan their user resources. These side effects are not addressed here --- coderd/apidoc/docs.go | 39 +++++++++++++ coderd/apidoc/swagger.json | 35 +++++++++++ coderd/coderd.go | 1 + coderd/database/dbauthz/dbauthz.go | 10 ++++ coderd/database/dbauthz/dbauthz_test.go | 17 ++++++ coderd/database/dbmem/dbmem.go | 18 ++++++ coderd/database/dbmetrics/dbmetrics.go | 7 +++ coderd/database/dbmock/dbmock.go | 14 +++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 19 ++++++ .../database/queries/organizationmembers.sql | 9 +++ coderd/members.go | 32 ++++++++++ coderd/members_test.go | 58 +++++++++++++++++++ codersdk/users.go | 13 +++++ docs/api/members.md | 48 +++++++++++++++ 15 files changed, 321 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3ee75d77e46c3..31330cd175222 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2395,6 +2395,45 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Remove organization member", + "operationId": "remove-organization-member", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationMember" + } + } + } } }, "/organizations/{organization}/members/{user}/roles": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9acd484da021f..254eaa54c46dd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2097,6 +2097,41 @@ } } } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Remove organization member", + "operationId": "remove-organization-member", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationMember" + } + } + } } }, "/organizations/{organization}/members/{user}/roles": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 7a697b58b7929..cc2de344a2cee 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -860,6 +860,7 @@ func New(options *Options) *API { r.Use( httpmw.ExtractOrganizationMemberParam(options.Database), ) + r.Delete("/", api.deleteOrganizationMember) r.Put("/roles", api.putMemberRoles) r.Post("/workspaces", api.postWorkspacesByOrganization) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 8aec14eb7bf47..f32e176754fa1 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1035,6 +1035,16 @@ func (q *querier) DeleteOrganization(ctx context.Context, id uuid.UUID) error { return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, q.db.DeleteOrganization)(ctx, id) } +func (q *querier) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error { + return deleteQ[database.OrganizationMember](q.log, q.auth, func(ctx context.Context, arg database.DeleteOrganizationMemberParams) (database.OrganizationMember, error) { + member, err := database.ExpectOne(q.OrganizationMembers(ctx, database.OrganizationMembersParams(arg))) + if err != nil { + return database.OrganizationMember{}, err + } + return member.OrganizationMember, nil + }, q.db.DeleteOrganizationMember)(ctx, arg) +} + func (q *querier) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 96b0e35874186..17c0d76c4ef31 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -628,6 +628,23 @@ func (s *MethodTestSuite) TestOrganization() { rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate) })) + s.Run("DeleteOrganizationMember", s.Subtest(func(db database.Store, check *expects) { + o := dbgen.Organization(s.T(), db, database.Organization{}) + u := dbgen.User(s.T(), db, database.User{}) + member := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: o.ID}) + + check.Args(database.DeleteOrganizationMemberParams{ + OrganizationID: o.ID, + UserID: u.ID, + }).Asserts( + // Reads the org member before it tries to delete it + member, policy.ActionRead, + member, policy.ActionDelete). + // SQL Filter returns a 404 + WithNotAuthorized("no rows"). + WithCancelled("no rows"). + Errors(sql.ErrNoRows) + })) s.Run("UpdateOrganization", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{ Name: "something-unique", diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 92961d4cc84ed..47d75e831469e 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1632,6 +1632,24 @@ func (q *FakeQuerier) DeleteOrganization(_ context.Context, id uuid.UUID) error return sql.ErrNoRows } +func (q *FakeQuerier) DeleteOrganizationMember(_ context.Context, arg database.DeleteOrganizationMemberParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + deleted := slices.DeleteFunc(q.data.organizationMembers, func(member database.OrganizationMember) bool { + return member.OrganizationID == arg.OrganizationID && member.UserID == arg.UserID + }) + if len(deleted) == 0 { + return sql.ErrNoRows + } + return nil +} + func (q *FakeQuerier) DeleteReplicasUpdatedBefore(_ context.Context, before time.Time) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 1891fe6f999e9..dc4c52a31faaf 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -291,6 +291,13 @@ func (m metricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) erro return r0 } +func (m metricsStore) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error { + start := time.Now() + r0 := m.s.DeleteOrganizationMember(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteOrganizationMember").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error { start := time.Now() err := m.s.DeleteReplicasUpdatedBefore(ctx, updatedAt) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b49d3e7f06c76..35f0312b7b20f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -469,6 +469,20 @@ func (mr *MockStoreMockRecorder) DeleteOrganization(arg0, arg1 any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganization", reflect.TypeOf((*MockStore)(nil).DeleteOrganization), arg0, arg1) } +// DeleteOrganizationMember mocks base method. +func (m *MockStore) DeleteOrganizationMember(arg0 context.Context, arg1 database.DeleteOrganizationMemberParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOrganizationMember", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteOrganizationMember indicates an expected call of DeleteOrganizationMember. +func (mr *MockStoreMockRecorder) DeleteOrganizationMember(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganizationMember", reflect.TypeOf((*MockStore)(nil).DeleteOrganizationMember), arg0, arg1) +} + // DeleteReplicasUpdatedBefore mocks base method. func (m *MockStore) DeleteReplicasUpdatedBefore(arg0 context.Context, arg1 time.Time) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f87e6015b517e..50645ab1e5eb5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -75,6 +75,7 @@ type sqlcQuerier interface { DeleteOldWorkspaceAgentLogs(ctx context.Context) error DeleteOldWorkspaceAgentStats(ctx context.Context) error DeleteOrganization(ctx context.Context, id uuid.UUID) error + DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) error DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error DeleteTailnetAgent(ctx context.Context, arg DeleteTailnetAgentParams) (DeleteTailnetAgentRow, error) DeleteTailnetClient(ctx context.Context, arg DeleteTailnetClientParams) (DeleteTailnetClientRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ac05a3f26d061..2c0b21824ad28 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3756,6 +3756,25 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg return i, err } +const deleteOrganizationMember = `-- name: DeleteOrganizationMember :exec +DELETE + FROM + organization_members + WHERE + organization_id = $1 AND + user_id = $2 +` + +type DeleteOrganizationMemberParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` +} + +func (q *sqlQuerier) DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) error { + _, err := q.db.ExecContext(ctx, deleteOrganizationMember, arg.OrganizationID, arg.UserID) + return err +} + const getOrganizationIDsByMemberIDs = `-- name: GetOrganizationIDsByMemberIDs :many SELECT user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs" diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index d32d9a8e8abc8..4722973d38589 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -36,6 +36,15 @@ INSERT INTO VALUES ($1, $2, $3, $4, $5) RETURNING *; +-- name: DeleteOrganizationMember :exec +DELETE + FROM + organization_members + WHERE + organization_id = @organization_id AND + user_id = @user_id +; + -- name: GetOrganizationIDsByMemberIDs :many SELECT diff --git a/coderd/members.go b/coderd/members.go index d958f401bb9b3..3352eefc3b3d6 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -62,6 +62,38 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, resp[0]) } +// @Summary Remove organization member +// @ID remove-organization-member +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Param organization path string true "Organization ID" +// @Param user path string true "User ID, name, or me" +// @Success 200 {object} codersdk.OrganizationMember +// @Router /organizations/{organization}/members/{user} [delete] +func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + member = httpmw.OrganizationMemberParam(r) + ) + + err := api.Database.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{ + OrganizationID: organization.ID, + UserID: member.UserID, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, "organization member removed") +} + // @Summary List organization members // @ID list-organization-members // @Security CoderSessionToken diff --git a/coderd/members_test.go b/coderd/members_test.go index 1f7e0ff56ae09..db80b28ad1fbb 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "net/http" "testing" "github.com/google/uuid" @@ -114,6 +115,63 @@ func TestListMembers(t *testing.T) { }) } +func TestRemoveMember(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + orgAdminClient, orgAdmin := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + _, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitMedium) + // Verify the org of 3 members + members, err := orgAdminClient.OrganizationMembers(ctx, first.OrganizationID) + require.NoError(t, err) + require.Len(t, members, 3) + require.ElementsMatch(t, + []uuid.UUID{first.UserID, user.ID, orgAdmin.ID}, + db2sdk.List(members, onlyIDs)) + + // Delete a member + err = orgAdminClient.DeleteOrganizationMember(ctx, first.OrganizationID, user.Username) + require.NoError(t, err) + + members, err = orgAdminClient.OrganizationMembers(ctx, first.OrganizationID) + require.NoError(t, err) + require.Len(t, members, 2) + require.ElementsMatch(t, + []uuid.UUID{first.UserID, orgAdmin.ID}, + db2sdk.List(members, onlyIDs)) + }) + + t.Run("MemberNotInOrg", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + + ctx := testutil.Context(t, testutil.WaitMedium) + // nolint:gocritic // requires owner to make a new org + org, _ := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "other", + DisplayName: "", + Description: "", + Icon: "", + }) + + _, user := coderdtest.CreateAnotherUser(t, owner, org.ID) + + // Delete a user that is not in the organization + err := orgAdminClient.DeleteOrganizationMember(ctx, first.OrganizationID, user.Username) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusNotFound, apiError.StatusCode()) + }) +} + func onlyIDs(u codersdk.OrganizationMemberWithName) uuid.UUID { return u.UserID } diff --git a/codersdk/users.go b/codersdk/users.go index 5cf01405af0d4..f99015b50bde5 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -393,6 +393,19 @@ func (c *Client) PostOrganizationMember(ctx context.Context, organizationID uuid return member, json.NewDecoder(res.Body).Decode(&member) } +// DeleteOrganizationMember removes a user from an organization +func (c *Client) DeleteOrganizationMember(ctx context.Context, organizationID uuid.UUID, user string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationID, user), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} + // OrganizationMembers lists all members in an organization func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]OrganizationMemberWithName, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil) diff --git a/docs/api/members.md b/docs/api/members.md index 2f0c8a97a9892..1a9beae285157 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -363,6 +363,54 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Remove organization member + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization}/members/{user} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /organizations/{organization}/members/{user}` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | -------------------- | +| `organization` | path | string | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMember](schemas.md#codersdkorganizationmember) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Assign role to organization member ### Code samples From 0793a4b35bb73fd37e1d7e7214e7ae88fc742af5 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Jun 2024 14:19:45 -0500 Subject: [PATCH 102/168] feat: add cross-origin reporting for telemetry in the dashboard (#13612) * feat: add cross-origin reporting for telemetry in the dashboard * Respect the telemetry flag * Fix embedded metadata * Fix compilation error * Fix linting --- coderd/apidoc/docs.go | 4 ++ coderd/apidoc/swagger.json | 4 ++ coderd/coderd.go | 1 + coderd/telemetry/telemetry.go | 6 +++ codersdk/deployment.go | 3 +- docs/api/general.md | 1 + docs/api/schemas.md | 2 + .../wsproxy/wsproxysdk/wsproxysdk_test.go | 2 +- site/jest.setup.ts | 1 + site/src/api/typesGenerated.ts | 1 + site/src/pages/LoginPage/LoginPage.tsx | 20 ++++++++++ site/src/pages/SetupPage/SetupPage.test.tsx | 40 ++++++++++++++++++- site/src/pages/SetupPage/SetupPage.tsx | 17 +++++++- site/src/testHelpers/entities.ts | 1 + site/src/utils/telemetry.ts | 33 +++++++++++++++ 15 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 site/src/utils/telemetry.ts diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 31330cd175222..88d08e868690c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8351,6 +8351,10 @@ const docTemplate = `{ "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" }, + "telemetry": { + "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", + "type": "boolean" + }, "upgrade_message": { "description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 254eaa54c46dd..b6e527d0580d7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7430,6 +7430,10 @@ "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" }, + "telemetry": { + "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", + "type": "boolean" + }, "upgrade_message": { "description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.", "type": "string" diff --git a/coderd/coderd.go b/coderd/coderd.go index cc2de344a2cee..6de169cce71b7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -447,6 +447,7 @@ func New(options *Options) *API { WorkspaceProxy: false, UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), DeploymentID: api.DeploymentID, + Telemetry: api.Telemetry.Enabled(), } api.SiteHandler = site.New(&site.Options{ BinFS: binFS, diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 1b9489db3af8f..9d16ba7922098 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -93,6 +93,7 @@ type Reporter interface { // database. For example, if a new user is added, a snapshot can // contain just that user entry. Report(snapshot *Snapshot) + Enabled() bool Close() } @@ -109,6 +110,10 @@ type remoteReporter struct { shutdownAt *time.Time } +func (*remoteReporter) Enabled() bool { + return true +} + func (r *remoteReporter) Report(snapshot *Snapshot) { go r.reportSync(snapshot) } @@ -948,4 +953,5 @@ type ExternalProvisioner struct { type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} +func (*noopReporter) Enabled() bool { return false } func (*noopReporter) Close() {} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ff35d67bacbb4..7b13d083a4435 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2173,11 +2173,12 @@ type BuildInfoResponse struct { ExternalURL string `json:"external_url"` // Version returns the semantic version of the build. Version string `json:"version"` - // DashboardURL is the URL to hit the deployment's dashboard. // For external workspace proxies, this is the coderd they are connected // to. DashboardURL string `json:"dashboard_url"` + // Telemetry is a boolean that indicates whether telemetry is enabled. + Telemetry bool `json:"telemetry"` WorkspaceProxy bool `json:"workspace_proxy"` diff --git a/docs/api/general.md b/docs/api/general.md index a92742ce0a707..620e3b238d7b3 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -57,6 +57,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ "dashboard_url": "string", "deployment_id": "string", "external_url": "string", + "telemetry": true, "upgrade_message": "string", "version": "string", "workspace_proxy": true diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a5c333b3d0bd6..c2ee20e288d42 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -865,6 +865,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "dashboard_url": "string", "deployment_id": "string", "external_url": "string", + "telemetry": true, "upgrade_message": "string", "version": "string", "workspace_proxy": true @@ -879,6 +880,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | | `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. | | `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | +| `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. | | `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | | `version` | string | false | | Version returns the semantic version of the build. | | `workspace_proxy` | boolean | false | | | diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go index 870d06b71da6d..c94b712cc9872 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go @@ -216,7 +216,7 @@ func TestDialCoordinator(t *testing.T) { Node: &proto.Node{ Id: 55, AsOf: timestamppb.New(time.Unix(1689653252, 0)), - Key: peerNodeKey[:], + Key: peerNodeKey, Disco: string(peerDiscoKey), PreferredDerp: 0, DerpLatency: map[string]float64{ diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 6282295870681..40bb92fa44965 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -41,6 +41,7 @@ global.scrollTo = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn(); window.open = jest.fn(); +navigator.sendBeacon = jest.fn(); // Polyfill the getRandomValues that is used on utils/random.ts Object.defineProperty(global.self, "crypto", { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0d9147c912e9e..052b2a6872b04 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -171,6 +171,7 @@ export interface BuildInfoResponse { readonly external_url: string; readonly version: string; readonly dashboard_url: string; + readonly telemetry: boolean; readonly workspace_proxy: boolean; readonly agent_api_version: string; readonly upgrade_message: string; diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index f05c0b40d981f..3fa2c5616be29 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -8,6 +8,7 @@ import { useAuthContext } from "contexts/auth/AuthProvider"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { getApplicationName } from "utils/appearance"; import { retrieveRedirect } from "utils/redirect"; +import { sendDeploymentEvent } from "utils/telemetry"; import { LoginPageView } from "./LoginPageView"; export const LoginPage: FC = () => { @@ -19,6 +20,7 @@ export const LoginPage: FC = () => { signIn, isSigningIn, signInError, + user, } = useAuthContext(); const authMethodsQuery = useQuery(authMethods()); const redirectTo = retrieveRedirect(location.search); @@ -29,6 +31,15 @@ export const LoginPage: FC = () => { const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); if (isSignedIn) { + if (buildInfoQuery.data) { + // This uses `navigator.sendBeacon`, so window.href + // will not stop the request from being sent! + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_login", + user_id: user?.id, + }); + } + // If the redirect is going to a workspace application, and we // are missing authentication, then we need to change the href location // to trigger a HTTP request. This allows the BE to generate the auth @@ -74,6 +85,15 @@ export const LoginPage: FC = () => { isSigningIn={isSigningIn} onSignIn={async ({ email, password }) => { await signIn(email, password); + if (buildInfoQuery.data) { + // This uses `navigator.sendBeacon`, so navigating away + // will not prevent it! + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_login", + user_id: user?.id, + }); + } + navigate("/"); }} /> diff --git a/site/src/pages/SetupPage/SetupPage.test.tsx b/site/src/pages/SetupPage/SetupPage.test.tsx index 2f558316d95cc..fb22dcf4f303a 100644 --- a/site/src/pages/SetupPage/SetupPage.test.tsx +++ b/site/src/pages/SetupPage/SetupPage.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { createMemoryRouter } from "react-router-dom"; import type { Response, User } from "api/typesGenerated"; -import { MockUser } from "testHelpers/entities"; +import { MockBuildInfo, MockUser } from "testHelpers/entities"; import { renderWithRouter, waitForLoaderToBeRemoved, @@ -99,4 +99,42 @@ describe("Setup Page", () => { await fillForm(); await waitFor(() => screen.findByText("Templates")); }); + it("calls sendBeacon with telemetry", async () => { + const sendBeacon = jest.fn(); + Object.defineProperty(window.navigator, "sendBeacon", { + value: sendBeacon, + }); + renderWithRouter( + createMemoryRouter( + [ + { + path: "/setup", + element: , + }, + { + path: "/templates", + element:

      Templates

      , + }, + ], + { initialEntries: ["/setup"] }, + ), + ); + await waitForLoaderToBeRemoved(); + await waitFor(() => { + expect(navigator.sendBeacon).toBeCalledWith( + "https://coder.com/api/track-deployment", + new Blob( + [ + JSON.stringify({ + type: "deployment_setup", + deployment_id: MockBuildInfo.deployment_id, + }), + ], + { + type: "application/json", + }, + ), + ); + }); + }); }); diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index fc5d0cf35f957..20899157c3b30 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -1,11 +1,14 @@ -import type { FC } from "react"; +import { useEffect, type FC } from "react"; import { Helmet } from "react-helmet-async"; -import { useMutation } from "react-query"; +import { useMutation, useQuery } from "react-query"; import { Navigate, useNavigate } from "react-router-dom"; +import { buildInfo } from "api/queries/buildInfo"; import { createFirstUser } from "api/queries/users"; import { Loader } from "components/Loader/Loader"; import { useAuthContext } from "contexts/auth/AuthProvider"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { pageTitle } from "utils/page"; +import { sendDeploymentEvent } from "utils/telemetry"; import { SetupPageView } from "./SetupPageView"; export const SetupPage: FC = () => { @@ -18,7 +21,17 @@ export const SetupPage: FC = () => { } = useAuthContext(); const createFirstUserMutation = useMutation(createFirstUser()); const setupIsComplete = !isConfiguringTheFirstUser; + const { metadata } = useEmbeddedMetadata(); + const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); const navigate = useNavigate(); + useEffect(() => { + if (!buildInfoQuery.data) { + return; + } + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_setup", + }); + }, [buildInfoQuery.data]); if (isLoading) { return ; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 055093570c7ab..8ddb6bd76b635 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -205,6 +205,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = { workspace_proxy: false, upgrade_message: "My custom upgrade message", deployment_id: "510d407f-e521-4180-b559-eab4a6d802b8", + telemetry: true, }; export const MockSupportLinks: TypesGen.LinkConfig[] = [ diff --git a/site/src/utils/telemetry.ts b/site/src/utils/telemetry.ts new file mode 100644 index 0000000000000..3b6906690cd6a --- /dev/null +++ b/site/src/utils/telemetry.ts @@ -0,0 +1,33 @@ +import type { BuildInfoResponse } from "api/typesGenerated"; + +// sendDeploymentEvent sends a CORs payload to coder.com +// to track a deployment event. +export const sendDeploymentEvent = ( + buildInfo: BuildInfoResponse, + payload: { + type: "deployment_setup" | "deployment_login"; + user_id?: string; + }, +) => { + if (typeof navigator === "undefined" || !navigator.sendBeacon) { + // It's fine if we don't report this, it's not required! + return; + } + if (!buildInfo.telemetry) { + return; + } + navigator.sendBeacon( + "https://coder.com/api/track-deployment", + new Blob( + [ + JSON.stringify({ + ...payload, + deployment_id: buildInfo.deployment_id, + }), + ], + { + type: "application/json", + }, + ), + ); +}; From 57b38e5bb80ebb851a0c523aef9b5850d2867291 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Jun 2024 15:05:22 -0500 Subject: [PATCH 103/168] fix: allow coder.com in CSP if telemetry is enabled (#13615) * fix: allow coder.com in CSP if telemetry is enabled * Fix control couple lint --- coderd/coderd.go | 2 +- coderd/httpmw/csp.go | 9 ++++++++- coderd/httpmw/csp_test.go | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 6de169cce71b7..cca4faf36a203 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1210,7 +1210,7 @@ func New(options *Options) *API { // Add CSP headers to all static assets and pages. CSP headers only affect // browsers, so these don't make sense on api routes. - cspMW := httpmw.CSPHeaders(func() []string { + cspMW := httpmw.CSPHeaders(options.Telemetry.Enabled(), func() []string { if api.DeploymentValues.Dangerous.AllowAllCors { // In this mode, allow all external requests return []string{"*"} diff --git a/coderd/httpmw/csp.go b/coderd/httpmw/csp.go index fde5c62d8bd6f..0862a0cd7cb2a 100644 --- a/coderd/httpmw/csp.go +++ b/coderd/httpmw/csp.go @@ -43,7 +43,9 @@ const ( // CSPHeaders returns a middleware that sets the Content-Security-Policy header // for coderd. It takes a function that allows adding supported external websocket // hosts. This is primarily to support the terminal connecting to a workspace proxy. -func CSPHeaders(websocketHosts func() []string) func(next http.Handler) http.Handler { +// +//nolint:revive +func CSPHeaders(telemetry bool, websocketHosts func() []string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Content-Security-Policy disables loading certain content types and can prevent XSS injections. @@ -83,6 +85,11 @@ func CSPHeaders(websocketHosts func() []string) func(next http.Handler) http.Han // "require-trusted-types-for" : []string{"'script'"}, } + if telemetry { + // If telemetry is enabled, we report to coder.com. + cspSrcs.Append(cspDirectiveConnectSrc, "https://coder.com") + } + // This extra connect-src addition is required to support old webkit // based browsers (Safari). // See issue: https://github.com/w3c/webappsec-csp/issues/7 diff --git a/coderd/httpmw/csp_test.go b/coderd/httpmw/csp_test.go index 2dca209faa5c3..d389d778eeba6 100644 --- a/coderd/httpmw/csp_test.go +++ b/coderd/httpmw/csp_test.go @@ -19,7 +19,7 @@ func TestCSPConnect(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/", nil) rw := httptest.NewRecorder() - httpmw.CSPHeaders(func() []string { + httpmw.CSPHeaders(false, func() []string { return expected })(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) From 43e45f4ab7cffd8e6a252b28a4536645c1d9fb7c Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 20 Jun 2024 12:40:08 -0800 Subject: [PATCH 104/168] fix: fill out zero-value user properties in /audit (#13604) --- coderd/audit.go | 40 ++++++++-------- coderd/audit_test.go | 51 ++++++++++++++++++++ coderd/database/dbmem/dbmem.go | 47 +++++++++++-------- coderd/database/queries.sql.go | 67 ++++++++++++++++++--------- coderd/database/queries/auditlogs.sql | 9 ++++ 5 files changed, 151 insertions(+), 63 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index ab82e91698d8c..a818f681038ed 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -20,7 +20,6 @@ import ( "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" ) @@ -183,27 +182,26 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs _ = json.Unmarshal(dblog.Diff, &diff) var user *codersdk.User - if dblog.UserUsername.Valid { - user = &codersdk.User{ - ReducedUser: codersdk.ReducedUser{ - MinimalUser: codersdk.MinimalUser{ - ID: dblog.UserID, - Username: dblog.UserUsername.String, - AvatarURL: dblog.UserAvatarUrl.String, - }, - Email: dblog.UserEmail.String, - CreatedAt: dblog.UserCreatedAt.Time, - Status: codersdk.UserStatus(dblog.UserStatus.UserStatus), - }, - Roles: []codersdk.SlimRole{}, - } - - for _, input := range dblog.UserRoles { - roleName, _ := rbac.RoleNameFromString(input) - rbacRole, _ := rbac.RoleByName(roleName) - user.Roles = append(user.Roles, db2sdk.SlimRole(rbacRole)) - } + // Leaving the organization IDs blank for now; not sure they are useful for + // the audit query anyway? + sdkUser := db2sdk.User(database.User{ + ID: dblog.UserID, + Email: dblog.UserEmail.String, + Username: dblog.UserUsername.String, + CreatedAt: dblog.UserCreatedAt.Time, + UpdatedAt: dblog.UserUpdatedAt.Time, + Status: dblog.UserStatus.UserStatus, + RBACRoles: dblog.UserRoles, + LoginType: dblog.UserLoginType.LoginType, + AvatarURL: dblog.UserAvatarUrl.String, + Deleted: dblog.UserDeleted.Bool, + LastSeenAt: dblog.UserLastSeenAt.Time, + QuietHoursSchedule: dblog.UserQuietHoursSchedule.String, + ThemePreference: dblog.UserThemePreference.String, + Name: dblog.UserName.String, + }, []uuid.UUID{}) + user = &sdkUser } var ( diff --git a/coderd/audit_test.go b/coderd/audit_test.go index b8b62cf27ecf0..9de6f65071a02 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -8,11 +8,13 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) @@ -42,6 +44,55 @@ func TestAuditLogs(t *testing.T) { require.Len(t, alogs.AuditLogs, 1) }) + t.Run("User", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + client2, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOwner()) + + err := client2.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{ + ResourceID: user2.ID, + }) + require.NoError(t, err) + + alogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + }, + }) + require.NoError(t, err) + require.Equal(t, int64(1), alogs.Count) + require.Len(t, alogs.AuditLogs, 1) + + // Make sure the returned user is fully populated. + foundUser, err := client.User(ctx, user2.ID.String()) + foundUser.OrganizationIDs = []uuid.UUID{} // Not included. + require.NoError(t, err) + require.Equal(t, foundUser, *alogs.AuditLogs[0].User) + + // Delete the user and try again. This is a soft delete so nothing should + // change. If users are hard deleted we should get nil, but there is no way + // to test this at the moment. + err = client.DeleteUser(ctx, user2.ID) + require.NoError(t, err) + + alogs, err = client.AuditLogs(ctx, codersdk.AuditLogsRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + }, + }) + require.NoError(t, err) + require.Equal(t, int64(1), alogs.Count) + require.Len(t, alogs.AuditLogs, 1) + + foundUser, err = client.User(ctx, user2.ID.String()) + foundUser.OrganizationIDs = []uuid.UUID{} // Not included. + require.NoError(t, err) + require.Equal(t, foundUser, *alogs.AuditLogs[0].User) + }) + t.Run("WorkspaceBuildAuditLink", func(t *testing.T) { t.Parallel() diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 47d75e831469e..f83838dd4be94 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1969,26 +1969,33 @@ func (q *FakeQuerier) GetAuditLogsOffset(_ context.Context, arg database.GetAudi userValid := err == nil logs = append(logs, database.GetAuditLogsOffsetRow{ - ID: alog.ID, - RequestID: alog.RequestID, - OrganizationID: alog.OrganizationID, - Ip: alog.Ip, - UserAgent: alog.UserAgent, - ResourceType: alog.ResourceType, - ResourceID: alog.ResourceID, - ResourceTarget: alog.ResourceTarget, - ResourceIcon: alog.ResourceIcon, - Action: alog.Action, - Diff: alog.Diff, - StatusCode: alog.StatusCode, - AdditionalFields: alog.AdditionalFields, - UserID: alog.UserID, - UserUsername: sql.NullString{String: user.Username, Valid: userValid}, - UserEmail: sql.NullString{String: user.Email, Valid: userValid}, - UserCreatedAt: sql.NullTime{Time: user.CreatedAt, Valid: userValid}, - UserStatus: database.NullUserStatus{UserStatus: user.Status, Valid: userValid}, - UserRoles: user.RBACRoles, - Count: 0, + ID: alog.ID, + RequestID: alog.RequestID, + OrganizationID: alog.OrganizationID, + Ip: alog.Ip, + UserAgent: alog.UserAgent, + ResourceType: alog.ResourceType, + ResourceID: alog.ResourceID, + ResourceTarget: alog.ResourceTarget, + ResourceIcon: alog.ResourceIcon, + Action: alog.Action, + Diff: alog.Diff, + StatusCode: alog.StatusCode, + AdditionalFields: alog.AdditionalFields, + UserID: alog.UserID, + UserUsername: sql.NullString{String: user.Username, Valid: userValid}, + UserName: sql.NullString{String: user.Name, Valid: userValid}, + UserEmail: sql.NullString{String: user.Email, Valid: userValid}, + UserCreatedAt: sql.NullTime{Time: user.CreatedAt, Valid: userValid}, + UserUpdatedAt: sql.NullTime{Time: user.UpdatedAt, Valid: userValid}, + UserLastSeenAt: sql.NullTime{Time: user.LastSeenAt, Valid: userValid}, + UserLoginType: database.NullLoginType{LoginType: user.LoginType, Valid: userValid}, + UserDeleted: sql.NullBool{Bool: user.Deleted, Valid: userValid}, + UserThemePreference: sql.NullString{String: user.ThemePreference, Valid: userValid}, + UserQuietHoursSchedule: sql.NullString{String: user.QuietHoursSchedule, Valid: userValid}, + UserStatus: database.NullUserStatus{UserStatus: user.Status, Valid: userValid}, + UserRoles: user.RBACRoles, + Count: 0, }) if len(logs) >= int(arg.Limit) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2c0b21824ad28..4f113323a024f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -444,12 +444,21 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP const getAuditLogsOffset = `-- name: GetAuditLogsOffset :many SELECT audit_logs.id, audit_logs.time, audit_logs.user_id, audit_logs.organization_id, audit_logs.ip, audit_logs.user_agent, audit_logs.resource_type, audit_logs.resource_id, audit_logs.resource_target, audit_logs.action, audit_logs.diff, audit_logs.status_code, audit_logs.additional_fields, audit_logs.request_id, audit_logs.resource_icon, + -- sqlc.embed(users) would be nice but it does not seem to play well with + -- left joins. users.username AS user_username, + users.name AS user_name, users.email AS user_email, users.created_at AS user_created_at, + users.updated_at AS user_updated_at, + users.last_seen_at AS user_last_seen_at, users.status AS user_status, + users.login_type AS user_login_type, users.rbac_roles AS user_roles, users.avatar_url AS user_avatar_url, + users.deleted AS user_deleted, + users.theme_preference AS user_theme_preference, + users.quiet_hours_schedule AS user_quiet_hours_schedule, COUNT(audit_logs.*) OVER () AS count FROM audit_logs @@ -563,28 +572,35 @@ type GetAuditLogsOffsetParams struct { } type GetAuditLogsOffsetRow struct { - ID uuid.UUID `db:"id" json:"id"` - Time time.Time `db:"time" json:"time"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - Ip pqtype.Inet `db:"ip" json:"ip"` - UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - ResourceType ResourceType `db:"resource_type" json:"resource_type"` - ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` - ResourceTarget string `db:"resource_target" json:"resource_target"` - Action AuditAction `db:"action" json:"action"` - Diff json.RawMessage `db:"diff" json:"diff"` - StatusCode int32 `db:"status_code" json:"status_code"` - AdditionalFields json.RawMessage `db:"additional_fields" json:"additional_fields"` - RequestID uuid.UUID `db:"request_id" json:"request_id"` - ResourceIcon string `db:"resource_icon" json:"resource_icon"` - UserUsername sql.NullString `db:"user_username" json:"user_username"` - UserEmail sql.NullString `db:"user_email" json:"user_email"` - UserCreatedAt sql.NullTime `db:"user_created_at" json:"user_created_at"` - UserStatus NullUserStatus `db:"user_status" json:"user_status"` - UserRoles pq.StringArray `db:"user_roles" json:"user_roles"` - UserAvatarUrl sql.NullString `db:"user_avatar_url" json:"user_avatar_url"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + Time time.Time `db:"time" json:"time"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Ip pqtype.Inet `db:"ip" json:"ip"` + UserAgent sql.NullString `db:"user_agent" json:"user_agent"` + ResourceType ResourceType `db:"resource_type" json:"resource_type"` + ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` + ResourceTarget string `db:"resource_target" json:"resource_target"` + Action AuditAction `db:"action" json:"action"` + Diff json.RawMessage `db:"diff" json:"diff"` + StatusCode int32 `db:"status_code" json:"status_code"` + AdditionalFields json.RawMessage `db:"additional_fields" json:"additional_fields"` + RequestID uuid.UUID `db:"request_id" json:"request_id"` + ResourceIcon string `db:"resource_icon" json:"resource_icon"` + UserUsername sql.NullString `db:"user_username" json:"user_username"` + UserName sql.NullString `db:"user_name" json:"user_name"` + UserEmail sql.NullString `db:"user_email" json:"user_email"` + UserCreatedAt sql.NullTime `db:"user_created_at" json:"user_created_at"` + UserUpdatedAt sql.NullTime `db:"user_updated_at" json:"user_updated_at"` + UserLastSeenAt sql.NullTime `db:"user_last_seen_at" json:"user_last_seen_at"` + UserStatus NullUserStatus `db:"user_status" json:"user_status"` + UserLoginType NullLoginType `db:"user_login_type" json:"user_login_type"` + UserRoles pq.StringArray `db:"user_roles" json:"user_roles"` + UserAvatarUrl sql.NullString `db:"user_avatar_url" json:"user_avatar_url"` + UserDeleted sql.NullBool `db:"user_deleted" json:"user_deleted"` + UserThemePreference sql.NullString `db:"user_theme_preference" json:"user_theme_preference"` + UserQuietHoursSchedule sql.NullString `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"` + Count int64 `db:"count" json:"count"` } // GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided @@ -628,11 +644,18 @@ func (q *sqlQuerier) GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOff &i.RequestID, &i.ResourceIcon, &i.UserUsername, + &i.UserName, &i.UserEmail, &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.UserLastSeenAt, &i.UserStatus, + &i.UserLoginType, &i.UserRoles, &i.UserAvatarUrl, + &i.UserDeleted, + &i.UserThemePreference, + &i.UserQuietHoursSchedule, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries/auditlogs.sql b/coderd/database/queries/auditlogs.sql index fc48489ca2104..d05b5bbe371e0 100644 --- a/coderd/database/queries/auditlogs.sql +++ b/coderd/database/queries/auditlogs.sql @@ -3,12 +3,21 @@ -- name: GetAuditLogsOffset :many SELECT audit_logs.*, + -- sqlc.embed(users) would be nice but it does not seem to play well with + -- left joins. users.username AS user_username, + users.name AS user_name, users.email AS user_email, users.created_at AS user_created_at, + users.updated_at AS user_updated_at, + users.last_seen_at AS user_last_seen_at, users.status AS user_status, + users.login_type AS user_login_type, users.rbac_roles AS user_roles, users.avatar_url AS user_avatar_url, + users.deleted AS user_deleted, + users.theme_preference AS user_theme_preference, + users.quiet_hours_schedule AS user_quiet_hours_schedule, COUNT(audit_logs.*) OVER () AS count FROM audit_logs From 495eea452fccb86585f5a3f90c069fe83b0c9e00 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 20 Jun 2024 16:33:47 -0500 Subject: [PATCH 105/168] fix: track login page correctly (#13618) --- site/src/pages/LoginPage/LoginPage.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 3fa2c5616be29..81fbe4cf5d0d6 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import { useEffect, type FC } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; @@ -26,10 +26,21 @@ export const LoginPage: FC = () => { const redirectTo = retrieveRedirect(location.search); const applicationName = getApplicationName(); const navigate = useNavigate(); - const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); + useEffect(() => { + if (!buildInfoQuery.data || isSignedIn) { + // isSignedIn already tracks with window.href! + return; + } + // This uses `navigator.sendBeacon`, so navigating away will not prevent it! + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_login", + user_id: user?.id, + }); + }, [isSignedIn, buildInfoQuery.data, user?.id]); + if (isSignedIn) { if (buildInfoQuery.data) { // This uses `navigator.sendBeacon`, so window.href @@ -85,15 +96,6 @@ export const LoginPage: FC = () => { isSigningIn={isSigningIn} onSignIn={async ({ email, password }) => { await signIn(email, password); - if (buildInfoQuery.data) { - // This uses `navigator.sendBeacon`, so navigating away - // will not prevent it! - sendDeploymentEvent(buildInfoQuery.data, { - type: "deployment_login", - user_id: user?.id, - }); - } - navigate("/"); }} /> From c4656d77cc605626436129b8a4b701f689345c70 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 20 Jun 2024 11:44:47 -1000 Subject: [PATCH 106/168] chore: add help to error to reset organization context (#13616) --- cli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/root.go b/cli/root.go index 2c7443cde5749..073486c640744 100644 --- a/cli/root.go +++ b/cli/root.go @@ -657,7 +657,7 @@ func CurrentOrganization(r *RootCmd, inv *serpent.Invocation, client *codersdk.C }) if index < 0 { - return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization?", selected) + return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization? If unsure, run 'coder organizations set \"\" ' to reset your current context.", selected) } return orgs[index], nil } From 889daf200ef328c50525027a818918fb8792002e Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Thu, 20 Jun 2024 17:22:27 -0500 Subject: [PATCH 107/168] feat(enterprise): add auditing to SCIM (#13614) --- coderd/audit/request.go | 15 +++++-- coderd/workspaces.go | 7 +--- enterprise/coderd/scim.go | 39 ++++++++++++++++- enterprise/coderd/scim_test.go | 36 +++++++++++++--- .../AuditLogDescription.stories.tsx | 42 +++++++++++++++++++ .../AuditLogDescription.tsx | 10 ++++- 6 files changed, 131 insertions(+), 18 deletions(-) diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 20eb8185af53e..2171366f4c66f 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -31,7 +31,7 @@ type RequestParams struct { OrganizationID uuid.UUID Request *http.Request Action database.AuditAction - AdditionalFields json.RawMessage + AdditionalFields interface{} } type Request[T Auditable] struct { @@ -283,8 +283,15 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request } } - if p.AdditionalFields == nil { - p.AdditionalFields = json.RawMessage("{}") + additionalFieldsRaw := json.RawMessage("{}") + + if p.AdditionalFields != nil { + data, err := json.Marshal(p.AdditionalFields) + if err != nil { + p.Log.Warn(logCtx, "marshal additional fields", slog.Error(err)) + } else { + additionalFieldsRaw = json.RawMessage(data) + } } var userID uuid.UUID @@ -319,7 +326,7 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request Diff: diffRaw, StatusCode: int32(sw.Status), RequestID: httpmw.RequestID(p.Request), - AdditionalFields: p.AdditionalFields, + AdditionalFields: additionalFieldsRaw, OrganizationID: requireOrgID[T](logCtx, p.OrganizationID, p.Log), } err := p.Audit.Export(ctx, auditLog) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 22a269fc5fb7f..7e6698736eeb6 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -361,17 +361,12 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req } ) - wriBytes, err := json.Marshal(workspaceResourceInfo) - if err != nil { - api.Logger.Warn(ctx, "marshal workspace owner name") - } - aReq, commitAudit := audit.InitRequest[database.Workspace](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionCreate, - AdditionalFields: wriBytes, + AdditionalFields: workspaceResourceInfo, OrganizationID: organization.ID, }) diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go index ca3f19fce2d3a..2e638e667e9a1 100644 --- a/enterprise/coderd/scim.go +++ b/enterprise/coderd/scim.go @@ -15,6 +15,7 @@ import ( "golang.org/x/xerrors" agpl "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -118,6 +119,11 @@ type SCIMUser struct { } `json:"meta"` } +var SCIMAuditAdditionalFields = map[string]string{ + "automatic_actor": "coder", + "automatic_subsystem": "scim", +} + // scimPostUser creates a new user, or returns the existing user if it exists. // // @Summary SCIM 2.0: Create new user @@ -135,6 +141,16 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { return } + auditor := *api.AGPL.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{ + Audit: auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + AdditionalFields: SCIMAuditAdditionalFields, + }) + defer commitAudit() + var sUser SCIMUser err := json.NewDecoder(r.Body).Decode(&sUser) if err != nil { @@ -170,7 +186,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { if sUser.Active && dbUser.Status == database.UserStatusSuspended { //nolint:gocritic - _, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{ + newUser, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{ ID: dbUser.ID, // The user will get transitioned to Active after logging in. Status: database.UserStatusDormant, @@ -180,8 +196,13 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { _ = handlerutil.WriteError(rw, err) return } + aReq.New = newUser + } else { + aReq.New = dbUser } + aReq.Old = dbUser + httpapi.Write(ctx, rw, http.StatusOK, sUser) return } @@ -223,6 +244,8 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { _ = handlerutil.WriteError(rw, err) return } + aReq.New = dbUser + aReq.UserID = dbUser.ID sUser.ID = dbUser.ID.String() sUser.UserName = dbUser.Username @@ -248,6 +271,15 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { return } + auditor := *api.AGPL.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{ + Audit: auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit() + id := chi.URLParam(r, "id") var sUser SCIMUser @@ -270,6 +302,8 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { _ = handlerutil.WriteError(rw, err) return } + aReq.Old = dbUser + aReq.UserID = dbUser.ID var status database.UserStatus if sUser.Active { @@ -280,7 +314,7 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { } //nolint:gocritic // needed for SCIM - _, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{ + userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{ ID: dbUser.ID, Status: status, UpdatedAt: dbtime.Now(), @@ -289,6 +323,7 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { _ = handlerutil.WriteError(rw, err) return } + aReq.New = userNew httpapi.Write(ctx, rw, http.StatusOK, sUser) } diff --git a/enterprise/coderd/scim_test.go b/enterprise/coderd/scim_test.go index 95d297605e1fc..237d53983a1a3 100644 --- a/enterprise/coderd/scim_test.go +++ b/enterprise/coderd/scim_test.go @@ -11,6 +11,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd" @@ -109,21 +112,34 @@ func TestScim(t *testing.T) { defer cancel() scimAPIKey := []byte("hi") + mockAudit := audit.NewMock() client, _ := coderdenttest.New(t, &coderdenttest.Options{ - SCIMAPIKey: scimAPIKey, + Options: &coderdtest.Options{Auditor: mockAudit}, + SCIMAPIKey: scimAPIKey, + AuditLogging: true, LicenseOptions: &coderdenttest.LicenseOptions{ AccountID: "coolin", Features: license.Features{ - codersdk.FeatureSCIM: 1, + codersdk.FeatureSCIM: 1, + codersdk.FeatureAuditLog: 1, }, }, }) + mockAudit.ResetLogs() sUser := makeScimUser(t) res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) require.NoError(t, err) defer res.Body.Close() - assert.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, http.StatusOK, res.StatusCode) + + aLogs := mockAudit.AuditLogs() + require.Len(t, aLogs, 1) + af := map[string]string{} + err = json.Unmarshal([]byte(aLogs[0].AdditionalFields), &af) + require.NoError(t, err) + assert.Equal(t, coderd.SCIMAuditAdditionalFields, af) + assert.Equal(t, database.AuditActionCreate, aLogs[0].Action) userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) require.NoError(t, err) @@ -306,21 +322,27 @@ func TestScim(t *testing.T) { defer cancel() scimAPIKey := []byte("hi") + mockAudit := audit.NewMock() client, _ := coderdenttest.New(t, &coderdenttest.Options{ - SCIMAPIKey: scimAPIKey, + Options: &coderdtest.Options{Auditor: mockAudit}, + SCIMAPIKey: scimAPIKey, + AuditLogging: true, LicenseOptions: &coderdenttest.LicenseOptions{ AccountID: "coolin", Features: license.Features{ - codersdk.FeatureSCIM: 1, + codersdk.FeatureSCIM: 1, + codersdk.FeatureAuditLog: 1, }, }, }) + mockAudit.ResetLogs() sUser := makeScimUser(t) res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) require.NoError(t, err) defer res.Body.Close() assert.Equal(t, http.StatusOK, res.StatusCode) + mockAudit.ResetLogs() err = json.NewDecoder(res.Body).Decode(&sUser) require.NoError(t, err) @@ -333,6 +355,10 @@ func TestScim(t *testing.T) { _ = res.Body.Close() assert.Equal(t, http.StatusOK, res.StatusCode) + aLogs := mockAudit.AuditLogs() + require.Len(t, aLogs, 1) + assert.Equal(t, database.AuditActionWrite, aLogs[0].Action) + userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) require.NoError(t, err) require.Len(t, userRes.Users, 1) diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.stories.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.stories.tsx index 956ef1df1d903..36eb866890eb1 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.stories.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.stories.tsx @@ -56,3 +56,45 @@ export const UnsuccessfulLoginForUnknownUser: Story = { auditLog: MockAuditLogUnsuccessfulLoginKnownUser, }, }; + +export const CreateUser: Story = { + args: { + auditLog: { + ...MockAuditLog, + resource_type: "user", + resource_target: "colin", + description: "{user} created user {target}", + }, + }, +}; + +export const SCIMCreateUser: Story = { + args: { + auditLog: { + ...MockAuditLog, + resource_type: "user", + resource_target: "colin", + description: "{user} created user {target}", + additional_fields: { + automatic_actor: "coder", + automatic_subsystem: "scim", + }, + }, + }, +}; + +export const SCIMUpdateUser: Story = { + args: { + auditLog: { + ...MockAuditLog, + action: "write", + resource_type: "user", + resource_target: "colin", + description: "{user} updated user {target}", + additional_fields: { + automatic_actor: "coder", + automatic_subsystem: "scim", + }, + }, + }, +}; diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.tsx index 3eea4ca12d6a1..e2ad2ab379dca 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.tsx @@ -12,7 +12,7 @@ export const AuditLogDescription: FC = ({ auditLog, }) => { let target = auditLog.resource_target.trim(); - const user = auditLog.user?.username.trim(); + let user = auditLog.user?.username.trim(); if (auditLog.resource_type === "workspace_build") { return ; @@ -23,6 +23,14 @@ export const AuditLogDescription: FC = ({ target = ""; } + // This occurs when SCIM creates a user. + if ( + auditLog.resource_type === "user" && + auditLog.additional_fields?.automatic_actor === "coder" + ) { + user = "Coder automatically"; + } + const truncatedDescription = auditLog.description .replace("{user}", `${user}`) .replace("{target}", ""); From 2ef2f9738857c96b3a9cb0925b4d4b6cfc7129b0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 20 Jun 2024 13:05:11 -1000 Subject: [PATCH 108/168] chore: improve error message on adding existing org_member (#13621) --- coderd/database/dbmem/dbmem.go | 14 ++++++++++++++ coderd/members.go | 6 ++++++ coderd/members_test.go | 13 +++++++++++++ 3 files changed, 33 insertions(+) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index f83838dd4be94..04eecd5d86355 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -6197,6 +6197,20 @@ func (q *FakeQuerier) InsertOrganizationMember(_ context.Context, arg database.I q.mutex.Lock() defer q.mutex.Unlock() + if slices.IndexFunc(q.data.organizationMembers, func(member database.OrganizationMember) bool { + return member.OrganizationID == arg.OrganizationID && member.UserID == arg.UserID + }) >= 0 { + // Error pulled from a live db error + return database.OrganizationMember{}, &pq.Error{ + Severity: "ERROR", + Code: "23505", + Message: "duplicate key value violates unique constraint \"organization_members_pkey\"", + Detail: "Key (organization_id, user_id)=(f7de1f4e-5833-4410-a28d-0a105f96003f, 36052a80-4a7f-4998-a7ca-44cefa608d3e) already exists.", + Table: "organization_members", + Constraint: "organization_members_pkey", + } + } + //nolint:gosimple organizationMember := database.OrganizationMember{ OrganizationID: arg.OrganizationID, diff --git a/coderd/members.go b/coderd/members.go index 3352eefc3b3d6..2528a17878f3b 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -43,6 +43,12 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) httpapi.ResourceNotFound(rw) return } + if database.IsUniqueViolation(err, database.UniqueOrganizationMembersPkey) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Organization member already exists in this organization", + }) + return + } if err != nil { httpapi.InternalServerError(rw, err) return diff --git a/coderd/members_test.go b/coderd/members_test.go index db80b28ad1fbb..3db296ef6009a 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -50,6 +50,19 @@ func TestAddMember(t *testing.T) { db2sdk.List(members, onlyIDs)) }) + t.Run("AlreadyMember", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + _, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitMedium) + // Add user to org, even though they already exist + // nolint:gocritic // must be an owner to see the user + _, err := owner.PostOrganizationMember(ctx, first.OrganizationID, user.Username) + require.ErrorContains(t, err, "already exists") + }) + t.Run("UserNotExists", func(t *testing.T) { t.Parallel() owner := coderdtest.New(t, nil) From 66a604d7798031eb88b61f3a3a1c95da74d23a78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Jun 2024 08:54:06 +0300 Subject: [PATCH 109/168] chore: bump golang.org/x/tools from 0.21.0 to 0.22.0 (#13513) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 84d1274adc4ee..037385d6d22b4 100644 --- a/go.mod +++ b/go.mod @@ -172,16 +172,16 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29 go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 - golang.org/x/crypto v0.23.0 + golang.org/x/crypto v0.24.0 golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 - golang.org/x/mod v0.17.0 - golang.org/x/net v0.25.0 + golang.org/x/mod v0.18.0 + golang.org/x/net v0.26.0 golang.org/x/oauth2 v0.20.0 golang.org/x/sync v0.7.0 - golang.org/x/sys v0.20.0 - golang.org/x/term v0.20.0 - golang.org/x/text v0.15.0 - golang.org/x/tools v0.21.0 + golang.org/x/sys v0.21.0 + golang.org/x/term v0.21.0 + golang.org/x/text v0.16.0 + golang.org/x/tools v0.22.0 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 google.golang.org/api v0.182.0 google.golang.org/grpc v1.64.0 diff --git a/go.sum b/go.sum index 0f5f7b9bf3b1c..20436c196de70 100644 --- a/go.sum +++ b/go.sum @@ -1000,8 +1000,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= @@ -1016,8 +1016,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1040,8 +1040,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= @@ -1097,8 +1097,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1108,8 +1108,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -1121,8 +1121,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= @@ -1138,8 +1138,8 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= -golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 819bfd317039e0e218477f5a72c556fad731e4bc Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 21 Jun 2024 03:01:39 -1000 Subject: [PATCH 110/168] fix: remove assigning org-member role, this is implied from membership (#13578) --- coderd/organizations.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/coderd/organizations.go b/coderd/organizations.go index 6c0b14697a642..6d40b765141ba 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -13,7 +13,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) @@ -95,12 +94,11 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { UserID: apiKey.UserID, CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), - Roles: []string{ + Roles: []string{ // TODO: When organizations are allowed to be created, we should // come back to determining the default role of the person who // creates the org. Until that happens, all users in an organization // should be just regular members. - rbac.RoleOrgMember(), }, }) if err != nil { From 73a25c3bc592f68e168941f08a74edab0b77a289 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 21 Jun 2024 10:54:55 -0300 Subject: [PATCH 111/168] chore(site): add InputGroup component (#13597) --- site/src/components/Filter/filter.tsx | 25 +----- .../InputGroup/InputGroup.stories.tsx | 79 +++++++++++++++++++ site/src/components/InputGroup/InputGroup.tsx | 58 ++++++++++++++ 3 files changed, 141 insertions(+), 21 deletions(-) create mode 100644 site/src/components/InputGroup/InputGroup.stories.tsx create mode 100644 site/src/components/InputGroup/InputGroup.tsx diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx index 91d8d78ee1cf4..29fb34ee4c251 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/filter.tsx @@ -22,6 +22,7 @@ import { hasError, isApiValidationError, } from "api/errors"; +import { InputGroup } from "components/InputGroup/InputGroup"; import { Loader } from "components/Loader/Loader"; import { Search, @@ -212,7 +213,7 @@ export const Filter: FC = ({ skeleton ) : ( <> -
      + filter.update(query)} presets={presets} @@ -221,7 +222,7 @@ export const Filter: FC = ({ learnMoreLink2={learnMoreLink2} /> = ({ setQueryCopy(filter.query); } }, - sx: { - borderRadius: "6px", - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - marginLeft: "-1px", - "&:hover": { - zIndex: 2, - }, - "&.Mui-error": { - zIndex: 3, - }, - }, }} /> -
      + {options} )} @@ -288,12 +277,6 @@ const PresetMenu: FC = ({ + + + ), + }, +}; + +export const FocusedTextField: Story = { + args: { + children: ( + <> + + + + ), + }, +}; + +export const ErroredTextField: Story = { + args: { + children: ( + <> + + + + ), + }, +}; + +export const FocusedErroredTextField: Story = { + args: { + children: ( + <> + + + + ), + }, +}; + +export const WithThreeElements: Story = { + args: { + children: ( + <> + + + + + ), + }, +}; diff --git a/site/src/components/InputGroup/InputGroup.tsx b/site/src/components/InputGroup/InputGroup.tsx new file mode 100644 index 0000000000000..d6965442ee96a --- /dev/null +++ b/site/src/components/InputGroup/InputGroup.tsx @@ -0,0 +1,58 @@ +import type { FC, HTMLProps } from "react"; + +export const InputGroup: FC> = (props) => { + return ( +
      *:not(:last-child)": { + marginRight: -1, + }, + + // Ensure the border of the hovered element is visible when borders + // overlap. + "& > *:hover": { + zIndex: 1, + }, + + // Display border elements when focused or in an error state, both of + // which take priority over hover. + "& .Mui-focused, & .Mui-error": { + zIndex: 2, + }, + + "& > *:first-child": { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + + "&.MuiFormControl-root .MuiInputBase-root": { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + }, + + "& > *:last-child": { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + + "&.MuiFormControl-root .MuiInputBase-root": { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + }, + + "& > *:not(:first-child):not(:last-child)": { + borderRadius: 0, + + "&.MuiFormControl-root .MuiInputBase-root": { + borderRadius: 0, + }, + }, + }} + /> + ); +}; From 714f2ef83c5e0f53d149cd8d5b61653445ce832c Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Sat, 22 Jun 2024 00:02:12 +1000 Subject: [PATCH 112/168] fix: fix shallow clones not retrieving a valid semver (#13609) --- scripts/version.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/version.sh b/scripts/version.sh index 4a87853d2c99d..3e813036fbbeb 100755 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -26,13 +26,12 @@ if [[ -n "${CODER_FORCE_VERSION:-}" ]]; then exit 0 fi -# To make contributing easier, if the upstream isn't coder/coder and there are -# no tags we will fall back to 0.1.0 with devel suffix. -remote_url=$(git remote get-url origin) +# To make contributing easier, if there are no tags, we'll use a default +# version. tag_list=$(git tag) -if ! [[ ${remote_url} =~ [@/]github.com ]] && ! [[ ${remote_url} =~ [:/]coder/coder(\.git)?$ ]] && [[ -z ${tag_list} ]]; then +if [[ -z ${tag_list} ]]; then log - log "INFO(version.sh): It appears you've checked out a fork of Coder." + log "INFO(version.sh): It appears you've checked out a fork or shallow clone of Coder." log "INFO(version.sh): By default GitHub does not include tags when forking." log "INFO(version.sh): We will use the default version 2.0.0 for this build." log "INFO(version.sh): To pull tags from upstream, use the following commands:" From cbdaa63b688301abc3a811a8622f7906564ce3ae Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 21 Jun 2024 11:15:37 -0300 Subject: [PATCH 113/168] chore(site): refactor popover to make it easier to extend (#13611) --- .../components/HelpTooltip/HelpTooltip.tsx | 2 +- site/src/components/IconField/IconField.tsx | 48 +++-- site/src/components/Popover/Popover.tsx | 81 ++++----- .../dashboard/Navbar/DeploymentDropdown.tsx | 2 +- .../dashboard/Navbar/ProxyMenu.stories.tsx | 7 +- .../Navbar/UserDropdown/UserDropdown.tsx | 77 ++++---- .../UserDropdown/UserDropdownContent.tsx | 2 +- .../resources/SSHButton/SSHButton.stories.tsx | 9 +- .../modules/resources/SSHButton/SSHButton.tsx | 4 +- .../WorkspaceOutdatedTooltip.tsx | 2 +- .../TemplateInsightsPage/DateRange.tsx | 165 +++++++++--------- .../UsersTable/EditRolesButton.stories.tsx | 24 +-- .../UsersPage/UsersTable/EditRolesButton.tsx | 4 +- .../BuildParametersPopover.tsx | 2 +- .../DownloadLogsDialog.stories.tsx | 9 +- .../WorkspaceActions.stories.tsx | 9 +- .../WorkspaceNotifications/Notifications.tsx | 2 +- site/src/testHelpers/storybook.tsx | 6 + 18 files changed, 218 insertions(+), 237 deletions(-) diff --git a/site/src/components/HelpTooltip/HelpTooltip.tsx b/site/src/components/HelpTooltip/HelpTooltip.tsx index 31e2fe1fb260d..ffa4aa61fe6a3 100644 --- a/site/src/components/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/HelpTooltip/HelpTooltip.tsx @@ -160,7 +160,7 @@ export const HelpTooltipAction: FC = ({ onClick={(event) => { event.stopPropagation(); onClick(); - popover.setIsOpen(false); + popover.setOpen(false); }} > diff --git a/site/src/components/IconField/IconField.tsx b/site/src/components/IconField/IconField.tsx index b2bf348d4d532..3690a78983195 100644 --- a/site/src/components/IconField/IconField.tsx +++ b/site/src/components/IconField/IconField.tsx @@ -3,7 +3,7 @@ import Button from "@mui/material/Button"; import InputAdornment from "@mui/material/InputAdornment"; import TextField, { type TextFieldProps } from "@mui/material/TextField"; import { visuallyHidden } from "@mui/utils"; -import { type FC, lazy, Suspense } from "react"; +import { type FC, lazy, Suspense, useState } from "react"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { Loader } from "components/Loader/Loader"; @@ -37,6 +37,7 @@ export const IconField: FC = ({ const theme = useTheme(); const hasIcon = textFieldProps.value && textFieldProps.value !== ""; + const [open, setOpen] = useState(false); return ( @@ -86,31 +87,26 @@ export const IconField: FC = ({ } `} /> - - {(popover) => ( - <> - - - - - }> - { - const value = - emoji.src ?? urlFromUnifiedCode(emoji.unified); - onPickEmoji(value); - popover.setIsOpen(false); - }} - /> - - - - )} + + + + + + }> + { + const value = emoji.src ?? urlFromUnifiedCode(emoji.unified); + onPickEmoji(value); + setOpen(false); + }} + /> + + {/* diff --git a/site/src/components/Popover/Popover.tsx b/site/src/components/Popover/Popover.tsx index 1ed4e90a73995..ffb56fd5d7349 100644 --- a/site/src/components/Popover/Popover.tsx +++ b/site/src/components/Popover/Popover.tsx @@ -12,7 +12,6 @@ import { type ReactNode, type RefObject, useContext, - useEffect, useId, useRef, useState, @@ -25,14 +24,12 @@ type TriggerRef = RefObject; type TriggerElement = ReactElement<{ ref: TriggerRef; onClick?: () => void; - "aria-haspopup"?: boolean; - "aria-owns"?: string | undefined; }>; type PopoverContextValue = { id: string; - isOpen: boolean; - setIsOpen: React.Dispatch>; + open: boolean; + setOpen: (open: boolean) => void; triggerRef: TriggerRef; mode: TriggerMode; }; @@ -41,32 +38,41 @@ const PopoverContext = createContext( undefined, ); -export interface PopoverProps { - children: ReactNode | ((popover: PopoverContextValue) => ReactNode); // Allows inline usage +type BasePopoverProps = { + children: ReactNode; mode?: TriggerMode; - isDefaultOpen?: boolean; -} +}; -export const Popover: FC = ({ - children, - mode, - isDefaultOpen, -}) => { +// By separating controlled and uncontrolled props, we achieve more accurate +// type inference. +type UncontrolledPopoverProps = BasePopoverProps & { + open?: undefined; + onOpenChange?: undefined; +}; + +type ControlledPopoverProps = BasePopoverProps & { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export type PopoverProps = UncontrolledPopoverProps | ControlledPopoverProps; + +export const Popover: FC = (props) => { const hookId = useId(); - const [isOpen, setIsOpen] = useState(isDefaultOpen ?? false); - const triggerRef = useRef(null); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const triggerRef: TriggerRef = useRef(null); const value: PopoverContextValue = { - isOpen, - setIsOpen, triggerRef, id: `${hookId}-popover`, - mode: mode ?? "click", + mode: props.mode ?? "click", + open: props.open ?? uncontrolledOpen, + setOpen: props.onOpenChange ?? setUncontrolledOpen, }; return ( - {typeof children === "function" ? children(value) : children} + {props.children} ); }; @@ -82,23 +88,25 @@ export const usePopover = () => { }; export const PopoverTrigger = ( - props: HTMLAttributes & { children: TriggerElement }, + props: HTMLAttributes & { + children: TriggerElement; + }, ) => { const popover = usePopover(); const { children, ...elementProps } = props; const clickProps = { onClick: () => { - popover.setIsOpen((isOpen) => !isOpen); + popover.setOpen(true); }, }; const hoverProps = { onPointerEnter: () => { - popover.setIsOpen(true); + popover.setOpen(true); }, onPointerLeave: () => { - popover.setIsOpen(false); + popover.setOpen(false); }, }; @@ -106,7 +114,8 @@ export const PopoverTrigger = ( ...elementProps, ...(popover.mode === "click" ? clickProps : hoverProps), "aria-haspopup": true, - "aria-owns": popover.isOpen ? popover.id : undefined, + "aria-owns": popover.id, + "aria-expanded": popover.open, ref: popover.triggerRef, }); }; @@ -125,22 +134,8 @@ export const PopoverContent: FC = ({ ...popoverProps }) => { const popover = usePopover(); - const [isReady, setIsReady] = useState(false); const hoverMode = popover.mode === "hover"; - // This is a hack to make sure the popover is not rendered until the trigger - // is ready. This is a limitation on MUI that does not support defaultIsOpen - // on Popover but we need it to storybook the component. - useEffect(() => { - if (!isReady && popover.triggerRef.current !== null) { - setIsReady(true); - } - }, [isReady, popover.triggerRef]); - - if (!popover.triggerRef.current) { - return null; - } - return ( = ({ {...modeProps(popover)} {...popoverProps} id={popover.id} - open={popover.isOpen} - onClose={() => popover.setIsOpen(false)} + open={popover.open} + onClose={() => popover.setOpen(false)} anchorEl={popover.triggerRef.current} /> ); @@ -172,10 +167,10 @@ const modeProps = (popover: PopoverContextValue) => { if (popover.mode === "hover") { return { onPointerEnter: () => { - popover.setIsOpen(true); + popover.setOpen(true); }, onPointerLeave: () => { - popover.setIsOpen(false); + popover.setOpen(false); }, }; } diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index e54210d831d8e..4a79bc00c1931 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -87,7 +87,7 @@ const DeploymentDropdownContent: FC = ({ }) => { const popover = usePopover(); - const onPopoverClose = () => popover.setIsOpen(false); + const onPopoverClose = () => popover.setOpen(false); return (
      -[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. +[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. - Define cloud development environments in Terraform - EC2 VMs, Kubernetes Pods, Docker Containers, etc. @@ -53,7 +53,7 @@ curl -L https://coder.com/install.sh | sh coder server # Navigate to http://localhost:3000 to create your initial user, -# create a Docker template, and provision a workspace +# create a Docker template and provision a workspace ``` ## Install @@ -69,7 +69,7 @@ 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. -> See [install](https://coder.com/docs/v2/latest/install) for additional methods. +> See [install](https://coder.com/docs/install) for additional methods. Once installed, you can start a production deployment with a single command: @@ -81,27 +81,27 @@ coder server coder server --postgres-url --access-url ``` -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. +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. ## Documentation -Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below: +Browse our docs [here](https://coder.com/docs) or visit a specific section below: -- [**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 +- [**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 +- [**Enterprise**](https://coder.com/docs/enterprise): Learn about our paid features built for large teams ## 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) to provide feedback on in-progress features and chat with the community using Coder! ## Integrations -We are always working on new integrations. Feel free to open an issue to request an integration. Contributions are welcome in any official or community repositories. +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. ### Official @@ -120,7 +120,7 @@ We are always working on new integrations. Feel free to open an issue to request ## 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/v2/latest/CONTRIBUTING). We'd love to see your +[a guide on how to get started](https://coder.com/docs/CONTRIBUTING). We'd love to see your contributions! ## Hiring From 87ad560affe56a99b7ba74caccb7d187fad2567a Mon Sep 17 00:00:00 2001 From: austinrhode <64610166+austinrhode@users.noreply.github.com> Date: Tue, 25 Jun 2024 11:01:40 -0700 Subject: [PATCH 142/168] feat: add groups and group members to telemetry snapshot (#13655) * feat: Added in groups and groups members to telemetry snapshot * feat: adding in test to dbauthz for getting group members and groups --- coderd/database/dbauthz/dbauthz.go | 18 ++++++- coderd/database/dbauthz/dbauthz_test.go | 10 +++- coderd/database/dbgen/dbgen_test.go | 2 +- coderd/database/dbmem/dbmem.go | 20 ++++++- coderd/database/dbmetrics/dbmetrics.go | 18 ++++++- coderd/database/dbmock/dbmock.go | 40 ++++++++++++-- coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 66 +++++++++++++++++++++++- coderd/database/queries/groupmembers.sql | 3 ++ coderd/database/queries/groups.sql | 3 ++ coderd/telemetry/telemetry.go | 62 ++++++++++++++++++++-- coderd/telemetry/telemetry_test.go | 4 ++ enterprise/coderd/groups.go | 10 ++-- enterprise/coderd/templates.go | 4 +- 14 files changed, 239 insertions(+), 25 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f32e176754fa1..b0b4adcf844c9 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1321,11 +1321,25 @@ func (q *querier) GetGroupByOrgAndName(ctx context.Context, arg database.GetGrou return fetch(q.log, q.auth, q.db.GetGroupByOrgAndName)(ctx, arg) } -func (q *querier) GetGroupMembers(ctx context.Context, id uuid.UUID) ([]database.User, error) { +func (q *querier) GetGroupMembers(ctx context.Context) ([]database.GroupMember, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetGroupMembers(ctx) +} + +func (q *querier) GetGroupMembersByGroupID(ctx context.Context, id uuid.UUID) ([]database.User, error) { if _, err := q.GetGroupByID(ctx, id); err != nil { // AuthZ check return nil, err } - return q.db.GetGroupMembers(ctx, id) + return q.db.GetGroupMembersByGroupID(ctx, id) +} + +func (q *querier) GetGroups(ctx context.Context) ([]database.Group, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetGroups(ctx) } func (q *querier) GetGroupsByOrganizationAndUserID(ctx context.Context, arg database.GetGroupsByOrganizationAndUserIDParams) ([]database.Group, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 17c0d76c4ef31..3ccafb33262cc 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -314,11 +314,19 @@ func (s *MethodTestSuite) TestGroup() { Name: g.Name, }).Asserts(g, policy.ActionRead).Returns(g) })) - s.Run("GetGroupMembers", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetGroupMembersByGroupID", s.Subtest(func(db database.Store, check *expects) { g := dbgen.Group(s.T(), db, database.Group{}) _ = dbgen.GroupMember(s.T(), db, database.GroupMember{}) check.Args(g.ID).Asserts(g, policy.ActionRead) })) + s.Run("GetGroupMembers", s.Subtest(func(db database.Store, check *expects) { + _ = dbgen.GroupMember(s.T(), db, database.GroupMember{}) + check.Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetGroups", s.Subtest(func(db database.Store, check *expects) { + _ = dbgen.Group(s.T(), db, database.Group{}) + check.Asserts(rbac.ResourceSystem, policy.ActionRead) + })) s.Run("GetGroupsByOrganizationAndUserID", s.Subtest(func(db database.Store, check *expects) { g := dbgen.Group(s.T(), db, database.Group{}) gm := dbgen.GroupMember(s.T(), db, database.GroupMember{GroupID: g.ID}) diff --git a/coderd/database/dbgen/dbgen_test.go b/coderd/database/dbgen/dbgen_test.go index 2681f6eb1fece..0690808eb590d 100644 --- a/coderd/database/dbgen/dbgen_test.go +++ b/coderd/database/dbgen/dbgen_test.go @@ -105,7 +105,7 @@ func TestGenerator(t *testing.T) { exp := []database.User{u} dbgen.GroupMember(t, db, database.GroupMember{GroupID: g.ID, UserID: u.ID}) - require.Equal(t, exp, must(db.GetGroupMembers(context.Background(), g.ID))) + require.Equal(t, exp, must(db.GetGroupMembersByGroupID(context.Background(), g.ID))) }) t.Run("Organization", func(t *testing.T) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 04eecd5d86355..573279db3ad39 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2370,7 +2370,16 @@ func (q *FakeQuerier) GetGroupByOrgAndName(_ context.Context, arg database.GetGr return database.Group{}, sql.ErrNoRows } -func (q *FakeQuerier) GetGroupMembers(_ context.Context, id uuid.UUID) ([]database.User, error) { +func (q *FakeQuerier) GetGroupMembers(_ context.Context) ([]database.GroupMember, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + out := make([]database.GroupMember, len(q.groupMembers)) + copy(out, q.groupMembers) + return out, nil +} + +func (q *FakeQuerier) GetGroupMembersByGroupID(_ context.Context, id uuid.UUID) ([]database.User, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -2399,6 +2408,15 @@ func (q *FakeQuerier) GetGroupMembers(_ context.Context, id uuid.UUID) ([]databa return users, nil } +func (q *FakeQuerier) GetGroups(_ context.Context) ([]database.Group, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + out := make([]database.Group, len(q.groups)) + copy(out, q.groups) + return out, nil +} + func (q *FakeQuerier) GetGroupsByOrganizationAndUserID(_ context.Context, arg database.GetGroupsByOrganizationAndUserIDParams) ([]database.Group, error) { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index dc4c52a31faaf..e45072cd71cdb 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -585,13 +585,27 @@ func (m metricsStore) GetGroupByOrgAndName(ctx context.Context, arg database.Get return group, err } -func (m metricsStore) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]database.User, error) { +func (m metricsStore) GetGroupMembers(ctx context.Context) ([]database.GroupMember, error) { start := time.Now() - users, err := m.s.GetGroupMembers(ctx, groupID) + r0, r1 := m.s.GetGroupMembers(ctx) m.queryLatencies.WithLabelValues("GetGroupMembers").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]database.User, error) { + start := time.Now() + users, err := m.s.GetGroupMembersByGroupID(ctx, groupID) + m.queryLatencies.WithLabelValues("GetGroupMembersByGroupID").Observe(time.Since(start).Seconds()) return users, err } +func (m metricsStore) GetGroups(ctx context.Context) ([]database.Group, error) { + start := time.Now() + r0, r1 := m.s.GetGroups(ctx) + m.queryLatencies.WithLabelValues("GetGroups").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetGroupsByOrganizationAndUserID(ctx context.Context, arg database.GetGroupsByOrganizationAndUserIDParams) ([]database.Group, error) { start := time.Now() r0, r1 := m.s.GetGroupsByOrganizationAndUserID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 35f0312b7b20f..4b952461c44a6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1139,18 +1139,48 @@ func (mr *MockStoreMockRecorder) GetGroupByOrgAndName(arg0, arg1 any) *gomock.Ca } // GetGroupMembers mocks base method. -func (m *MockStore) GetGroupMembers(arg0 context.Context, arg1 uuid.UUID) ([]database.User, error) { +func (m *MockStore) GetGroupMembers(arg0 context.Context) ([]database.GroupMember, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetGroupMembers", arg0, arg1) - ret0, _ := ret[0].([]database.User) + ret := m.ctrl.Call(m, "GetGroupMembers", arg0) + ret0, _ := ret[0].([]database.GroupMember) ret1, _ := ret[1].(error) return ret0, ret1 } // GetGroupMembers indicates an expected call of GetGroupMembers. -func (mr *MockStoreMockRecorder) GetGroupMembers(arg0, arg1 any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetGroupMembers(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembers", reflect.TypeOf((*MockStore)(nil).GetGroupMembers), arg0) +} + +// GetGroupMembersByGroupID mocks base method. +func (m *MockStore) GetGroupMembersByGroupID(arg0 context.Context, arg1 uuid.UUID) ([]database.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupMembersByGroupID", arg0, arg1) + ret0, _ := ret[0].([]database.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupMembersByGroupID indicates an expected call of GetGroupMembersByGroupID. +func (mr *MockStoreMockRecorder) GetGroupMembersByGroupID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersByGroupID", reflect.TypeOf((*MockStore)(nil).GetGroupMembersByGroupID), arg0, arg1) +} + +// GetGroups mocks base method. +func (m *MockStore) GetGroups(arg0 context.Context) ([]database.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroups", arg0) + ret0, _ := ret[0].([]database.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroups indicates an expected call of GetGroups. +func (mr *MockStoreMockRecorder) GetGroups(arg0 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembers", reflect.TypeOf((*MockStore)(nil).GetGroupMembers), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroups", reflect.TypeOf((*MockStore)(nil).GetGroups), arg0) } // GetGroupsByOrganizationAndUserID mocks base method. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 50645ab1e5eb5..f244f52026eb0 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -124,9 +124,11 @@ type sqlcQuerier interface { GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) + GetGroupMembers(ctx context.Context) ([]GroupMember, error) // If the group is a user made group, then we need to check the group_members table. // If it is the "Everyone" group, then we need to check the organization_members table. - GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) + GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]User, error) + GetGroups(ctx context.Context) ([]Group, error) GetGroupsByOrganizationAndUserID(ctx context.Context, arg GetGroupsByOrganizationAndUserIDParams) ([]Group, error) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) GetHealthSettings(ctx context.Context) (string, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4f113323a024f..b25d99eb9e03b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1312,6 +1312,33 @@ func (q *sqlQuerier) DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteG } const getGroupMembers = `-- name: GetGroupMembers :many +SELECT user_id, group_id FROM group_members +` + +func (q *sqlQuerier) GetGroupMembers(ctx context.Context) ([]GroupMember, error) { + rows, err := q.db.QueryContext(ctx, getGroupMembers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GroupMember + for rows.Next() { + var i GroupMember + if err := rows.Scan(&i.UserID, &i.GroupID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGroupMembersByGroupID = `-- name: GetGroupMembersByGroupID :many SELECT users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at, users.quiet_hours_schedule, users.theme_preference, users.name FROM @@ -1337,8 +1364,8 @@ AND // If the group is a user made group, then we need to check the group_members table. // If it is the "Everyone" group, then we need to check the organization_members table. -func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) { - rows, err := q.db.QueryContext(ctx, getGroupMembers, groupID) +func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]User, error) { + rows, err := q.db.QueryContext(ctx, getGroupMembersByGroupID, groupID) if err != nil { return nil, err } @@ -1507,6 +1534,41 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg return i, err } +const getGroups = `-- name: GetGroups :many +SELECT id, name, organization_id, avatar_url, quota_allowance, display_name, source FROM groups +` + +func (q *sqlQuerier) GetGroups(ctx context.Context) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, getGroups) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Group + for rows.Next() { + var i Group + if err := rows.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + &i.QuotaAllowance, + &i.DisplayName, + &i.Source, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getGroupsByOrganizationAndUserID = `-- name: GetGroupsByOrganizationAndUserID :many SELECT groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance, groups.display_name, groups.source diff --git a/coderd/database/queries/groupmembers.sql b/coderd/database/queries/groupmembers.sql index d755212132383..8f4770eff112e 100644 --- a/coderd/database/queries/groupmembers.sql +++ b/coderd/database/queries/groupmembers.sql @@ -1,4 +1,7 @@ -- name: GetGroupMembers :many +SELECT * FROM group_members; + +-- name: GetGroupMembersByGroupID :many SELECT users.* FROM diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 53d0b25874987..9dea20f0fa6e6 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -1,3 +1,6 @@ +-- name: GetGroups :many +SELECT * FROM groups; + -- name: GetGroupByID :one SELECT * diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 9d16ba7922098..91251053663f5 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -344,9 +344,6 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { users := database.ConvertUserRows(userRows) var firstUser database.User for _, dbUser := range users { - if dbUser.Status != database.UserStatusActive { - continue - } if firstUser.CreatedAt.IsZero() { firstUser = dbUser } @@ -366,6 +363,28 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } return nil }) + eg.Go(func() error { + groups, err := r.options.Database.GetGroups(ctx) + if err != nil { + return xerrors.Errorf("get groups: %w", err) + } + snapshot.Groups = make([]Group, 0, len(groups)) + for _, group := range groups { + snapshot.Groups = append(snapshot.Groups, ConvertGroup(group)) + } + return nil + }) + eg.Go(func() error { + groupMembers, err := r.options.Database.GetGroupMembers(ctx) + if err != nil { + return xerrors.Errorf("get groups: %w", err) + } + snapshot.GroupMembers = make([]GroupMember, 0, len(groupMembers)) + for _, member := range groupMembers { + snapshot.GroupMembers = append(snapshot.GroupMembers, ConvertGroupMember(member)) + } + return nil + }) eg.Go(func() error { workspaceRows, err := r.options.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{}) if err != nil { @@ -642,6 +661,26 @@ func ConvertUser(dbUser database.User) User { EmailHashed: emailHashed, RBACRoles: dbUser.RBACRoles, CreatedAt: dbUser.CreatedAt, + Status: dbUser.Status, + } +} + +func ConvertGroup(group database.Group) Group { + return Group{ + ID: group.ID, + Name: group.Name, + OrganizationID: group.OrganizationID, + AvatarURL: group.AvatarURL, + QuotaAllowance: group.QuotaAllowance, + DisplayName: group.DisplayName, + Source: group.Source, + } +} + +func ConvertGroupMember(member database.GroupMember) GroupMember { + return GroupMember{ + GroupID: member.GroupID, + UserID: member.UserID, } } @@ -746,6 +785,8 @@ type Snapshot struct { TemplateVersions []TemplateVersion `json:"template_versions"` Templates []Template `json:"templates"` Users []User `json:"users"` + Groups []Group `json:"groups"` + GroupMembers []GroupMember `json:"group_members"` WorkspaceAgentStats []WorkspaceAgentStat `json:"workspace_agent_stats"` WorkspaceAgents []WorkspaceAgent `json:"workspace_agents"` WorkspaceApps []WorkspaceApp `json:"workspace_apps"` @@ -797,6 +838,21 @@ type User struct { Status database.UserStatus `json:"status"` } +type Group struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + OrganizationID uuid.UUID `json:"organization_id"` + AvatarURL string `json:"avatar_url"` + QuotaAllowance int32 `json:"quota_allowance"` + DisplayName string `json:"display_name"` + Source database.GroupSource `json:"source"` +} + +type GroupMember struct { + UserID uuid.UUID `json:"user_id"` + GroupID uuid.UUID `json:"group_id"` +} + type WorkspaceResource struct { ID uuid.UUID `json:"id"` CreatedAt time.Time `json:"created_at"` diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index fa8650de3f3d5..2eff919ddc63d 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -55,6 +55,8 @@ func TestTelemetry(t *testing.T) { SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, }) + _ = dbgen.Group(t, db, database.Group{}) + _ = dbgen.GroupMember(t, db, database.GroupMember{}) wsagent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{}) // Update the workspace agent to have a valid subsystem. err = db.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{ @@ -91,6 +93,8 @@ func TestTelemetry(t *testing.T) { require.Len(t, snapshot.Templates, 1) require.Len(t, snapshot.TemplateVersions, 1) require.Len(t, snapshot.Users, 1) + require.Len(t, snapshot.Groups, 2) + require.Len(t, snapshot.GroupMembers, 1) require.Len(t, snapshot.Workspaces, 1) require.Len(t, snapshot.WorkspaceApps, 1) require.Len(t, snapshot.WorkspaceAgents, 1) diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 65220e5cbabf7..dffbc5200c767 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -150,7 +150,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } - currentMembers, err := api.Database.GetGroupMembers(ctx, group.ID) + currentMembers, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) if err != nil { httpapi.InternalServerError(rw, err) return @@ -276,7 +276,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } - patchedMembers, err := api.Database.GetGroupMembers(ctx, group.ID) + patchedMembers, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) if err != nil { httpapi.InternalServerError(rw, err) return @@ -317,7 +317,7 @@ func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { return } - groupMembers, getMembersErr := api.Database.GetGroupMembers(ctx, group.ID) + groupMembers, getMembersErr := api.Database.GetGroupMembersByGroupID(ctx, group.ID) if getMembersErr != nil { httpapi.InternalServerError(rw, getMembersErr) return @@ -363,7 +363,7 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) - users, err := api.Database.GetGroupMembers(ctx, group.ID) + users, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return @@ -408,7 +408,7 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) { resp := make([]codersdk.Group, 0, len(groups)) for _, group := range groups { - members, err := api.Database.GetGroupMembers(ctx, group.ID) + members, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) if err != nil { httpapi.InternalServerError(rw, err) return diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index 6caf882192816..d9d5f245fcb41 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -59,7 +59,7 @@ func (api *API) templateAvailablePermissions(rw http.ResponseWriter, r *http.Req sdkGroups := make([]codersdk.Group, 0, len(groups)) for _, group := range groups { // nolint:gocritic - members, err := api.Database.GetGroupMembers(dbauthz.AsSystemRestricted(ctx), group.ID) + members, err := api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), group.ID) if err != nil { httpapi.InternalServerError(rw, err) return @@ -128,7 +128,7 @@ func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) { // them read the group members. // We should probably at least return more truncated user data here. // nolint:gocritic - members, err = api.Database.GetGroupMembers(dbauthz.AsSystemRestricted(ctx), group.ID) + members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), group.ID) if err != nil { httpapi.InternalServerError(rw, err) return From 8a3592582b7f23dd8e248dac098f49862c7b4339 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Jun 2024 09:00:42 +0100 Subject: [PATCH 143/168] feat: add "Full Name" field to user creation (#13659) Adds the ability to specify "Full Name" (a.k.a. Name) when creating users either via CLI or UI. --- Makefile | 4 + cli/login.go | 28 ++++ cli/login_test.go | 149 ++++++++++++++++-- cli/server_createadminuser.go | 3 + cli/testdata/coder_login_--help.golden | 3 + cli/testdata/coder_users_create_--help.golden | 3 + .../coder_users_list_--output_json.golden | 2 +- cli/usercreate.go | 24 +++ cli/usercreate_test.go | 89 ++++++++++- cli/userlist.go | 1 + cli/userlist_test.go | 10 +- coderd/apidoc/docs.go | 6 + coderd/apidoc/swagger.json | 6 + coderd/coderdtest/coderdtest.go | 25 +++ coderd/database/dbauthz/dbauthz_test.go | 1 + coderd/database/dbgen/dbgen.go | 1 + coderd/database/dbmem/dbmem.go | 2 + coderd/database/modelmethods.go | 1 + coderd/database/queries.sql.go | 5 +- coderd/database/queries/users.sql | 3 +- coderd/users.go | 2 + coderd/users_test.go | 9 +- codersdk/users.go | 2 + docs/api/schemas.md | 4 + docs/api/users.md | 2 + docs/cli/login.md | 9 ++ docs/cli/users_create.md | 8 + scripts/develop.sh | 4 +- site/e2e/api.ts | 1 + .../users/createUserWithPassword.spec.ts | 32 ++++ site/src/api/typesGenerated.ts | 2 + .../pages/CreateUserPage/CreateUserForm.tsx | 10 ++ site/src/pages/SetupPage/SetupPageView.tsx | 9 +- 33 files changed, 435 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index ca54d51842c0b..75c5a945e113b 100644 --- a/Makefile +++ b/Makefile @@ -865,3 +865,7 @@ test-tailnet-integration: test-clean: go clean -testcache .PHONY: test-clean + +.PHONY: test-e2e +test-e2e: + cd ./site && DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1 diff --git a/cli/login.go b/cli/login.go index faec491270344..7dde98b118c5d 100644 --- a/cli/login.go +++ b/cli/login.go @@ -58,6 +58,21 @@ func promptFirstUsername(inv *serpent.Invocation) (string, error) { return username, nil } +func promptFirstName(inv *serpent.Invocation) (string, error) { + name, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "(Optional) What " + pretty.Sprint(cliui.DefaultStyles.Field, "name") + " would you like?", + Default: "", + }) + if err != nil { + if errors.Is(err, cliui.Canceled) { + return "", nil + } + return "", err + } + + return name, nil +} + func promptFirstPassword(inv *serpent.Invocation) (string, error) { retry: password, err := cliui.Prompt(inv, cliui.PromptOptions{ @@ -130,6 +145,7 @@ func (r *RootCmd) login() *serpent.Command { var ( email string username string + name string password string trial bool useTokenForSession bool @@ -191,6 +207,7 @@ func (r *RootCmd) login() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "Attempting to authenticate with %s URL: '%s'\n", urlSource, serverURL) + // nolint: nestif if !hasFirstUser { _, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n") @@ -212,6 +229,10 @@ func (r *RootCmd) login() *serpent.Command { if err != nil { return err } + name, err = promptFirstName(inv) + if err != nil { + return err + } } if email == "" { @@ -249,6 +270,7 @@ func (r *RootCmd) login() *serpent.Command { _, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ Email: email, Username: username, + Name: name, Password: password, Trial: trial, }) @@ -360,6 +382,12 @@ func (r *RootCmd) login() *serpent.Command { Description: "Specifies a username to use if creating the first user for the deployment.", Value: serpent.StringOf(&username), }, + { + Flag: "first-user-full-name", + Env: "CODER_FIRST_USER_FULL_NAME", + Description: "Specifies a human-readable name for the first user of the deployment.", + Value: serpent.StringOf(&name), + }, { Flag: "first-user-password", Env: "CODER_FIRST_USER_PASSWORD", diff --git a/cli/login_test.go b/cli/login_test.go index dc551ccaf2422..b2f93ad5e6813 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -20,6 +20,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestLogin(t *testing.T) { @@ -91,10 +92,11 @@ func TestLogin(t *testing.T) { matches := []string{ "first user?", "yes", - "username", "testuser", - "email", "user@coder.com", - "password", "SomeSecurePassword!", - "password", "SomeSecurePassword!", // Confirm. + "username", coderdtest.FirstUserParams.Username, + "name", coderdtest.FirstUserParams.Name, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", coderdtest.FirstUserParams.Password, // confirm "trial", "yes", } for i := 0; i < len(matches); i += 2 { @@ -105,6 +107,64 @@ func TestLogin(t *testing.T) { } pty.ExpectMatch("Welcome to Coder") <-doneChan + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) + }) + + t.Run("InitialUserTTYNameOptional", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + // The --force-tty flag is required on Windows, because the `isatty` library does not + // accurately detect Windows ptys when they are not attached to a process: + // https://github.com/mattn/go-isatty/issues/59 + doneChan := make(chan struct{}) + root, _ := clitest.New(t, "login", "--force-tty", client.URL.String()) + pty := ptytest.New(t).Attach(root) + go func() { + defer close(doneChan) + err := root.Run() + assert.NoError(t, err) + }() + + matches := []string{ + "first user?", "yes", + "username", coderdtest.FirstUserParams.Username, + "name", "", + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", coderdtest.FirstUserParams.Password, // confirm + "trial", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + pty.ExpectMatch("Welcome to Coder") + <-doneChan + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) + assert.Empty(t, me.Name) }) t.Run("InitialUserTTYFlag", func(t *testing.T) { @@ -121,10 +181,11 @@ func TestLogin(t *testing.T) { pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String())) matches := []string{ "first user?", "yes", - "username", "testuser", - "email", "user@coder.com", - "password", "SomeSecurePassword!", - "password", "SomeSecurePassword!", // Confirm. + "username", coderdtest.FirstUserParams.Username, + "name", coderdtest.FirstUserParams.Name, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", coderdtest.FirstUserParams.Password, // confirm "trial", "yes", } for i := 0; i < len(matches); i += 2 { @@ -134,6 +195,18 @@ func TestLogin(t *testing.T) { pty.WriteLine(value) } pty.ExpectMatch("Welcome to Coder") + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) }) t.Run("InitialUserFlags", func(t *testing.T) { @@ -141,13 +214,56 @@ func TestLogin(t *testing.T) { client := coderdtest.New(t, nil) inv, _ := clitest.New( t, "login", client.URL.String(), - "--first-user-username", "testuser", "--first-user-email", "user@coder.com", - "--first-user-password", "SomeSecurePassword!", "--first-user-trial", + "--first-user-username", coderdtest.FirstUserParams.Username, + "--first-user-full-name", coderdtest.FirstUserParams.Name, + "--first-user-email", coderdtest.FirstUserParams.Email, + "--first-user-password", coderdtest.FirstUserParams.Password, + "--first-user-trial", + ) + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + pty.ExpectMatch("Welcome to Coder") + w.RequireSuccess() + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) + }) + + t.Run("InitialUserFlagsNameOptional", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + inv, _ := clitest.New( + t, "login", client.URL.String(), + "--first-user-username", coderdtest.FirstUserParams.Username, + "--first-user-email", coderdtest.FirstUserParams.Email, + "--first-user-password", coderdtest.FirstUserParams.Password, + "--first-user-trial", ) pty := ptytest.New(t).Attach(inv) w := clitest.StartWithWaiter(t, inv) pty.ExpectMatch("Welcome to Coder") w.RequireSuccess() + ctx := testutil.Context(t, testutil.WaitShort) + resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: coderdtest.FirstUserParams.Email, + Password: coderdtest.FirstUserParams.Password, + }) + require.NoError(t, err) + client.SetSessionToken(resp.SessionToken) + me, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username) + assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email) + assert.Empty(t, me.Name) }) t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) { @@ -169,10 +285,11 @@ func TestLogin(t *testing.T) { matches := []string{ "first user?", "yes", - "username", "testuser", - "email", "user@coder.com", - "password", "MyFirstSecurePassword!", - "password", "MyNonMatchingSecurePassword!", // Confirm. + "username", coderdtest.FirstUserParams.Username, + "name", coderdtest.FirstUserParams.Name, + "email", coderdtest.FirstUserParams.Email, + "password", coderdtest.FirstUserParams.Password, + "password", "something completely different", } for i := 0; i < len(matches); i += 2 { match := matches[i] @@ -185,9 +302,9 @@ func TestLogin(t *testing.T) { pty.ExpectMatch("Passwords do not match") pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password")) - pty.WriteLine("SomeSecurePassword!") + pty.WriteLine(coderdtest.FirstUserParams.Password) pty.ExpectMatch("Confirm") - pty.WriteLine("SomeSecurePassword!") + pty.WriteLine(coderdtest.FirstUserParams.Password) pty.ExpectMatch("trial") pty.WriteLine("yes") pty.ExpectMatch("Welcome to Coder") diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index e43a9c401b8a0..19326ba728ce6 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -85,6 +85,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { // Use the validator tags so we match the API's validation. req := codersdk.CreateUserRequest{ Username: "username", + Name: "Admin User", Email: "email@coder.com", Password: "ValidPa$$word123!", OrganizationID: uuid.New(), @@ -116,6 +117,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { return err } } + if newUserEmail == "" { newUserEmail, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Email", @@ -189,6 +191,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { ID: uuid.New(), Email: newUserEmail, Username: newUserUsername, + Name: "Admin User", HashedPassword: []byte(hashedPassword), CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), diff --git a/cli/testdata/coder_login_--help.golden b/cli/testdata/coder_login_--help.golden index f6fe15dc07273..e4109a494ed39 100644 --- a/cli/testdata/coder_login_--help.golden +++ b/cli/testdata/coder_login_--help.golden @@ -10,6 +10,9 @@ OPTIONS: Specifies an email address to use if creating the first user for the deployment. + --first-user-full-name string, $CODER_FIRST_USER_FULL_NAME + Specifies a human-readable name for the first user of the deployment. + --first-user-password string, $CODER_FIRST_USER_PASSWORD Specifies a password to use if creating the first user for the deployment. diff --git a/cli/testdata/coder_users_create_--help.golden b/cli/testdata/coder_users_create_--help.golden index 5216e00f3467b..d55d522181c95 100644 --- a/cli/testdata/coder_users_create_--help.golden +++ b/cli/testdata/coder_users_create_--help.golden @@ -7,6 +7,9 @@ OPTIONS: -e, --email string Specifies an email address for the new user. + -n, --full-name string + Specifies an optional human-readable name for the new user. + --login-type string Optionally specify the login type for the user. Valid values are: password, none, github, oidc. Using 'none' prevents the user from diff --git a/cli/testdata/coder_users_list_--output_json.golden b/cli/testdata/coder_users_list_--output_json.golden index b62ce009922f6..3c7ff44b6675a 100644 --- a/cli/testdata/coder_users_list_--output_json.golden +++ b/cli/testdata/coder_users_list_--output_json.golden @@ -3,7 +3,7 @@ "id": "[first user ID]", "username": "testuser", "avatar_url": "", - "name": "", + "name": "Test User", "email": "testuser@coder.com", "created_at": "[timestamp]", "last_seen_at": "[timestamp]", diff --git a/cli/usercreate.go b/cli/usercreate.go index 28cc3c0fe7049..3c4a43b33bc2d 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -10,6 +10,7 @@ import ( "github.com/coder/pretty" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/serpent" @@ -19,6 +20,7 @@ func (r *RootCmd) userCreate() *serpent.Command { var ( email string username string + name string password string disableLogin bool loginType string @@ -35,6 +37,9 @@ func (r *RootCmd) userCreate() *serpent.Command { if err != nil { return err } + // We only prompt for the full name if both username and email have not + // been set. This is to avoid breaking existing non-interactive usage. + shouldPromptName := username == "" && email == "" if username == "" { username, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Username:", @@ -58,6 +63,18 @@ func (r *RootCmd) userCreate() *serpent.Command { return err } } + if name == "" && shouldPromptName { + rawName, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Full name (optional):", + }) + if err != nil { + return err + } + name = httpapi.NormalizeRealUsername(rawName) + if !strings.EqualFold(rawName, name) { + cliui.Warnf(inv.Stderr, "Normalized name to %q", name) + } + } userLoginType := codersdk.LoginTypePassword if disableLogin && loginType != "" { return xerrors.New("You cannot specify both --disable-login and --login-type") @@ -79,6 +96,7 @@ func (r *RootCmd) userCreate() *serpent.Command { _, err = client.CreateUser(inv.Context(), codersdk.CreateUserRequest{ Email: email, Username: username, + Name: name, Password: password, OrganizationID: organization.ID, UserLoginType: userLoginType, @@ -127,6 +145,12 @@ Create a workspace `+pretty.Sprint(cliui.DefaultStyles.Code, "coder create")+`! Description: "Specifies a username for the new user.", Value: serpent.StringOf(&username), }, + { + Flag: "full-name", + FlagShorthand: "n", + Description: "Specifies an optional human-readable name for the new user.", + Value: serpent.StringOf(&name), + }, { Flag: "password", FlagShorthand: "p", diff --git a/cli/usercreate_test.go b/cli/usercreate_test.go index 5726cc84d25b5..66f7975d0bcdf 100644 --- a/cli/usercreate_test.go +++ b/cli/usercreate_test.go @@ -4,16 +4,19 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestUserCreate(t *testing.T) { t.Parallel() t.Run("Prompts", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) inv, root := clitest.New(t, "users", "create") @@ -28,6 +31,7 @@ func TestUserCreate(t *testing.T) { matches := []string{ "Username", "dean", "Email", "dean@coder.com", + "Full name (optional):", "Mr. Dean Deanington", } for i := 0; i < len(matches); i += 2 { match := matches[i] @@ -35,6 +39,89 @@ func TestUserCreate(t *testing.T) { pty.ExpectMatch(match) pty.WriteLine(value) } - <-doneChan + _ = testutil.RequireRecvCtx(ctx, t, doneChan) + created, err := client.User(ctx, matches[1]) + require.NoError(t, err) + assert.Equal(t, matches[1], created.Username) + assert.Equal(t, matches[3], created.Email) + assert.Equal(t, matches[5], created.Name) + }) + + t.Run("PromptsNoName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + inv, root := clitest.New(t, "users", "create") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + matches := []string{ + "Username", "noname", + "Email", "noname@coder.com", + "Full name (optional):", "", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + _ = testutil.RequireRecvCtx(ctx, t, doneChan) + created, err := client.User(ctx, matches[1]) + require.NoError(t, err) + assert.Equal(t, matches[1], created.Username) + assert.Equal(t, matches[3], created.Email) + assert.Empty(t, created.Name) + }) + + t.Run("Args", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + args := []string{ + "users", "create", + "-e", "dean@coder.com", + "-u", "dean", + "-n", "Mr. Dean Deanington", + "-p", "1n5ecureP4ssw0rd!", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.NoError(t, err) + ctx := testutil.Context(t, testutil.WaitShort) + created, err := client.User(ctx, "dean") + require.NoError(t, err) + assert.Equal(t, args[3], created.Email) + assert.Equal(t, args[5], created.Username) + assert.Equal(t, args[7], created.Name) + }) + + t.Run("ArgsNoName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + args := []string{ + "users", "create", + "-e", "dean@coder.com", + "-u", "dean", + "-p", "1n5ecureP4ssw0rd!", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.NoError(t, err) + ctx := testutil.Context(t, testutil.WaitShort) + created, err := client.User(ctx, args[5]) + require.NoError(t, err) + assert.Equal(t, args[3], created.Email) + assert.Equal(t, args[5], created.Username) + assert.Empty(t, created.Name) }) } diff --git a/cli/userlist.go b/cli/userlist.go index 955154ce30f62..616126699cc03 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -137,6 +137,7 @@ func (*userShowFormat) Format(_ context.Context, out interface{}) (string, error // Add rows for each of the user's fields. addRow("ID", user.ID.String()) addRow("Username", user.Username) + addRow("Full name", user.Name) addRow("Email", user.Email) addRow("Status", user.Status) addRow("Created At", user.CreatedAt.Format(time.Stamp)) diff --git a/cli/userlist_test.go b/cli/userlist_test.go index feca8746df32c..1a4409bb898ac 100644 --- a/cli/userlist_test.go +++ b/cli/userlist_test.go @@ -57,7 +57,14 @@ func TestUserList(t *testing.T) { err := json.Unmarshal(buf.Bytes(), &users) require.NoError(t, err, "unmarshal JSON output") require.Len(t, users, 2) - require.Contains(t, users[0].Email, "coder.com") + for _, u := range users { + assert.NotEmpty(t, u.ID) + assert.NotEmpty(t, u.Email) + assert.NotEmpty(t, u.Username) + assert.NotEmpty(t, u.Name) + assert.NotEmpty(t, u.CreatedAt) + assert.NotEmpty(t, u.Status) + } }) t.Run("NoURLFileErrorHasHelperText", func(t *testing.T) { t.Parallel() @@ -133,5 +140,6 @@ func TestUserShow(t *testing.T) { require.Equal(t, otherUser.ID, newUser.ID) require.Equal(t, otherUser.Username, newUser.Username) require.Equal(t, otherUser.Email, newUser.Email) + require.Equal(t, otherUser.Name, newUser.Name) }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1e2f8fa7b1d22..7ff97bba2968d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8425,6 +8425,9 @@ const docTemplate = `{ "email": { "type": "string" }, + "name": { + "type": "string" + }, "password": { "type": "string" }, @@ -8787,6 +8790,9 @@ const docTemplate = `{ } ] }, + "name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c58e0a9de10f5..bc6fcc19142a9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7493,6 +7493,9 @@ "email": { "type": "string" }, + "name": { + "type": "string" + }, "password": { "type": "string" }, @@ -7824,6 +7827,9 @@ } ] }, + "name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 0c5dd2fbbecd2..472c380926ec4 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -29,6 +29,7 @@ import ( "sync/atomic" "testing" "time" + "unicode" "cloud.google.com/go/compute/metadata" "github.com/fullsailor/pkcs7" @@ -658,6 +659,7 @@ var FirstUserParams = codersdk.CreateFirstUserRequest{ Email: "testuser@coder.com", Username: "testuser", Password: "SomeSecurePassword!", + Name: "Test User", } // CreateFirstUser creates a user with preset credentials and authenticates @@ -712,6 +714,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI req := codersdk.CreateUserRequest{ Email: namesgenerator.GetRandomName(10) + "@coder.com", Username: RandomUsername(t), + Name: RandomName(t), Password: "SomeSecurePassword!", OrganizationID: organizationID, } @@ -1390,6 +1393,28 @@ func RandomUsername(t testing.TB) string { return n } +func RandomName(t testing.TB) string { + var sb strings.Builder + var err error + ss := strings.Split(namesgenerator.GetRandomName(10), "_") + for si, s := range ss { + for ri, r := range s { + if ri == 0 { + _, err = sb.WriteRune(unicode.ToTitle(r)) + require.NoError(t, err) + } else { + _, err = sb.WriteRune(r) + require.NoError(t, err) + } + } + if si < len(ss)-1 { + _, err = sb.WriteRune(' ') + require.NoError(t, err) + } + } + return sb.String() +} + // Used to easily create an HTTP transport! type roundTripper func(req *http.Request) (*http.Response, error) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 3ccafb33262cc..9288f52260d78 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1122,6 +1122,7 @@ func (s *MethodTestSuite) TestUser() { ID: u.ID, Email: u.Email, Username: u.Username, + Name: u.Name, UpdatedAt: u.UpdatedAt, }).Asserts(u, policy.ActionUpdatePersonal).Returns(u) })) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 23c6e8a351da0..d2b66e5d4b6df 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -289,6 +289,7 @@ func User(t testing.TB, db database.Store, orig database.User) database.User { ID: takeFirst(orig.ID, uuid.New()), Email: takeFirst(orig.Email, namesgenerator.GetRandomName(1)), Username: takeFirst(orig.Username, namesgenerator.GetRandomName(1)), + Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), HashedPassword: takeFirstSlice(orig.HashedPassword, []byte(must(cryptorand.String(32)))), CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 573279db3ad39..d2d60c3bb6541 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -322,6 +322,7 @@ func convertUsers(users []database.User, count int64) []database.GetUsersRow { ID: u.ID, Email: u.Email, Username: u.Username, + Name: u.Name, HashedPassword: u.HashedPassword, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, @@ -6492,6 +6493,7 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam CreatedAt: arg.CreatedAt, UpdatedAt: arg.UpdatedAt, Username: arg.Username, + Name: arg.Name, Status: database.UserStatusDormant, RBACRoles: arg.RBACRoles, LoginType: arg.LoginType, diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 0ae838894aa8b..f8a3fc2c537b1 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -330,6 +330,7 @@ func ConvertUserRows(rows []GetUsersRow) []User { ID: r.ID, Email: r.Email, Username: r.Username, + Name: r.Name, HashedPassword: r.HashedPassword, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b25d99eb9e03b..2e1c1ad744978 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8917,6 +8917,7 @@ INSERT INTO id, email, username, + name, hashed_password, created_at, updated_at, @@ -8924,13 +8925,14 @@ INSERT INTO login_type ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name ` type InsertUserParams struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` Username string `db:"username" json:"username"` + Name string `db:"name" json:"name"` HashedPassword []byte `db:"hashed_password" json:"hashed_password"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` @@ -8943,6 +8945,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User arg.ID, arg.Email, arg.Username, + arg.Name, arg.HashedPassword, arg.CreatedAt, arg.UpdatedAt, diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index cd2b3456379fa..6bbfdac112d7a 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -62,6 +62,7 @@ INSERT INTO id, email, username, + name, hashed_password, created_at, updated_at, @@ -69,7 +70,7 @@ INSERT INTO login_type ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; -- name: UpdateUserProfile :one UPDATE diff --git a/coderd/users.go b/coderd/users.go index b8a3306b12121..5ef0b2f8316e8 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -187,6 +187,7 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { CreateUserRequest: codersdk.CreateUserRequest{ Email: createUser.Email, Username: createUser.Username, + Name: createUser.Name, Password: createUser.Password, OrganizationID: defaultOrg.ID, }, @@ -1224,6 +1225,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create ID: uuid.New(), Email: req.Email, Username: req.Username, + Name: httpapi.NormalizeRealUsername(req.Name), CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), HashedPassword: []byte{}, diff --git a/coderd/users_test.go b/coderd/users_test.go index c85b35e49ea81..758a3ba738b90 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -70,8 +70,14 @@ func TestFirstUser(t *testing.T) { t.Run("Create", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) + u, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + assert.Equal(t, coderdtest.FirstUserParams.Name, u.Name) + assert.Equal(t, coderdtest.FirstUserParams.Email, u.Email) + assert.Equal(t, coderdtest.FirstUserParams.Username, u.Username) }) t.Run("Trial", func(t *testing.T) { @@ -96,6 +102,7 @@ func TestFirstUser(t *testing.T) { req := codersdk.CreateFirstUserRequest{ Email: "testuser@coder.com", Username: "testuser", + Name: "Test User", Password: "SomeSecurePassword!", Trial: true, } @@ -1486,7 +1493,7 @@ func TestUsersFilter(t *testing.T) { exp = append(exp, made) } } - require.ElementsMatch(t, exp, matched.Users, "expected workspaces returned") + require.ElementsMatch(t, exp, matched.Users, "expected users returned") }) } } diff --git a/codersdk/users.go b/codersdk/users.go index f99015b50bde5..dd6779e3a0342 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -90,6 +90,7 @@ type LicensorTrialRequest struct { type CreateFirstUserRequest struct { Email string `json:"email" validate:"required,email"` Username string `json:"username" validate:"required,username"` + Name string `json:"name" validate:"user_real_name"` Password string `json:"password" validate:"required"` Trial bool `json:"trial"` TrialInfo CreateFirstUserTrialInfo `json:"trial_info"` @@ -114,6 +115,7 @@ type CreateFirstUserResponse struct { type CreateUserRequest struct { Email string `json:"email" validate:"required,email" format:"email"` Username string `json:"username" validate:"required,username"` + Name string `json:"name" validate:"user_real_name"` Password string `json:"password"` // UserLoginType defaults to LoginTypePassword. UserLoginType LoginType `json:"login_type"` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 6329b49ee820e..161c5bc41213a 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -938,6 +938,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "email": "string", + "name": "string", "password": "string", "trial": true, "trial_info": { @@ -958,6 +959,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | ------------ | ---------------------------------------------------------------------- | -------- | ------------ | ----------- | | `email` | string | true | | | +| `name` | string | false | | | | `password` | string | true | | | | `trial` | boolean | false | | | | `trial_info` | [codersdk.CreateFirstUserTrialInfo](#codersdkcreatefirstusertrialinfo) | false | | | @@ -1248,6 +1250,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "disable_login": true, "email": "user@example.com", "login_type": "", + "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -1261,6 +1264,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `disable_login` | boolean | false | | Disable login sets the user's login type to 'none'. This prevents the user from being able to use a password or any other authentication method to login. Deprecated: Set UserLoginType=LoginTypeDisabled instead. | | `email` | string | true | | | | `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | +| `name` | string | false | | | | `organization_id` | string | false | | | | `password` | string | false | | | | `username` | string | true | | | diff --git a/docs/api/users.md b/docs/api/users.md index 0fd67493def34..22d1c7b9cfca8 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -83,6 +83,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ "disable_login": true, "email": "user@example.com", "login_type": "", + "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -229,6 +230,7 @@ curl -X POST http://coder-server:8080/api/v2/users/first \ ```json { "email": "string", + "name": "string", "password": "string", "trial": true, "trial_info": { diff --git a/docs/cli/login.md b/docs/cli/login.md index 8dab8a884149c..9a27e4a6357c8 100644 --- a/docs/cli/login.md +++ b/docs/cli/login.md @@ -30,6 +30,15 @@ Specifies an email address to use if creating the first user for the deployment. Specifies a username to use if creating the first user for the deployment. +### --first-user-full-name + +| | | +| ----------- | ---------------------------------------- | +| Type | string | +| Environment | $CODER_FIRST_USER_FULL_NAME | + +Specifies a human-readable name for the first user of the deployment. + ### --first-user-password | | | diff --git a/docs/cli/users_create.md b/docs/cli/users_create.md index 3934f2482ac02..1e8e12530939f 100644 --- a/docs/cli/users_create.md +++ b/docs/cli/users_create.md @@ -26,6 +26,14 @@ Specifies an email address for the new user. Specifies a username for the new user. +### -n, --full-name + +| | | +| ---- | ------------------- | +| Type | string | + +Specifies an optional human-readable name for the new user. + ### -p, --password | | | diff --git a/scripts/develop.sh b/scripts/develop.sh index 00d96389e21fd..3eb9c006003de 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -155,7 +155,7 @@ fatal() { if [ ! -f "${PROJECT_ROOT}/.coderv2/developsh-did-first-setup" ]; then # Try to create the initial admin user. - if "${CODER_DEV_SHIM}" login http://127.0.0.1:3000 --first-user-username=admin --first-user-email=admin@coder.com --first-user-password="${password}" --first-user-trial=true; then + if "${CODER_DEV_SHIM}" login http://127.0.0.1:3000 --first-user-username=admin --first-user-email=admin@coder.com --first-user-password="${password}" --first-user-full-name="Admin User" --first-user-trial=true; then # Only create this file if an admin user was successfully # created, otherwise we won't retry on a later attempt. touch "${PROJECT_ROOT}/.coderv2/developsh-did-first-setup" @@ -164,7 +164,7 @@ fatal() { fi # Try to create a regular user. - "${CODER_DEV_SHIM}" users create --email=member@coder.com --username=member --password="${password}" || + "${CODER_DEV_SHIM}" users create --email=member@coder.com --username=member --full-name "Regular User" --password="${password}" || echo 'Failed to create regular user. To troubleshoot, try running this command manually.' fi diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 08a25543b0fb6..5b9e5254d2930 100644 --- a/site/e2e/api.ts +++ b/site/e2e/api.ts @@ -33,6 +33,7 @@ export const createUser = async (orgId: string) => { const user = await API.createUser({ email: `${name}@coder.com`, username: name, + name: name, password: "s3cure&password!", login_type: "password", disable_login: false, diff --git a/site/e2e/tests/users/createUserWithPassword.spec.ts b/site/e2e/tests/users/createUserWithPassword.spec.ts index b8c95d35b32d7..9620d56fd8e9f 100644 --- a/site/e2e/tests/users/createUserWithPassword.spec.ts +++ b/site/e2e/tests/users/createUserWithPassword.spec.ts @@ -11,6 +11,38 @@ test("create user with password", async ({ page, baseURL }) => { await page.getByRole("button", { name: "Create user" }).click(); await expect(page).toHaveTitle("Create User - Coder"); + const name = randomName(); + const userValues = { + username: name, + name: name, + email: `${name}@coder.com`, + loginType: "password", + password: "s3cure&password!", + }; + + await page.getByLabel("Username").fill(userValues.username); + await page.getByLabel("Full name").fill(userValues.username); + await page.getByLabel("Email").fill(userValues.email); + await page.getByLabel("Login Type").click(); + await page.getByRole("option", { name: "Password", exact: false }).click(); + // Using input[name=password] due to the select element utilizing 'password' + // as the label for the currently active option. + const passwordField = page.locator("input[name=password]"); + await passwordField.fill(userValues.password); + await page.getByRole("button", { name: "Create user" }).click(); + await expect(page.getByText("Successfully created user.")).toBeVisible(); + + await expect(page).toHaveTitle("Users - Coder"); + await expect(page.locator("tr", { hasText: userValues.email })).toBeVisible(); +}); + +test("create user without full name is optional", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/users`, { waitUntil: "domcontentloaded" }); + await expect(page).toHaveTitle("Users - Coder"); + + await page.getByRole("button", { name: "Create user" }).click(); + await expect(page).toHaveTitle("Create User - Coder"); + const name = randomName(); const userValues = { username: name, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ef3e997f1a1e4..cdcacf1823edb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -194,6 +194,7 @@ export interface ConvertLoginRequest { export interface CreateFirstUserRequest { readonly email: string; readonly username: string; + readonly name: string; readonly password: string; readonly trial: boolean; readonly trial_info: CreateFirstUserTrialInfo; @@ -294,6 +295,7 @@ export interface CreateTokenRequest { export interface CreateUserRequest { readonly email: string; readonly username: string; + readonly name: string; readonly password: string; readonly login_type: LoginType; readonly disable_login: boolean; diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 3f44c718381d8..8aeb478b08dd1 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -11,6 +11,7 @@ import { FormFooter } from "components/FormFooter/FormFooter"; import { FullPageForm } from "components/FullPageForm/FullPageForm"; import { Stack } from "components/Stack/Stack"; import { + displayNameValidator, getFormHelpers, nameValidator, onChangeTrimmed, @@ -20,6 +21,7 @@ export const Language = { emailLabel: "Email", passwordLabel: "Password", usernameLabel: "Username", + nameLabel: "Full name", emailInvalid: "Please enter a valid email address.", emailRequired: "Please enter an email address.", passwordRequired: "Please enter a password.", @@ -78,6 +80,7 @@ const validationSchema = Yup.object({ otherwise: (schema) => schema, }), username: nameValidator(Language.usernameLabel), + name: displayNameValidator(Language.nameLabel), login_type: Yup.string().oneOf(Object.keys(authMethodLanguage)), }); @@ -90,6 +93,7 @@ export const CreateUserForm: FC< email: "", password: "", username: "", + name: "", organization_id: organizationId, disable_login: false, login_type: "", @@ -124,6 +128,12 @@ export const CreateUserForm: FC< fullWidth label={Language.usernameLabel} /> + = ({ email: "", password: "", username: "", + name: "", trial: false, trial_info: { first_name: "", @@ -152,6 +154,12 @@ export const SetupPageView: FC = ({ fullWidth label={Language.usernameLabel} /> + = ({ label={Language.passwordLabel} type="password" /> -