From f7b22eaad7dd9b6fc9879443e72ca446ad5ec55f Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 17 May 2022 14:02:34 +0000 Subject: [PATCH 01/21] move docker-local to docker --- examples/{docker-local => docker}/README.md | 0 examples/{docker-local => docker}/main.tf | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename examples/{docker-local => docker}/README.md (100%) rename examples/{docker-local => docker}/main.tf (100%) diff --git a/examples/docker-local/README.md b/examples/docker/README.md similarity index 100% rename from examples/docker-local/README.md rename to examples/docker/README.md diff --git a/examples/docker-local/main.tf b/examples/docker/main.tf similarity index 100% rename from examples/docker-local/main.tf rename to examples/docker/main.tf From 21c224f68282d9f6705949e4dd64c710c8a3a05e Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 17 May 2022 15:48:13 +0000 Subject: [PATCH 02/21] example: add docker-image-builds + docker docs --- examples/docker-image-builds/README.md | 159 ++++++++++++++++++ .../images/base.Dockerfile | 33 ++++ .../images/java.Dockerfile | 57 +++++++ .../images/node.Dockerfile | 18 ++ examples/docker-image-builds/main.tf | 101 +++++++++++ examples/docker/README.md | 80 ++++++++- examples/docker/main.tf | 65 +++++-- 7 files changed, 500 insertions(+), 13 deletions(-) create mode 100644 examples/docker-image-builds/README.md create mode 100644 examples/docker-image-builds/images/base.Dockerfile create mode 100644 examples/docker-image-builds/images/java.Dockerfile create mode 100644 examples/docker-image-builds/images/node.Dockerfile create mode 100644 examples/docker-image-builds/main.tf diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md new file mode 100644 index 0000000000000..5558f6081af18 --- /dev/null +++ b/examples/docker-image-builds/README.md @@ -0,0 +1,159 @@ +--- +name: Develop in Docker with custom image builds +description: Builds images and runs workspaces on the Docker host, no image registry required +tags: [local, docker] +--- + +# docker-image-builds + +This example bundles Dockerfiles in the Coder template, allowing the Docker host to build images itself instead of relying on an external registry. + +For large use cases, we recommend building images using CI/CD pipelines and registries instead of at workspace "runtime." However, this example is practical for tinkering and iterating on Dockerfiles. + +## Getting started + +Pick this template in `coder templates init` and follow instructions. + +## Adding images + +Create a Dockerfile (e.g `images/golang.Dockerfile`) + +```sh +vim images/golang.Dockerfile +``` + +```Dockerfile +# Start from base image (built on Docker host) +FROM coder-base:latest + +# Install everything as root +USER root + +# Install go +RUN curl -L "https://dl.google.com/go/go1.17.1.linux-amd64.tar.gz" | tar -C /usr/local -xzvf - + +# Setup go env vars +ENV GOROOT /usr/local/go +ENV PATH $PATH:$GOROOT/bin + +ENV GOPATH /home/coder/go +ENV GOBIN $GOPATH/bin +ENV PATH $PATH:$GOBIN + +# Set back to coder user +USER coder +``` + +Edit the template Terraform (`main.tf`) + +```sh +vim main.tf +``` + +Edit the validation to include the new image: + +```diff +variable "docker_image" { + description = "What docker image would you like to use for your workspace?" + default = "base" + + # List of images available for the user to choose from. + # Delete this condition to give users free text input. + validation { +- condition = contains(["base", "java", "node"], var.docker_image) ++ condition = contains(["base", "java", "node", "golang], var.docker_image) + error_message = "Invalid Docker Image!" + } +} +``` + +Bump the image tag to a new version: + +```diff +resource "docker_image" "coder_image" { + name = "coder-base-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + build { + path = "./images/" + dockerfile = "${var.docker_image}.Dockerfile" +- tag = ["coder-${var.docker_image}:v0.1"] ++ tag = ["coder-${var.docker_image}:v0.2"] + } + + # Keep alive for other workspaces to use upon deletion + keep_locally = true +} +``` + +Update the template: + +```sh +coder template update docker-image-builds +``` + +Images can also be removed from the validation list. Workspaces using older template versions will continue using +the removed image until the workspace is updated to the latest version. + +## Updating images + +Edit the Dockerfile (or related assets) + +```sh +vim images/node.Dockerfile +``` + +```diff +# Install Node +- RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - ++ RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - +RUN DEBIAN_FRONTEND="noninteractive" apt-get update -y && \ + apt-get install -y nodejs +``` + +1. Edit the template Terraform (`main.tf`) + +```sh +vim main.tf +``` + +Bump the image tag to a new version: + +```diff +resource "docker_image" "coder_image" { + name = "coder-base-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + build { + path = "./images/" + dockerfile = "${var.docker_image}.Dockerfile" +- tag = ["coder-${var.docker_image}:v0.1"] ++ tag = ["coder-${var.docker_image}:v0.2"] + } + + # Keep alive for other workspaces to use upon deletion + keep_locally = true +} +``` + +Update the template: + +```sh +coder template update docker-image-builds +``` + +Optional: Update workspaces to the latest template version + +```sh +coder ls +coder update [workspace name] +``` + +## Extending this template + +See the [kreuzwerker/docker](https://registry.terraform.io/providers/kreuzwerker/docker) Terraform provider documentation to +add the following features to your Coder template: + +- SSH/TCP docker host +- Build args +- Volume mounts +- Custom container spec +- More + +Contributions are also welcome! diff --git a/examples/docker-image-builds/images/base.Dockerfile b/examples/docker-image-builds/images/base.Dockerfile new file mode 100644 index 0000000000000..02398e2ab3eb3 --- /dev/null +++ b/examples/docker-image-builds/images/base.Dockerfile @@ -0,0 +1,33 @@ +FROM ubuntu:20.04 + +RUN apt-get update && \ + DEBIAN_FRONTEND="noninteractive" apt-get install --yes \ + bash \ + build-essential \ + ca-certificates \ + curl \ + htop \ + locales \ + man \ + python3 \ + python3-pip \ + software-properties-common \ + sudo \ + systemd \ + systemd-sysv \ + unzip \ + vim \ + wget && \ + # Install latest Git using their official PPA + add-apt-repository ppa:git-core/ppa && \ + DEBIAN_FRONTEND="noninteractive" apt-get install --yes git + +# Add a user `coder` so that you're not developing as the `root` user +RUN useradd coder \ + --create-home \ + --shell=/bin/bash \ + --uid=1000 \ + --user-group && \ + echo "coder ALL=(ALL) NOPASSWD:ALL" >>/etc/sudoers.d/nopasswd + +USER coder \ No newline at end of file diff --git a/examples/docker-image-builds/images/java.Dockerfile b/examples/docker-image-builds/images/java.Dockerfile new file mode 100644 index 0000000000000..0818804506987 --- /dev/null +++ b/examples/docker-image-builds/images/java.Dockerfile @@ -0,0 +1,57 @@ +# From the base image (built on Docker host) +FROM coder-base:latest + +# Install everything as root +USER root + +# Install JDK (OpenJDK 8) +RUN DEBIAN_FRONTEND="noninteractive" apt-get update -y && \ + apt-get install -y openjdk-11-jdk +ENV JAVA_HOME /usr/lib/jvm/java-11-openjdk-amd64 +ENV PATH $PATH:$JAVA_HOME/bin + +# Install Maven +ARG MAVEN_VERSION=3.6.3 +ARG MAVEN_SHA512=c35a1803a6e70a126e80b2b3ae33eed961f83ed74d18fcd16909b2d44d7dada3203f1ffe726c17ef8dcca2dcaa9fca676987befeadc9b9f759967a8cb77181c0 + +ENV MAVEN_HOME /usr/share/maven +ENV MAVEN_CONFIG "/home/coder/.m2" + +RUN mkdir -p $MAVEN_HOME $MAVEN_HOME/ref \ + && echo "Downloading maven" \ + && curl -fsSL -o /tmp/apache-maven.tar.gz https://apache.osuosl.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ + \ + && echo "Checking downloaded file hash" \ + && echo "${MAVEN_SHA512} /tmp/apache-maven.tar.gz" | sha512sum -c - \ + \ + && echo "Unzipping maven" \ + && tar -xzf /tmp/apache-maven.tar.gz -C $MAVEN_HOME --strip-components=1 \ + \ + && echo "Cleaning and setting links" \ + && rm -f /tmp/apache-maven.tar.gz \ + && ln -s $MAVEN_HOME/bin/mvn /usr/bin/mvn + +# Install Gradle +ENV GRADLE_VERSION=6.7 +ARG GRADLE_SHA512=d495bc65379d2a854d2cca843bd2eeb94f381e5a7dcae89e6ceb6ef4c5835524932313e7f30d7a875d5330add37a5fe23447dc3b55b4d95dffffa870c0b24493 + +ENV GRADLE_HOME /usr/bin/gradle + +RUN mkdir -p /usr/share/gradle /usr/share/gradle/ref \ + && echo "Downloading gradle" \ + && curl -fsSL -o /tmp/gradle.zip https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip \ + \ + && echo "Checking downloaded file hash" \ + && echo "${GRADLE_SHA512} /tmp/gradle.zip" | sha512sum -c - \ + \ + && echo "Unziping gradle" \ + && unzip -d /usr/share/gradle /tmp/gradle.zip \ + \ + && echo "Cleaning and setting links" \ + && rm -f /tmp/gradle.zip \ + && ln -s /usr/share/gradle/gradle-${GRADLE_VERSION} /usr/bin/gradle + +ENV PATH $PATH:$GRADLE_HOME/bin + +# Set back to coder user +USER coder \ No newline at end of file diff --git a/examples/docker-image-builds/images/node.Dockerfile b/examples/docker-image-builds/images/node.Dockerfile new file mode 100644 index 0000000000000..1f6819e504655 --- /dev/null +++ b/examples/docker-image-builds/images/node.Dockerfile @@ -0,0 +1,18 @@ +# Start from base image (built on Docker host) +FROM coder-base:latest + +# Install everything as root +USER root + +# Install Node +RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - +RUN DEBIAN_FRONTEND="noninteractive" apt-get update -y && \ + apt-get install -y nodejs + +# Install Yarn +RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - +RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list +RUN DEBIAN_FRONTEND="noninteractive" apt-get update && apt-get install -y yarn + +# Set back to coder user +USER coder \ No newline at end of file diff --git a/examples/docker-image-builds/main.tf b/examples/docker-image-builds/main.tf new file mode 100644 index 0000000000000..37552e71ab5e9 --- /dev/null +++ b/examples/docker-image-builds/main.tf @@ -0,0 +1,101 @@ + +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "0.4.1" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 2.16.0" + } + } +} + +# Admin parameters +variable "step1_docker_host_warning" { + description = <<-EOF + This template will use the Docker socket present on + the Coder host, which is not necessarily your local machine. + + You can specify a different host in the template file and + surpress this warning. + EOF + validation { + condition = contains(["Continue using /var/run/docker.sock on the Coder host"], var.step1_docker_host_warning) + error_message = "Cancelling template create." + } + + sensitive = true +} +variable "step2_arch" { + description = "arch: What archicture is your Docker host on?" + validation { + condition = contains(["amd64", "arm64", "armv7"], var.arch) + error_message = "Value must be amd64, arm64 or armv7." + } + sensitive = true +} + +provider "docker" { + host = "unix:///var/run/docker.sock" +} + +data "coder_workspace" "me" { +} + +resource "coder_agent" "dev" { + arch = var.step2_arch + os = "linux" +} + +variable "docker_image" { + description = "What docker image would you like to use for your workspace?" + default = "base" + + # List of images available for the user to choose from. + # Delete this condition to give users free text input. + validation { + condition = contains(["base", "java", "node"], var.docker_image) + error_message = "Invalid Docker Image!" + } + + # Prevents admin errors when the image is not found + validation { + condition = fileexists("images/${var.docker_image}.Dockerfile") + error_message = "Invalid docker image. The file does not exist in the images directory." + } +} + +resource "docker_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}-root" +} + +resource "docker_image" "coder_image" { + name = "coder-base-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + build { + path = "./images/" + dockerfile = "${var.docker_image}.Dockerfile" + tag = ["coder-${var.docker_image}:v0.1"] + } + + # Keep alive for other workspaces to use upon deletion + keep_locally = true +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = docker_image.coder_image.latest + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = lower(data.coder_workspace.me.name) + dns = ["1.1.1.1"] + command = ["sh", "-c", coder_agent.dev.init_script] + env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"] + volumes { + container_path = "/home/coder/" + volume_name = docker_volume.home_volume.name + read_only = false + } +} diff --git a/examples/docker/README.md b/examples/docker/README.md index 8cb28f85c2e21..c452d690b3135 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -1,5 +1,81 @@ --- -name: Develop in Docker on the same host that runs Coder -description: Get started with Linux development using a Docker container locally as workspace provider. +name: Develop in Docker +description: Run workspaces on a Docker host using registry images tags: [local, docker] --- + +# docker + +## Getting started + +Pick this template in `coder templates init` and follow instructions. + +## Adding/removing images + +After building and pushing an image to an image registry (e.g DockerHub), you can make the +image available to users in the template. + +Edit the template: + +```sh +vim main.tf +``` +variable "docker_image" { + description = "What docker image would you like to use for your workspace?" + default = "codercom/enterprise-base:ubuntu" + validation { +- condition = contains(["codercom/enterprise-base:ubuntu", "codercom/enterprise-node:ubuntu", "codercom/enterprise-intellij:ubuntu"], var.docker_image) ++ condition = contains(["codercom/enterprise-base:ubuntu", "codercom/enterprise-node:ubuntu", "codercom/enterprise-intellij:ubuntu", "codercom/enterprise-golang:ubuntu"], var.docker_image) + + error_message = "Invalid Docker Image!" + } +} +``` + +Update the template: + +```sh +coder template update docker +``` + +Images can also be removed from the validation list. Workspaces using older template versions will continue using +the removed image until the workspace is updated to the latest version. + +## Updating images + +To reduce drift, we recommend versioning images in your registry via tags. Update the image tag in the template: + +```sh +variable "docker_image" { + description = "What docker image would you like to use for your workspace?" + default = "codercom/enterprise-base:ubuntu" + validation { +- condition = contains(["my-org/base-development:v1.1", "myorg-java-development:v1.1"], var.docker_image) ++ condition = contains(["my-org/base-development:v1.1", "myorg-java-development:v1.2"], var.docker_image) + + error_message = "Invalid Docker Image!" + } +} +``` + +Optional: Update workspaces to the latest template version + +```sh +coder ls +coder update [workspace name] +``` + +## Extending this template + +See the [kreuzwerker/docker](https://registry.terraform.io/providers/kreuzwerker/docker) Terraform provider documentation to +add the following features to your Coder template: + +- SSH/TCP docker host +- Registry authentication +- Build args +- Volume mounts +- Custom container spec +- More + +Contributions are also welcome! + diff --git a/examples/docker/main.tf b/examples/docker/main.tf index 3a12b54cb3fed..b3775a83ec391 100644 --- a/examples/docker/main.tf +++ b/examples/docker/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.3.4" + version = "0.4.1" } docker = { source = "kreuzwerker/docker" @@ -11,6 +11,39 @@ terraform { } } +# Admin parameters + +# Comment this out if you are specifying a different docker +# host on the "docker" provider below. +variable "step1_docker_host_warning" { + description = <<-EOF + This template will use the Docker socket present on + the Coder host, which is not necessarily your local machine. + + You can specify a different host in the template file and + surpress this warning. + EOF + validation { + condition = contains(["Continue using /var/run/docker.sock on the Coder host"], var.step1_docker_host_warning) + error_message = "Cancelling template create." + } + + sensitive = true +} +variable "step2_arch" { + description = <<-EOF + arch: What archicture is your Docker host on? + + note: codercom/enterprise-* images are only built for amd64 + EOF + + validation { + condition = contains(["amd64", "arm64", "armv7"], var.step2_arch) + error_message = "Value must be amd64, arm64, or armv7." + } + sensitive = true +} + provider "docker" { host = "unix:///var/run/docker.sock" } @@ -19,33 +52,43 @@ data "coder_workspace" "me" { } resource "coder_agent" "dev" { - arch = "amd64" + arch = var.step2_arch os = "linux" } variable "docker_image" { description = "What docker image would you like to use for your workspace?" - default = "codercom/enterprise-base:ubuntu" + # The codercom/enterprise-* images are only built for amd64 + default = "codercom/enterprise-base:ubuntu" validation { condition = contains(["codercom/enterprise-base:ubuntu", "codercom/enterprise-node:ubuntu", "codercom/enterprise-intellij:ubuntu"], var.docker_image) error_message = "Invalid Docker Image!" } + + validation { + condition = contains(["codercom/enterprise-base:ubuntu", "codercom/enterprise-node:ubuntu", "codercom/enterprise-intellij:ubuntu"], var.docker_image) + error_message = "Invalid Docker Image!" + } + } -resource "docker_volume" "coder_volume" { +resource "docker_volume" "home_volume" { name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-root" } resource "docker_container" "workspace" { - count = data.coder_workspace.me.start_count - image = var.docker_image - name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-root" - dns = ["1.1.1.1"] - command = ["sh", "-c", coder_agent.dev.init_script] - env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"] + count = data.coder_workspace.me.start_count + image = var.docker_image + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = lower(data.coder_workspace.me.name) + dns = ["1.1.1.1"] + command = ["sh", "-c", coder_agent.dev.init_script] + env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"] volumes { container_path = "/home/coder/" - volume_name = docker_volume.coder_volume.name + volume_name = docker_volume.home_volume.name read_only = false } } From 3b609fccb2f5a2d1b4bfccbc1ccea6ab5ff05701 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 17 May 2022 16:07:41 +0000 Subject: [PATCH 03/21] fix bug --- examples/docker-image-builds/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/main.tf b/examples/docker-image-builds/main.tf index 37552e71ab5e9..b8700ddd1a083 100644 --- a/examples/docker-image-builds/main.tf +++ b/examples/docker-image-builds/main.tf @@ -31,7 +31,7 @@ variable "step1_docker_host_warning" { variable "step2_arch" { description = "arch: What archicture is your Docker host on?" validation { - condition = contains(["amd64", "arm64", "armv7"], var.arch) + condition = contains(["amd64", "arm64", "armv7"], var.step2_arch) error_message = "Value must be amd64, arm64 or armv7." } sensitive = true From 6bacbc8da12ba2c3bbb553ad5c95d3fb8d9c7f06 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 17 May 2022 16:12:44 +0000 Subject: [PATCH 04/21] fix a versioning bug --- examples/docker-image-builds/images/java.Dockerfile | 4 ++-- examples/docker-image-builds/images/node.Dockerfile | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/docker-image-builds/images/java.Dockerfile b/examples/docker-image-builds/images/java.Dockerfile index 0818804506987..aac7a4e32a27e 100644 --- a/examples/docker-image-builds/images/java.Dockerfile +++ b/examples/docker-image-builds/images/java.Dockerfile @@ -1,5 +1,5 @@ # From the base image (built on Docker host) -FROM coder-base:latest +FROM coder-base:v0.1 # Install everything as root USER root @@ -54,4 +54,4 @@ RUN mkdir -p /usr/share/gradle /usr/share/gradle/ref \ ENV PATH $PATH:$GRADLE_HOME/bin # Set back to coder user -USER coder \ No newline at end of file +USER coder diff --git a/examples/docker-image-builds/images/node.Dockerfile b/examples/docker-image-builds/images/node.Dockerfile index 1f6819e504655..f8e4fd32d86e4 100644 --- a/examples/docker-image-builds/images/node.Dockerfile +++ b/examples/docker-image-builds/images/node.Dockerfile @@ -1,11 +1,11 @@ # Start from base image (built on Docker host) -FROM coder-base:latest +FROM coder-base:v0.1 # Install everything as root USER root # Install Node -RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - +RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - RUN DEBIAN_FRONTEND="noninteractive" apt-get update -y && \ apt-get install -y nodejs @@ -15,4 +15,4 @@ RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources RUN DEBIAN_FRONTEND="noninteractive" apt-get update && apt-get install -y yarn # Set back to coder user -USER coder \ No newline at end of file +USER coder From e2d007b5b2caaa86d36d0882a12daa4fc82c8ced Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 17 May 2022 16:28:08 +0000 Subject: [PATCH 05/21] use node 14, like readme --- examples/docker-image-builds/images/node.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/images/node.Dockerfile b/examples/docker-image-builds/images/node.Dockerfile index f8e4fd32d86e4..1c371f8bb40a1 100644 --- a/examples/docker-image-builds/images/node.Dockerfile +++ b/examples/docker-image-builds/images/node.Dockerfile @@ -5,7 +5,7 @@ FROM coder-base:v0.1 USER root # Install Node -RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - +RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - RUN DEBIAN_FRONTEND="noninteractive" apt-get update -y && \ apt-get install -y nodejs From 89d462e0bb7cf5040a2cb49256dfa986df7c346c Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 17 May 2022 17:22:31 +0000 Subject: [PATCH 06/21] fixes from review --- examples/docker-image-builds/README.md | 2 +- examples/docker-image-builds/images/base.Dockerfile | 2 +- examples/docker/main.tf | 5 ----- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index 5558f6081af18..c7063df185e54 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -30,7 +30,7 @@ FROM coder-base:latest USER root # Install go -RUN curl -L "https://dl.google.com/go/go1.17.1.linux-amd64.tar.gz" | tar -C /usr/local -xzvf - +RUN curl -L "https://dl.google.com/go/go1.18.1.linux-amd64.tar.gz" | tar -C /usr/local -xzvf - # Setup go env vars ENV GOROOT /usr/local/go diff --git a/examples/docker-image-builds/images/base.Dockerfile b/examples/docker-image-builds/images/base.Dockerfile index 02398e2ab3eb3..620b0fa0cd088 100644 --- a/examples/docker-image-builds/images/base.Dockerfile +++ b/examples/docker-image-builds/images/base.Dockerfile @@ -30,4 +30,4 @@ RUN useradd coder \ --user-group && \ echo "coder ALL=(ALL) NOPASSWD:ALL" >>/etc/sudoers.d/nopasswd -USER coder \ No newline at end of file +USER coder diff --git a/examples/docker/main.tf b/examples/docker/main.tf index fa3cb0b5f564e..bb89e5401849c 100644 --- a/examples/docker/main.tf +++ b/examples/docker/main.tf @@ -69,11 +69,6 @@ variable "docker_image" { error_message = "Invalid Docker Image!" } - validation { - condition = contains(["codercom/enterprise-base:ubuntu", "codercom/enterprise-node:ubuntu", "codercom/enterprise-intellij:ubuntu"], var.docker_image) - error_message = "Invalid Docker Image!" - } - } resource "docker_volume" "home_volume" { From 3c841e5eb66c891dcde08b780713cdc5f7d8bd88 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:00 -0500 Subject: [PATCH 07/21] Update examples/docker-image-builds/README.md Co-authored-by: Katie Horne --- examples/docker-image-builds/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index c7063df185e54..3e54c8af7effa 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -1,6 +1,6 @@ --- name: Develop in Docker with custom image builds -description: Builds images and runs workspaces on the Docker host, no image registry required +description: Build images and run workspaces on the Docker host with no image registry required tags: [local, docker] --- From f47b38abfe124eb2af3ea9032647af6a2072e400 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:08 -0500 Subject: [PATCH 08/21] Update examples/docker-image-builds/README.md Co-authored-by: Katie Horne --- examples/docker-image-builds/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index 3e54c8af7effa..786883cb5f630 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -6,7 +6,7 @@ tags: [local, docker] # docker-image-builds -This example bundles Dockerfiles in the Coder template, allowing the Docker host to build images itself instead of relying on an external registry. +This example bundles Dockerfiles with the Coder template, allowing the Docker host to build images itself instead of relying on an external registry. For large use cases, we recommend building images using CI/CD pipelines and registries instead of at workspace "runtime." However, this example is practical for tinkering and iterating on Dockerfiles. From 6f02c132c9ef14ce531c6afe02845d16f0d0d4eb Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:14 -0500 Subject: [PATCH 09/21] Update examples/docker-image-builds/README.md Co-authored-by: Katie Horne --- examples/docker-image-builds/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index 786883cb5f630..2d328a198ba24 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -8,7 +8,7 @@ tags: [local, docker] This example bundles Dockerfiles with the Coder template, allowing the Docker host to build images itself instead of relying on an external registry. -For large use cases, we recommend building images using CI/CD pipelines and registries instead of at workspace "runtime." However, this example is practical for tinkering and iterating on Dockerfiles. +For large use cases, we recommend building images using CI/CD pipelines and registries instead of at workspace runtime. However, this example is practical for tinkering and iterating on Dockerfiles. ## Getting started From 64c39eab8aa2adb222e4105a011f8db7908b149d Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:21 -0500 Subject: [PATCH 10/21] Update examples/docker-image-builds/README.md Co-authored-by: Katie Horne --- examples/docker-image-builds/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index 2d328a198ba24..0a7c969cb74dd 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -16,7 +16,7 @@ Pick this template in `coder templates init` and follow instructions. ## Adding images -Create a Dockerfile (e.g `images/golang.Dockerfile`) +Create a Dockerfile (e.g `images/golang.Dockerfile`): ```sh vim images/golang.Dockerfile From 1e51794709a09c3b504f638eea1ae0fc42b832ef Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:30 -0500 Subject: [PATCH 11/21] Update examples/docker-image-builds/README.md Co-authored-by: Katie Horne --- examples/docker-image-builds/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index 0a7c969cb74dd..0607f6bfc42c3 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -12,7 +12,7 @@ For large use cases, we recommend building images using CI/CD pipelines and regi ## Getting started -Pick this template in `coder templates init` and follow instructions. +Run `coder templates init` and select this template. Follow the instructions that appear. ## Adding images From 272dfe2b178660ac0f6f5f1f27c43460e1364919 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:35 -0500 Subject: [PATCH 12/21] Update examples/docker/README.md Co-authored-by: Katie Horne --- examples/docker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker/README.md b/examples/docker/README.md index c452d690b3135..36527b0067ab6 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -53,7 +53,7 @@ variable "docker_image" { - condition = contains(["my-org/base-development:v1.1", "myorg-java-development:v1.1"], var.docker_image) + condition = contains(["my-org/base-development:v1.1", "myorg-java-development:v1.2"], var.docker_image) - error_message = "Invalid Docker Image!" + error_message = "Invalid Docker image!" } } ``` From 601d6649f06373a82e0e3cb71cfe8d2522e5678f Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:40 -0500 Subject: [PATCH 13/21] Update examples/docker/README.md Co-authored-by: Katie Horne --- examples/docker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker/README.md b/examples/docker/README.md index 36527b0067ab6..2da1018f95c9d 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -58,7 +58,7 @@ variable "docker_image" { } ``` -Optional: Update workspaces to the latest template version +Optional: Update workspaces to the latest template version: ```sh coder ls From 759bc066dbe4986292b5f5d64ede2f4adee553ef Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:44 -0500 Subject: [PATCH 14/21] Update examples/docker/README.md Co-authored-by: Katie Horne --- examples/docker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker/README.md b/examples/docker/README.md index 2da1018f95c9d..6185a0d73ca5f 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -77,5 +77,5 @@ add the following features to your Coder template: - Custom container spec - More -Contributions are also welcome! +We also welcome contributions! From 64707af0602323163694285764cb54b23061c239 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 12:59:51 -0500 Subject: [PATCH 15/21] Update examples/docker/main.tf Co-authored-by: Katie Horne --- examples/docker/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker/main.tf b/examples/docker/main.tf index bb89e5401849c..4e65fa6e5a5e5 100644 --- a/examples/docker/main.tf +++ b/examples/docker/main.tf @@ -61,7 +61,7 @@ resource "coder_agent" "dev" { } variable "docker_image" { - description = "What docker image would you like to use for your workspace?" + description = "Which Docker image would you like to use for your workspace?" # The codercom/enterprise-* images are only built for amd64 default = "codercom/enterprise-base:ubuntu" validation { From 0dd31fcbf8028ef4817c20d3d1ed58077b7b5464 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 13:00:01 -0500 Subject: [PATCH 16/21] Update examples/docker-image-builds/README.md Co-authored-by: Katie Horne --- examples/docker-image-builds/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index 0607f6bfc42c3..3dca6aae52e66 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -44,7 +44,7 @@ ENV PATH $PATH:$GOBIN USER coder ``` -Edit the template Terraform (`main.tf`) +Edit the Terraform template (`main.tf`): ```sh vim main.tf From 6b2dbe9631b5a1b34d7e33ba43a2c1f352a50fb2 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 18 May 2022 13:00:18 -0500 Subject: [PATCH 17/21] Update examples/docker-image-builds/README.md Co-authored-by: Katie Horne --- examples/docker-image-builds/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index 3dca6aae52e66..c4a1dd885cbce 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -90,7 +90,7 @@ Update the template: coder template update docker-image-builds ``` -Images can also be removed from the validation list. Workspaces using older template versions will continue using +You can also remove images from the validation list. Workspaces using older template versions will continue using the removed image until the workspace is updated to the latest version. ## Updating images From a747cec844e4ef06312f13c7a544977d54ac5acc Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 May 2022 18:21:42 +0000 Subject: [PATCH 18/21] fixes from feedback --- examples/docker-image-builds/README.md | 12 ++++++------ .../docker-image-builds/images/java.Dockerfile | 6 +++--- examples/docker-image-builds/main.tf | 8 ++++---- examples/docker/README.md | 17 ++++++++--------- examples/docker/main.tf | 2 +- 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/examples/docker-image-builds/README.md b/examples/docker-image-builds/README.md index c4a1dd885cbce..3513f631cc677 100644 --- a/examples/docker-image-builds/README.md +++ b/examples/docker-image-builds/README.md @@ -54,7 +54,7 @@ Edit the validation to include the new image: ```diff variable "docker_image" { - description = "What docker image would you like to use for your workspace?" + description = "What Docker imagewould you like to use for your workspace?" default = "base" # List of images available for the user to choose from. @@ -62,7 +62,7 @@ variable "docker_image" { validation { - condition = contains(["base", "java", "node"], var.docker_image) + condition = contains(["base", "java", "node", "golang], var.docker_image) - error_message = "Invalid Docker Image!" + error_message = "Invalid Docker image!" } } ``` @@ -91,11 +91,11 @@ coder template update docker-image-builds ``` You can also remove images from the validation list. Workspaces using older template versions will continue using -the removed image until the workspace is updated to the latest version. +the removed image until you update the workspace to the latest version. ## Updating images -Edit the Dockerfile (or related assets) +Edit the Dockerfile (or related assets): ```sh vim images/node.Dockerfile @@ -109,7 +109,7 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get update -y && \ apt-get install -y nodejs ``` -1. Edit the template Terraform (`main.tf`) +1. Edit the Terraform template (`main.tf`) ```sh vim main.tf @@ -156,4 +156,4 @@ add the following features to your Coder template: - Custom container spec - More -Contributions are also welcome! +We also welcome all contributions! diff --git a/examples/docker-image-builds/images/java.Dockerfile b/examples/docker-image-builds/images/java.Dockerfile index aac7a4e32a27e..a35fc20230786 100644 --- a/examples/docker-image-builds/images/java.Dockerfile +++ b/examples/docker-image-builds/images/java.Dockerfile @@ -18,13 +18,13 @@ ENV MAVEN_HOME /usr/share/maven ENV MAVEN_CONFIG "/home/coder/.m2" RUN mkdir -p $MAVEN_HOME $MAVEN_HOME/ref \ - && echo "Downloading maven" \ + && echo "Downloading Maven" \ && curl -fsSL -o /tmp/apache-maven.tar.gz https://apache.osuosl.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ \ && echo "Checking downloaded file hash" \ && echo "${MAVEN_SHA512} /tmp/apache-maven.tar.gz" | sha512sum -c - \ \ - && echo "Unzipping maven" \ + && echo "Unzipping Maven" \ && tar -xzf /tmp/apache-maven.tar.gz -C $MAVEN_HOME --strip-components=1 \ \ && echo "Cleaning and setting links" \ @@ -38,7 +38,7 @@ ARG GRADLE_SHA512=d495bc65379d2a854d2cca843bd2eeb94f381e5a7dcae89e6ceb6ef4c58355 ENV GRADLE_HOME /usr/bin/gradle RUN mkdir -p /usr/share/gradle /usr/share/gradle/ref \ - && echo "Downloading gradle" \ + && echo "Downloading Gradle" \ && curl -fsSL -o /tmp/gradle.zip https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip \ \ && echo "Checking downloaded file hash" \ diff --git a/examples/docker-image-builds/main.tf b/examples/docker-image-builds/main.tf index b81f6ff149225..766b1b92c64dc 100644 --- a/examples/docker-image-builds/main.tf +++ b/examples/docker-image-builds/main.tf @@ -32,7 +32,7 @@ variable "step2_arch" { description = "arch: What archicture is your Docker host on?" validation { condition = contains(["amd64", "arm64", "armv7"], var.step2_arch) - error_message = "Value must be amd64, arm64 or armv7." + error_message = "Value must be amd64, arm64, or armv7." } sensitive = true } @@ -54,20 +54,20 @@ resource "coder_agent" "dev" { } variable "docker_image" { - description = "What docker image would you like to use for your workspace?" + description = "What Docker imagewould you like to use for your workspace?" default = "base" # List of images available for the user to choose from. # Delete this condition to give users free text input. validation { condition = contains(["base", "java", "node"], var.docker_image) - error_message = "Invalid Docker Image!" + error_message = "Invalid Docker image!" } # Prevents admin errors when the image is not found validation { condition = fileexists("images/${var.docker_image}.Dockerfile") - error_message = "Invalid docker image. The file does not exist in the images directory." + error_message = "Invalid Docker image. The file does not exist in the images directory." } } diff --git a/examples/docker/README.md b/examples/docker/README.md index 6185a0d73ca5f..7246ddfc1a5fc 100644 --- a/examples/docker/README.md +++ b/examples/docker/README.md @@ -8,12 +8,11 @@ tags: [local, docker] ## Getting started -Pick this template in `coder templates init` and follow instructions. +Run `coder templates init` and select this template. Follow the instructions that appear. ## Adding/removing images -After building and pushing an image to an image registry (e.g DockerHub), you can make the -image available to users in the template. +After building and pushing an image to an image registry (e.g., DockerHub), you can edit the template to make the image available to users. Edit the template: @@ -21,13 +20,13 @@ Edit the template: vim main.tf ``` variable "docker_image" { - description = "What docker image would you like to use for your workspace?" + description = "What Docker imagewould you like to use for your workspace?" default = "codercom/enterprise-base:ubuntu" validation { - condition = contains(["codercom/enterprise-base:ubuntu", "codercom/enterprise-node:ubuntu", "codercom/enterprise-intellij:ubuntu"], var.docker_image) + condition = contains(["codercom/enterprise-base:ubuntu", "codercom/enterprise-node:ubuntu", "codercom/enterprise-intellij:ubuntu", "codercom/enterprise-golang:ubuntu"], var.docker_image) - error_message = "Invalid Docker Image!" + error_message = "Invalid Docker image!" } } ``` @@ -38,16 +37,16 @@ Update the template: coder template update docker ``` -Images can also be removed from the validation list. Workspaces using older template versions will continue using -the removed image until the workspace is updated to the latest version. +You can also remove images from the validation list. Workspaces using older template versions will continue using +the removed image until you update the workspace to the latest version. ## Updating images -To reduce drift, we recommend versioning images in your registry via tags. Update the image tag in the template: +To reduce drift, we recommend versioning images in your registry by creating tags. To update the image tag in the template: ```sh variable "docker_image" { - description = "What docker image would you like to use for your workspace?" + description = "What Docker imagewould you like to use for your workspace?" default = "codercom/enterprise-base:ubuntu" validation { - condition = contains(["my-org/base-development:v1.1", "myorg-java-development:v1.1"], var.docker_image) diff --git a/examples/docker/main.tf b/examples/docker/main.tf index 4e65fa6e5a5e5..218b48890583b 100644 --- a/examples/docker/main.tf +++ b/examples/docker/main.tf @@ -66,7 +66,7 @@ variable "docker_image" { default = "codercom/enterprise-base:ubuntu" validation { condition = contains(["codercom/enterprise-base:ubuntu", "codercom/enterprise-node:ubuntu", "codercom/enterprise-intellij:ubuntu"], var.docker_image) - error_message = "Invalid Docker Image!" + error_message = "Invalid Docker image!" } } From 30136fcac05f250c370c2d7b9a590468928d019f Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 May 2022 18:23:03 +0000 Subject: [PATCH 19/21] Merge branch 'main' into bpmct/add-docker-builds --- .goreleaser.yaml | 2 +- .vscode/settings.json | 4 + Makefile | 4 + agent/agent.go | 114 ++++ agent/agent_test.go | 165 +++++- agent/conn.go | 43 +- cli/autostart_test.go | 4 +- cli/autostop_test.go | 4 +- cli/cliui/prompt.go | 6 +- cli/cliui/prompt_test.go | 57 ++ cli/list.go | 2 +- cli/portforward.go | 379 +++++++++++++ cli/portforward_test.go | 532 ++++++++++++++++++ cli/resetpassword_test.go | 4 +- cli/root.go | 19 +- cli/server_test.go | 5 +- cli/ssh.go | 145 +++-- cli/templates.go | 9 +- cli/tunnel.go | 14 - coderd/audit/table.go | 2 +- coderd/authorize.go | 43 ++ .../autobuild/executor/lifecycle_executor.go | 28 +- .../executor/lifecycle_executor_test.go | 9 +- coderd/autobuild/schedule/schedule_test.go | 47 +- coderd/coderd.go | 53 +- coderd/coderd_test.go | 211 ++++++- coderd/coderdtest/coderdtest.go | 32 +- coderd/database/databasefake/databasefake.go | 186 +++--- coderd/database/dump.sql | 8 +- coderd/database/migrations/000004_jobs.up.sql | 6 +- .../000012_template_version_readme.down.sql | 1 + .../000012_template_version_readme.up.sql | 1 + coderd/database/models.go | 5 +- coderd/database/postgres/postgres_test.go | 4 +- coderd/database/pubsub_test.go | 8 +- coderd/database/querier.go | 10 +- coderd/database/queries.sql.go | 362 ++++++------ coderd/database/queries/templateversions.sql | 11 +- coderd/database/queries/workspacebuilds.sql | 60 +- coderd/database/queries/workspaces.sql | 32 +- coderd/gitsshkey.go | 11 + coderd/httpapi/httpapi.go | 6 + coderd/httpmw/authorize.go | 82 --- coderd/httpmw/oauth2.go | 4 +- coderd/httpmw/organizationparam.go | 2 +- coderd/httpmw/organizationparam_test.go | 2 +- coderd/organizations.go | 10 +- coderd/organizations_test.go | 8 +- coderd/provisionerdaemons.go | 10 + coderd/rbac/authz.go | 8 +- coderd/rbac/builtin.go | 19 +- coderd/rbac/error.go | 2 +- coderd/rbac/object.go | 59 +- coderd/roles.go | 33 +- coderd/roles_test.go | 4 +- coderd/templateversions.go | 3 +- coderd/users.go | 159 ++++-- coderd/users_test.go | 20 +- coderd/workspaceagents.go | 4 +- coderd/workspaceagents_test.go | 25 +- coderd/workspacebuilds.go | 46 +- coderd/workspacebuilds_test.go | 44 +- coderd/workspaceresourceauth.go | 4 +- coderd/workspaces.go | 154 +++-- coderd/workspaces_test.go | 29 +- codersdk/buildinfo.go | 2 +- codersdk/client.go | 4 +- codersdk/files.go | 4 +- codersdk/gitsshkey.go | 6 +- codersdk/organizations.go | 20 +- codersdk/pagination.go | 2 +- codersdk/parameters.go | 6 +- codersdk/provisionerdaemons.go | 4 +- codersdk/roles.go | 6 +- codersdk/templates.go | 10 +- codersdk/templateversions.go | 11 +- codersdk/users.go | 52 +- codersdk/workspaceagents.go | 14 +- codersdk/workspacebuilds.go | 13 +- codersdk/workspaceresources.go | 2 +- codersdk/workspaces.go | 58 +- cryptorand/slices.go | 20 + cryptorand/slices_test.go | 56 ++ docs/CONTRIBUTING.md | 2 +- examples/aws-linux/README.md | 59 ++ examples/aws-linux/main.tf | 5 + examples/docker-image-builds/main.tf | 2 + examples/docker/main.tf | 2 + go.mod | 2 +- peer/channel.go | 4 +- peer/conn.go | 19 +- peer/conn_test.go | 14 +- provisioner/echo/serve.go | 1 - provisioner/terraform/provision.go | 6 +- provisionerd/proto/provisionerd.pb.go | 79 +-- provisionerd/proto/provisionerd.proto | 1 + provisionerd/provisionerd.go | 81 ++- provisionerd/provisionerd_test.go | 9 +- pty/pty.go | 31 +- pty/pty_linux.go | 13 + pty/pty_other.go | 9 +- pty/pty_windows.go | 9 +- develop.sh => scripts/develop.sh | 0 site/package.json | 4 +- site/src/AppRouter.tsx | 12 + site/src/api/api.ts | 16 +- site/src/api/typesGenerated.ts | 23 +- .../BorderedMenu/BorderedMenu.stories.tsx | 10 +- .../BorderedMenuRow/BorderedMenuRow.tsx | 54 +- .../BuildsTable/BuildsTable.stories.tsx | 21 + .../components/BuildsTable/BuildsTable.tsx | 97 ++++ site/src/components/NavbarView/NavbarView.tsx | 5 + .../TerminalLink/TerminalLink.stories.tsx | 16 + .../components/TerminalLink/TerminalLink.tsx | 28 + .../components/UserDropdown/UsersDropdown.tsx | 5 +- site/src/components/Workspace/Workspace.tsx | 18 +- .../WorkspaceSection/WorkspaceSection.tsx | 10 +- .../pages/TemplatePage/TemplatePageView.tsx | 153 +++++ .../TemplatesPage/TemplatesPage.test.tsx | 67 +++ .../src/pages/TemplatesPage/TemplatesPage.tsx | 21 + .../TemplatesPageView.stories.tsx | 36 ++ .../pages/TemplatesPage/TemplatesPageView.tsx | 156 +++++ .../pages/TerminalPage/TerminalPage.test.tsx | 20 +- site/src/pages/TerminalPage/TerminalPage.tsx | 6 +- .../WorkspacePage/WorkspacePage.test.tsx | 159 ++++-- .../src/pages/WorkspacePage/WorkspacePage.tsx | 3 +- .../WorkspacesPage/WorkspacesPage.test.tsx | 2 +- .../WorkspacesPage/WorkspacesPageView.tsx | 81 +-- site/src/testHelpers/entities.ts | 15 +- site/src/testHelpers/handlers.ts | 19 +- site/src/util/workspace.ts | 69 +++ site/src/xServices/auth/authXService.ts | 7 + .../xServices/templates/templatesXService.ts | 104 ++++ .../xServices/terminal/terminalXService.ts | 17 +- .../xServices/workspace/workspaceXService.ts | 136 ++++- site/yarn.lock | 16 +- 136 files changed, 4282 insertions(+), 1141 deletions(-) create mode 100644 cli/portforward.go create mode 100644 cli/portforward_test.go delete mode 100644 cli/tunnel.go create mode 100644 coderd/authorize.go create mode 100644 coderd/database/migrations/000012_template_version_readme.down.sql create mode 100644 coderd/database/migrations/000012_template_version_readme.up.sql create mode 100644 cryptorand/slices.go create mode 100644 cryptorand/slices_test.go create mode 100644 pty/pty_linux.go rename develop.sh => scripts/develop.sh (100%) create mode 100644 site/src/components/BuildsTable/BuildsTable.stories.tsx create mode 100644 site/src/components/BuildsTable/BuildsTable.tsx create mode 100644 site/src/components/TerminalLink/TerminalLink.stories.tsx create mode 100644 site/src/components/TerminalLink/TerminalLink.tsx create mode 100644 site/src/pages/TemplatePage/TemplatePageView.tsx create mode 100644 site/src/pages/TemplatesPage/TemplatesPage.test.tsx create mode 100644 site/src/pages/TemplatesPage/TemplatesPage.tsx create mode 100644 site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx create mode 100644 site/src/pages/TemplatesPage/TemplatesPageView.tsx create mode 100644 site/src/xServices/templates/templatesXService.ts diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 36ad5247f38d7..30b7863e69231 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,7 @@ archives: - id: coder-linux builds: [coder-linux] - format: tar + format: tar.gz files: - src: docs/README.md dst: README.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 6f7ea5c69fce3..a04dc17791f5f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -73,6 +73,10 @@ { "match": "database/queries/*.sql", "cmd": "make gen" + }, + { + "match": "provisionerd/proto/provisionerd.proto", + "cmd": "make provisionerd/proto/provisionerd.pb.go", } ] }, diff --git a/Makefile b/Makefile index 9ea8aa90a13b0..b3d38a5e0e02d 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,10 @@ coderd/database/dump.sql: $(wildcard coderd/database/migrations/*.sql) coderd/database/querier.go: coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql) coderd/database/generate.sh +dev: + ./scripts/develop.sh +.PHONY: dev + dist/artifacts.json: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum goreleaser release --snapshot --rm-dist --skip-sign diff --git a/agent/agent.go b/agent/agent.go index b946166056532..75787b4cfc5e1 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net" + "net/url" "os" "os/exec" "os/user" @@ -211,6 +212,8 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { go a.sshServer.HandleConn(channel.NetConn()) case "reconnecting-pty": go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn()) + case "dial": + go a.handleDial(ctx, channel.Label(), channel.NetConn()) default: a.logger.Warn(ctx, "unhandled protocol from channel", slog.F("protocol", channel.Protocol()), @@ -617,6 +620,70 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne } } +// dialResponse is written to datachannels with protocol "dial" by the agent as +// the first packet to signify whether the dial succeeded or failed. +type dialResponse struct { + Error string `json:"error,omitempty"` +} + +func (a *agent) handleDial(ctx context.Context, label string, conn net.Conn) { + defer conn.Close() + + writeError := func(responseError error) error { + msg := "" + if responseError != nil { + msg = responseError.Error() + if !xerrors.Is(responseError, io.EOF) { + a.logger.Warn(ctx, "handle dial", slog.F("label", label), slog.Error(responseError)) + } + } + b, err := json.Marshal(dialResponse{ + Error: msg, + }) + if err != nil { + a.logger.Warn(ctx, "write dial response", slog.F("label", label), slog.Error(err)) + return xerrors.Errorf("marshal agent webrtc dial response: %w", err) + } + + _, err = conn.Write(b) + return err + } + + u, err := url.Parse(label) + if err != nil { + _ = writeError(xerrors.Errorf("parse URL %q: %w", label, err)) + return + } + + network := u.Scheme + addr := u.Host + u.Path + if strings.HasPrefix(network, "unix") { + if runtime.GOOS == "windows" { + _ = writeError(xerrors.New("Unix forwarding is not supported from Windows workspaces")) + return + } + addr, err = ExpandRelativeHomePath(addr) + if err != nil { + _ = writeError(xerrors.Errorf("expand path %q: %w", addr, err)) + return + } + } + + d := net.Dialer{Timeout: 3 * time.Second} + nconn, err := d.DialContext(ctx, network, addr) + if err != nil { + _ = writeError(xerrors.Errorf("dial '%v://%v': %w", network, addr, err)) + return + } + + err = writeError(nil) + if err != nil { + return + } + + Bicopy(ctx, conn, nconn) +} + // isClosed returns whether the API is closed or not. func (a *agent) isClosed() bool { select { @@ -662,3 +729,50 @@ func (r *reconnectingPTY) Close() { r.circularBuffer.Reset() r.timeout.Stop() } + +// Bicopy copies all of the data between the two connections and will close them +// after one or both of them are done writing. If the context is canceled, both +// of the connections will be closed. +func Bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) { + defer c1.Close() + defer c2.Close() + + var wg sync.WaitGroup + copyFunc := func(dst io.WriteCloser, src io.Reader) { + defer wg.Done() + _, _ = io.Copy(dst, src) + } + + wg.Add(2) + go copyFunc(c1, c2) + go copyFunc(c2, c1) + + // Convert waitgroup to a channel so we can also wait on the context. + done := make(chan struct{}) + go func() { + defer close(done) + wg.Wait() + }() + + select { + case <-ctx.Done(): + case <-done: + } +} + +// ExpandRelativeHomePath expands the tilde at the beginning of a path to the +// current user's home directory and returns a full absolute path. +func ExpandRelativeHomePath(in string) (string, error) { + usr, err := user.Current() + if err != nil { + return "", xerrors.Errorf("get current user details: %w", err) + } + + if in == "~" { + in = usr.HomeDir + } else if strings.HasPrefix(in, "~/") { + in = filepath.Join(usr.HomeDir, in[2:]) + } + + return filepath.Abs(in) +} diff --git a/agent/agent_test.go b/agent/agent_test.go index bd26fae7f0a69..db3235417960e 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1,6 +1,7 @@ package agent_test import ( + "bufio" "context" "encoding/json" "fmt" @@ -16,6 +17,7 @@ import ( "time" "github.com/google/uuid" + "github.com/pion/udp" "github.com/pion/webrtc/v3" "github.com/pkg/sftp" "github.com/stretchr/testify/require" @@ -203,6 +205,11 @@ func TestAgent(t *testing.T) { id := uuid.NewString() netConn, err := conn.ReconnectingPTY(id, 100, 100) require.NoError(t, err) + bufRead := bufio.NewReader(netConn) + + // Brief pause to reduce the likelihood that we send keystrokes while + // the shell is simultaneously sending a prompt. + time.Sleep(100 * time.Millisecond) data, err := json.Marshal(agent.ReconnectingPTYRequest{ Data: "echo test\r\n", @@ -211,28 +218,141 @@ func TestAgent(t *testing.T) { _, err = netConn.Write(data) require.NoError(t, err) - findEcho := func() { + expectLine := func(matcher func(string) bool) { for { - read, err := netConn.Read(data) + line, err := bufRead.ReadString('\n') require.NoError(t, err) - if strings.Contains(string(data[:read]), "test") { + if matcher(line) { break } } } + matchEchoCommand := func(line string) bool { + return strings.Contains(line, "echo test") + } + matchEchoOutput := func(line string) bool { + return strings.Contains(line, "test") && !strings.Contains(line, "echo") + } // Once for typing the command... - findEcho() + expectLine(matchEchoCommand) // And another time for the actual output. - findEcho() + expectLine(matchEchoOutput) _ = netConn.Close() netConn, err = conn.ReconnectingPTY(id, 100, 100) require.NoError(t, err) + bufRead = bufio.NewReader(netConn) // Same output again! - findEcho() - findEcho() + expectLine(matchEchoCommand) + expectLine(matchEchoOutput) + }) + + t.Run("Dial", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + setup func(t *testing.T) net.Listener + }{ + { + name: "TCP", + setup: func(t *testing.T) net.Listener { + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "create TCP listener") + return l + }, + }, + { + name: "UDP", + setup: func(t *testing.T) net.Listener { + addr := net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + } + l, err := udp.Listen("udp", &addr) + require.NoError(t, err, "create UDP listener") + return l + }, + }, + { + name: "Unix", + setup: func(t *testing.T) net.Listener { + if runtime.GOOS == "windows" { + t.Skip("Unix socket forwarding isn't supported on Windows") + } + + tmpDir, err := os.MkdirTemp("", "coderd_agent_test_") + require.NoError(t, err, "create temp dir for unix listener") + t.Cleanup(func() { + _ = os.RemoveAll(tmpDir) + }) + + l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock")) + require.NoError(t, err, "create UDP listener") + return l + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + // Setup listener + l := c.setup(t) + defer l.Close() + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + + go testAccept(t, c) + } + }() + + // Dial the listener over WebRTC twice and test out of order + conn := setupAgent(t, agent.Metadata{}, 0) + conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String()) + require.NoError(t, err) + defer conn1.Close() + conn2, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String()) + require.NoError(t, err) + defer conn2.Close() + testDial(t, conn2) + testDial(t, conn1) + }) + } + }) + + t.Run("DialError", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + // This test uses Unix listeners so we can very easily ensure that + // no other tests decide to listen on the same random port we + // picked. + t.Skip("this test is unsupported on Windows") + return + } + + tmpDir, err := os.MkdirTemp("", "coderd_agent_test_") + require.NoError(t, err, "create temp dir") + t.Cleanup(func() { + _ = os.RemoveAll(tmpDir) + }) + + // Try to dial the non-existent Unix socket over WebRTC + conn := setupAgent(t, agent.Metadata{}, 0) + netConn, err := conn.DialContext(context.Background(), "unix", filepath.Join(tmpDir, "test.sock")) + require.Error(t, err) + require.ErrorContains(t, err, "remote dial error") + require.ErrorContains(t, err, "no such file") + require.Nil(t, netConn) }) } @@ -303,3 +423,34 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) Conn: conn, } } + +var dialTestPayload = []byte("dean-was-here123") + +func testDial(t *testing.T, c net.Conn) { + t.Helper() + + assertWritePayload(t, c, dialTestPayload) + assertReadPayload(t, c, dialTestPayload) +} + +func testAccept(t *testing.T, c net.Conn) { + t.Helper() + defer c.Close() + + assertReadPayload(t, c, dialTestPayload) + assertWritePayload(t, c, dialTestPayload) +} + +func assertReadPayload(t *testing.T, r io.Reader, payload []byte) { + b := make([]byte, len(payload)+16) + n, err := r.Read(b) + require.NoError(t, err, "read payload") + require.Equal(t, len(payload), n, "read payload length does not match") + require.Equal(t, payload, b[:n]) +} + +func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { + n, err := w.Write(payload) + require.NoError(t, err, "write payload") + require.Equal(t, len(payload), n, "payload length does not match") +} diff --git a/agent/conn.go b/agent/conn.go index 81a6315af26de..56d3d42ea1784 100644 --- a/agent/conn.go +++ b/agent/conn.go @@ -2,8 +2,11 @@ package agent import ( "context" + "encoding/json" "fmt" "net" + "net/url" + "strings" "golang.org/x/crypto/ssh" "golang.org/x/xerrors" @@ -32,7 +35,7 @@ type Conn struct { // ReconnectingPTY returns a connection serving a TTY that can // be reconnected to via ID. func (c *Conn) ReconnectingPTY(id string, height, width uint16) (net.Conn, error) { - channel, err := c.Dial(context.Background(), fmt.Sprintf("%s:%d:%d", id, height, width), &peer.ChannelOptions{ + channel, err := c.CreateChannel(context.Background(), fmt.Sprintf("%s:%d:%d", id, height, width), &peer.ChannelOptions{ Protocol: "reconnecting-pty", }) if err != nil { @@ -43,7 +46,7 @@ func (c *Conn) ReconnectingPTY(id string, height, width uint16) (net.Conn, error // SSH dials the built-in SSH server. func (c *Conn) SSH() (net.Conn, error) { - channel, err := c.Dial(context.Background(), "ssh", &peer.ChannelOptions{ + channel, err := c.CreateChannel(context.Background(), "ssh", &peer.ChannelOptions{ Protocol: "ssh", }) if err != nil { @@ -71,6 +74,42 @@ func (c *Conn) SSHClient() (*ssh.Client, error) { return ssh.NewClient(sshConn, channels, requests), nil } +// DialContext dials an arbitrary protocol+address from inside the workspace and +// proxies it through the provided net.Conn. +func (c *Conn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) { + u := &url.URL{ + Scheme: network, + } + if strings.HasPrefix(network, "unix") { + u.Path = addr + } else { + u.Host = addr + } + + channel, err := c.CreateChannel(ctx, u.String(), &peer.ChannelOptions{ + Protocol: "dial", + Unordered: strings.HasPrefix(network, "udp"), + }) + if err != nil { + return nil, xerrors.Errorf("create datachannel: %w", err) + } + + // The first message written from the other side is a JSON payload + // containing the dial error. + dec := json.NewDecoder(channel) + var res dialResponse + err = dec.Decode(&res) + if err != nil { + return nil, xerrors.Errorf("failed to decode initial packet: %w", err) + } + if res.Error != "" { + _ = channel.Close() + return nil, xerrors.Errorf("remote dial error: %v", res.Error) + } + + return channel.NetConn(), nil +} + func (c *Conn) Close() error { _ = c.Negotiator.DRPCConn().Close() return c.Conn.Close() diff --git a/cli/autostart_test.go b/cli/autostart_test.go index 78f11a5380145..8c9ff40ee25d0 100644 --- a/cli/autostart_test.go +++ b/cli/autostart_test.go @@ -110,7 +110,7 @@ func TestAutostart(t *testing.T) { clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") + require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error") }) t.Run("Disable_NotFound", func(t *testing.T) { @@ -128,7 +128,7 @@ func TestAutostart(t *testing.T) { clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") + require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error") }) t.Run("Enable_DefaultSchedule", func(t *testing.T) { diff --git a/cli/autostop_test.go b/cli/autostop_test.go index 2d5205ad9c732..14447ac037ee4 100644 --- a/cli/autostop_test.go +++ b/cli/autostop_test.go @@ -109,7 +109,7 @@ func TestAutostop(t *testing.T) { clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") + require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error") }) t.Run("Disable_NotFound", func(t *testing.T) { @@ -127,7 +127,7 @@ func TestAutostop(t *testing.T) { clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") + require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error") }) t.Run("Enable_DefaultSchedule", func(t *testing.T) { diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 3e4c0689da162..ac39404e27d3f 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -34,8 +34,6 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { _, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") ")) } interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt) - defer signal.Stop(interrupt) errCh := make(chan error, 1) lineCh := make(chan string) @@ -45,8 +43,12 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { inFile, isInputFile := cmd.InOrStdin().(*os.File) if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { + // we don't install a signal handler here because speakeasy has its own line, err = speakeasy.Ask("") } else { + signal.Notify(interrupt, os.Interrupt) + defer signal.Stop(interrupt) + reader := bufio.NewReader(cmd.InOrStdin()) line, err = reader.ReadString('\n') diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index dc14925cc16bc..1926349c2d1fc 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -2,12 +2,16 @@ package cliui_test import ( "context" + "os" + "os/exec" "testing" + "time" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/pty" "github.com/coder/coder/pty/ptytest" ) @@ -110,3 +114,56 @@ func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) { cmd.SetIn(ptty.Input()) return value, cmd.ExecuteContext(context.Background()) } + +func TestPasswordTerminalState(t *testing.T) { + if os.Getenv("TEST_SUBPROCESS") == "1" { + passwordHelper() + return + } + t.Parallel() + + ptty := ptytest.New(t) + ptyWithFlags, ok := ptty.PTY.(pty.WithFlags) + if !ok { + t.Skip("unable to check PTY local echo on this platform") + } + + cmd := exec.Command(os.Args[0], "-test.run=TestPasswordTerminalState") //nolint:gosec + cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1") + // connect the child process's stdio to the PTY directly, not via a pipe + cmd.Stdin = ptty.Input().Reader + cmd.Stdout = ptty.Output().Writer + cmd.Stderr = os.Stderr + err := cmd.Start() + require.NoError(t, err) + process := cmd.Process + defer process.Kill() + + ptty.ExpectMatch("Password: ") + time.Sleep(100 * time.Millisecond) // wait for child process to turn off echo and start reading input + + echo, err := ptyWithFlags.EchoEnabled() + require.NoError(t, err) + require.False(t, echo, "echo is on while reading password") + + err = process.Signal(os.Interrupt) + require.NoError(t, err) + _, err = process.Wait() + require.NoError(t, err) + + echo, err = ptyWithFlags.EchoEnabled() + require.NoError(t, err) + require.True(t, echo, "echo is off after reading password") +} + +func passwordHelper() { + cmd := &cobra.Command{ + Run: func(cmd *cobra.Command, args []string) { + cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Password:", + Secret: true, + }) + }, + } + cmd.ExecuteContext(context.Background()) +} diff --git a/cli/list.go b/cli/list.go index 6fb2a369d63fd..23454bc85674e 100644 --- a/cli/list.go +++ b/cli/list.go @@ -29,7 +29,7 @@ func list() *cobra.Command { if err != nil { return err } - workspaces, err := client.WorkspacesByUser(cmd.Context(), codersdk.Me) + workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{}) if err != nil { return err } diff --git a/cli/portforward.go b/cli/portforward.go new file mode 100644 index 0000000000000..51206687f9cdb --- /dev/null +++ b/cli/portforward.go @@ -0,0 +1,379 @@ +package cli + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + + "github.com/pion/udp" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + coderagent "github.com/coder/coder/agent" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" +) + +func portForward() *cobra.Command { + var ( + tcpForwards []string // : + udpForwards []string // : + unixForwards []string // : OR : + ) + cmd := &cobra.Command{ + Use: "port-forward ", + Aliases: []string{"tunnel"}, + Args: cobra.ExactArgs(1), + Example: ` + - Port forward a single TCP port from 1234 in the workspace to port 5678 on + your local machine + + ` + cliui.Styles.Code.Render("$ coder port-forward --tcp 5678:1234") + ` + + - Port forward a single UDP port from port 9000 to port 9000 on your local + machine + + ` + cliui.Styles.Code.Render("$ coder port-forward --udp 9000") + ` + + - Forward a Unix socket in the workspace to a local Unix socket + + ` + cliui.Styles.Code.Render("$ coder port-forward --unix ./local.sock:~/remote.sock") + ` + + - Forward a Unix socket in the workspace to a local TCP port + + ` + cliui.Styles.Code.Render("$ coder port-forward --unix 8080:~/remote.sock") + ` + + - Port forward multiple TCP ports and a UDP port + + ` + cliui.Styles.Code.Render("$ coder port-forward --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53"), + RunE: func(cmd *cobra.Command, args []string) error { + specs, err := parsePortForwards(tcpForwards, udpForwards, unixForwards) + if err != nil { + return xerrors.Errorf("parse port-forward specs: %w", err) + } + if len(specs) == 0 { + err = cmd.Help() + if err != nil { + return xerrors.Errorf("generate help output: %w", err) + } + return xerrors.New("no port-forwards requested") + } + + client, err := createClient(cmd) + if err != nil { + return err + } + organization, err := currentOrganization(cmd, client) + if err != nil { + return err + } + + workspace, agent, err := getWorkspaceAndAgent(cmd, client, organization.ID, codersdk.Me, args[0], false) + if err != nil { + return err + } + if workspace.LatestBuild.Transition != database.WorkspaceTransitionStart { + return xerrors.New("workspace must be in start transition to port-forward") + } + if workspace.LatestBuild.Job.CompletedAt == nil { + err = cliui.WorkspaceBuild(cmd.Context(), cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt) + if err != nil { + return err + } + } + + err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{ + WorkspaceName: workspace.Name, + Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) { + return client.WorkspaceAgent(ctx, agent.ID) + }, + }) + if err != nil { + return xerrors.Errorf("await agent: %w", err) + } + + conn, err := client.DialWorkspaceAgent(cmd.Context(), agent.ID, nil) + if err != nil { + return xerrors.Errorf("dial workspace agent: %w", err) + } + defer conn.Close() + + // Start all listeners. + var ( + ctx, cancel = context.WithCancel(cmd.Context()) + wg = new(sync.WaitGroup) + listeners = make([]net.Listener, len(specs)) + closeAllListeners = func() { + for _, l := range listeners { + if l == nil { + continue + } + _ = l.Close() + } + } + ) + defer cancel() + for i, spec := range specs { + l, err := listenAndPortForward(ctx, cmd, conn, wg, spec) + if err != nil { + closeAllListeners() + return err + } + listeners[i] = l + } + + // Wait for the context to be canceled or for a signal and close + // all listeners. + var closeErr error + go func() { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-ctx.Done(): + closeErr = ctx.Err() + case <-sigs: + _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Received signal, closing all listeners and active connections") + closeErr = xerrors.New("signal received") + } + + cancel() + closeAllListeners() + }() + + _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Ready!") + wg.Wait() + return closeErr + }, + } + + cmd.Flags().StringArrayVarP(&tcpForwards, "tcp", "p", []string{}, "Forward a TCP port from the workspace to the local machine") + cmd.Flags().StringArrayVar(&udpForwards, "udp", []string{}, "Forward a UDP port from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols") + cmd.Flags().StringArrayVar(&unixForwards, "unix", []string{}, "Forward a Unix socket in the workspace to a local Unix socket or TCP port") + + return cmd +} + +func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderagent.Conn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) { + _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress) + + var ( + l net.Listener + err error + ) + switch spec.listenNetwork { + case "tcp": + l, err = net.Listen(spec.listenNetwork, spec.listenAddress) + case "udp": + var host, port string + host, port, err = net.SplitHostPort(spec.listenAddress) + if err != nil { + return nil, xerrors.Errorf("split %q: %w", spec.listenAddress, err) + } + + var portInt int + portInt, err = strconv.Atoi(port) + if err != nil { + return nil, xerrors.Errorf("parse port %v from %q as int: %w", port, spec.listenAddress, err) + } + + l, err = udp.Listen(spec.listenNetwork, &net.UDPAddr{ + IP: net.ParseIP(host), + Port: portInt, + }) + case "unix": + l, err = net.Listen(spec.listenNetwork, spec.listenAddress) + default: + return nil, xerrors.Errorf("unknown listen network %q", spec.listenNetwork) + } + if err != nil { + return nil, xerrors.Errorf("listen '%v://%v': %w", spec.listenNetwork, spec.listenAddress, err) + } + + wg.Add(1) + go func(spec portForwardSpec) { + defer wg.Done() + for { + netConn, err := l.Accept() + if err != nil { + _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %+v\n", spec.listenNetwork, spec.listenAddress, err) + _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Killing listener") + return + } + + go func(netConn net.Conn) { + defer netConn.Close() + remoteConn, err := conn.DialContext(ctx, spec.dialNetwork, spec.dialAddress) + if err != nil { + _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Failed to dial '%v://%v' in workspace: %s\n", spec.dialNetwork, spec.dialAddress, err) + return + } + defer remoteConn.Close() + + coderagent.Bicopy(ctx, netConn, remoteConn) + }(netConn) + } + }(spec) + + return l, nil +} + +type portForwardSpec struct { + listenNetwork string // tcp, udp, unix + listenAddress string // : or path + + dialNetwork string // tcp, udp, unix + dialAddress string // : or path +} + +func parsePortForwards(tcpSpecs, udpSpecs, unixSpecs []string) ([]portForwardSpec, error) { + specs := []portForwardSpec{} + + for _, spec := range tcpSpecs { + local, remote, err := parsePortPort(spec) + if err != nil { + return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err) + } + + specs = append(specs, portForwardSpec{ + listenNetwork: "tcp", + listenAddress: fmt.Sprintf("127.0.0.1:%v", local), + dialNetwork: "tcp", + dialAddress: fmt.Sprintf("127.0.0.1:%v", remote), + }) + } + + for _, spec := range udpSpecs { + local, remote, err := parsePortPort(spec) + if err != nil { + return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err) + } + + specs = append(specs, portForwardSpec{ + listenNetwork: "udp", + listenAddress: fmt.Sprintf("127.0.0.1:%v", local), + dialNetwork: "udp", + dialAddress: fmt.Sprintf("127.0.0.1:%v", remote), + }) + } + + for _, specStr := range unixSpecs { + localPath, localTCP, remotePath, err := parseUnixUnix(specStr) + if err != nil { + return nil, xerrors.Errorf("failed to parse Unix port-forward specification %q: %w", specStr, err) + } + + spec := portForwardSpec{ + dialNetwork: "unix", + dialAddress: remotePath, + } + if localPath == "" { + spec.listenNetwork = "tcp" + spec.listenAddress = fmt.Sprintf("127.0.0.1:%v", localTCP) + } else { + if runtime.GOOS == "windows" { + return nil, xerrors.Errorf("Unix port-forwarding is not supported on Windows") + } + spec.listenNetwork = "unix" + spec.listenAddress = localPath + } + specs = append(specs, spec) + } + + // Check for duplicate entries. + locals := map[string]struct{}{} + for _, spec := range specs { + localStr := fmt.Sprintf("%v:%v", spec.listenNetwork, spec.listenAddress) + if _, ok := locals[localStr]; ok { + return nil, xerrors.Errorf("local %v %v is specified twice", spec.listenNetwork, spec.listenAddress) + } + locals[localStr] = struct{}{} + } + + return specs, nil +} + +func parsePort(in string) (uint16, error) { + port, err := strconv.ParseUint(strings.TrimSpace(in), 10, 16) + if err != nil { + return 0, xerrors.Errorf("parse port %q: %w", in, err) + } + if port == 0 { + return 0, xerrors.New("port cannot be 0") + } + + return uint16(port), nil +} + +func parseUnixPath(in string) (string, error) { + path, err := coderagent.ExpandRelativeHomePath(strings.TrimSpace(in)) + if err != nil { + return "", xerrors.Errorf("tidy path %q: %w", in, err) + } + + return path, nil +} + +func parsePortPort(in string) (local uint16, remote uint16, err error) { + parts := strings.Split(in, ":") + if len(parts) > 2 { + return 0, 0, xerrors.Errorf("invalid port specification %q", in) + } + if len(parts) == 1 { + // Duplicate the single part + parts = append(parts, parts[0]) + } + + local, err = parsePort(parts[0]) + if err != nil { + return 0, 0, xerrors.Errorf("parse local port from %q: %w", in, err) + } + remote, err = parsePort(parts[1]) + if err != nil { + return 0, 0, xerrors.Errorf("parse remote port from %q: %w", in, err) + } + + return local, remote, nil +} + +func parsePortOrUnixPath(in string) (string, uint16, error) { + port, err := parsePort(in) + if err == nil { + return "", port, nil + } + + path, err := parseUnixPath(in) + if err != nil { + return "", 0, xerrors.Errorf("could not parse port or unix path %q: %w", in, err) + } + + return path, 0, nil +} + +func parseUnixUnix(in string) (string, uint16, string, error) { + parts := strings.Split(in, ":") + if len(parts) > 2 { + return "", 0, "", xerrors.Errorf("invalid port-forward specification %q", in) + } + if len(parts) == 1 { + // Duplicate the single part + parts = append(parts, parts[0]) + } + + localPath, localPort, err := parsePortOrUnixPath(parts[0]) + if err != nil { + return "", 0, "", xerrors.Errorf("parse local part of spec %q: %w", in, err) + } + + // We don't really touch the remote path at all since it gets cleaned + // up/expanded on the remote. + return localPath, localPort, parts[1], nil +} diff --git a/cli/portforward_test.go b/cli/portforward_test.go new file mode 100644 index 0000000000000..0c0d3ddc5fa08 --- /dev/null +++ b/cli/portforward_test.go @@ -0,0 +1,532 @@ +package cli_test + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/pion/udp" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" +) + +func TestPortForward(t *testing.T) { + t.Parallel() + + t.Run("None", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + cmd, root := clitest.New(t, "port-forward", "blah") + clitest.SetupConfig(t, client, root) + buf := newThreadSafeBuffer() + cmd.SetOut(buf) + + err := cmd.Execute() + require.Error(t, err) + require.ErrorContains(t, err, "no port-forwards") + + // Check that the help was printed. + require.Contains(t, buf.String(), "port-forward ") + }) + + cases := []struct { + name string + network string + // The flag to pass to `coder port-forward X` to port-forward this type + // of connection. Has two format args (both strings), the first is the + // local address and the second is the remote address. + flag string + // setupRemote creates a "remote" listener to emulate a service in the + // workspace. + setupRemote func(t *testing.T) net.Listener + // setupLocal returns an available port or Unix socket path that the + // port-forward command will listen on "locally". Returns the address + // you pass to net.Dial, and the port/path you pass to `coder + // port-forward`. + setupLocal func(t *testing.T) (string, string) + }{ + { + name: "TCP", + network: "tcp", + flag: "--tcp=%v:%v", + setupRemote: func(t *testing.T) net.Listener { + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "create TCP listener") + return l + }, + setupLocal: func(t *testing.T) (string, string) { + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "create TCP listener to generate random port") + defer l.Close() + + _, port, err := net.SplitHostPort(l.Addr().String()) + require.NoErrorf(t, err, "split TCP address %q", l.Addr().String()) + return l.Addr().String(), port + }, + }, + { + name: "UDP", + network: "udp", + flag: "--udp=%v:%v", + setupRemote: func(t *testing.T) net.Listener { + addr := net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + } + l, err := udp.Listen("udp", &addr) + require.NoError(t, err, "create UDP listener") + return l + }, + setupLocal: func(t *testing.T) (string, string) { + addr := net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + } + l, err := udp.Listen("udp", &addr) + require.NoError(t, err, "create UDP listener to generate random port") + defer l.Close() + + _, port, err := net.SplitHostPort(l.Addr().String()) + require.NoErrorf(t, err, "split UDP address %q", l.Addr().String()) + return l.Addr().String(), port + }, + }, + { + name: "Unix", + network: "unix", + flag: "--unix=%v:%v", + setupRemote: func(t *testing.T) net.Listener { + if runtime.GOOS == "windows" { + t.Skip("Unix socket forwarding isn't supported on Windows") + } + + tmpDir, err := os.MkdirTemp("", "coderd_agent_test_") + require.NoError(t, err, "create temp dir for unix listener") + t.Cleanup(func() { + _ = os.RemoveAll(tmpDir) + }) + + l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock")) + require.NoError(t, err, "create UDP listener") + return l + }, + setupLocal: func(t *testing.T) (string, string) { + tmpDir, err := os.MkdirTemp("", "coderd_agent_test_") + require.NoError(t, err, "create temp dir for unix listener") + t.Cleanup(func() { + _ = os.RemoveAll(tmpDir) + }) + + path := filepath.Join(tmpDir, "test.sock") + return path, path + }, + }, + } + + for _, c := range cases { //nolint:paralleltest // the `c := c` confuses the linter + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + t.Run("OnePort", func(t *testing.T) { + t.Parallel() + var ( + client = coderdtest.New(t, nil) + user = coderdtest.CreateFirstUser(t, client) + _, workspace = runAgent(t, client, user.UserID) + l1, p1 = setupTestListener(t, c.setupRemote(t)) + ) + t.Cleanup(func() { + _ = l1.Close() + }) + + // Create a flag that forwards from local to listener 1. + localAddress, localFlag := c.setupLocal(t) + flag := fmt.Sprintf(c.flag, localFlag, p1) + + // Launch port-forward in a goroutine so we can start dialing + // the "local" listener. + cmd, root := clitest.New(t, "port-forward", workspace.Name, flag) + clitest.SetupConfig(t, client, root) + buf := newThreadSafeBuffer() + cmd.SetOut(io.MultiWriter(buf, os.Stderr)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + err := cmd.ExecuteContext(ctx) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + }() + waitForPortForwardReady(t, buf) + + // Open two connections simultaneously and test them out of + // sync. + d := net.Dialer{Timeout: 3 * time.Second} + c1, err := d.DialContext(ctx, c.network, localAddress) + require.NoError(t, err, "open connection 1 to 'local' listener") + defer c1.Close() + c2, err := d.DialContext(ctx, c.network, localAddress) + require.NoError(t, err, "open connection 2 to 'local' listener") + defer c2.Close() + testDial(t, c2) + testDial(t, c1) + }) + + t.Run("TwoPorts", func(t *testing.T) { + t.Parallel() + var ( + client = coderdtest.New(t, nil) + user = coderdtest.CreateFirstUser(t, client) + _, workspace = runAgent(t, client, user.UserID) + l1, p1 = setupTestListener(t, c.setupRemote(t)) + l2, p2 = setupTestListener(t, c.setupRemote(t)) + ) + t.Cleanup(func() { + _ = l1.Close() + _ = l2.Close() + }) + + // Create a flags for listener 1 and listener 2. + localAddress1, localFlag1 := c.setupLocal(t) + localAddress2, localFlag2 := c.setupLocal(t) + flag1 := fmt.Sprintf(c.flag, localFlag1, p1) + flag2 := fmt.Sprintf(c.flag, localFlag2, p2) + + // Launch port-forward in a goroutine so we can start dialing + // the "local" listeners. + cmd, root := clitest.New(t, "port-forward", workspace.Name, flag1, flag2) + clitest.SetupConfig(t, client, root) + buf := newThreadSafeBuffer() + cmd.SetOut(io.MultiWriter(buf, os.Stderr)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + err := cmd.ExecuteContext(ctx) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + }() + waitForPortForwardReady(t, buf) + + // Open a connection to both listener 1 and 2 simultaneously and + // then test them out of order. + d := net.Dialer{Timeout: 3 * time.Second} + c1, err := d.DialContext(ctx, c.network, localAddress1) + require.NoError(t, err, "open connection 1 to 'local' listener 1") + defer c1.Close() + c2, err := d.DialContext(ctx, c.network, localAddress2) + require.NoError(t, err, "open connection 2 to 'local' listener 2") + defer c2.Close() + testDial(t, c2) + testDial(t, c1) + }) + }) + } + + // Test doing a TCP -> Unix forward. + t.Run("TCP2Unix", func(t *testing.T) { + t.Parallel() + var ( + client = coderdtest.New(t, nil) + user = coderdtest.CreateFirstUser(t, client) + _, workspace = runAgent(t, client, user.UserID) + + // Find the TCP and Unix cases so we can use their setupLocal and + // setupRemote methods respectively. + tcpCase = cases[0] + unixCase = cases[2] + + // Setup remote Unix listener. + l1, p1 = setupTestListener(t, unixCase.setupRemote(t)) + ) + t.Cleanup(func() { + _ = l1.Close() + }) + + // Create a flag that forwards from local TCP to Unix listener 1. + // Notably this is a --unix flag. + localAddress, localFlag := tcpCase.setupLocal(t) + flag := fmt.Sprintf(unixCase.flag, localFlag, p1) + + // Launch port-forward in a goroutine so we can start dialing + // the "local" listener. + cmd, root := clitest.New(t, "port-forward", workspace.Name, flag) + clitest.SetupConfig(t, client, root) + buf := newThreadSafeBuffer() + cmd.SetOut(io.MultiWriter(buf, os.Stderr)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + err := cmd.ExecuteContext(ctx) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + }() + waitForPortForwardReady(t, buf) + + // Open two connections simultaneously and test them out of + // sync. + d := net.Dialer{Timeout: 3 * time.Second} + c1, err := d.DialContext(ctx, tcpCase.network, localAddress) + require.NoError(t, err, "open connection 1 to 'local' listener") + defer c1.Close() + c2, err := d.DialContext(ctx, tcpCase.network, localAddress) + require.NoError(t, err, "open connection 2 to 'local' listener") + defer c2.Close() + testDial(t, c2) + testDial(t, c1) + }) + + // Test doing TCP, UDP and Unix at the same time. + t.Run("All", func(t *testing.T) { + t.Parallel() + var ( + client = coderdtest.New(t, nil) + user = coderdtest.CreateFirstUser(t, client) + _, workspace = runAgent(t, client, user.UserID) + // These aren't fixed size because we exclude Unix on Windows. + dials = []addr{} + flags = []string{} + ) + + // Start listeners and populate arrays with the cases. + for _, c := range cases { + if strings.HasPrefix(c.network, "unix") && runtime.GOOS == "windows" { + // Unix isn't supported on Windows, but we can still + // test other protocols together. + continue + } + + l, p := setupTestListener(t, c.setupRemote(t)) + t.Cleanup(func() { + _ = l.Close() + }) + + localAddress, localFlag := c.setupLocal(t) + dials = append(dials, addr{ + network: c.network, + addr: localAddress, + }) + flags = append(flags, fmt.Sprintf(c.flag, localFlag, p)) + } + + // Launch port-forward in a goroutine so we can start dialing + // the "local" listeners. + cmd, root := clitest.New(t, append([]string{"port-forward", workspace.Name}, flags...)...) + clitest.SetupConfig(t, client, root) + buf := newThreadSafeBuffer() + cmd.SetOut(io.MultiWriter(buf, os.Stderr)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + err := cmd.ExecuteContext(ctx) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + }() + waitForPortForwardReady(t, buf) + + // Open connections to all items in the "dial" array. + var ( + d = net.Dialer{Timeout: 3 * time.Second} + conns = make([]net.Conn, len(dials)) + ) + for i, a := range dials { + c, err := d.DialContext(ctx, a.network, a.addr) + require.NoErrorf(t, err, "open connection %v to 'local' listener %v", i+1, i+1) + t.Cleanup(func() { + _ = c.Close() + }) + conns[i] = c + } + + // Test each connection in reverse order. + for i := len(conns) - 1; i >= 0; i-- { + testDial(t, conns[i]) + } + }) +} + +// runAgent creates a fake workspace and starts an agent locally for that +// workspace. The agent will be cleaned up on test completion. +func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]codersdk.WorkspaceResource, codersdk.Workspace) { + ctx := context.Background() + user, err := client.User(ctx, userID.String()) + require.NoError(t, err, "specified user does not exist") + require.Greater(t, len(user.OrganizationIDs), 0, "user has no organizations") + orgID := user.OrganizationIDs[0] + + // Setup echo provisioner + agentToken := uuid.NewString() + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "somename", + Type: "someinstance", + Agents: []*proto.Agent{{ + Auth: &proto.Agent_Token{ + Token: agentToken, + }, + }}, + }}, + }, + }, + }}, + }) + + // Create template and workspace + template := coderdtest.CreateTemplate(t, client, orgID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // Start workspace agent in a goroutine + cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String()) + clitest.SetupConfig(t, client, root) + agentCtx, agentCancel := context.WithCancel(ctx) + t.Cleanup(agentCancel) + go func() { + err := cmd.ExecuteContext(agentCtx) + require.NoError(t, err) + }() + + coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID) + require.NoError(t, err) + + return resources, workspace +} + +// setupTestListener starts accepting connections and echoing a single packet. +// Returns the listener and the listen port or Unix path. +func setupTestListener(t *testing.T, l net.Listener) (net.Listener, string) { + t.Cleanup(func() { + _ = l.Close() + }) + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + + go testAccept(t, c) + } + }() + + addr := l.Addr().String() + if !strings.HasPrefix(l.Addr().Network(), "unix") { + _, port, err := net.SplitHostPort(addr) + require.NoErrorf(t, err, "split non-Unix listen path %q", addr) + addr = port + } + + return l, addr +} + +var dialTestPayload = []byte("dean-was-here123") + +func testDial(t *testing.T, c net.Conn) { + t.Helper() + + assertWritePayload(t, c, dialTestPayload) + assertReadPayload(t, c, dialTestPayload) +} + +func testAccept(t *testing.T, c net.Conn) { + t.Helper() + defer c.Close() + + assertReadPayload(t, c, dialTestPayload) + assertWritePayload(t, c, dialTestPayload) +} + +func assertReadPayload(t *testing.T, r io.Reader, payload []byte) { + b := make([]byte, len(payload)+16) + n, err := r.Read(b) + require.NoError(t, err, "read payload") + require.Equal(t, len(payload), n, "read payload length does not match") + require.Equal(t, payload, b[:n]) +} + +func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { + n, err := w.Write(payload) + require.NoError(t, err, "write payload") + require.Equal(t, len(payload), n, "payload length does not match") +} + +func waitForPortForwardReady(t *testing.T, output *threadSafeBuffer) { + for i := 0; i < 100; i++ { + time.Sleep(250 * time.Millisecond) + + data := output.String() + if strings.Contains(data, "Ready!") { + return + } + } + + t.Fatal("port-forward command did not become ready in time") +} + +type addr struct { + network string + addr string +} + +type threadSafeBuffer struct { + b *bytes.Buffer + mut *sync.RWMutex +} + +func newThreadSafeBuffer() *threadSafeBuffer { + return &threadSafeBuffer{ + b: bytes.NewBuffer(nil), + mut: new(sync.RWMutex), + } +} + +var _ io.Reader = &threadSafeBuffer{} +var _ io.Writer = &threadSafeBuffer{} + +// Read implements io.Reader. +func (b *threadSafeBuffer) Read(p []byte) (int, error) { + b.mut.RLock() + defer b.mut.RUnlock() + + return b.b.Read(p) +} + +// Write implements io.Writer. +func (b *threadSafeBuffer) Write(p []byte) (int, error) { + b.mut.Lock() + defer b.mut.Unlock() + + return b.b.Write(p) +} + +func (b *threadSafeBuffer) String() string { + b.mut.RLock() + defer b.mut.RUnlock() + + return b.b.String() +} diff --git a/cli/resetpassword_test.go b/cli/resetpassword_test.go index eafa097e3e842..20f69cda7e93f 100644 --- a/cli/resetpassword_test.go +++ b/cli/resetpassword_test.go @@ -15,8 +15,10 @@ import ( "github.com/coder/coder/pty/ptytest" ) +// nolint:paralleltest func TestResetPassword(t *testing.T) { - t.Parallel() + // postgres.Open() seems to be creating race conditions when run in parallel. + // t.Parallel() if runtime.GOOS != "linux" || testing.Short() { // Skip on non-Linux because it spawns a PostgreSQL instance. diff --git a/cli/root.go b/cli/root.go index 9e96be9be3f53..424ec54155c03 100644 --- a/cli/root.go +++ b/cli/root.go @@ -36,6 +36,15 @@ const ( varForceTty = "force-tty" ) +func init() { + // Customizes the color of headings to make subcommands more visually + // appealing. + header := cliui.Styles.Placeholder + cobra.AddTemplateFunc("usageHeader", func(s string) string { + return header.Render(s) + }) +} + func Root() *cobra.Command { cmd := &cobra.Command{ Use: "coder", @@ -71,7 +80,7 @@ func Root() *cobra.Command { templates(), update(), users(), - tunnel(), + portForward(), workspaceAgent(), ) @@ -179,13 +188,7 @@ func isTTY(cmd *cobra.Command) bool { } func usageTemplate() string { - // Customizes the color of headings to make subcommands - // more visually appealing. - header := cliui.Styles.Placeholder - cobra.AddTemplateFunc("usageHeader", func(s string) string { - return header.Render(s) - }) - + // usageHeader is defined in init(). return `{{usageHeader "Usage:"}} {{- if .Runnable}} {{.UseLine}} diff --git a/cli/server_test.go b/cli/server_test.go index f20944367853f..ef0d72a1cd493 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -32,10 +32,11 @@ import ( ) // This cannot be ran in parallel because it uses a signal. -// nolint:tparallel +// nolint:paralleltest func TestServer(t *testing.T) { t.Run("Production", func(t *testing.T) { - t.Parallel() + // postgres.Open() seems to be creating race conditions when run in parallel. + // t.Parallel() if runtime.GOOS != "linux" || testing.Short() { // Skip on non-Linux because it spawns a PostgreSQL instance. t.SkipNow() diff --git a/cli/ssh.go b/cli/ssh.go index 4ccaa241d5902..4dfd68463aa64 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" + "github.com/coder/coder/cryptorand" ) var autostopPollInterval = 30 * time.Second @@ -31,13 +32,14 @@ var autostopNotifyCountdown = []time.Duration{30 * time.Minute} func ssh() *cobra.Command { var ( - stdio bool + stdio bool + shuffle bool ) cmd := &cobra.Command{ Annotations: workspaceCommand, Use: "ssh ", Short: "SSH into a workspace", - Args: cobra.MinimumNArgs(1), + Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { @@ -48,58 +50,23 @@ func ssh() *cobra.Command { return err } - workspaceParts := strings.Split(args[0], ".") - workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, workspaceParts[0]) - if err != nil { - return err - } - - if workspace.LatestBuild.Transition != database.WorkspaceTransitionStart { - return xerrors.New("workspace must be in start transition to ssh") - } - - if workspace.LatestBuild.Job.CompletedAt == nil { - err = cliui.WorkspaceBuild(cmd.Context(), cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt) + if shuffle { + err := cobra.ExactArgs(0)(cmd, args) + if err != nil { + return err + } + } else { + err := cobra.MinimumNArgs(1)(cmd, args) if err != nil { return err } } - if workspace.LatestBuild.Transition == database.WorkspaceTransitionDelete { - return xerrors.New("workspace is deleting...") - } - - resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) + workspace, agent, err := getWorkspaceAndAgent(cmd, client, organization.ID, codersdk.Me, args[0], shuffle) if err != nil { return err } - agents := make([]codersdk.WorkspaceAgent, 0) - for _, resource := range resources { - agents = append(agents, resource.Agents...) - } - if len(agents) == 0 { - return xerrors.New("workspace has no agents") - } - var agent codersdk.WorkspaceAgent - if len(workspaceParts) >= 2 { - for _, otherAgent := range agents { - if otherAgent.Name != workspaceParts[1] { - continue - } - agent = otherAgent - break - } - if agent.ID == uuid.Nil { - return xerrors.Errorf("agent not found by name %q", workspaceParts[1]) - } - } - if agent.ID == uuid.Nil { - if len(agents) > 1 { - return xerrors.New("you must specify the name of an agent") - } - agent = agents[0] - } // OpenSSH passes stderr directly to the calling TTY. // This is required in "stdio" mode so a connecting indicator can be displayed. err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{ @@ -189,10 +156,98 @@ func ssh() *cobra.Command { }, } cliflag.BoolVarP(cmd.Flags(), &stdio, "stdio", "", "CODER_SSH_STDIO", false, "Specifies whether to emit SSH output over stdin/stdout.") + cliflag.BoolVarP(cmd.Flags(), &shuffle, "shuffle", "", "CODER_SSH_SHUFFLE", false, "Specifies whether to choose a random workspace") + _ = cmd.Flags().MarkHidden("shuffle") return cmd } +// getWorkspaceAgent returns the workspace and agent selected using either the +// `[.]` syntax via `in` or picks a random workspace and agent +// if `shuffle` is true. +func getWorkspaceAndAgent(cmd *cobra.Command, client *codersdk.Client, orgID uuid.UUID, userID string, in string, shuffle bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive + ctx := cmd.Context() + + var ( + workspace codersdk.Workspace + workspaceParts = strings.Split(in, ".") + err error + ) + if shuffle { + workspaces, err := client.WorkspacesByOwner(cmd.Context(), orgID, userID) + if err != nil { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + } + if len(workspaces) == 0 { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("no workspaces to shuffle") + } + + workspace, err = cryptorand.Element(workspaces) + if err != nil { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + } + } else { + workspace, err = client.WorkspaceByOwnerAndName(cmd.Context(), orgID, userID, workspaceParts[0]) + if err != nil { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + } + } + + if workspace.LatestBuild.Transition != database.WorkspaceTransitionStart { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("workspace must be in start transition to ssh") + } + if workspace.LatestBuild.Job.CompletedAt == nil { + err := cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt) + if err != nil { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + } + } + if workspace.LatestBuild.Transition == database.WorkspaceTransitionDelete { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is being deleted", workspace.Name) + } + + resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) + if err != nil { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("fetch workspace resources: %w", err) + } + + agents := make([]codersdk.WorkspaceAgent, 0) + for _, resource := range resources { + agents = append(agents, resource.Agents...) + } + if len(agents) == 0 { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name) + } + var agent codersdk.WorkspaceAgent + if len(workspaceParts) >= 2 { + for _, otherAgent := range agents { + if otherAgent.Name != workspaceParts[1] { + continue + } + agent = otherAgent + break + } + if agent.ID == uuid.Nil { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q", workspaceParts[1]) + } + } + if agent.ID == uuid.Nil { + if len(agents) > 1 { + if !shuffle { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("you must specify the name of an agent") + } + agent, err = cryptorand.Element(agents) + if err != nil { + return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err + } + } else { + agent = agents[0] + } + } + + return workspace, agent, nil +} + // Attempt to poll workspace autostop. We write a per-workspace lockfile to // avoid spamming the user with notifications in case of multiple instances // of the CLI running simultaneously. diff --git a/cli/templates.go b/cli/templates.go index 45f6224369ba8..2e5b179c900a4 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -1,8 +1,9 @@ package cli import ( - "github.com/fatih/color" "github.com/spf13/cobra" + + "github.com/coder/coder/cli/cliui" ) func templates() *cobra.Command { @@ -13,15 +14,15 @@ func templates() *cobra.Command { Example: ` - Create a template for developers to create workspaces - ` + color.New(color.FgHiMagenta).Sprint("$ coder templates create") + ` + ` + cliui.Styles.Code.Render("$ coder templates create") + ` - Make changes to your template, and plan the changes - ` + color.New(color.FgHiMagenta).Sprint("$ coder templates plan ") + ` + ` + cliui.Styles.Code.Render("$ coder templates plan ") + ` - Update the template. Your developers can update their workspaces - ` + color.New(color.FgHiMagenta).Sprint("$ coder templates update "), + ` + cliui.Styles.Code.Render("$ coder templates update "), } cmd.AddCommand( templateCreate(), diff --git a/cli/tunnel.go b/cli/tunnel.go deleted file mode 100644 index 887d766a090b3..0000000000000 --- a/cli/tunnel.go +++ /dev/null @@ -1,14 +0,0 @@ -package cli - -import "github.com/spf13/cobra" - -func tunnel() *cobra.Command { - return &cobra.Command{ - Annotations: workspaceCommand, - Use: "tunnel", - Short: "Forward ports to your local machine", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - } -} diff --git a/coderd/audit/table.go b/coderd/audit/table.go index 1556d9d3a3909..f7edadbcf21f2 100644 --- a/coderd/audit/table.go +++ b/coderd/audit/table.go @@ -78,7 +78,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff. "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. "name": ActionTrack, - "description": ActionTrack, + "readme": ActionTrack, "job_id": ActionIgnore, // Not helpful in a diff because jobs aren't tracked in audit logs. }, &database.User{}: { diff --git a/coderd/authorize.go b/coderd/authorize.go new file mode 100644 index 0000000000000..69b9cf8c596c9 --- /dev/null +++ b/coderd/authorize.go @@ -0,0 +1,43 @@ +package coderd + +import ( + "net/http" + + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" +) + +func (api *api) Authorize(rw http.ResponseWriter, r *http.Request, action rbac.Action, object rbac.Object) bool { + roles := httpmw.UserRoles(r) + err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object) + if err != nil { + httpapi.Write(rw, http.StatusForbidden, httpapi.Response{ + Message: err.Error(), + }) + + // Log the errors for debugging + internalError := new(rbac.UnauthorizedError) + logger := api.Logger + if xerrors.As(err, internalError) { + logger = api.Logger.With(slog.F("internal", internalError.Internal())) + } + // Log information for debugging. This will be very helpful + // in the early days + logger.Warn(r.Context(), "unauthorized", + slog.F("roles", roles.Roles), + slog.F("user_id", roles.ID), + slog.F("username", roles.Username), + slog.F("route", r.URL.Path), + slog.F("action", action), + slog.F("object", object), + ) + + return false + } + return true +} diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index 3fd1eb7fc28af..f402e7cedcc51 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -57,7 +57,7 @@ func (e *Executor) runOnce(t time.Time) error { for _, ws := range eligibleWorkspaces { // Determine the workspace state based on its latest build. - priorHistory, err := db.GetWorkspaceBuildByWorkspaceIDWithoutAfter(e.ctx, ws.ID) + priorHistory, err := db.GetLatestWorkspaceBuildByWorkspaceID(e.ctx, ws.ID) if err != nil { e.log.Warn(e.ctx, "get latest workspace build", slog.F("workspace_id", ws.ID), @@ -152,12 +152,8 @@ func build(ctx context.Context, store database.Store, workspace database.Workspa return xerrors.Errorf("get workspace template: %w", err) } - priorHistoryID := uuid.NullUUID{ - UUID: priorHistory.ID, - Valid: true, - } + priorBuildNumber := priorHistory.BuildNumber - var newWorkspaceBuild database.WorkspaceBuild // This must happen in a transaction to ensure history can be inserted, and // the prior history can update it's "after" column to point at the new. workspaceBuildID := uuid.New() @@ -186,13 +182,13 @@ func build(ctx context.Context, store database.Store, workspace database.Workspa if err != nil { return xerrors.Errorf("insert provisioner job: %w", err) } - newWorkspaceBuild, err = store.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + _, err = store.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ ID: workspaceBuildID, CreatedAt: now, UpdatedAt: now, WorkspaceID: workspace.ID, TemplateVersionID: priorHistory.TemplateVersionID, - BeforeID: priorHistoryID, + BuildNumber: priorBuildNumber + 1, Name: namesgenerator.GetRandomName(1), ProvisionerState: priorHistory.ProvisionerState, InitiatorID: workspace.OwnerID, @@ -202,21 +198,5 @@ func build(ctx context.Context, store database.Store, workspace database.Workspa if err != nil { return xerrors.Errorf("insert workspace build: %w", err) } - - if priorHistoryID.Valid { - // Update the prior history entries "after" column. - err = store.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ - ID: priorHistory.ID, - ProvisionerState: priorHistory.ProvisionerState, - UpdatedAt: now, - AfterID: uuid.NullUUID{ - UUID: newWorkspaceBuild.ID, - Valid: true, - }, - }) - if err != nil { - return xerrors.Errorf("update prior workspace build: %w", err) - } - } return nil } diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index 445be2d8889c3..38642cd54158b 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -419,10 +419,17 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur") require.Equal(t, codersdk.ProvisionerJobSucceeded, ws.LatestBuild.Job.Status, "expected provisioner job to have succeeded") require.Equal(t, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected latest transition to be start") - builds, err := client.WorkspaceBuilds(ctx, ws.ID) + builds, err := client.WorkspaceBuilds(ctx, codersdk.WorkspaceBuildsRequest{WorkspaceID: ws.ID}) require.NoError(t, err, "fetch list of workspace builds from primary") // One build to start, one stop transition, and one autostart. No more. + require.Equal(t, database.WorkspaceTransitionStart, builds[0].Transition) + require.Equal(t, database.WorkspaceTransitionStop, builds[1].Transition) + require.Equal(t, database.WorkspaceTransitionStart, builds[2].Transition) require.Len(t, builds, 3, "unexpected number of builds for workspace from primary") + + // Builds are returned most recent first. + require.True(t, builds[0].CreatedAt.After(builds[1].CreatedAt)) + require.True(t, builds[1].CreatedAt.After(builds[2].CreatedAt)) } func mustProvisionWorkspace(t *testing.T, client *codersdk.Client) codersdk.Workspace { diff --git a/coderd/autobuild/schedule/schedule_test.go b/coderd/autobuild/schedule/schedule_test.go index 0dd9884fb3b2a..0af54e04a3e0c 100644 --- a/coderd/autobuild/schedule/schedule_test.go +++ b/coderd/autobuild/schedule/schedule_test.go @@ -12,31 +12,34 @@ import ( func Test_Weekly(t *testing.T) { t.Parallel() testCases := []struct { - name string - spec string - at time.Time - expectedNext time.Time - expectedError string - expectedCron string - expectedTz string + name string + spec string + at time.Time + expectedNext time.Time + expectedError string + expectedCron string + expectedTz string + expectedString string }{ { - name: "with timezone", - spec: "CRON_TZ=US/Central 30 9 * * 1-5", - at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC), - expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC), - expectedError: "", - expectedCron: "30 9 * * 1-5", - expectedTz: "US/Central", + name: "with timezone", + spec: "CRON_TZ=US/Central 30 9 * * 1-5", + at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC), + expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC), + expectedError: "", + expectedCron: "30 9 * * 1-5", + expectedTz: "US/Central", + expectedString: "CRON_TZ=US/Central 30 9 * * 1-5", }, { - name: "without timezone", - spec: "CRON_TZ=UTC 30 9 * * 1-5", - at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.Local), - expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.Local), - expectedError: "", - expectedCron: "30 9 * * 1-5", - expectedTz: "UTC", + name: "without timezone", + spec: "30 9 * * 1-5", + at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.UTC), + expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.UTC), + expectedError: "", + expectedCron: "30 9 * * 1-5", + expectedTz: "UTC", + expectedString: "CRON_TZ=UTC 30 9 * * 1-5", }, { name: "invalid schedule", @@ -91,9 +94,9 @@ func Test_Weekly(t *testing.T) { nextTime := actual.Next(testCase.at) require.NoError(t, err) require.Equal(t, testCase.expectedNext, nextTime) - require.Equal(t, testCase.spec, actual.String()) require.Equal(t, testCase.expectedCron, actual.Cron()) require.Equal(t, testCase.expectedTz, actual.Timezone()) + require.Equal(t, testCase.expectedString, actual.String()) } else { require.EqualError(t, err, testCase.expectedError) require.Nil(t, actual) diff --git a/coderd/coderd.go b/coderd/coderd.go index 27e5a667cb75f..1f1a4ff18dfac 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -50,7 +50,7 @@ type Options struct { SecureAuthCookie bool SSHKeygenAlgorithm gitsshkey.Algorithm TURNServer *turnconn.Server - Authorizer *rbac.RegoAuthorizer + Authorizer rbac.Authorizer } // New constructs the Coder API into an HTTP handler. @@ -83,10 +83,6 @@ func New(options *Options) (http.Handler, func()) { // TODO: @emyrk we should just move this into 'ExtractAPIKey'. authRolesMiddleware := httpmw.ExtractUserRoles(options.Database) - authorize := func(f http.HandlerFunc, actions rbac.Action) http.HandlerFunc { - return httpmw.Authorize(api.Logger, api.Authorizer, actions)(f).ServeHTTP - } - r := chi.NewRouter() r.Use( @@ -158,10 +154,7 @@ func New(options *Options) (http.Handler, func()) { }) }) r.Route("/members", func(r chi.Router) { - r.Route("/roles", func(r chi.Router) { - r.Use(httpmw.WithRBACObject(rbac.ResourceUserRole)) - r.Get("/", authorize(api.assignableOrgRoles, rbac.ActionRead)) - }) + r.Get("/roles", api.assignableOrgRoles) r.Route("/{user}", func(r chi.Router) { r.Use( httpmw.ExtractUserParam(options.Database), @@ -182,8 +175,8 @@ func New(options *Options) (http.Handler, func()) { r.Use( apiKeyMiddleware, httpmw.ExtractTemplateParam(options.Database), - httpmw.ExtractOrganizationParam(options.Database), ) + r.Get("/", api.template) r.Delete("/", api.deleteTemplate) r.Route("/versions", func(r chi.Router) { @@ -196,7 +189,6 @@ func New(options *Options) (http.Handler, func()) { r.Use( apiKeyMiddleware, httpmw.ExtractTemplateVersionParam(options.Database), - httpmw.ExtractOrganizationParam(options.Database), ) r.Get("/", api.templateVersion) @@ -232,8 +224,7 @@ func New(options *Options) (http.Handler, func()) { r.Get("/", api.users) // These routes query information about site wide roles. r.Route("/roles", func(r chi.Router) { - r.Use(httpmw.WithRBACObject(rbac.ResourceUserRole)) - r.Get("/", authorize(api.assignableSiteRoles, rbac.ActionRead)) + r.Get("/", api.assignableSiteRoles) }) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) @@ -244,8 +235,7 @@ func New(options *Options) (http.Handler, func()) { r.Put("/active", api.putUserStatus(database.UserStatusActive)) }) r.Route("/password", func(r chi.Router) { - r.Use(httpmw.WithRBACObject(rbac.ResourceUserPasswordRole)) - r.Put("/", authorize(api.putUserPassword, rbac.ActionUpdate)) + r.Put("/", api.putUserPassword) }) r.Get("/organizations", api.organizationsByUser) r.Post("/organizations", api.postOrganizationsByUser) @@ -263,7 +253,6 @@ func New(options *Options) (http.Handler, func()) { }) r.Get("/gitsshkey", api.gitSSHKey) r.Put("/gitsshkey", api.regenerateGitSSHKey) - r.Get("/workspaces", api.workspacesByUser) }) }) }) @@ -299,22 +288,28 @@ func New(options *Options) (http.Handler, func()) { ) r.Get("/", api.workspaceResource) }) - r.Route("/workspaces/{workspace}", func(r chi.Router) { + r.Route("/workspaces", func(r chi.Router) { r.Use( apiKeyMiddleware, - httpmw.ExtractWorkspaceParam(options.Database), + authRolesMiddleware, ) - r.Get("/", api.workspace) - r.Route("/builds", func(r chi.Router) { - r.Get("/", api.workspaceBuilds) - r.Post("/", api.postWorkspaceBuilds) - r.Get("/{workspacebuildname}", api.workspaceBuildByName) - }) - r.Route("/autostart", func(r chi.Router) { - r.Put("/", api.putWorkspaceAutostart) - }) - r.Route("/autostop", func(r chi.Router) { - r.Put("/", api.putWorkspaceAutostop) + r.Get("/", api.workspaces) + r.Route("/{workspace}", func(r chi.Router) { + r.Use( + httpmw.ExtractWorkspaceParam(options.Database), + ) + r.Get("/", api.workspace) + r.Route("/builds", func(r chi.Router) { + r.Get("/", api.workspaceBuilds) + r.Post("/", api.postWorkspaceBuilds) + r.Get("/{workspacebuildname}", api.workspaceBuildByName) + }) + r.Route("/autostart", func(r chi.Router) { + r.Put("/", api.putWorkspaceAutostart) + }) + r.Route("/autostop", func(r chi.Router) { + r.Put("/", api.putWorkspaceAutostop) + }) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 73d3c3d308def..cc34d89ed6bee 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -2,14 +2,19 @@ package coderd_test import ( "context" + "net/http" + "strings" "testing" - "go.uber.org/goleak" - + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "golang.org/x/xerrors" "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/rbac" ) func TestMain(m *testing.M) { @@ -24,3 +29,205 @@ func TestBuildInfo(t *testing.T) { require.Equal(t, buildinfo.ExternalURL(), buildInfo.ExternalURL, "external URL") require.Equal(t, buildinfo.Version(), buildInfo.Version, "version") } + +// TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered. +func TestAuthorizeAllEndpoints(t *testing.T) { + t.Parallel() + + authorizer := &fakeAuthorizer{} + srv, client := coderdtest.NewMemoryCoderd(t, &coderdtest.Options{ + Authorizer: authorizer, + }) + admin := coderdtest.CreateFirstUser(t, client) + organization, err := client.Organization(context.Background(), admin.OrganizationID) + require.NoError(t, err, "fetch org") + + // Setup some data in the database. + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, admin.OrganizationID, template.ID) + + // Always fail auth from this point forward + authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil) + + // skipRoutes allows skipping routes from being checked. + type routeCheck struct { + NoAuthorize bool + AssertObject rbac.Object + StatusCode int + } + assertRoute := map[string]routeCheck{ + // These endpoints do not require auth + "GET:/api/v2": {NoAuthorize: true}, + "GET:/api/v2/buildinfo": {NoAuthorize: true}, + "GET:/api/v2/users/first": {NoAuthorize: true}, + "POST:/api/v2/users/first": {NoAuthorize: true}, + "POST:/api/v2/users/login": {NoAuthorize: true}, + "POST:/api/v2/users/logout": {NoAuthorize: true}, + "GET:/api/v2/users/authmethods": {NoAuthorize: true}, + + // All workspaceagents endpoints do not use rbac + "POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true}, + "POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true}, + "POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/iceservers": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/listen": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/{workspaceagent}": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/{workspaceagent}/dial": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/{workspaceagent}/pty": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/{workspaceagent}/turn": {NoAuthorize: true}, + + // TODO: @emyrk these need to be fixed by adding authorize calls + "GET:/api/v2/workspaceresources/{workspaceresource}": {NoAuthorize: true}, + "GET:/api/v2/workspacebuilds/{workspacebuild}": {NoAuthorize: true}, + "GET:/api/v2/workspacebuilds/{workspacebuild}/logs": {NoAuthorize: true}, + "GET:/api/v2/workspacebuilds/{workspacebuild}/resources": {NoAuthorize: true}, + "GET:/api/v2/workspacebuilds/{workspacebuild}/state": {NoAuthorize: true}, + "PATCH:/api/v2/workspacebuilds/{workspacebuild}/cancel": {NoAuthorize: true}, + "GET:/api/v2/workspaces/{workspace}/builds/{workspacebuildname}": {NoAuthorize: true}, + + "GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true}, + + "PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true}, + "GET:/api/v2/organizations/{organization}/provisionerdaemons": {NoAuthorize: true}, + "POST:/api/v2/organizations/{organization}/templates": {NoAuthorize: true}, + "GET:/api/v2/organizations/{organization}/templates": {NoAuthorize: true}, + "GET:/api/v2/organizations/{organization}/templates/{templatename}": {NoAuthorize: true}, + "POST:/api/v2/organizations/{organization}/templateversions": {NoAuthorize: true}, + "POST:/api/v2/organizations/{organization}/workspaces": {NoAuthorize: true}, + + "POST:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true}, + "GET:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true}, + "DELETE:/api/v2/parameters/{scope}/{id}/{name}": {NoAuthorize: true}, + + "GET:/api/v2/provisionerdaemons/me/listen": {NoAuthorize: true}, + + "DELETE:/api/v2/templates/{template}": {NoAuthorize: true}, + "GET:/api/v2/templates/{template}": {NoAuthorize: true}, + "GET:/api/v2/templates/{template}/versions": {NoAuthorize: true}, + "PATCH:/api/v2/templates/{template}/versions": {NoAuthorize: true}, + "GET:/api/v2/templates/{template}/versions/{templateversionname}": {NoAuthorize: true}, + + "GET:/api/v2/templateversions/{templateversion}": {NoAuthorize: true}, + "PATCH:/api/v2/templateversions/{templateversion}/cancel": {NoAuthorize: true}, + "GET:/api/v2/templateversions/{templateversion}/logs": {NoAuthorize: true}, + "GET:/api/v2/templateversions/{templateversion}/parameters": {NoAuthorize: true}, + "GET:/api/v2/templateversions/{templateversion}/resources": {NoAuthorize: true}, + "GET:/api/v2/templateversions/{templateversion}/schema": {NoAuthorize: true}, + + "POST:/api/v2/users/{user}/organizations": {NoAuthorize: true}, + + "GET:/api/v2/workspaces/{workspace}": {NoAuthorize: true}, + "PUT:/api/v2/workspaces/{workspace}/autostart": {NoAuthorize: true}, + "PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true}, + "GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, + "POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, + + "POST:/api/v2/files": {NoAuthorize: true}, + "GET:/api/v2/files/{hash}": {NoAuthorize: true}, + + // These endpoints have more assertions. This is good, add more endpoints to assert if you can! + "GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(admin.OrganizationID)}, + "GET:/api/v2/users/{user}/organizations": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceOrganization}, + "GET:/api/v2/users/{user}/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, + "GET:/api/v2/organizations/{organization}/workspaces/{user}": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, + "GET:/api/v2/organizations/{organization}/workspaces/{user}/{workspace}": { + AssertObject: rbac.ResourceWorkspace.InOrg(organization.ID).WithID(workspace.ID.String()).WithOwner(workspace.OwnerID.String()), + }, + "GET:/api/v2/organizations/{organization}/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, + "GET:/api/v2/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, + + // These endpoints need payloads to get to the auth part. + "PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + } + + for k, v := range assertRoute { + noTrailSlash := strings.TrimRight(k, "/") + if _, ok := assertRoute[noTrailSlash]; ok && noTrailSlash != k { + t.Errorf("route %q & %q is declared twice", noTrailSlash, k) + t.FailNow() + } + assertRoute[noTrailSlash] = v + } + + c, _ := srv.Config.Handler.(*chi.Mux) + err = chi.Walk(c, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + name := method + ":" + route + t.Run(name, func(t *testing.T) { + authorizer.reset() + routeAssertions, ok := assertRoute[strings.TrimRight(name, "/")] + if !ok { + // By default, all omitted routes check for just "authorize" called + routeAssertions = routeCheck{} + } + if routeAssertions.StatusCode == 0 { + routeAssertions.StatusCode = http.StatusForbidden + } + + // Replace all url params with known values + route = strings.ReplaceAll(route, "{organization}", admin.OrganizationID.String()) + route = strings.ReplaceAll(route, "{user}", admin.UserID.String()) + route = strings.ReplaceAll(route, "{organizationname}", organization.Name) + route = strings.ReplaceAll(route, "{workspace}", workspace.Name) + + resp, err := client.Request(context.Background(), method, route, nil) + require.NoError(t, err, "do req") + _ = resp.Body.Close() + + if !routeAssertions.NoAuthorize { + assert.NotNil(t, authorizer.Called, "authorizer expected") + assert.Equal(t, routeAssertions.StatusCode, resp.StatusCode, "expect unauthorized") + if authorizer.Called != nil { + if routeAssertions.AssertObject.Type != "" { + assert.Equal(t, routeAssertions.AssertObject.Type, authorizer.Called.Object.Type, "resource type") + } + if routeAssertions.AssertObject.Owner != "" { + assert.Equal(t, routeAssertions.AssertObject.Owner, authorizer.Called.Object.Owner, "resource owner") + } + if routeAssertions.AssertObject.OrgID != "" { + assert.Equal(t, routeAssertions.AssertObject.OrgID, authorizer.Called.Object.OrgID, "resource org") + } + if routeAssertions.AssertObject.ResourceID != "" { + assert.Equal(t, routeAssertions.AssertObject.ResourceID, authorizer.Called.Object.ResourceID, "resource ID") + } + } + } else { + assert.Nil(t, authorizer.Called, "authorize not expected") + } + }) + return nil + }) + require.NoError(t, err) +} + +type authCall struct { + SubjectID string + Roles []string + Action rbac.Action + Object rbac.Object +} + +type fakeAuthorizer struct { + Called *authCall + AlwaysReturn error +} + +func (f *fakeAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error { + f.Called = &authCall{ + SubjectID: subjectID, + Roles: roleNames, + Action: action, + Object: object, + } + return f.AlwaysReturn +} + +func (f *fakeAuthorizer) reset() { + f.Called = nil +} diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index f2cf11cfc02ae..117e96e6ac04f 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -56,6 +56,7 @@ import ( type Options struct { AWSCertificates awsidentity.Certificates + Authorizer rbac.Authorizer AzureCertificates x509.VerifyOptions GithubOAuth2Config *coderd.GithubOAuth2Config GoogleTokenValidator *idtoken.Validator @@ -66,7 +67,7 @@ type Options struct { // New constructs an in-memory coderd instance and returns // the connected client. -func New(t *testing.T, options *Options) *codersdk.Client { +func NewMemoryCoderd(t *testing.T, options *Options) (*httptest.Server, *codersdk.Client) { if options == nil { options = &Options{} } @@ -147,6 +148,7 @@ func New(t *testing.T, options *Options) *codersdk.Client { SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, TURNServer: turnServer, APIRateLimit: options.APIRateLimit, + Authorizer: options.Authorizer, }) t.Cleanup(func() { cancelFunc() @@ -155,7 +157,14 @@ func New(t *testing.T, options *Options) *codersdk.Client { closeWait() }) - return codersdk.New(serverURL) + return srv, codersdk.New(serverURL) +} + +// New constructs an in-memory coderd instance and returns +// the connected client. +func New(t *testing.T, options *Options) *codersdk.Client { + _, cli := NewMemoryCoderd(t, options) + return cli } // NewProvisionerDaemon launches a provisionerd instance configured to work @@ -252,9 +261,8 @@ func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uui for _, r := range user.Roles { siteRoles = append(siteRoles, r.Name) } - // TODO: @emyrk switch "other" to "client" when we support updating other - // users. - _, err := other.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: siteRoles}) + + _, err := client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: siteRoles}) require.NoError(t, err, "update site roles") // Update org roles @@ -287,6 +295,20 @@ func CreateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID return templateVersion } +// CreateWorkspaceBuild creates a workspace build for the given workspace and transition. +func CreateWorkspaceBuild( + t *testing.T, + client *codersdk.Client, + workspace codersdk.Workspace, + transition database.WorkspaceTransition) codersdk.WorkspaceBuild { + req := codersdk.CreateWorkspaceBuildRequest{ + Transition: transition, + } + build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, req) + require.NoError(t, err) + return build +} + // CreateTemplate creates a template with the "echo" provisioner for // compatibility with testing. The name assigned is randomly generated. func CreateTemplate(t *testing.T, client *codersdk.Client, organization uuid.UUID, version uuid.UUID) codersdk.Template { diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 8455f522cc605..e876a54fdad55 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -6,6 +6,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/google/uuid" "golang.org/x/exp/slices" @@ -290,6 +291,27 @@ func (q *fakeQuerier) GetAllUserRoles(_ context.Context, userID uuid.UUID) (data }, nil } +func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.GetWorkspacesWithFilterParams) ([]database.Workspace, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspaces { + if arg.OrganizationID != uuid.Nil && workspace.OrganizationID != arg.OrganizationID { + continue + } + if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID { + continue + } + if !arg.Deleted && workspace.Deleted { + continue + } + workspaces = append(workspaces, workspace) + } + + return workspaces, nil +} + func (q *fakeQuerier) GetWorkspacesByTemplateID(_ context.Context, arg database.GetWorkspacesByTemplateIDParams) ([]database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -419,50 +441,100 @@ func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUI return database.WorkspaceBuild{}, sql.ErrNoRows } -func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() + var row database.WorkspaceBuild + var buildNum int32 for _, workspaceBuild := range q.workspaceBuilds { - if workspaceBuild.WorkspaceID.String() != workspaceID.String() { - continue - } - if !workspaceBuild.AfterID.Valid { - return workspaceBuild, nil + if workspaceBuild.WorkspaceID.String() == workspaceID.String() && workspaceBuild.BuildNumber > buildNum { + row = workspaceBuild + buildNum = workspaceBuild.BuildNumber } } - return database.WorkspaceBuild{}, sql.ErrNoRows + if buildNum == 0 { + return database.WorkspaceBuild{}, sql.ErrNoRows + } + return row, nil } -func (q *fakeQuerier) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() - builds := make([]database.WorkspaceBuild, 0) + builds := make(map[uuid.UUID]database.WorkspaceBuild) + buildNumbers := make(map[uuid.UUID]int32) for _, workspaceBuild := range q.workspaceBuilds { for _, id := range ids { - if id.String() != workspaceBuild.WorkspaceID.String() { - continue + if id.String() == workspaceBuild.WorkspaceID.String() && workspaceBuild.BuildNumber > buildNumbers[id] { + builds[id] = workspaceBuild + buildNumbers[id] = workspaceBuild.BuildNumber } - builds = append(builds, workspaceBuild) } } - if len(builds) == 0 { + var returnBuilds []database.WorkspaceBuild + for i, n := range buildNumbers { + if n > 0 { + b := builds[i] + returnBuilds = append(returnBuilds, b) + } + } + if len(returnBuilds) == 0 { return nil, sql.ErrNoRows } - return builds, nil + return returnBuilds, nil } -func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceID(_ context.Context, workspaceID uuid.UUID) ([]database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceID(_ context.Context, + params database.GetWorkspaceBuildByWorkspaceIDParams) ([]database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() history := make([]database.WorkspaceBuild, 0) for _, workspaceBuild := range q.workspaceBuilds { - if workspaceBuild.WorkspaceID.String() == workspaceID.String() { + if workspaceBuild.WorkspaceID.String() == params.WorkspaceID.String() { history = append(history, workspaceBuild) } } + + // Order by build_number + slices.SortFunc(history, func(a, b database.WorkspaceBuild) bool { + // use greater than since we want descending order + return a.BuildNumber > b.BuildNumber + }) + + if params.AfterID != uuid.Nil { + found := false + for i, v := range history { + if v.ID == params.AfterID { + // We want to return all builds after index i. + history = history[i+1:] + found = true + break + } + } + + // If no builds after the time, then we return an empty list. + if !found { + return nil, sql.ErrNoRows + } + } + + if params.OffsetOpt > 0 { + if int(params.OffsetOpt) > len(history)-1 { + return nil, sql.ErrNoRows + } + history = history[params.OffsetOpt:] + } + + if params.LimitOpt > 0 { + if int(params.LimitOpt) > len(history) { + params.LimitOpt = int32(len(history)) + } + history = history[:params.LimitOpt] + } + if len(history) == 0 { return nil, sql.ErrNoRows } @@ -485,26 +557,6 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndName(_ context.Context, a return database.WorkspaceBuild{}, sql.ErrNoRows } -func (q *fakeQuerier) GetWorkspacesByOrganizationID(_ context.Context, req database.GetWorkspacesByOrganizationIDParams) ([]database.Workspace, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - workspaces := make([]database.Workspace, 0) - for _, workspace := range q.workspaces { - if workspace.OrganizationID != req.OrganizationID { - continue - } - if workspace.Deleted != req.Deleted { - continue - } - workspaces = append(workspaces, workspace) - } - if len(workspaces) == 0 { - return nil, sql.ErrNoRows - } - return workspaces, nil -} - func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req database.GetWorkspacesByOrganizationIDsParams) ([]database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -524,23 +576,6 @@ func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req data return workspaces, nil } -func (q *fakeQuerier) GetWorkspacesByOwnerID(_ context.Context, req database.GetWorkspacesByOwnerIDParams) ([]database.Workspace, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - workspaces := make([]database.Workspace, 0) - for _, workspace := range q.workspaces { - if workspace.OwnerID != req.OwnerID { - continue - } - if workspace.Deleted != req.Deleted { - continue - } - workspaces = append(workspaces, workspace) - } - return workspaces, nil -} - func (q *fakeQuerier) GetOrganizations(_ context.Context) ([]database.Organization, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1189,7 +1224,7 @@ func (q *fakeQuerier) InsertTemplateVersion(_ context.Context, arg database.Inse CreatedAt: arg.CreatedAt, UpdatedAt: arg.UpdatedAt, Name: arg.Name, - Description: arg.Description, + Readme: arg.Readme, JobID: arg.JobID, } q.templateVersions = append(q.templateVersions, version) @@ -1444,7 +1479,7 @@ func (q *fakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.Inser WorkspaceID: arg.WorkspaceID, Name: arg.Name, TemplateVersionID: arg.TemplateVersionID, - BeforeID: arg.BeforeID, + BuildNumber: arg.BuildNumber, Transition: arg.Transition, InitiatorID: arg.InitiatorID, JobID: arg.JobID, @@ -1478,7 +1513,7 @@ func (q *fakeQuerier) UpdateTemplateActiveVersionByID(_ context.Context, arg dat defer q.mutex.Unlock() for index, template := range q.templates { - if template.ID.String() != arg.ID.String() { + if template.ID != arg.ID { continue } template.ActiveVersionID = arg.ActiveVersionID @@ -1493,7 +1528,7 @@ func (q *fakeQuerier) UpdateTemplateDeletedByID(_ context.Context, arg database. defer q.mutex.Unlock() for index, template := range q.templates { - if template.ID.String() != arg.ID.String() { + if template.ID != arg.ID { continue } template.Deleted = arg.Deleted @@ -1508,7 +1543,7 @@ func (q *fakeQuerier) UpdateTemplateVersionByID(_ context.Context, arg database. defer q.mutex.Unlock() for index, templateVersion := range q.templateVersions { - if templateVersion.ID.String() != arg.ID.String() { + if templateVersion.ID != arg.ID { continue } templateVersion.TemplateID = arg.TemplateID @@ -1519,12 +1554,28 @@ func (q *fakeQuerier) UpdateTemplateVersionByID(_ context.Context, arg database. return sql.ErrNoRows } +func (q *fakeQuerier) UpdateTemplateVersionDescriptionByJobID(_ context.Context, arg database.UpdateTemplateVersionDescriptionByJobIDParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, templateVersion := range q.templateVersions { + if templateVersion.JobID != arg.JobID { + continue + } + templateVersion.Readme = arg.Readme + templateVersion.UpdatedAt = time.Now() + q.templateVersions[index] = templateVersion + return nil + } + return sql.ErrNoRows +} + func (q *fakeQuerier) UpdateProvisionerDaemonByID(_ context.Context, arg database.UpdateProvisionerDaemonByIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() for index, daemon := range q.provisionerDaemons { - if arg.ID.String() != daemon.ID.String() { + if arg.ID != daemon.ID { continue } daemon.UpdatedAt = arg.UpdatedAt @@ -1540,7 +1591,7 @@ func (q *fakeQuerier) UpdateWorkspaceAgentConnectionByID(_ context.Context, arg defer q.mutex.Unlock() for index, agent := range q.provisionerJobAgents { - if agent.ID.String() != arg.ID.String() { + if agent.ID != arg.ID { continue } agent.FirstConnectedAt = arg.FirstConnectedAt @@ -1557,7 +1608,7 @@ func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.U defer q.mutex.Unlock() for index, job := range q.provisionerJobs { - if arg.ID.String() != job.ID.String() { + if arg.ID != job.ID { continue } job.UpdatedAt = arg.UpdatedAt @@ -1572,7 +1623,7 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCancelByID(_ context.Context, arg defer q.mutex.Unlock() for index, job := range q.provisionerJobs { - if arg.ID.String() != job.ID.String() { + if arg.ID != job.ID { continue } job.CanceledAt = arg.CanceledAt @@ -1587,7 +1638,7 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar defer q.mutex.Unlock() for index, job := range q.provisionerJobs { - if arg.ID.String() != job.ID.String() { + if arg.ID != job.ID { continue } job.UpdatedAt = arg.UpdatedAt @@ -1604,7 +1655,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.U defer q.mutex.Unlock() for index, workspace := range q.workspaces { - if workspace.ID.String() != arg.ID.String() { + if workspace.ID != arg.ID { continue } workspace.AutostartSchedule = arg.AutostartSchedule @@ -1620,7 +1671,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.Up defer q.mutex.Unlock() for index, workspace := range q.workspaces { - if workspace.ID.String() != arg.ID.String() { + if workspace.ID != arg.ID { continue } workspace.AutostopSchedule = arg.AutostopSchedule @@ -1636,11 +1687,10 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U defer q.mutex.Unlock() for index, workspaceBuild := range q.workspaceBuilds { - if workspaceBuild.ID.String() != arg.ID.String() { + if workspaceBuild.ID != arg.ID { continue } workspaceBuild.UpdatedAt = arg.UpdatedAt - workspaceBuild.AfterID = arg.AfterID workspaceBuild.ProvisionerState = arg.ProvisionerState q.workspaceBuilds[index] = workspaceBuild return nil @@ -1653,7 +1703,7 @@ func (q *fakeQuerier) UpdateWorkspaceDeletedByID(_ context.Context, arg database defer q.mutex.Unlock() for index, workspace := range q.workspaces { - if workspace.ID.String() != arg.ID.String() { + if workspace.ID != arg.ID { continue } workspace.Deleted = arg.Deleted diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 89ca22acab100..f1041e3519475 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -234,7 +234,7 @@ CREATE TABLE template_versions ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, name character varying(64) NOT NULL, - description character varying(1048576) NOT NULL, + readme character varying(1048576) NOT NULL, job_id uuid NOT NULL ); @@ -288,8 +288,7 @@ CREATE TABLE workspace_builds ( workspace_id uuid NOT NULL, template_version_id uuid NOT NULL, name character varying(64) NOT NULL, - before_id uuid, - after_id uuid, + build_number integer NOT NULL, transition workspace_transition NOT NULL, initiator_id uuid NOT NULL, provisioner_state bytea, @@ -389,6 +388,9 @@ ALTER TABLE ONLY workspace_builds ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_pkey PRIMARY KEY (id); +ALTER TABLE ONLY workspace_builds + ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number); + ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_name_key UNIQUE (workspace_id, name); diff --git a/coderd/database/migrations/000004_jobs.up.sql b/coderd/database/migrations/000004_jobs.up.sql index d1c6633f0996e..cc87d95b95b59 100644 --- a/coderd/database/migrations/000004_jobs.up.sql +++ b/coderd/database/migrations/000004_jobs.up.sql @@ -165,8 +165,7 @@ CREATE TABLE workspace_builds ( workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, template_version_id uuid NOT NULL REFERENCES template_versions (id) ON DELETE CASCADE, name varchar(64) NOT NULL, - before_id uuid, - after_id uuid, + build_number integer NOT NULL, transition workspace_transition NOT NULL, initiator_id uuid NOT NULL, -- State stored by the provisioner @@ -174,5 +173,6 @@ CREATE TABLE workspace_builds ( -- Job ID of the action job_id uuid NOT NULL UNIQUE REFERENCES provisioner_jobs (id) ON DELETE CASCADE, PRIMARY KEY (id), - UNIQUE(workspace_id, name) + UNIQUE(workspace_id, name), + UNIQUE(workspace_id, build_number) ); diff --git a/coderd/database/migrations/000012_template_version_readme.down.sql b/coderd/database/migrations/000012_template_version_readme.down.sql new file mode 100644 index 0000000000000..9f090f164993b --- /dev/null +++ b/coderd/database/migrations/000012_template_version_readme.down.sql @@ -0,0 +1 @@ +ALTER TABLE template_versions RENAME README TO description; diff --git a/coderd/database/migrations/000012_template_version_readme.up.sql b/coderd/database/migrations/000012_template_version_readme.up.sql new file mode 100644 index 0000000000000..684b3b90a715e --- /dev/null +++ b/coderd/database/migrations/000012_template_version_readme.up.sql @@ -0,0 +1 @@ +ALTER TABLE template_versions RENAME description TO readme; diff --git a/coderd/database/models.go b/coderd/database/models.go index 1fc101a16ee1a..a705a02410411 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -446,7 +446,7 @@ type TemplateVersion struct { CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` - Description string `db:"description" json:"description"` + Readme string `db:"readme" json:"readme"` JobID uuid.UUID `db:"job_id" json:"job_id"` } @@ -501,8 +501,7 @@ type WorkspaceBuild struct { WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` Name string `db:"name" json:"name"` - BeforeID uuid.NullUUID `db:"before_id" json:"before_id"` - AfterID uuid.NullUUID `db:"after_id" json:"after_id"` + BuildNumber int32 `db:"build_number" json:"build_number"` Transition WorkspaceTransition `db:"transition" json:"transition"` InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` diff --git a/coderd/database/postgres/postgres_test.go b/coderd/database/postgres/postgres_test.go index 178a434c3be69..d11fdb21e89c0 100644 --- a/coderd/database/postgres/postgres_test.go +++ b/coderd/database/postgres/postgres_test.go @@ -17,8 +17,10 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain(m) } +// nolint:paralleltest func TestPostgres(t *testing.T) { - t.Parallel() + // postgres.Open() seems to be creating race conditions when run in parallel. + // t.Parallel() if testing.Short() { t.Skip() diff --git a/coderd/database/pubsub_test.go b/coderd/database/pubsub_test.go index 1312326a20a73..73bd96dd1597a 100644 --- a/coderd/database/pubsub_test.go +++ b/coderd/database/pubsub_test.go @@ -22,8 +22,10 @@ func TestPubsub(t *testing.T) { return } + // nolint:paralleltest t.Run("Postgres", func(t *testing.T) { - t.Parallel() + // postgres.Open() seems to be creating race conditions when run in parallel. + // t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() @@ -52,8 +54,10 @@ func TestPubsub(t *testing.T) { assert.Equal(t, string(message), data) }) + // nolint:paralleltest t.Run("PostgresCloseCancel", func(t *testing.T) { - t.Parallel() + // postgres.Open() seems to be creating race conditions when run in parallel. + // t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() connectionURL, closePg, err := postgres.Open() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9787aca37e017..387a2c9a06698 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -27,6 +27,8 @@ type querier interface { GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBeforeParams) ([]AuditLog, error) GetFileByHash(ctx context.Context, hash string) (File, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) + GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) + GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) 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) @@ -61,20 +63,17 @@ type querier interface { GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) - GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceBuild, error) + GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDParams) ([]WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndNameParams) (WorkspaceBuild, error) - GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) - GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error) - GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error) - GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) + GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) @@ -103,6 +102,7 @@ type querier interface { UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error + UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d4abbefd040f5..5985afbf33d65 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1830,7 +1830,7 @@ func (q *sqlQuerier) UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTe const getTemplateVersionByID = `-- name: GetTemplateVersionByID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, description, job_id + id, template_id, organization_id, created_at, updated_at, name, readme, job_id FROM template_versions WHERE @@ -1847,7 +1847,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Description, + &i.Readme, &i.JobID, ) return i, err @@ -1855,7 +1855,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( const getTemplateVersionByJobID = `-- name: GetTemplateVersionByJobID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, description, job_id + id, template_id, organization_id, created_at, updated_at, name, readme, job_id FROM template_versions WHERE @@ -1872,7 +1872,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Description, + &i.Readme, &i.JobID, ) return i, err @@ -1880,7 +1880,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U const getTemplateVersionByTemplateIDAndName = `-- name: GetTemplateVersionByTemplateIDAndName :one SELECT - id, template_id, organization_id, created_at, updated_at, name, description, job_id + id, template_id, organization_id, created_at, updated_at, name, readme, job_id FROM template_versions WHERE @@ -1903,7 +1903,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Description, + &i.Readme, &i.JobID, ) return i, err @@ -1911,7 +1911,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, const getTemplateVersionsByTemplateID = `-- name: GetTemplateVersionsByTemplateID :many SELECT - id, template_id, organization_id, created_at, updated_at, name, description, job_id + id, template_id, organization_id, created_at, updated_at, name, readme, job_id FROM template_versions WHERE @@ -1972,7 +1972,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Description, + &i.Readme, &i.JobID, ); err != nil { return nil, err @@ -1997,11 +1997,11 @@ INSERT INTO created_at, updated_at, "name", - description, + readme, job_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, template_id, organization_id, created_at, updated_at, name, description, job_id + ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, template_id, organization_id, created_at, updated_at, name, readme, job_id ` type InsertTemplateVersionParams struct { @@ -2011,7 +2011,7 @@ type InsertTemplateVersionParams struct { CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` - Description string `db:"description" json:"description"` + Readme string `db:"readme" json:"readme"` JobID uuid.UUID `db:"job_id" json:"job_id"` } @@ -2023,7 +2023,7 @@ func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTempla arg.CreatedAt, arg.UpdatedAt, arg.Name, - arg.Description, + arg.Readme, arg.JobID, ) var i TemplateVersion @@ -2034,7 +2034,7 @@ func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTempla &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Description, + &i.Readme, &i.JobID, ) return i, err @@ -2061,6 +2061,26 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe return err } +const updateTemplateVersionDescriptionByJobID = `-- name: UpdateTemplateVersionDescriptionByJobID :exec +UPDATE + template_versions +SET + readme = $2, + updated_at = now() +WHERE + job_id = $1 +` + +type UpdateTemplateVersionDescriptionByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + Readme string `db:"readme" json:"readme"` +} + +func (q *sqlQuerier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionDescriptionByJobID, arg.JobID, arg.Readme) + return err +} + const getAllUserRoles = `-- name: GetAllUserRoles :one SELECT -- username is returned just to help for logging purposes @@ -2729,50 +2749,21 @@ func (q *sqlQuerier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg return err } -const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one +const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id + id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id FROM workspace_builds WHERE - id = $1 -LIMIT - 1 -` - -func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByID, id) - var i WorkspaceBuild - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.Name, - &i.BeforeID, - &i.AfterID, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - ) - return i, err -} - -const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one -SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id -FROM - workspace_builds -WHERE - job_id = $1 + workspace_id = $1 +ORDER BY + build_number desc LIMIT 1 ` -func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByJobID, jobID) +func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) { + row := q.db.QueryRowContext(ctx, getLatestWorkspaceBuildByWorkspaceID, workspaceID) var i WorkspaceBuild err := row.Scan( &i.ID, @@ -2781,8 +2772,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BeforeID, - &i.AfterID, + &i.BuildNumber, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2791,17 +2781,25 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU return i, err } -const getWorkspaceBuildByWorkspaceID = `-- name: GetWorkspaceBuildByWorkspaceID :many -SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id -FROM - workspace_builds -WHERE - workspace_id = $1 -` - -func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaceBuildByWorkspaceID, workspaceID) +const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.name, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id +FROM ( + SELECT + workspace_id, MAX(build_number) as max_build_number + FROM + workspace_builds + WHERE + workspace_id = ANY($1 :: uuid [ ]) + GROUP BY + workspace_id +) m +JOIN + workspace_builds wb +ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number +` + +func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) { + rows, err := q.db.QueryContext(ctx, getLatestWorkspaceBuildsByWorkspaceIDs, pq.Array(ids)) if err != nil { return nil, err } @@ -2816,8 +2814,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspa &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BeforeID, - &i.AfterID, + &i.BuildNumber, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2836,23 +2833,19 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspa return items, nil } -const getWorkspaceBuildByWorkspaceIDAndName = `-- name: GetWorkspaceBuildByWorkspaceIDAndName :one +const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id + id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id FROM workspace_builds WHERE - workspace_id = $1 - AND "name" = $2 + id = $1 +LIMIT + 1 ` -type GetWorkspaceBuildByWorkspaceIDAndNameParams struct { - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - Name string `db:"name" json:"name"` -} - -func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndNameParams) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByWorkspaceIDAndName, arg.WorkspaceID, arg.Name) +func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceBuildByID, id) var i WorkspaceBuild err := row.Scan( &i.ID, @@ -2861,8 +2854,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BeforeID, - &i.AfterID, + &i.BuildNumber, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2871,20 +2863,19 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, return i, err } -const getWorkspaceBuildByWorkspaceIDWithoutAfter = `-- name: GetWorkspaceBuildByWorkspaceIDWithoutAfter :one +const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id + id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id FROM workspace_builds WHERE - workspace_id = $1 - AND after_id IS NULL + job_id = $1 LIMIT 1 ` -func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByWorkspaceIDWithoutAfter, workspaceID) +func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceBuildByJobID, jobID) var i WorkspaceBuild err := row.Scan( &i.ID, @@ -2893,8 +2884,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Cont &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BeforeID, - &i.AfterID, + &i.BuildNumber, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2903,18 +2893,53 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Cont return i, err } -const getWorkspaceBuildsByWorkspaceIDsWithoutAfter = `-- name: GetWorkspaceBuildsByWorkspaceIDsWithoutAfter :many +const getWorkspaceBuildByWorkspaceID = `-- name: GetWorkspaceBuildByWorkspaceID :many SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id + id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id FROM workspace_builds WHERE - workspace_id = ANY($1 :: uuid [ ]) - AND after_id IS NULL + workspace_builds.workspace_id = $1 + AND CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the build_number field, so select all + -- rows after the cursor. + build_number > ( + SELECT + build_number + FROM + workspace_builds + WHERE + id = $2 + ) + ) + ELSE true +END +ORDER BY + build_number desc OFFSET $3 +LIMIT + -- A null limit means "no limit", so -1 means return all + NULLIF($4 :: int, -1) ` -func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaceBuildsByWorkspaceIDsWithoutAfter, pq.Array(ids)) +type GetWorkspaceBuildByWorkspaceIDParams struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDParams) ([]WorkspaceBuild, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceBuildByWorkspaceID, + arg.WorkspaceID, + arg.AfterID, + arg.OffsetOpt, + arg.LimitOpt, + ) if err != nil { return nil, err } @@ -2929,8 +2954,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Co &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BeforeID, - &i.AfterID, + &i.BuildNumber, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2949,6 +2973,40 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Co return items, nil } +const getWorkspaceBuildByWorkspaceIDAndName = `-- name: GetWorkspaceBuildByWorkspaceIDAndName :one +SELECT + id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id +FROM + workspace_builds +WHERE + workspace_id = $1 + AND "name" = $2 +` + +type GetWorkspaceBuildByWorkspaceIDAndNameParams struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndNameParams) (WorkspaceBuild, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceBuildByWorkspaceIDAndName, arg.WorkspaceID, arg.Name) + var i WorkspaceBuild + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.Name, + &i.BuildNumber, + &i.Transition, + &i.InitiatorID, + &i.ProvisionerState, + &i.JobID, + ) + return i, err +} + const insertWorkspaceBuild = `-- name: InsertWorkspaceBuild :one INSERT INTO workspace_builds ( @@ -2957,7 +3015,7 @@ INSERT INTO updated_at, workspace_id, template_version_id, - before_id, + "build_number", "name", transition, initiator_id, @@ -2965,7 +3023,7 @@ INSERT INTO provisioner_state ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id ` type InsertWorkspaceBuildParams struct { @@ -2974,7 +3032,7 @@ type InsertWorkspaceBuildParams struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - BeforeID uuid.NullUUID `db:"before_id" json:"before_id"` + BuildNumber int32 `db:"build_number" json:"build_number"` Name string `db:"name" json:"name"` Transition WorkspaceTransition `db:"transition" json:"transition"` InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` @@ -2989,7 +3047,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa arg.UpdatedAt, arg.WorkspaceID, arg.TemplateVersionID, - arg.BeforeID, + arg.BuildNumber, arg.Name, arg.Transition, arg.InitiatorID, @@ -3004,8 +3062,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BeforeID, - &i.AfterID, + &i.BuildNumber, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -3019,26 +3076,19 @@ UPDATE workspace_builds SET updated_at = $2, - after_id = $3, - provisioner_state = $4 + provisioner_state = $3 WHERE id = $1 ` type UpdateWorkspaceBuildByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - AfterID uuid.NullUUID `db:"after_id" json:"after_id"` - ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` } func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceBuildByID, - arg.ID, - arg.UpdatedAt, - arg.AfterID, - arg.ProvisionerState, - ) + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildByID, arg.ID, arg.UpdatedAt, arg.ProvisionerState) return err } @@ -3294,49 +3344,6 @@ func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Work return items, nil } -const getWorkspacesByOrganizationID = `-- name: GetWorkspacesByOrganizationID :many -SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = $1 AND deleted = $2 -` - -type GetWorkspacesByOrganizationIDParams struct { - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - Deleted bool `db:"deleted" json:"deleted"` -} - -func (q *sqlQuerier) GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesByOrganizationID, arg.OrganizationID, arg.Deleted) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Workspace - for rows.Next() { - var i Workspace - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.OwnerID, - &i.OrganizationID, - &i.TemplateID, - &i.Deleted, - &i.Name, - &i.AutostartSchedule, - &i.AutostopSchedule, - ); 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 getWorkspacesByOrganizationIDs = `-- name: GetWorkspacesByOrganizationIDs :many SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2 ` @@ -3380,23 +3387,23 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg Get return items, nil } -const getWorkspacesByOwnerID = `-- name: GetWorkspacesByOwnerID :many +const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE - owner_id = $1 + template_id = $1 AND deleted = $2 ` -type GetWorkspacesByOwnerIDParams struct { - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - Deleted bool `db:"deleted" json:"deleted"` +type GetWorkspacesByTemplateIDParams struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` } -func (q *sqlQuerier) GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesByOwnerID, arg.OwnerID, arg.Deleted) +func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesByTemplateID, arg.TemplateID, arg.Deleted) if err != nil { return nil, err } @@ -3429,23 +3436,36 @@ func (q *sqlQuerier) GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspac return items, nil } -const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many +const getWorkspacesWithFilter = `-- name: GetWorkspacesWithFilter :many SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM - workspaces + workspaces WHERE - template_id = $1 - AND deleted = $2 + -- Optionally include deleted workspaces + deleted = $1 + -- Filter by organization_id + AND CASE + WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN + organization_id = $2 + ELSE true + END + -- Filter by owner_id + AND CASE + WHEN $3 :: uuid != '00000000-00000000-00000000-00000000' THEN + owner_id = $3 + ELSE true + END ` -type GetWorkspacesByTemplateIDParams struct { - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` +type GetWorkspacesWithFilterParams struct { + Deleted bool `db:"deleted" json:"deleted"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` } -func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesByTemplateID, arg.TemplateID, arg.Deleted) +func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesWithFilter, arg.Deleted, arg.OrganizationID, arg.OwnerID) if err != nil { return nil, err } diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index b6a3550d5f2bd..148c8856deeb0 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -66,7 +66,7 @@ INSERT INTO created_at, updated_at, "name", - description, + readme, job_id ) VALUES @@ -80,3 +80,12 @@ SET updated_at = $3 WHERE id = $1; + +-- name: UpdateTemplateVersionDescriptionByJobID :exec +UPDATE + template_versions +SET + readme = $2, + updated_at = now() +WHERE + job_id = $1; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 8b9220e72520e..733725131eb9f 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -33,27 +33,60 @@ SELECT FROM workspace_builds WHERE - workspace_id = $1; + workspace_builds.workspace_id = $1 + AND CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN @after_id :: uuid != '00000000-00000000-00000000-00000000' THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the build_number field, so select all + -- rows after the cursor. + build_number > ( + SELECT + build_number + FROM + workspace_builds + WHERE + id = @after_id + ) + ) + ELSE true +END +ORDER BY + build_number desc OFFSET @offset_opt +LIMIT + -- A null limit means "no limit", so -1 means return all + NULLIF(@limit_opt :: int, -1); --- name: GetWorkspaceBuildByWorkspaceIDWithoutAfter :one +-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT * FROM workspace_builds WHERE workspace_id = $1 - AND after_id IS NULL +ORDER BY + build_number desc LIMIT 1; --- name: GetWorkspaceBuildsByWorkspaceIDsWithoutAfter :many -SELECT - * -FROM - workspace_builds -WHERE - workspace_id = ANY(@ids :: uuid [ ]) - AND after_id IS NULL; +-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many +SELECT wb.* +FROM ( + SELECT + workspace_id, MAX(build_number) as max_build_number + FROM + workspace_builds + WHERE + workspace_id = ANY(@ids :: uuid [ ]) + GROUP BY + workspace_id +) m +JOIN + workspace_builds wb +ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; + -- name: InsertWorkspaceBuild :one INSERT INTO @@ -63,7 +96,7 @@ INSERT INTO updated_at, workspace_id, template_version_id, - before_id, + "build_number", "name", transition, initiator_id, @@ -78,7 +111,6 @@ UPDATE workspace_builds SET updated_at = $2, - after_id = $3, - provisioner_state = $4 + provisioner_state = $3 WHERE id = $1; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index f8e68b110656d..eb87ad9a51d41 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -8,8 +8,27 @@ WHERE LIMIT 1; --- name: GetWorkspacesByOrganizationID :many -SELECT * FROM workspaces WHERE organization_id = $1 AND deleted = $2; +-- name: GetWorkspacesWithFilter :many +SELECT + * +FROM + workspaces +WHERE + -- Optionally include deleted workspaces + deleted = @deleted + -- Filter by organization_id + AND CASE + WHEN @organization_id :: uuid != '00000000-00000000-00000000-00000000' THEN + organization_id = @organization_id + ELSE true + END + -- Filter by owner_id + AND CASE + WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN + owner_id = @owner_id + ELSE true + END +; -- name: GetWorkspacesByOrganizationIDs :many SELECT * FROM workspaces WHERE organization_id = ANY(@ids :: uuid [ ]) AND deleted = @deleted; @@ -37,15 +56,6 @@ WHERE template_id = $1 AND deleted = $2; --- name: GetWorkspacesByOwnerID :many -SELECT - * -FROM - workspaces -WHERE - owner_id = $1 - AND deleted = $2; - -- name: GetWorkspaceByOwnerIDAndName :one SELECT * diff --git a/coderd/gitsshkey.go b/coderd/gitsshkey.go index 1543980ab6eb2..d5b2b049f892c 100644 --- a/coderd/gitsshkey.go +++ b/coderd/gitsshkey.go @@ -8,11 +8,17 @@ import ( "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) func (api *api) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) + + if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceUserData.WithOwner(user.ID.String())) { + return + } + privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ @@ -53,6 +59,11 @@ func (api *api) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) { func (api *api) gitSSHKey(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) + + if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUserData.WithOwner(user.ID.String())) { + return + } + gitSSHKey, err := api.Database.GetGitSSHKey(r.Context(), user.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index 331ec527e4328..afdb4685ed063 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -62,6 +62,12 @@ type Error struct { Detail string `json:"detail" validate:"required"` } +func Forbidden(rw http.ResponseWriter) { + Write(rw, http.StatusForbidden, Response{ + Message: "forbidden", + }) +} + // Write outputs a standardized format to an HTTP response body. func Write(rw http.ResponseWriter, status int, response interface{}) { buf := &bytes.Buffer{} diff --git a/coderd/httpmw/authorize.go b/coderd/httpmw/authorize.go index 2eb221f1893eb..84bf7cbfa04b4 100644 --- a/coderd/httpmw/authorize.go +++ b/coderd/httpmw/authorize.go @@ -4,92 +4,10 @@ import ( "context" "net/http" - "golang.org/x/xerrors" - - "cdr.dev/slog" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" ) -// Authorize will enforce if the user roles can complete the action on the AuthObject. -// The organization and owner are found using the ExtractOrganization and -// ExtractUser middleware if present. -func Authorize(logger slog.Logger, auth *rbac.RegoAuthorizer, action rbac.Action) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - roles := UserRoles(r) - object := rbacObject(r) - - if object.Type == "" { - panic("developer error: auth object has no type") - } - - // First extract the object's owner and organization if present. - unknownOrg := r.Context().Value(organizationParamContextKey{}) - if organization, castOK := unknownOrg.(database.Organization); unknownOrg != nil { - if !castOK { - panic("developer error: organization param middleware not provided for authorize") - } - object = object.InOrg(organization.ID) - } - - unknownOwner := r.Context().Value(userParamContextKey{}) - if owner, castOK := unknownOwner.(database.User); unknownOwner != nil { - if !castOK { - panic("developer error: user param middleware not provided for authorize") - } - object = object.WithOwner(owner.ID.String()) - } - - err := auth.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object) - if err != nil { - internalError := new(rbac.UnauthorizedError) - if xerrors.As(err, internalError) { - logger = logger.With(slog.F("internal", internalError.Internal())) - } - // Log information for debugging. This will be very helpful - // in the early days if we over secure endpoints. - logger.Warn(r.Context(), "unauthorized", - slog.F("roles", roles.Roles), - slog.F("user_id", roles.ID), - slog.F("username", roles.Username), - slog.F("route", r.URL.Path), - slog.F("action", action), - slog.F("object", object), - ) - httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: err.Error(), - }) - return - } - next.ServeHTTP(rw, r) - }) - } -} - -type authObjectKey struct{} - -// APIKey returns the API key from the ExtractAPIKey handler. -func rbacObject(r *http.Request) rbac.Object { - obj, ok := r.Context().Value(authObjectKey{}).(rbac.Object) - if !ok { - panic("developer error: auth object middleware not provided") - } - return obj -} - -// WithRBACObject sets the object for 'Authorize()' for all routes handled -// by this middleware. The important field to set is 'Type' -func WithRBACObject(object rbac.Object) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - ctx := context.WithValue(r.Context(), authObjectKey{}, object) - next.ServeHTTP(rw, r.WithContext(ctx)) - }) - } -} - // User roles are the 'subject' field of Authorize() type userRolesKey struct{} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index ba14f8dafdfc0..92ba5da38162b 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "reflect" "golang.org/x/oauth2" @@ -46,7 +47,8 @@ func OAuth2(r *http.Request) OAuth2State { func ExtractOAuth2(config OAuth2Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if config == nil { + // Interfaces can hold a nil value + if config == nil || reflect.ValueOf(config).IsNil() { httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{ Message: "The oauth2 method requested is not configured!", }) diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index d66e49236672e..8b088ee76b4d8 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -63,7 +63,7 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler UserID: apiKey.UserID, }) if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + httpapi.Write(rw, http.StatusForbidden, httpapi.Response{ Message: "not a member of the organization", }) return diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index 2e4a8eddf4414..b062e63bc3819 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -141,7 +141,7 @@ func TestOrganizationParam(t *testing.T) { rtr.ServeHTTP(rw, r) res := rw.Result() defer res.Body.Close() - require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, http.StatusForbidden, res.StatusCode) }) t.Run("Success", func(t *testing.T) { diff --git a/coderd/organizations.go b/coderd/organizations.go index feb7a7ba9dc18..b0b57f748ccd6 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -6,11 +6,19 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) -func (*api) organization(rw http.ResponseWriter, r *http.Request) { +func (api *api) organization(rw http.ResponseWriter, r *http.Request) { organization := httpmw.OrganizationParam(r) + + if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceOrganization. + InOrg(organization.ID). + WithID(organization.ID.String())) { + return + } + httpapi.Write(rw, http.StatusOK, convertOrganization(organization)) } diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index e6338f61cb7fe..01b5822a15611 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -30,7 +30,7 @@ func TestOrganizationByUserAndName(t *testing.T) { _, err := client.OrganizationByName(context.Background(), codersdk.Me, "nothing") var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) }) t.Run("NoMember", func(t *testing.T) { @@ -38,14 +38,14 @@ func TestOrganizationByUserAndName(t *testing.T) { client := coderdtest.New(t, nil) first := coderdtest.CreateFirstUser(t, client) other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) - org, err := other.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{ + org, err := client.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{ Name: "another", }) require.NoError(t, err) - _, err = client.OrganizationByName(context.Background(), codersdk.Me, org.Name) + _, err = other.OrganizationByName(context.Background(), codersdk.Me, org.Name) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) }) t.Run("Valid", func(t *testing.T) { diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 9a27fbe6e4857..5cdebc1c942f2 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -348,6 +348,16 @@ func (server *provisionerdServer) UpdateJob(ctx context.Context, request *proto. } } + if len(request.Readme) > 0 { + err := server.Database.UpdateTemplateVersionDescriptionByJobID(ctx, database.UpdateTemplateVersionDescriptionByJobIDParams{ + JobID: job.ID, + Readme: string(request.Readme), + }) + if err != nil { + return nil, xerrors.Errorf("update template version description: %w", err) + } + } + if len(request.ParameterSchemas) > 0 { for _, protoParameter := range request.ParameterSchemas { validationTypeSystem, err := convertValidationTypeSystem(protoParameter.ValidationTypeSystem) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 39cd7ed102906..6325a4b8c506b 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -9,6 +9,10 @@ import ( "github.com/open-policy-agent/opa/rego" ) +type Authorizer interface { + ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error +} + // RegoAuthorizer will use a prepared rego query for performing authorize() type RegoAuthorizer struct { query rego.PreparedEvalQuery @@ -38,10 +42,10 @@ type authSubject struct { Roles []Role `json:"roles"` } -// AuthorizeByRoleName will expand all roleNames into roles before calling Authorize(). +// ByRoleName will expand all roleNames into roles before calling Authorize(). // This is the function intended to be used outside this package. // The role is fetched from the builtin map located in memory. -func (a RegoAuthorizer) AuthorizeByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error { +func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error { roles := make([]Role, 0, len(roleNames)) for _, n := range roleNames { r, err := RoleByName(n) diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 6a85cfe3256a2..71b0f059f628d 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -64,6 +64,10 @@ var ( return Role{ Name: member, DisplayName: "Member", + Site: permissions(map[Object][]Action{ + // All users can read all other users and know they exist. + ResourceUser: {ActionRead}, + }), User: permissions(map[Object][]Action{ ResourceWildcard: {WildcardSymbol}, }), @@ -111,7 +115,20 @@ var ( Name: roleName(orgMember, organizationID), DisplayName: "Organization Member", Org: map[string][]Permission{ - organizationID: {}, + organizationID: { + { + // All org members can read the other members in their org. + ResourceType: ResourceOrganizationMember.Type, + Action: ActionRead, + ResourceID: "*", + }, + { + // All org members can read the organization + ResourceType: ResourceOrganization.Type, + Action: ActionRead, + ResourceID: "*", + }, + }, }, } }, diff --git a/coderd/rbac/error.go b/coderd/rbac/error.go index 593ca4d0fc23a..6b63bb88602db 100644 --- a/coderd/rbac/error.go +++ b/coderd/rbac/error.go @@ -6,7 +6,7 @@ const ( // errUnauthorized is the error message that should be returned to // clients when an action is forbidden. It is intentionally vague to prevent // disclosing information that a client should not have access to. - errUnauthorized = "unauthorized" + errUnauthorized = "forbidden" ) // UnauthorizedError is the error type for authorization errors diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index e4fa5013a16ce..862653f50286e 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -9,6 +9,10 @@ const WildcardSymbol = "*" // Resources are just typed objects. Making resources this way allows directly // passing them into an Authorize function and use the chaining api. var ( + // ResourceWorkspace CRUD. Org + User owner + // create/delete = make or delete workspaces + // read = access workspace + // update = edit workspace variables ResourceWorkspace = Object{ Type: "workspace", } @@ -17,19 +21,60 @@ var ( Type: "template", } - ResourceUser = Object{ - Type: "user", + ResourceFile = Object{ + Type: "file", + } + + // ResourceOrganization CRUD. Has an org owner on all but 'create'. + // create/delete = make or delete organizations + // read = view org information (Can add user owner for read) + // update = ?? + ResourceOrganization = Object{ + Type: "organization", } - // ResourceUserRole might be expanded later to allow more granular permissions + // ResourceRoleAssignment might be expanded later to allow more granular permissions // to modifying roles. For now, this covers all possible roles, so having this permission // allows granting/deleting **ALL** roles. - ResourceUserRole = Object{ - Type: "user_role", + // create = Assign roles + // update = ?? + // read = View available roles to assign + // delete = Remove role + ResourceRoleAssignment = Object{ + Type: "assign_role", + } + + // ResourceAPIKey is owned by a user. + // create = Create a new api key for user + // update = ?? + // read = View api key + // delete = Delete api key + ResourceAPIKey = Object{ + Type: "api_key", + } + + // ResourceUser is the user in the 'users' table. + // ResourceUser never has any owners or in an org, as it's site wide. + // create/delete = make or delete a new user. + // read = view all 'user' table data + // update = update all 'user' table data + ResourceUser = Object{ + Type: "user", + } + + // ResourceUserData is any data associated with a user. A user has control + // over their data (profile, password, etc). So this resource has an owner. + ResourceUserData = Object{ + Type: "user_data", } - ResourceUserPasswordRole = Object{ - Type: "user_password", + // ResourceOrganizationMember is a user's membership in an organization. + // Has ONLY an organization owner. The resource ID is the user's ID + // create/delete = Create/delete member from org. + // update = Update organization member + // read = View member + ResourceOrganizationMember = Object{ + Type: "organization_member", } // ResourceWildcard represents all resource types diff --git a/coderd/roles.go b/coderd/roles.go index 205e8633b4bbe..308b1bf791984 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -11,32 +11,43 @@ import ( ) // assignableSiteRoles returns all site wide roles that can be assigned. -func (*api) assignableSiteRoles(rw http.ResponseWriter, _ *http.Request) { +func (api *api) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) { // TODO: @emyrk in the future, allow granular subsets of roles to be returned based on the // role of the user. + + if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceRoleAssignment) { + return + } + roles := rbac.SiteRoles() httpapi.Write(rw, http.StatusOK, convertRoles(roles)) } // assignableSiteRoles returns all site wide roles that can be assigned. -func (*api) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { +func (api *api) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { // TODO: @emyrk in the future, allow granular subsets of roles to be returned based on the // role of the user. organization := httpmw.OrganizationParam(r) + + if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceRoleAssignment.InOrg(organization.ID)) { + return + } + roles := rbac.OrganizationRoles(organization.ID) httpapi.Write(rw, http.StatusOK, convertRoles(roles)) } func (api *api) checkPermissions(rw http.ResponseWriter, r *http.Request) { - roles := httpmw.UserRoles(r) user := httpmw.UserParam(r) - if user.ID != roles.ID { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - // TODO: @Emyrk in the future we could have an rbac check here. - // If the user can masquerade/impersonate as the user passed in, - // we could allow this or something like that. - Message: "only allowed to check permissions on yourself", - }) + + if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUser.WithOwner(user.ID.String())) { + return + } + + // use the roles of the user specified, not the person making the request. + roles, err := api.Database.GetAllUserRoles(r.Context(), user.ID) + if err != nil { + httpapi.Forbidden(rw) return } @@ -57,7 +68,7 @@ func (api *api) checkPermissions(rw http.ResponseWriter, r *http.Request) { if v.Object.OwnerID == "me" { v.Object.OwnerID = roles.ID.String() } - err := api.Authorizer.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.Action(v.Action), + err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.Action(v.Action), rbac.Object{ ResourceID: v.Object.ResourceID, Owner: v.Object.OwnerID, diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 83d4f1f23d83a..0350bcf835377 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -112,7 +112,7 @@ func TestListRoles(t *testing.T) { }) require.NoError(t, err, "create org") - const unauth = "unauthorized" + const unauth = "forbidden" const notMember = "not a member of the organization" testCases := []struct { @@ -191,7 +191,7 @@ func TestListRoles(t *testing.T) { if c.AuthorizedError != "" { var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) require.Contains(t, apiErr.Message, c.AuthorizedError) } else { require.NoError(t, err) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 99c6c62383811..f1d70cb4c9b97 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -357,7 +357,7 @@ func (api *api) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht CreatedAt: database.Now(), UpdatedAt: database.Now(), Name: namesgenerator.GetRandomName(1), - Description: "", + Readme: "", JobID: provisionerJob.ID, }) if err != nil { @@ -407,5 +407,6 @@ func convertTemplateVersion(version database.TemplateVersion, job codersdk.Provi UpdatedAt: version.UpdatedAt, Name: version.Name, Job: job, + Readme: version.Readme, } } diff --git a/coderd/users.go b/coderd/users.go index 5f34b951223f6..fbbbde5e250c1 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -109,6 +109,11 @@ func (api *api) users(rw http.ResponseWriter, r *http.Request) { statusFilter = r.URL.Query().Get("status") ) + // Reading all users across the site + if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUser) { + return + } + paginationParams, ok := parsePagination(rw, r) if !ok { return @@ -157,12 +162,24 @@ func (api *api) users(rw http.ResponseWriter, r *http.Request) { // Creates a new user. func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { - apiKey := httpmw.APIKey(r) + // Create the user on the site + if !api.Authorize(rw, r, rbac.ActionCreate, rbac.ResourceUser) { + return + } var createUser codersdk.CreateUserRequest if !httpapi.Read(rw, r, &createUser) { return } + + // Create the organization member in the org. + if !api.Authorize(rw, r, rbac.ActionCreate, + rbac.ResourceOrganizationMember.InOrg(createUser.OrganizationID)) { + return + } + + // TODO: @emyrk Authorize the organization create if the createUser will do that. + _, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ Username: createUser.Username, Email: createUser.Email, @@ -180,7 +197,7 @@ func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { return } - organization, err := api.Database.GetOrganizationByID(r.Context(), createUser.OrganizationID) + _, err = api.Database.GetOrganizationByID(r.Context(), createUser.OrganizationID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ Message: "organization does not exist with the provided id", @@ -193,23 +210,6 @@ func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { }) return } - // Check if the caller has permissions to the organization requested. - _, err = api.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ - OrganizationID: organization.ID, - UserID: apiKey.UserID, - }) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: "you are not authorized to add members to that organization", - }) - return - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get organization member: %s", err), - }) - return - } user, _, err := api.createUser(r.Context(), createUser) if err != nil { @@ -228,6 +228,10 @@ func (api *api) userByName(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) organizationIDs, err := userOrganizationIDs(r.Context(), api, user) + if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUser.WithID(user.ID.String())) { + return + } + if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get organization IDs: %s", err.Error()), @@ -241,6 +245,10 @@ func (api *api) userByName(rw http.ResponseWriter, r *http.Request) { func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) + if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceUser.WithOwner(user.ID.String())) { + return + } + var params codersdk.UpdateUserProfileRequest if !httpapi.Read(rw, r, ¶ms) { return @@ -307,6 +315,11 @@ func (api *api) putUserStatus(status database.UserStatus) func(rw http.ResponseW return func(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) apiKey := httpmw.APIKey(r) + + if !api.Authorize(rw, r, rbac.ActionDelete, rbac.ResourceUser.WithID(user.ID.String())) { + return + } + if status == database.UserStatusSuspended && user.ID == apiKey.UserID { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: "You cannot suspend yourself", @@ -344,6 +357,11 @@ func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) { user = httpmw.UserParam(r) params codersdk.UpdateUserPasswordRequest ) + + if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceUserData.WithOwner(user.ID.String())) { + return + } + if !httpapi.Read(rw, r, ¶ms) { return } @@ -371,6 +389,12 @@ func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) { func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) + roles := httpmw.UserRoles(r) + + if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUserData. + WithOwner(user.ID.String())) { + return + } resp := codersdk.UserRoles{ Roles: user.RBACRoles, @@ -386,7 +410,16 @@ func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { } for _, mem := range memberships { - resp.OrganizationRoles[mem.OrganizationID] = mem.Roles + err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, + rbac.ResourceOrganizationMember. + WithID(user.ID.String()). + InOrg(mem.OrganizationID), + ) + + // If we can read the org member, include the roles + if err == nil { + resp.OrganizationRoles[mem.OrganizationID] = mem.Roles + } } httpapi.Write(rw, http.StatusOK, resp) @@ -394,22 +427,41 @@ func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { func (api *api) putUserRoles(rw http.ResponseWriter, r *http.Request) { // User is the user to modify - // TODO: Until rbac authorize is implemented, only be able to change your - // own roles. This also means you can grant yourself whatever roles you want. user := httpmw.UserParam(r) - apiKey := httpmw.APIKey(r) - if apiKey.UserID != user.ID { - httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: "modifying other users is not supported at this time", - }) - return - } + roles := httpmw.UserRoles(r) var params codersdk.UpdateRoles if !httpapi.Read(rw, r, ¶ms) { return } + has := make(map[string]struct{}) + for _, exists := range roles.Roles { + has[exists] = struct{}{} + } + + for _, roleName := range params.Roles { + // If the user already has the role assigned, we don't need to check the permission + // to reassign it. Only run permission checks on the difference in the set of + // roles. + if _, ok := has[roleName]; ok { + delete(has, roleName) + continue + } + + // Assigning a role requires the create permission. + if !api.Authorize(rw, r, rbac.ActionCreate, rbac.ResourceRoleAssignment.WithID(roleName)) { + return + } + } + + // Any roles that were removed also need to be checked. + for roleName := range has { + if !api.Authorize(rw, r, rbac.ActionDelete, rbac.ResourceRoleAssignment.WithID(roleName)) { + return + } + } + updatedUser, err := api.updateSiteUserRoles(r.Context(), database.UpdateUserRolesParams{ GrantedRoles: params.Roles, ID: user.ID, @@ -432,6 +484,8 @@ func (api *api) putUserRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusOK, convertUser(updatedUser, organizationIDs)) } +// updateSiteUserRoles will ensure only site wide roles are passed in as arguments. +// If an organization role is included, an error is returned. func (api *api) updateSiteUserRoles(ctx context.Context, args database.UpdateUserRolesParams) (database.User, error) { // Enforce only site wide roles for _, r := range args.GrantedRoles { @@ -454,6 +508,7 @@ func (api *api) updateSiteUserRoles(ctx context.Context, args database.UpdateUse // Returns organizations the parameterized user has access to. func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) + roles := httpmw.UserRoles(r) organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), user.ID) if errors.Is(err, sql.ErrNoRows) { @@ -469,42 +524,38 @@ func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { publicOrganizations := make([]codersdk.Organization, 0, len(organizations)) for _, organization := range organizations { - publicOrganizations = append(publicOrganizations, convertOrganization(organization)) + err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, + rbac.ResourceOrganization. + WithID(organization.ID.String()). + InOrg(organization.ID), + ) + if err == nil { + // Only return orgs the user can read + publicOrganizations = append(publicOrganizations, convertOrganization(organization)) + } } httpapi.Write(rw, http.StatusOK, publicOrganizations) } func (api *api) organizationByUserAndName(rw http.ResponseWriter, r *http.Request) { - user := httpmw.UserParam(r) organizationName := chi.URLParam(r, "organizationname") organization, err := api.Database.GetOrganizationByName(r.Context(), organizationName) if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ - Message: fmt.Sprintf("no organization found by name %q", organizationName), - }) + // Return unauthorized rather than a 404 to not leak if the organization + // exists. + httpapi.Forbidden(rw) return } if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get organization by name: %s", err), - }) - return - } - _, err = api.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ - OrganizationID: organization.ID, - UserID: user.ID, - }) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("no organization found by name %q", organizationName), - }) + httpapi.Forbidden(rw) return } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get organization member: %s", err), - }) + + if !api.Authorize(rw, r, rbac.ActionRead, + rbac.ResourceOrganization. + InOrg(organization.ID). + WithID(organization.ID.String())) { return } @@ -617,12 +668,8 @@ func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) { // Creates a new session key, used for logging in via the CLI func (api *api) postAPIKey(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - apiKey := httpmw.APIKey(r) - if user.ID != apiKey.UserID { - httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: "Keys can only be generated for the authenticated user", - }) + if !api.Authorize(rw, r, rbac.ActionCreate, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { return } diff --git a/coderd/users_test.go b/coderd/users_test.go index ef4eabe74972e..9c2846ed96cbd 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -172,13 +172,14 @@ func TestPostUsers(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) first := coderdtest.CreateFirstUser(t, client) + notInOrg := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) org, err := other.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{ Name: "another", }) require.NoError(t, err) - _, err = client.CreateUser(context.Background(), codersdk.CreateUserRequest{ + _, err = notInOrg.CreateUser(context.Background(), codersdk.CreateUserRequest{ Email: "some@domain.com", Username: "anotheruser", Password: "testing", @@ -186,7 +187,7 @@ func TestPostUsers(t *testing.T) { }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) }) t.Run("Create", func(t *testing.T) { @@ -401,10 +402,11 @@ func TestGrantRoles(t *testing.T) { []string{rbac.RoleOrgMember(first.OrganizationID)}, ) + memberUser, err := member.User(ctx, codersdk.Me) + require.NoError(t, err, "fetch member") + // Grant - // TODO: @emyrk this should be 'admin.UpdateUserRoles' once proper authz - // is enforced. - _, err = member.UpdateUserRoles(ctx, codersdk.Me, codersdk.UpdateRoles{ + _, err = admin.UpdateUserRoles(ctx, memberUser.ID.String(), codersdk.UpdateRoles{ Roles: []string{ // Promote to site admin rbac.RoleMember(), @@ -597,7 +599,9 @@ func TestWorkspacesByUser(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - workspaces, err := client.WorkspacesByUser(context.Background(), codersdk.Me) + workspaces, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{ + Owner: codersdk.Me, + }) require.NoError(t, err) require.Len(t, workspaces, 0) }) @@ -626,11 +630,11 @@ func TestWorkspacesByUser(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - workspaces, err := newUserClient.WorkspacesByUser(context.Background(), codersdk.Me) + workspaces, err := newUserClient.Workspaces(context.Background(), codersdk.WorkspaceFilter{Owner: codersdk.Me}) require.NoError(t, err) require.Len(t, workspaces, 0) - workspaces, err = client.WorkspacesByUser(context.Background(), codersdk.Me) + workspaces, err = client.Workspaces(context.Background(), codersdk.WorkspaceFilter{Owner: codersdk.Me}) require.NoError(t, err) require.Len(t, workspaces, 1) }) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index d2a7baeab560d..ec26001b99ff1 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -212,11 +212,11 @@ func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { // Ensure the resource is still valid! // We only accept agents for resources on the latest build. ensureLatestBuild := func() error { - latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), build.WorkspaceID) + latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), build.WorkspaceID) if err != nil { return err } - if build.ID.String() != latestBuild.ID.String() { + if build.ID != latestBuild.ID { return xerrors.New("build is outdated") } return nil diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 09d3af4500148..15bf01d065bad 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1,11 +1,13 @@ package coderd_test import ( + "bufio" "context" "encoding/json" "runtime" "strings" "testing" + "time" "github.com/google/uuid" "github.com/pion/webrtc/v3" @@ -230,6 +232,11 @@ func TestWorkspaceAgentPTY(t *testing.T) { require.NoError(t, err) _, err = conn.Write(data) require.NoError(t, err) + bufRead := bufio.NewReader(conn) + + // Brief pause to reduce the likelihood that we send keystrokes while + // the shell is simultaneously sending a prompt. + time.Sleep(100 * time.Millisecond) data, err = json.Marshal(agent.ReconnectingPTYRequest{ Data: "echo test\r\n", @@ -238,16 +245,22 @@ func TestWorkspaceAgentPTY(t *testing.T) { _, err = conn.Write(data) require.NoError(t, err) - findEcho := func() { + expectLine := func(matcher func(string) bool) { for { - read, err := conn.Read(data) + line, err := bufRead.ReadString('\n') require.NoError(t, err) - if strings.Contains(string(data[:read]), "test") { - return + if matcher(line) { + break } } } + matchEchoCommand := func(line string) bool { + return strings.Contains(line, "echo test") + } + matchEchoOutput := func(line string) bool { + return strings.Contains(line, "test") && !strings.Contains(line, "echo") + } - findEcho() - findEcho() + expectLine(matchEchoCommand) + expectLine(matchEchoOutput) } diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index e9e908bc660eb..b3cf97462d969 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -34,7 +34,17 @@ func (api *api) workspaceBuild(rw http.ResponseWriter, r *http.Request) { func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) - builds, err := api.Database.GetWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) + paginationParams, ok := parsePagination(rw, r) + if !ok { + return + } + req := database.GetWorkspaceBuildByWorkspaceIDParams{ + WorkspaceID: workspace.ID, + AfterID: paginationParams.AfterID, + OffsetOpt: int32(paginationParams.Offset), + LimitOpt: int32(paginationParams.Limit), + } + builds, err := api.Database.GetWorkspaceBuildByWorkspaceID(r.Context(), req) if xerrors.Is(err, sql.ErrNoRows) { err = nil } @@ -116,7 +126,7 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } if createBuild.TemplateVersionID == uuid.Nil { - latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get latest workspace build: %s", err), @@ -176,9 +186,9 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - // Store prior history ID if it exists to update it after we create new! - priorHistoryID := uuid.NullUUID{} - priorHistory, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + // Store prior build number to compute new build number + var priorBuildNum int32 + priorHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) if err == nil { priorJob, err := api.Database.GetProvisionerJobByID(r.Context(), priorHistory.JobID) if err == nil && convertProvisionerJob(priorJob).Status.Active() { @@ -188,10 +198,7 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - priorHistoryID = uuid.NullUUID{ - UUID: priorHistory.ID, - Valid: true, - } + priorBuildNum = priorHistory.BuildNumber } else if !errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get prior workspace build: %s", err), @@ -237,7 +244,7 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { UpdatedAt: database.Now(), WorkspaceID: workspace.ID, TemplateVersionID: templateVersion.ID, - BeforeID: priorHistoryID, + BuildNumber: priorBuildNum + 1, Name: namesgenerator.GetRandomName(1), ProvisionerState: state, InitiatorID: apiKey.UserID, @@ -248,22 +255,6 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("insert workspace build: %w", err) } - if priorHistoryID.Valid { - // Update the prior history entries "after" column. - err = db.UpdateWorkspaceBuildByID(r.Context(), database.UpdateWorkspaceBuildByIDParams{ - ID: priorHistory.ID, - ProvisionerState: priorHistory.ProvisionerState, - UpdatedAt: database.Now(), - AfterID: uuid.NullUUID{ - UUID: workspaceBuild.ID, - Valid: true, - }, - }) - if err != nil { - return xerrors.Errorf("update prior workspace build: %w", err) - } - } - return nil }) if err != nil { @@ -355,8 +346,7 @@ func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job codersdk. UpdatedAt: workspaceBuild.UpdatedAt, WorkspaceID: workspaceBuild.WorkspaceID, TemplateVersionID: workspaceBuild.TemplateVersionID, - BeforeID: workspaceBuild.BeforeID.UUID, - AfterID: workspaceBuild.AfterID.UUID, + BuildNumber: workspaceBuild.BuildNumber, Name: workspaceBuild.Name, Transition: workspaceBuild.Transition, InitiatorID: workspaceBuild.InitiatorID, diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index a9cec2cf3355c..e50c2281f7e13 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -38,9 +39,50 @@ func TestWorkspaceBuilds(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - _, err := client.WorkspaceBuilds(context.Background(), workspace.ID) + builds, err := client.WorkspaceBuilds(context.Background(), + codersdk.WorkspaceBuildsRequest{WorkspaceID: workspace.ID}) + require.Len(t, builds, 1) + require.Equal(t, int32(1), builds[0].BuildNumber) require.NoError(t, err) }) + + t.Run("PaginateLimitOffset", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + var expectedBuilds []codersdk.WorkspaceBuild + extraBuilds := 4 + for i := 0; i < extraBuilds; i++ { + b := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) + expectedBuilds = append(expectedBuilds, b) + coderdtest.AwaitWorkspaceBuildJob(t, client, b.ID) + } + + pageSize := 3 + firstPage, err := client.WorkspaceBuilds(context.Background(), codersdk.WorkspaceBuildsRequest{ + WorkspaceID: workspace.ID, + Pagination: codersdk.Pagination{Limit: pageSize, Offset: 0}, + }) + require.NoError(t, err) + require.Len(t, firstPage, pageSize) + for i := 0; i < pageSize; i++ { + require.Equal(t, expectedBuilds[extraBuilds-i-1].ID, firstPage[i].ID) + } + secondPage, err := client.WorkspaceBuilds(context.Background(), codersdk.WorkspaceBuildsRequest{ + WorkspaceID: workspace.ID, + Pagination: codersdk.Pagination{Limit: pageSize, Offset: pageSize}, + }) + require.NoError(t, err) + require.Len(t, secondPage, 2) + require.Equal(t, expectedBuilds[0].ID, secondPage[0].ID) + require.Equal(t, workspace.LatestBuild.ID, secondPage[1].ID) // build created while creating workspace + }) } func TestPatchCancelWorkspaceBuild(t *testing.T) { diff --git a/coderd/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index f9d8f701f8daf..17a713b651be5 100644 --- a/coderd/workspaceresourceauth.go +++ b/coderd/workspaceresourceauth.go @@ -137,14 +137,14 @@ func (api *api) handleAuthInstanceID(rw http.ResponseWriter, r *http.Request, in // This token should only be exchanged if the instance ID is valid // for the latest history. If an instance ID is recycled by a cloud, // we'd hate to leak access to a user's workspace. - latestHistory, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), resourceHistory.WorkspaceID) + latestHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), resourceHistory.WorkspaceID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get latest workspace build: %s", err), }) return } - if latestHistory.ID.String() != resourceHistory.ID.String() { + if latestHistory.ID != resourceHistory.ID { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: fmt.Sprintf("resource found for id %q, but isn't registered on the latest history", instanceID), }) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 7176f3d20198a..4ef3b61301fe6 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -25,7 +25,7 @@ import ( func (api *api) workspace(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) - build, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get workspace build: %s", err), @@ -58,13 +58,19 @@ func (api *api) workspace(rw http.ResponseWriter, r *http.Request) { return } + if !api.Authorize(rw, r, rbac.ActionRead, + rbac.ResourceWorkspace.InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { + return + } + httpapi.Write(rw, http.StatusOK, convertWorkspace(workspace, convertWorkspaceBuild(build, convertProvisionerJob(job)), template, owner)) } func (api *api) workspacesByOrganization(rw http.ResponseWriter, r *http.Request) { organization := httpmw.OrganizationParam(r) - workspaces, err := api.Database.GetWorkspacesByOrganizationID(r.Context(), database.GetWorkspacesByOrganizationIDParams{ + roles := httpmw.UserRoles(r) + workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), database.GetWorkspacesWithFilterParams{ OrganizationID: organization.ID, Deleted: false, }) @@ -77,7 +83,18 @@ func (api *api) workspacesByOrganization(rw http.ResponseWriter, r *http.Request }) return } - apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, workspaces) + + allowedWorkspaces := make([]database.Workspace, 0) + for _, ws := range workspaces { + ws := ws + err = api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, + rbac.ResourceWorkspace.InOrg(ws.OrganizationID).WithOwner(ws.OwnerID.String()).WithID(ws.ID.String())) + if err == nil { + allowedWorkspaces = append(allowedWorkspaces, ws) + } + } + + apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, allowedWorkspaces) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("convert workspaces: %s", err), @@ -87,64 +104,67 @@ func (api *api) workspacesByOrganization(rw http.ResponseWriter, r *http.Request httpapi.Write(rw, http.StatusOK, apiWorkspaces) } -func (api *api) workspacesByUser(rw http.ResponseWriter, r *http.Request) { - user := httpmw.UserParam(r) +// workspaces returns all workspaces a user can read. +// Optional filters with query params +func (api *api) workspaces(rw http.ResponseWriter, r *http.Request) { roles := httpmw.UserRoles(r) + apiKey := httpmw.APIKey(r) - organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), user.ID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get organizations: %s", err), - }) - return - } - organizationIDs := make([]uuid.UUID, 0) - for _, organization := range organizations { - err = api.Authorizer.AuthorizeByRoleName(r.Context(), user.ID.String(), roles.Roles, rbac.ActionRead, rbac.ResourceWorkspace.All().InOrg(organization.ID)) - var apiErr *rbac.UnauthorizedError - if xerrors.As(err, &apiErr) { - continue - } + // Empty strings mean no filter + orgFilter := r.URL.Query().Get("organization_id") + ownerFilter := r.URL.Query().Get("owner_id") + + filter := database.GetWorkspacesWithFilterParams{Deleted: false} + if orgFilter != "" { + orgID, err := uuid.Parse(orgFilter) if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("authorize: %s", err), + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("organization_id must be a uuid: %s", err.Error()), }) return } - organizationIDs = append(organizationIDs, organization.ID) + filter.OrganizationID = orgID } - - workspaceIDs := map[uuid.UUID]struct{}{} - allWorkspaces, err := api.Database.GetWorkspacesByOrganizationIDs(r.Context(), database.GetWorkspacesByOrganizationIDsParams{ - Ids: organizationIDs, - }) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get workspaces for organizations: %s", err), - }) - return - } - for _, ws := range allWorkspaces { - workspaceIDs[ws.ID] = struct{}{} + if ownerFilter == "me" { + filter.OwnerID = apiKey.UserID + } else if ownerFilter != "" { + userID, err := uuid.Parse(ownerFilter) + if err != nil { + // Maybe it's a username + user, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + // Why not just accept 1 arg and use it for both in the sql? + Username: ownerFilter, + Email: ownerFilter, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "owner must be a uuid or username", + }) + return + } + userID = user.ID + } + filter.OwnerID = userID } - userWorkspaces, err := api.Database.GetWorkspacesByOwnerID(r.Context(), database.GetWorkspacesByOwnerIDParams{ - OwnerID: user.ID, - }) + + allowedWorkspaces := make([]database.Workspace, 0) + allWorkspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), filter) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get workspaces for user: %s", err), }) return } - for _, ws := range userWorkspaces { - _, exists := workspaceIDs[ws.ID] - if exists { - continue + for _, ws := range allWorkspaces { + ws := ws + err = api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, + rbac.ResourceWorkspace.InOrg(ws.OrganizationID).WithOwner(ws.OwnerID.String()).WithID(ws.ID.String())) + if err == nil { + allowedWorkspaces = append(allowedWorkspaces, ws) } - allWorkspaces = append(allWorkspaces, ws) } - apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, allWorkspaces) + apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, allowedWorkspaces) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("convert workspaces: %s", err), @@ -156,8 +176,10 @@ func (api *api) workspacesByUser(rw http.ResponseWriter, r *http.Request) { func (api *api) workspacesByOwner(rw http.ResponseWriter, r *http.Request) { owner := httpmw.UserParam(r) - workspaces, err := api.Database.GetWorkspacesByOwnerID(r.Context(), database.GetWorkspacesByOwnerIDParams{ + roles := httpmw.UserRoles(r) + workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), database.GetWorkspacesWithFilterParams{ OwnerID: owner.ID, + Deleted: false, }) if errors.Is(err, sql.ErrNoRows) { err = nil @@ -168,7 +190,18 @@ func (api *api) workspacesByOwner(rw http.ResponseWriter, r *http.Request) { }) return } - apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, workspaces) + + allowedWorkspaces := make([]database.Workspace, 0) + for _, ws := range workspaces { + ws := ws + err = api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, + rbac.ResourceWorkspace.InOrg(ws.OrganizationID).WithOwner(ws.OwnerID.String()).WithID(ws.ID.String())) + if err == nil { + allowedWorkspaces = append(allowedWorkspaces, ws) + } + } + + apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, allowedWorkspaces) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("convert workspaces: %s", err), @@ -188,9 +221,8 @@ func (api *api) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) Name: workspaceName, }) if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ - Message: fmt.Sprintf("no workspace found by name %q", workspaceName), - }) + // Do not leak information if the workspace exists or not + httpapi.Forbidden(rw) return } if err != nil { @@ -207,7 +239,12 @@ func (api *api) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) return } - build, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + if !api.Authorize(rw, r, rbac.ActionRead, + rbac.ResourceWorkspace.InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { + return + } + + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get workspace build: %s", err), @@ -409,6 +446,7 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req InitiatorID: apiKey.UserID, Transition: database.WorkspaceTransitionStart, JobID: provisionerJob.ID, + BuildNumber: 1, // First build! }) if err != nil { return xerrors.Errorf("insert workspace build: %w", err) @@ -506,7 +544,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data templateIDs = append(templateIDs, workspace.TemplateID) ownerIDs = append(ownerIDs, workspace.OwnerID) } - workspaceBuilds, err := db.GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx, workspaceIDs) + workspaceBuilds, err := db.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, workspaceIDs) if errors.Is(err, sql.ErrNoRows) { err = nil } @@ -538,7 +576,19 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data buildByWorkspaceID := map[uuid.UUID]database.WorkspaceBuild{} for _, workspaceBuild := range workspaceBuilds { - buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild + buildByWorkspaceID[workspaceBuild.WorkspaceID] = database.WorkspaceBuild{ + ID: workspaceBuild.ID, + CreatedAt: workspaceBuild.CreatedAt, + UpdatedAt: workspaceBuild.UpdatedAt, + WorkspaceID: workspaceBuild.WorkspaceID, + TemplateVersionID: workspaceBuild.TemplateVersionID, + Name: workspaceBuild.Name, + BuildNumber: workspaceBuild.BuildNumber, + Transition: workspaceBuild.Transition, + InitiatorID: workspaceBuild.InitiatorID, + ProvisionerState: workspaceBuild.ProvisionerState, + JobID: workspaceBuild.JobID, + } } templateByID := map[uuid.UUID]database.Template{} for _, template := range templates { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 3e4d8b57244c5..58140b1d00e1d 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -134,16 +134,29 @@ func TestWorkspacesByOwner(t *testing.T) { _, err := client.WorkspacesByOwner(context.Background(), user.OrganizationID, codersdk.Me) require.NoError(t, err) }) - t.Run("List", func(t *testing.T) { + + t.Run("ListMine", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) coderdtest.NewProvisionerDaemon(t, client) user := coderdtest.CreateFirstUser(t, client) + me, err := client.User(context.Background(), codersdk.Me) + require.NoError(t, err) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - workspaces, err := client.WorkspacesByOwner(context.Background(), user.OrganizationID, codersdk.Me) + + // Create noise workspace that should be filtered out + other := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + _ = coderdtest.CreateWorkspace(t, other, user.OrganizationID, template.ID) + + // Use a username + workspaces, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{ + OrganizationID: user.OrganizationID, + Owner: me.Username, + }) require.NoError(t, err) require.Len(t, workspaces, 1) }) @@ -158,7 +171,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { _, err := client.WorkspaceByOwnerAndName(context.Background(), user.OrganizationID, codersdk.Me, "something") var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) }) t.Run("Get", func(t *testing.T) { t.Parallel() @@ -235,7 +248,7 @@ func TestPostWorkspaceBuild(t *testing.T) { require.Equal(t, http.StatusConflict, apiErr.StatusCode()) }) - t.Run("UpdatePriorAfterField", func(t *testing.T) { + t.Run("IncrementBuildNumber", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -250,11 +263,7 @@ func TestPostWorkspaceBuild(t *testing.T) { Transition: database.WorkspaceTransitionStart, }) require.NoError(t, err) - require.Equal(t, workspace.LatestBuild.ID.String(), build.BeforeID.String()) - - firstBuild, err := client.WorkspaceBuild(context.Background(), workspace.LatestBuild.ID) - require.NoError(t, err) - require.Equal(t, build.ID.String(), firstBuild.AfterID.String()) + require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) }) t.Run("WithState", func(t *testing.T) { @@ -294,7 +303,7 @@ func TestPostWorkspaceBuild(t *testing.T) { Transition: database.WorkspaceTransitionDelete, }) require.NoError(t, err) - require.Equal(t, workspace.LatestBuild.ID.String(), build.BeforeID.String()) + require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) workspaces, err := client.WorkspacesByOwner(context.Background(), user.OrganizationID, user.UserID.String()) diff --git a/codersdk/buildinfo.go b/codersdk/buildinfo.go index a3aecc1ffdfed..0233047caf98c 100644 --- a/codersdk/buildinfo.go +++ b/codersdk/buildinfo.go @@ -18,7 +18,7 @@ type BuildInfoResponse struct { // BuildInfo returns build information for this instance of Coder. func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) if err != nil { return BuildInfoResponse{}, err } diff --git a/codersdk/client.go b/codersdk/client.go index 1654c141e6827..48571adff5d0b 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -35,9 +35,9 @@ type Client struct { type requestOption func(*http.Request) -// request performs an HTTP request with the body provided. +// Request performs an HTTP request with the body provided. // The caller is responsible for closing the response body. -func (c *Client) request(ctx context.Context, method, path string, body interface{}, opts ...requestOption) (*http.Response, error) { +func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...requestOption) (*http.Response, error) { serverURL, err := c.URL.Parse(path) if err != nil { return nil, xerrors.Errorf("parse url: %w", err) diff --git a/codersdk/files.go b/codersdk/files.go index 15c30f30f87f7..52fcf0215081b 100644 --- a/codersdk/files.go +++ b/codersdk/files.go @@ -20,7 +20,7 @@ type UploadResponse struct { // Upload uploads an arbitrary file with the content type provided. // This is used to upload a source-code archive. func (c *Client) Upload(ctx context.Context, contentType string, content []byte) (UploadResponse, error) { - res, err := c.request(ctx, http.MethodPost, "/api/v2/files", content, func(r *http.Request) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/files", content, func(r *http.Request) { r.Header.Set("Content-Type", contentType) }) if err != nil { @@ -36,7 +36,7 @@ func (c *Client) Upload(ctx context.Context, contentType string, content []byte) // Download fetches a file by uploaded hash. func (c *Client) Download(ctx context.Context, hash string) ([]byte, string, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/files/%s", hash), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/files/%s", hash), nil) if err != nil { return nil, "", err } diff --git a/codersdk/gitsshkey.go b/codersdk/gitsshkey.go index f20c0666caf0f..e345a2733ab02 100644 --- a/codersdk/gitsshkey.go +++ b/codersdk/gitsshkey.go @@ -25,7 +25,7 @@ type AgentGitSSHKey struct { // GitSSHKey returns the user's git SSH public key. func (c *Client) GitSSHKey(ctx context.Context, user string) (GitSSHKey, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil) if err != nil { return GitSSHKey{}, xerrors.Errorf("execute request: %w", err) } @@ -41,7 +41,7 @@ func (c *Client) GitSSHKey(ctx context.Context, user string) (GitSSHKey, error) // RegenerateGitSSHKey will create a new SSH key pair for the user and return it. func (c *Client) RegenerateGitSSHKey(ctx context.Context, user string) (GitSSHKey, error) { - res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil) + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil) if err != nil { return GitSSHKey{}, xerrors.Errorf("execute request: %w", err) } @@ -57,7 +57,7 @@ func (c *Client) RegenerateGitSSHKey(ctx context.Context, user string) (GitSSHKe // AgentGitSSHKey will return the user's SSH key pair for the workspace. func (c *Client) AgentGitSSHKey(ctx context.Context) (AgentGitSSHKey, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil) if err != nil { return AgentGitSSHKey{}, xerrors.Errorf("execute request: %w", err) } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 0843e6ddbcaa6..7ebb16aedca97 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -62,7 +62,7 @@ type CreateWorkspaceRequest struct { } func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", id.String()), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", id.String()), nil) if err != nil { return Organization{}, xerrors.Errorf("execute request: %w", err) } @@ -78,7 +78,7 @@ func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, // ProvisionerDaemonsByOrganization returns provisioner daemons available for an organization. func (c *Client) ProvisionerDaemonsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerDaemon, error) { - res, err := c.request(ctx, http.MethodGet, + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons", organizationID.String()), nil, ) @@ -98,7 +98,7 @@ func (c *Client) ProvisionerDaemonsByOrganization(ctx context.Context, organizat // CreateTemplateVersion processes source-code and optionally associates the version with a template. // Executing without a template is useful for validating source-code. func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid.UUID, req CreateTemplateVersionRequest) (TemplateVersion, error) { - res, err := c.request(ctx, http.MethodPost, + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/templateversions", organizationID.String()), req, ) @@ -117,7 +117,7 @@ func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid. // CreateTemplate creates a new template inside an organization. func (c *Client) CreateTemplate(ctx context.Context, organizationID uuid.UUID, request CreateTemplateRequest) (Template, error) { - res, err := c.request(ctx, http.MethodPost, + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/templates", organizationID.String()), request, ) @@ -136,7 +136,7 @@ func (c *Client) CreateTemplate(ctx context.Context, organizationID uuid.UUID, r // TemplatesByOrganization lists all templates inside of an organization. func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uuid.UUID) ([]Template, error) { - res, err := c.request(ctx, http.MethodGet, + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/templates", organizationID.String()), nil, ) @@ -155,7 +155,7 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui // TemplateByName finds a template inside the organization provided with a case-insensitive name. func (c *Client) TemplateByName(ctx context.Context, organizationID uuid.UUID, name string) (Template, error) { - res, err := c.request(ctx, http.MethodGet, + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/templates/%s", organizationID.String(), name), nil, ) @@ -174,7 +174,7 @@ func (c *Client) TemplateByName(ctx context.Context, organizationID uuid.UUID, n // CreateWorkspace creates a new workspace for the template specified. func (c *Client) CreateWorkspace(ctx context.Context, organizationID uuid.UUID, request CreateWorkspaceRequest) (Workspace, error) { - res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/workspaces", organizationID), request) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/workspaces", organizationID), request) if err != nil { return Workspace{}, err } @@ -190,7 +190,7 @@ func (c *Client) CreateWorkspace(ctx context.Context, organizationID uuid.UUID, // WorkspacesByOrganization returns all workspaces in the specified organization. func (c *Client) WorkspacesByOrganization(ctx context.Context, organizationID uuid.UUID) ([]Workspace, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces", organizationID), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces", organizationID), nil) if err != nil { return nil, err } @@ -206,7 +206,7 @@ func (c *Client) WorkspacesByOrganization(ctx context.Context, organizationID uu // WorkspacesByOwner returns all workspaces contained in the organization owned by the user. func (c *Client) WorkspacesByOwner(ctx context.Context, organizationID uuid.UUID, user string) ([]Workspace, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces/%s", organizationID, user), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces/%s", organizationID, user), nil) if err != nil { return nil, err } @@ -222,7 +222,7 @@ func (c *Client) WorkspacesByOwner(ctx context.Context, organizationID uuid.UUID // WorkspaceByOwnerAndName returns a workspace by the owner's UUID and the workspace's name. func (c *Client) WorkspaceByOwnerAndName(ctx context.Context, organization uuid.UUID, owner string, name string) (Workspace, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces/%s/%s", organization, owner, name), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces/%s/%s", organization, owner, name), nil) if err != nil { return Workspace{}, err } diff --git a/codersdk/pagination.go b/codersdk/pagination.go index a4adee6b6e567..c059266dd34c4 100644 --- a/codersdk/pagination.go +++ b/codersdk/pagination.go @@ -26,7 +26,7 @@ type Pagination struct { Offset int `json:"offset,omitempty"` } -// asRequestOption returns a function that can be used in (*Client).request. +// asRequestOption returns a function that can be used in (*Client).Request. // It modifies the request query parameters. func (p Pagination) asRequestOption() requestOption { return func(r *http.Request) { diff --git a/codersdk/parameters.go b/codersdk/parameters.go index 4697d07c51190..9fd9d5d9cad8d 100644 --- a/codersdk/parameters.go +++ b/codersdk/parameters.go @@ -43,7 +43,7 @@ type CreateParameterRequest struct { } func (c *Client) CreateParameter(ctx context.Context, scope ParameterScope, id uuid.UUID, req CreateParameterRequest) (Parameter, error) { - res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id.String()), req) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id.String()), req) if err != nil { return Parameter{}, err } @@ -58,7 +58,7 @@ func (c *Client) CreateParameter(ctx context.Context, scope ParameterScope, id u } func (c *Client) DeleteParameter(ctx context.Context, scope ParameterScope, id uuid.UUID, name string) error { - res, err := c.request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/parameters/%s/%s/%s", scope, id.String(), name), nil) + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/parameters/%s/%s/%s", scope, id.String(), name), nil) if err != nil { return err } @@ -73,7 +73,7 @@ func (c *Client) DeleteParameter(ctx context.Context, scope ParameterScope, id u } func (c *Client) Parameters(ctx context.Context, scope ParameterScope, id uuid.UUID) ([]Parameter, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id.String()), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id.String()), nil) if err != nil { return nil, err } diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index c726f8f255eef..1c906fa8c23b8 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -99,7 +99,7 @@ func (c *Client) provisionerJobLogsBefore(ctx context.Context, path string, befo if !before.IsZero() { values["before"] = []string{strconv.FormatInt(before.UTC().UnixMilli(), 10)} } - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("%s?%s", path, values.Encode()), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("%s?%s", path, values.Encode()), nil) if err != nil { return nil, err } @@ -118,7 +118,7 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after if !after.IsZero() { afterQuery = fmt.Sprintf("&after=%d", after.UTC().UnixMilli()) } - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("%s?follow%s", path, afterQuery), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("%s?follow%s", path, afterQuery), nil) if err != nil { return nil, err } diff --git a/codersdk/roles.go b/codersdk/roles.go index 09aa19b806ccd..377565c06d404 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -17,7 +17,7 @@ type Role struct { // ListSiteRoles lists all available site wide roles. // This is not user specific. func (c *Client) ListSiteRoles(ctx context.Context) ([]Role, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/users/roles", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/roles", nil) if err != nil { return nil, err } @@ -32,7 +32,7 @@ func (c *Client) ListSiteRoles(ctx context.Context) ([]Role, error) { // ListOrganizationRoles lists all available roles for a given organization. // This is not user specific. func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Role, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/roles/", org.String()), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/roles", org.String()), nil) if err != nil { return nil, err } @@ -45,7 +45,7 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Ro } func (c *Client) CheckPermissions(ctx context.Context, checks UserAuthorizationRequest) (UserAuthorizationResponse, error) { - res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/authorization", Me), checks) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/authorization", Me), checks) if err != nil { return nil, err } diff --git a/codersdk/templates.go b/codersdk/templates.go index 14a14ed976650..972a8a5b2b8dd 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -32,7 +32,7 @@ type UpdateActiveTemplateVersion struct { // Template returns a single template. func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil) if err != nil { return Template{}, nil } @@ -45,7 +45,7 @@ func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, er } func (c *Client) DeleteTemplate(ctx context.Context, template uuid.UUID) error { - res, err := c.request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/templates/%s", template), nil) + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/templates/%s", template), nil) if err != nil { return err } @@ -59,7 +59,7 @@ func (c *Client) DeleteTemplate(ctx context.Context, template uuid.UUID) error { // UpdateActiveTemplateVersion updates the active template version to the ID provided. // The template version must be attached to the template. func (c *Client) UpdateActiveTemplateVersion(ctx context.Context, template uuid.UUID, req UpdateActiveTemplateVersion) error { - res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templates/%s/versions", template), req) + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templates/%s/versions", template), req) if err != nil { return nil } @@ -79,7 +79,7 @@ type TemplateVersionsByTemplateRequest struct { // TemplateVersionsByTemplate lists versions associated with a template. func (c *Client) TemplateVersionsByTemplate(ctx context.Context, req TemplateVersionsByTemplateRequest) ([]TemplateVersion, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/versions", req.TemplateID), nil, req.Pagination.asRequestOption()) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/versions", req.TemplateID), nil, req.Pagination.asRequestOption()) if err != nil { return nil, err } @@ -94,7 +94,7 @@ func (c *Client) TemplateVersionsByTemplate(ctx context.Context, req TemplateVer // TemplateVersionByName returns a template version by it's friendly name. // This is used for path-based routing. Like: /templates/example/versions/helloworld func (c *Client) TemplateVersionByName(ctx context.Context, template uuid.UUID, name string) (TemplateVersion, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/versions/%s", template, name), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/versions/%s", template, name), nil) if err != nil { return TemplateVersion{}, err } diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index f7cf29006e514..64e3934c8732a 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -21,6 +21,7 @@ type TemplateVersion struct { UpdatedAt time.Time `json:"updated_at"` Name string `json:"name"` Job ProvisionerJob `json:"job"` + Readme string `json:"readme"` } // TemplateVersionParameterSchema represents a parameter parsed from template version source. @@ -31,7 +32,7 @@ type TemplateVersionParameter parameter.ComputedValue // TemplateVersion returns a template version by ID. func (c *Client) TemplateVersion(ctx context.Context, id uuid.UUID) (TemplateVersion, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s", id), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s", id), nil) if err != nil { return TemplateVersion{}, err } @@ -45,7 +46,7 @@ func (c *Client) TemplateVersion(ctx context.Context, id uuid.UUID) (TemplateVer // CancelTemplateVersion marks a template version job as canceled. func (c *Client) CancelTemplateVersion(ctx context.Context, version uuid.UUID) error { - res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templateversions/%s/cancel", version), nil) + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templateversions/%s/cancel", version), nil) if err != nil { return err } @@ -58,7 +59,7 @@ func (c *Client) CancelTemplateVersion(ctx context.Context, version uuid.UUID) e // TemplateVersionSchema returns schemas for a template version by ID. func (c *Client) TemplateVersionSchema(ctx context.Context, version uuid.UUID) ([]TemplateVersionParameterSchema, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/schema", version), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/schema", version), nil) if err != nil { return nil, err } @@ -72,7 +73,7 @@ func (c *Client) TemplateVersionSchema(ctx context.Context, version uuid.UUID) ( // TemplateVersionParameters returns computed parameters for a template version. func (c *Client) TemplateVersionParameters(ctx context.Context, version uuid.UUID) ([]TemplateVersionParameter, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/parameters", version), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/parameters", version), nil) if err != nil { return nil, err } @@ -86,7 +87,7 @@ func (c *Client) TemplateVersionParameters(ctx context.Context, version uuid.UUI // TemplateVersionResources returns resources a template version declares. func (c *Client) TemplateVersionResources(ctx context.Context, version uuid.UUID) ([]WorkspaceResource, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/resources", version), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/resources", version), nil) if err != nil { return nil, err } diff --git a/codersdk/users.go b/codersdk/users.go index 8af79d720fff0..61faa398d0fa6 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -155,7 +155,7 @@ type AuthMethods struct { // HasFirstUser returns whether the first user has been created. func (c *Client) HasFirstUser(ctx context.Context) (bool, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/users/first", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/first", nil) if err != nil { return false, err } @@ -172,7 +172,7 @@ func (c *Client) HasFirstUser(ctx context.Context) (bool, error) { // CreateFirstUser attempts to create the first user on a Coder deployment. // This initial user has superadmin privileges. If >0 users exist, this request will fail. func (c *Client) CreateFirstUser(ctx context.Context, req CreateFirstUserRequest) (CreateFirstUserResponse, error) { - res, err := c.request(ctx, http.MethodPost, "/api/v2/users/first", req) + res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/first", req) if err != nil { return CreateFirstUserResponse{}, err } @@ -186,7 +186,7 @@ func (c *Client) CreateFirstUser(ctx context.Context, req CreateFirstUserRequest // CreateUser creates a new user. func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, error) { - res, err := c.request(ctx, http.MethodPost, "/api/v2/users", req) + res, err := c.Request(ctx, http.MethodPost, "/api/v2/users", req) if err != nil { return User{}, err } @@ -200,7 +200,7 @@ func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, e // UpdateUserProfile enables callers to update profile information func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateUserProfileRequest) (User, error) { - res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/profile", user), req) + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/profile", user), req) if err != nil { return User{}, err } @@ -224,7 +224,7 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS return User{}, xerrors.Errorf("status %q is not supported", status) } - res, err := c.request(ctx, http.MethodPut, path, nil) + res, err := c.Request(ctx, http.MethodPut, path, nil) if err != nil { return User{}, err } @@ -240,7 +240,7 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS // UpdateUserPassword updates a user password. // It calls PUT /users/{user}/password func (c *Client) UpdateUserPassword(ctx context.Context, user string, req UpdateUserPasswordRequest) error { - res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/password", user), req) + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/password", user), req) if err != nil { return err } @@ -254,7 +254,7 @@ func (c *Client) UpdateUserPassword(ctx context.Context, user string, req Update // 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) { - res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/roles", user), req) + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/roles", user), req) if err != nil { return User{}, err } @@ -269,7 +269,7 @@ func (c *Client) UpdateUserRoles(ctx context.Context, user string, req UpdateRol // UpdateOrganizationMemberRoles grants the userID the specified roles in an org. // Include ALL roles the user has. func (c *Client) UpdateOrganizationMemberRoles(ctx context.Context, organizationID uuid.UUID, user string, req UpdateRoles) (OrganizationMember, error) { - res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/organizations/%s/members/%s/roles", organizationID, user), req) + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/organizations/%s/members/%s/roles", organizationID, user), req) if err != nil { return OrganizationMember{}, err } @@ -283,7 +283,7 @@ func (c *Client) UpdateOrganizationMemberRoles(ctx context.Context, organization // GetUserRoles returns all roles the user has func (c *Client) GetUserRoles(ctx context.Context, user string) (UserRoles, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/roles", user), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/roles", user), nil) if err != nil { return UserRoles{}, err } @@ -297,7 +297,7 @@ func (c *Client) GetUserRoles(ctx context.Context, user string) (UserRoles, erro // CreateAPIKey generates an API key for the user ID provided. func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKeyResponse, error) { - res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", user), nil) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", user), nil) if err != nil { return nil, err } @@ -312,7 +312,7 @@ func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKey // LoginWithPassword creates a session token authenticating with an email and password. // Call `SetSessionToken()` to apply the newly acquired token to the client. func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordRequest) (LoginWithPasswordResponse, error) { - res, err := c.request(ctx, http.MethodPost, "/api/v2/users/login", req) + res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/login", req) if err != nil { return LoginWithPasswordResponse{}, err } @@ -333,7 +333,7 @@ func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordReq func (c *Client) Logout(ctx context.Context) error { // Since `LoginWithPassword` doesn't actually set a SessionToken // (it requires a call to SetSessionToken), this is essentially a no-op - res, err := c.request(ctx, http.MethodPost, "/api/v2/users/logout", nil) + res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/logout", nil) if err != nil { return err } @@ -343,7 +343,7 @@ func (c *Client) Logout(ctx context.Context) error { // User returns a user for the ID/username provided. func (c *Client) User(ctx context.Context, userIdent string) (User, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s", userIdent), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s", userIdent), nil) if err != nil { return User{}, err } @@ -358,7 +358,7 @@ func (c *Client) User(ctx context.Context, userIdent string) (User, error) { // Users returns all users according to the request parameters. If no parameters are set, // the default behavior is to return all users in a single page. func (c *Client) Users(ctx context.Context, req UsersRequest) ([]User, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/users", nil, + res, err := c.Request(ctx, http.MethodGet, "/api/v2/users", nil, req.Pagination.asRequestOption(), func(r *http.Request) { q := r.URL.Query() @@ -382,7 +382,7 @@ func (c *Client) Users(ctx context.Context, req UsersRequest) ([]User, error) { // OrganizationsByUser returns all organizations the user is a member of. func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organization, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations", user), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations", user), nil) if err != nil { return nil, err } @@ -395,7 +395,7 @@ func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organi } func (c *Client) OrganizationByName(ctx context.Context, user string, name string) (Organization, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations/%s", user, name), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations/%s", user, name), nil) if err != nil { return Organization{}, err } @@ -409,7 +409,7 @@ func (c *Client) OrganizationByName(ctx context.Context, user string, name strin // CreateOrganization creates an organization and adds the provided user as an admin. func (c *Client) CreateOrganization(ctx context.Context, user string, req CreateOrganizationRequest) (Organization, error) { - res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/organizations", user), req) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/organizations", user), req) if err != nil { return Organization{}, err } @@ -425,7 +425,7 @@ func (c *Client) CreateOrganization(ctx context.Context, user string, req Create // AuthMethods returns types of authentication available to the user. func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/users/authmethods", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/authmethods", nil) if err != nil { return AuthMethods{}, err } @@ -438,19 +438,3 @@ func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) { var userAuth AuthMethods return userAuth, json.NewDecoder(res.Body).Decode(&userAuth) } - -// WorkspacesByUser returns all workspaces a user has access to. -func (c *Client) WorkspacesByUser(ctx context.Context, user string) ([]Workspace, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspaces", user), nil) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, readBodyAsError(res) - } - - var workspaces []Workspace - return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) -} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index be98b4696fd8b..d64b42bc5faaa 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -65,7 +65,7 @@ func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, servic if err != nil { return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err) } - res, err := c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{ + res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{ JSONWebToken: jwt, }) if err != nil { @@ -129,7 +129,7 @@ func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (Workspac return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err) } - res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{ + res, err = c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{ Signature: string(signature), Document: string(document), }) @@ -164,7 +164,7 @@ func (c *Client) AuthWorkspaceAzureInstanceIdentity(ctx context.Context) (Worksp return WorkspaceAgentAuthenticateResponse{}, err } - res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token) + res, err = c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token) if err != nil { return WorkspaceAgentAuthenticateResponse{}, err } @@ -213,7 +213,7 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) ( } listener, err := peerbroker.Listen(session, func(ctx context.Context) ([]webrtc.ICEServer, *peer.ConnOptions, error) { // This can be cached if it adds to latency too much. - res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/iceservers", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/iceservers", nil) if err != nil { return nil, nil, err } @@ -240,7 +240,7 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) ( if err != nil { return agent.Metadata{}, nil, xerrors.Errorf("listen peerbroker: %w", err) } - res, err = c.request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil) + res, err = c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil) if err != nil { return agent.Metadata{}, nil, err } @@ -292,7 +292,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti return nil, xerrors.Errorf("negotiate connection: %w", err) } - res, err = c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/iceservers", agentID.String()), nil) + res, err = c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/iceservers", agentID.String()), nil) if err != nil { return nil, err } @@ -326,7 +326,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti // WorkspaceAgent returns an agent by ID. func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s", id), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s", id), nil) if err != nil { return WorkspaceAgent{}, err } diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index ef6e68d6bab8f..83e2a4b535cf0 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -14,15 +14,14 @@ import ( ) // WorkspaceBuild is an at-point representation of a workspace state. -// Iterate on before/after to determine a chronological history. +// BuildNumbers start at 1 and increase by 1 for each subsequent build type WorkspaceBuild struct { ID uuid.UUID `json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` WorkspaceID uuid.UUID `json:"workspace_id"` TemplateVersionID uuid.UUID `json:"template_version_id"` - BeforeID uuid.UUID `json:"before_id"` - AfterID uuid.UUID `json:"after_id"` + BuildNumber int32 `json:"build_number"` Name string `json:"name"` Transition database.WorkspaceTransition `json:"transition"` InitiatorID uuid.UUID `json:"initiator_id"` @@ -32,7 +31,7 @@ type WorkspaceBuild struct { // WorkspaceBuild returns a single workspace build for a workspace. // If history is "", the latest version is returned. func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s", id), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s", id), nil) if err != nil { return WorkspaceBuild{}, err } @@ -46,7 +45,7 @@ func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBui // CancelWorkspaceBuild marks a workspace build job as canceled. func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error { - res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspacebuilds/%s/cancel", id), nil) + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspacebuilds/%s/cancel", id), nil) if err != nil { return err } @@ -59,7 +58,7 @@ func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error { // WorkspaceResourcesByBuild returns resources for a workspace build. func (c *Client) WorkspaceResourcesByBuild(ctx context.Context, build uuid.UUID) ([]WorkspaceResource, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/resources", build), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/resources", build), nil) if err != nil { return nil, err } @@ -83,7 +82,7 @@ func (c *Client) WorkspaceBuildLogsAfter(ctx context.Context, build uuid.UUID, a // WorkspaceBuildState returns the provisioner state of the build. func (c *Client) WorkspaceBuildState(ctx context.Context, build uuid.UUID) ([]byte, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/state", build), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/state", build), nil) if err != nil { return nil, err } diff --git a/codersdk/workspaceresources.go b/codersdk/workspaceresources.go index b21451bbc63ea..f1dc5d74a04f9 100644 --- a/codersdk/workspaceresources.go +++ b/codersdk/workspaceresources.go @@ -69,7 +69,7 @@ type WorkspaceAgentInstanceMetadata struct { } func (c *Client) WorkspaceResource(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceresources/%s", id), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceresources/%s", id), nil) if err != nil { return WorkspaceResource{}, err } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 644f90422ff07..6e4ab7afd6e57 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -40,7 +40,7 @@ type CreateWorkspaceBuildRequest struct { // Workspace returns a single workspace. func (c *Client) Workspace(ctx context.Context, id uuid.UUID) (Workspace, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s", id), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s", id), nil) if err != nil { return Workspace{}, err } @@ -52,8 +52,14 @@ func (c *Client) Workspace(ctx context.Context, id uuid.UUID) (Workspace, error) return workspace, json.NewDecoder(res.Body).Decode(&workspace) } -func (c *Client) WorkspaceBuilds(ctx context.Context, workspace uuid.UUID) ([]WorkspaceBuild, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds", workspace), nil) +type WorkspaceBuildsRequest struct { + WorkspaceID uuid.UUID + Pagination +} + +func (c *Client) WorkspaceBuilds(ctx context.Context, req WorkspaceBuildsRequest) ([]WorkspaceBuild, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds", req.WorkspaceID), + nil, req.Pagination.asRequestOption()) if err != nil { return nil, err } @@ -67,7 +73,7 @@ func (c *Client) WorkspaceBuilds(ctx context.Context, workspace uuid.UUID) ([]Wo // CreateWorkspaceBuild queues a new build to occur for a workspace. func (c *Client) CreateWorkspaceBuild(ctx context.Context, workspace uuid.UUID, request CreateWorkspaceBuildRequest) (WorkspaceBuild, error) { - res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s/builds", workspace), request) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s/builds", workspace), request) if err != nil { return WorkspaceBuild{}, err } @@ -80,7 +86,7 @@ func (c *Client) CreateWorkspaceBuild(ctx context.Context, workspace uuid.UUID, } func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, name string) (WorkspaceBuild, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds/%s", workspace, name), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds/%s", workspace, name), nil) if err != nil { return WorkspaceBuild{}, err } @@ -101,7 +107,7 @@ type UpdateWorkspaceAutostartRequest struct { // If the provided schedule is empty, autostart is disabled for the workspace. func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostartRequest) error { path := fmt.Sprintf("/api/v2/workspaces/%s/autostart", id.String()) - res, err := c.request(ctx, http.MethodPut, path, req) + res, err := c.Request(ctx, http.MethodPut, path, req) if err != nil { return xerrors.Errorf("update workspace autostart: %w", err) } @@ -121,7 +127,7 @@ type UpdateWorkspaceAutostopRequest struct { // If the provided schedule is empty, autostop is disabled for the workspace. func (c *Client) UpdateWorkspaceAutostop(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostopRequest) error { path := fmt.Sprintf("/api/v2/workspaces/%s/autostop", id.String()) - res, err := c.request(ctx, http.MethodPut, path, req) + res, err := c.Request(ctx, http.MethodPut, path, req) if err != nil { return xerrors.Errorf("update workspace autostop: %w", err) } @@ -131,3 +137,41 @@ func (c *Client) UpdateWorkspaceAutostop(ctx context.Context, id uuid.UUID, req } return nil } + +type WorkspaceFilter struct { + OrganizationID uuid.UUID + // Owner can be a user_id (uuid), "me", or a username + Owner string +} + +// asRequestOption returns a function that can be used in (*Client).Request. +// It modifies the request query parameters. +func (f WorkspaceFilter) asRequestOption() requestOption { + return func(r *http.Request) { + q := r.URL.Query() + if f.OrganizationID != uuid.Nil { + q.Set("organization_id", f.OrganizationID.String()) + } + if f.Owner != "" { + q.Set("owner_id", f.Owner) + } + r.URL.RawQuery = q.Encode() + } +} + +// Workspaces returns all workspaces the authenticated user has access to. +func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaces", nil, filter.asRequestOption()) + + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + + var workspaces []Workspace + return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) +} diff --git a/cryptorand/slices.go b/cryptorand/slices.go new file mode 100644 index 0000000000000..90bc6a9b20368 --- /dev/null +++ b/cryptorand/slices.go @@ -0,0 +1,20 @@ +package cryptorand + +import ( + "golang.org/x/xerrors" +) + +// Element returns a random element of the slice. An error will be returned if +// the slice has no elements in it. +func Element[T any](s []T) (out T, err error) { + if len(s) == 0 { + return out, xerrors.New("slice must have at least one element") + } + + i, err := Intn(len(s)) + if err != nil { + return out, xerrors.Errorf("generate random integer from 0-%v: %w", len(s), err) + } + + return s[i], nil +} diff --git a/cryptorand/slices_test.go b/cryptorand/slices_test.go new file mode 100644 index 0000000000000..f4c7be248c0cc --- /dev/null +++ b/cryptorand/slices_test.go @@ -0,0 +1,56 @@ +package cryptorand_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cryptorand" +) + +func TestRandomElement(t *testing.T) { + t.Parallel() + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + s := []string{} + v, err := cryptorand.Element(s) + require.Error(t, err) + require.ErrorContains(t, err, "slice must have at least one element") + require.Empty(t, v) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + // Generate random slices of ints and strings + var ( + ints = make([]int, 20) + strings = make([]string, 20) + ) + for i := range ints { + v, err := cryptorand.Intn(1024) + require.NoError(t, err, "generate random int for test slice") + ints[i] = v + } + for i := range strings { + v, err := cryptorand.String(10) + require.NoError(t, err, "generate random string for test slice") + strings[i] = v + } + + // Get a random value from each 20 times. + for i := 0; i < 20; i++ { + iv, err := cryptorand.Element(ints) + require.NoError(t, err, "unexpected error from Element(ints)") + t.Logf("random int slice element: %v", iv) + require.Contains(t, ints, iv) + + sv, err := cryptorand.Element(strings) + require.NoError(t, err, "unexpected error from Element(strings)") + t.Logf("random string slice element: %v", sv) + require.Contains(t, strings, sv) + } + }) +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0658262128fc6..4164727df51fb 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -9,10 +9,10 @@ Coder requires Go 1.18+, Node 14+, and GNU Make. Use the following `make` commands and scripts in development: +- `make dev` runs the frontend and backend development server - `make build` compiles binaries and release packages - `make install` installs binaries to `$GOPATH/bin` - `make test` -- `./develop.sh` hot reloads for front-end development ## Styling diff --git a/examples/aws-linux/README.md b/examples/aws-linux/README.md index 6bc248d3ba837..bf50e661334bc 100644 --- a/examples/aws-linux/README.md +++ b/examples/aws-linux/README.md @@ -3,3 +3,62 @@ name: Develop in Linux on AWS EC2 description: Get started with Linux development on AWS EC2. tags: [cloud, aws] --- + +# aws-linux + +## Getting started + +Pick this template in `coder templates init` and follow instructions. + +## Required permissions / policy + +This example policy allows Coder to create EC2 instances and modify instances provisioned by Coder. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "ec2:GetDefaultCreditSpecification", + "ec2:DescribeIamInstanceProfileAssociations", + "ec2:DescribeTags", + "ec2:CreateTags", + "ec2:RunInstances", + "ec2:DescribeInstanceCreditSpecifications", + "ec2:DescribeImages", + "ec2:ModifyDefaultCreditSpecification", + "ec2:DescribeVolumes" + ], + "Resource": "*" + }, + { + "Sid": "CoderResouces", + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeInstanceAttribute", + "ec2:UnmonitorInstances", + "ec2:TerminateInstances", + "ec2:StartInstances", + "ec2:StopInstances", + "ec2:DeleteTags", + "ec2:MonitorInstances", + "ec2:CreateTags", + "ec2:RunInstances", + "ec2:ModifyInstanceAttribute", + "ec2:ModifyInstanceCreditSpecification" + ], + "Resource": "arn:aws:ec2:*:*:instance/*", + "Condition": { + "StringEquals": { + "aws:ResourceTag/Coder_Provisioned": "true" + } + } + } + ] +} +``` + diff --git a/examples/aws-linux/main.tf b/examples/aws-linux/main.tf index b5fc1f3283ea4..d6eb41a2da6ac 100644 --- a/examples/aws-linux/main.tf +++ b/examples/aws-linux/main.tf @@ -11,6 +11,9 @@ variable "access_key" { description = < 0 { didLog.Store(true) } + if len(update.Readme) > 0 { + didReadme.Store(true) + } return &proto.UpdateJobResponse{}, nil }, completeJob: func(ctx context.Context, job *proto.CompletedJob) (*proto.Empty, error) { diff --git a/pty/pty.go b/pty/pty.go index 00eb7d33ea4c1..7a8fe6c99edb6 100644 --- a/pty/pty.go +++ b/pty/pty.go @@ -2,6 +2,7 @@ package pty import ( "io" + "os" ) // PTY is a minimal interface for interacting with a TTY. @@ -14,7 +15,7 @@ type PTY interface { // uses the output stream for writing. // // The same stream could be read to validate output. - Output() io.ReadWriter + Output() ReadWriter // Input handles TTY input. // @@ -22,18 +23,38 @@ type PTY interface { // uses the PTY input for reading. // // The same stream would be used to provide user input: pty.Input().Write(...) - Input() io.ReadWriter + Input() ReadWriter // Resize sets the size of the PTY. Resize(height uint16, width uint16) error } +// WithFlags represents a PTY whose flags can be inspected, in particular +// to determine whether local echo is enabled. +type WithFlags interface { + PTY + + // EchoEnabled determines whether local echo is currently enabled for this terminal. + EchoEnabled() (bool, error) +} + // New constructs a new Pty. func New() (PTY, error) { return newPty() } -type readWriter struct { - io.Reader - io.Writer +// ReadWriter is an implementation of io.ReadWriter that wraps two separate +// underlying file descriptors, one for reading and one for writing, and allows +// them to be accessed separately. +type ReadWriter struct { + Reader *os.File + Writer *os.File +} + +func (rw ReadWriter) Read(p []byte) (int, error) { + return rw.Reader.Read(p) +} + +func (rw ReadWriter) Write(p []byte) (int, error) { + return rw.Writer.Write(p) } diff --git a/pty/pty_linux.go b/pty/pty_linux.go new file mode 100644 index 0000000000000..b18d801c228e8 --- /dev/null +++ b/pty/pty_linux.go @@ -0,0 +1,13 @@ +// go:build linux + +package pty + +import "golang.org/x/sys/unix" + +func (p *otherPty) EchoEnabled() (bool, error) { + termios, err := unix.IoctlGetTermios(int(p.pty.Fd()), unix.TCGETS) + if err != nil { + return false, err + } + return (termios.Lflag & unix.ECHO) != 0, nil +} diff --git a/pty/pty_other.go b/pty/pty_other.go index b826bd3a3398f..d6e21d4d3ffe1 100644 --- a/pty/pty_other.go +++ b/pty/pty_other.go @@ -4,7 +4,6 @@ package pty import ( - "io" "os" "sync" @@ -28,15 +27,15 @@ type otherPty struct { pty, tty *os.File } -func (p *otherPty) Input() io.ReadWriter { - return readWriter{ +func (p *otherPty) Input() ReadWriter { + return ReadWriter{ Reader: p.tty, Writer: p.pty, } } -func (p *otherPty) Output() io.ReadWriter { - return readWriter{ +func (p *otherPty) Output() ReadWriter { + return ReadWriter{ Reader: p.pty, Writer: p.tty, } diff --git a/pty/pty_windows.go b/pty/pty_windows.go index 854ecfe36eeda..93e58c4405772 100644 --- a/pty/pty_windows.go +++ b/pty/pty_windows.go @@ -4,7 +4,6 @@ package pty import ( - "io" "os" "sync" "unsafe" @@ -67,15 +66,15 @@ type ptyWindows struct { closed bool } -func (p *ptyWindows) Output() io.ReadWriter { - return readWriter{ +func (p *ptyWindows) Output() ReadWriter { + return ReadWriter{ Reader: p.outputRead, Writer: p.outputWrite, } } -func (p *ptyWindows) Input() io.ReadWriter { - return readWriter{ +func (p *ptyWindows) Input() ReadWriter { + return ReadWriter{ Reader: p.inputRead, Writer: p.inputWrite, } diff --git a/develop.sh b/scripts/develop.sh similarity index 100% rename from develop.sh rename to scripts/develop.sh diff --git a/site/package.json b/site/package.json index dafb4078a732b..98b0ee16e29bd 100644 --- a/site/package.json +++ b/site/package.json @@ -34,7 +34,7 @@ "@xstate/inspect": "0.6.5", "@xstate/react": "3.0.0", "axios": "0.26.1", - "cronstrue": "2.4.0", + "cronstrue": "2.5.0", "dayjs": "1.11.2", "formik": "2.2.9", "history": "5.3.0", @@ -60,7 +60,7 @@ "@storybook/react": "6.4.22", "@testing-library/jest-dom": "5.16.4", "@testing-library/react": "12.1.5", - "@testing-library/user-event": "14.1.1", + "@testing-library/user-event": "14.2.0", "@types/express": "4.17.13", "@types/jest": "27.4.1", "@types/node": "14.18.16", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index b3b8f59981ae9..f0145fdde8b03 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -12,6 +12,7 @@ import { OrgsPage } from "./pages/OrgsPage/OrgsPage" import { SettingsPage } from "./pages/SettingsPage/SettingsPage" import { AccountPage } from "./pages/SettingsPages/AccountPage/AccountPage" import { SSHKeysPage } from "./pages/SettingsPages/SSHKeysPage/SSHKeysPage" +import TemplatesPage from "./pages/TemplatesPage/TemplatesPage" import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage" import { UsersPage } from "./pages/UsersPage/UsersPage" import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage" @@ -73,6 +74,17 @@ export const AppRouter: React.FC = () => ( + + + + + } + /> + + => { + const response = await axios.get(`/api/v2/organizations/${organizationId}/templates`) + return response.data +} + export const getWorkspace = async (workspaceId: string): Promise => { const response = await axios.get(`/api/v2/workspaces/${workspaceId}`) return response.data } -export const getWorkspaces = async (userID = "me"): Promise => { - const response = await axios.get(`/api/v2/users/${userID}/workspaces`) +// TODO: @emyrk add query params as arguments. Supports 'organization_id' and 'owner' +// 'owner' can be a username, user_id, or 'me' +export const getWorkspaces = async (): Promise => { + const response = await axios.get(`/api/v2/workspaces`) return response.data } @@ -221,3 +228,8 @@ export const regenerateUserSSHKey = async (userId = "me"): Promise(`/api/v2/users/${userId}/gitsshkey`) return response.data } + +export const getWorkspaceBuilds = async (workspaceId: string): Promise => { + const response = await axios.get(`/api/v2/workspaces/${workspaceId}/builds`) + return response.data +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index da8c18eee2d2c..792ef3961313c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -231,9 +231,10 @@ export interface TemplateVersion { readonly updated_at: string readonly name: string readonly job: ProvisionerJob + readonly readme: string } -// From codersdk/templateversions.go:30:6 +// From codersdk/templateversions.go:31:6 export interface TemplateVersionParameter { // Named type "github.com/coder/coder/coderd/database.ParameterValue" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -242,7 +243,7 @@ export interface TemplateVersionParameter { readonly default_source_value: boolean } -// From codersdk/templateversions.go:27:6 +// From codersdk/templateversions.go:28:6 export interface TemplateVersionParameterSchema { readonly id: string readonly created_at: string @@ -291,12 +292,12 @@ export interface UpdateUserProfileRequest { readonly username: string } -// From codersdk/workspaces.go:96:6 +// From codersdk/workspaces.go:102:6 export interface UpdateWorkspaceAutostartRequest { readonly schedule: string } -// From codersdk/workspaces.go:116:6 +// From codersdk/workspaces.go:122:6 export interface UpdateWorkspaceAutostopRequest { readonly schedule: string } @@ -420,8 +421,7 @@ export interface WorkspaceBuild { readonly updated_at: string readonly workspace_id: string readonly template_version_id: string - readonly before_id: string - readonly after_id: string + readonly build_number: number readonly name: string // This is likely an enum in an external package ("github.com/coder/coder/coderd/database.WorkspaceTransition") readonly transition: string @@ -429,6 +429,17 @@ export interface WorkspaceBuild { readonly job: ProvisionerJob } +// From codersdk/workspaces.go:55:6 +export interface WorkspaceBuildsRequest extends Pagination { + readonly WorkspaceID: string +} + +// From codersdk/workspaces.go:141:6 +export interface WorkspaceFilter { + readonly OrganizationID: string + readonly Owner: string +} + // From codersdk/workspaceresources.go:23:6 export interface WorkspaceResource { readonly id: string diff --git a/site/src/components/BorderedMenu/BorderedMenu.stories.tsx b/site/src/components/BorderedMenu/BorderedMenu.stories.tsx index 73186fd77faec..e53deab3fab06 100644 --- a/site/src/components/BorderedMenu/BorderedMenu.stories.tsx +++ b/site/src/components/BorderedMenu/BorderedMenu.stories.tsx @@ -12,8 +12,14 @@ export default { const Template: Story = (args: BorderedMenuProps) => ( - - + + ) diff --git a/site/src/components/BorderedMenuRow/BorderedMenuRow.tsx b/site/src/components/BorderedMenuRow/BorderedMenuRow.tsx index d83bebdf52166..32b218b282853 100644 --- a/site/src/components/BorderedMenuRow/BorderedMenuRow.tsx +++ b/site/src/components/BorderedMenuRow/BorderedMenuRow.tsx @@ -17,7 +17,7 @@ interface BorderedMenuRowProps { /** An SvgIcon that will be rendered to the left of the title */ Icon: typeof SvgIcon /** URL path */ - path?: string + path: string /** Required title of this row */ title: string /** Defaults to `"wide"` */ @@ -37,38 +37,30 @@ export const BorderedMenuRow: React.FC = ({ }) => { const styles = useStyles() - const Component = () => ( - -
-
- - {title} - {active && } -
+ return ( + + +
+
+ + {title} + {active && } +
- {description && ( - - {ellipsizeText(description)} - - )} -
-
+ {description && ( + + {ellipsizeText(description)} + + )} +
+
+ ) - - if (path) { - return ( - - - - ) - } - - return } const iconSize = 20 diff --git a/site/src/components/BuildsTable/BuildsTable.stories.tsx b/site/src/components/BuildsTable/BuildsTable.stories.tsx new file mode 100644 index 0000000000000..4626b8723cd87 --- /dev/null +++ b/site/src/components/BuildsTable/BuildsTable.stories.tsx @@ -0,0 +1,21 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockBuilds } from "../../testHelpers/entities" +import { BuildsTable, BuildsTableProps } from "./BuildsTable" + +export default { + title: "components/BuildsTable", + component: BuildsTable, +} as ComponentMeta + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + builds: MockBuilds, +} + +export const Empty = Template.bind({}) +Empty.args = { + builds: [], +} diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx new file mode 100644 index 0000000000000..3e26910894e75 --- /dev/null +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -0,0 +1,97 @@ +import Box from "@material-ui/core/Box" +import { Theme } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import useTheme from "@material-ui/styles/useTheme" +import dayjs from "dayjs" +import duration from "dayjs/plugin/duration" +import relativeTime from "dayjs/plugin/relativeTime" +import React from "react" +import * as TypesGen from "../../api/typesGenerated" +import { getDisplayStatus } from "../../util/workspace" +import { EmptyState } from "../EmptyState/EmptyState" +import { TableLoader } from "../TableLoader/TableLoader" + +dayjs.extend(relativeTime) +dayjs.extend(duration) + +export const Language = { + emptyMessage: "No builds found", + inProgressLabel: "In progress", + actionLabel: "Action", + durationLabel: "Duration", + startedAtLabel: "Started at", + statusLabel: "Status", +} + +const getDurationInSeconds = (build: TypesGen.WorkspaceBuild) => { + let display = Language.inProgressLabel + + if (build.job.started_at && build.job.completed_at) { + const startedAt = dayjs(build.job.started_at) + const completedAt = dayjs(build.job.completed_at) + const diff = completedAt.diff(startedAt, "seconds") + display = `${diff} seconds` + } + + return display +} + +export interface BuildsTableProps { + builds?: TypesGen.WorkspaceBuild[] + className?: string +} + +export const BuildsTable: React.FC = ({ builds, className }) => { + const isLoading = !builds + const theme: Theme = useTheme() + + return ( + + + + {Language.actionLabel} + {Language.durationLabel} + {Language.startedAtLabel} + {Language.statusLabel} + + + + {isLoading && } + {builds && + builds.map((b) => { + const status = getDisplayStatus(theme, b) + const duration = getDurationInSeconds(b) + + return ( + + {b.transition} + + {duration} + + + {new Date(b.created_at).toLocaleString()} + + + {status.status} + + + ) + })} + + {builds && builds.length === 0 && ( + + + + + + + + )} + +
+ ) +} diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 2938240744b0f..1a471dc8bcb28 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -30,6 +30,11 @@ export const NavbarView: React.FC = ({ user, onSignOut, display Workspaces + + + Templates + +
{displayAdminDropdown && } diff --git a/site/src/components/TerminalLink/TerminalLink.stories.tsx b/site/src/components/TerminalLink/TerminalLink.stories.tsx new file mode 100644 index 0000000000000..417821f53d30b --- /dev/null +++ b/site/src/components/TerminalLink/TerminalLink.stories.tsx @@ -0,0 +1,16 @@ +import { Story } from "@storybook/react" +import React from "react" +import { MockWorkspace } from "../../testHelpers/renderHelpers" +import { TerminalLink, TerminalLinkProps } from "./TerminalLink" + +export default { + title: "components/TerminalLink", + component: TerminalLink, +} + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + workspaceName: MockWorkspace.name, +} diff --git a/site/src/components/TerminalLink/TerminalLink.tsx b/site/src/components/TerminalLink/TerminalLink.tsx new file mode 100644 index 0000000000000..f8c93212103c7 --- /dev/null +++ b/site/src/components/TerminalLink/TerminalLink.tsx @@ -0,0 +1,28 @@ +import Link from "@material-ui/core/Link" +import React from "react" +import * as TypesGen from "../../api/typesGenerated" + +export const Language = { + linkText: "Open in terminal", +} + +export interface TerminalLinkProps { + agentName?: TypesGen.WorkspaceAgent["name"] + userName?: TypesGen.User["username"] + workspaceName: TypesGen.Workspace["name"] +} + +/** + * Generate a link to a terminal connected to the provided workspace agent. If + * no agent is provided connect to the first agent. + * + * If no user name is provided "me" is used however it makes the link not + * shareable. + */ +export const TerminalLink: React.FC = ({ agentName, userName = "me", workspaceName }) => { + return ( + + {Language.linkText} + + ) +} diff --git a/site/src/components/UserDropdown/UsersDropdown.tsx b/site/src/components/UserDropdown/UsersDropdown.tsx index dc61cd6215302..3e1de2c41162a 100644 --- a/site/src/components/UserDropdown/UsersDropdown.tsx +++ b/site/src/components/UserDropdown/UsersDropdown.tsx @@ -8,6 +8,7 @@ import AccountIcon from "@material-ui/icons/AccountCircleOutlined" import React, { useState } from "react" import { Link } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" +import { navHeight } from "../../theme/constants" import { BorderedMenu } from "../BorderedMenu/BorderedMenu" import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows" import { DocsIcon } from "../Icons/DocsIcon" @@ -38,7 +39,7 @@ export const UserDropdown: React.FC = ({ user, onSignOut }: U return ( <> - +
@@ -121,7 +122,7 @@ export const useStyles = makeStyles((theme) => ({ }, menuItem: { - height: 44, + height: navHeight, padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`, "&:hover": { diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index ab880a1d4169d..5f94b749463ce 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -3,6 +3,7 @@ import Typography from "@material-ui/core/Typography" import React from "react" import * as TypesGen from "../../api/typesGenerated" import { WorkspaceStatus } from "../../util/workspace" +import { BuildsTable } from "../BuildsTable/BuildsTable" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" @@ -16,6 +17,7 @@ export interface WorkspaceProps { handleRetry: () => void handleUpdate: () => void workspaceStatus: WorkspaceStatus + builds?: TypesGen.WorkspaceBuild[] } /** @@ -28,6 +30,7 @@ export const Workspace: React.FC = ({ handleRetry, handleUpdate, workspaceStatus, + builds, }) => { const styles = useStyles() @@ -56,13 +59,8 @@ export const Workspace: React.FC = ({
- -
- -
+ +
@@ -105,5 +103,11 @@ export const useStyles = makeStyles(() => { timelineContainer: { flex: 1, }, + timelineContents: { + margin: 0, + }, + timelineTable: { + border: 0, + }, } }) diff --git a/site/src/components/WorkspaceSection/WorkspaceSection.tsx b/site/src/components/WorkspaceSection/WorkspaceSection.tsx index bcdb90c03463c..73dac822eb8d6 100644 --- a/site/src/components/WorkspaceSection/WorkspaceSection.tsx +++ b/site/src/components/WorkspaceSection/WorkspaceSection.tsx @@ -1,14 +1,16 @@ import Paper from "@material-ui/core/Paper" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" -import React from "react" +import React, { HTMLProps } from "react" import { CardPadding, CardRadius } from "../../theme/constants" +import { combineClasses } from "../../util/combineClasses" export interface WorkspaceSectionProps { title?: string + contentsProps?: HTMLProps } -export const WorkspaceSection: React.FC = ({ title, children }) => { +export const WorkspaceSection: React.FC = ({ title, children, contentsProps }) => { const styles = useStyles() return ( @@ -21,7 +23,9 @@ export const WorkspaceSection: React.FC = ({ title, child )} -
{children}
+
+ {children} +
) } diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx new file mode 100644 index 0000000000000..03e18499f12c9 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -0,0 +1,153 @@ +import Avatar from "@material-ui/core/Avatar" +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +import { makeStyles } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import AddCircleOutline from "@material-ui/icons/AddCircleOutline" +import dayjs from "dayjs" +import relativeTime from "dayjs/plugin/relativeTime" +import React from "react" +import { Link as RouterLink } from "react-router-dom" +import * as TypesGen from "../../api/typesGenerated" +import { Margins } from "../../components/Margins/Margins" +import { Stack } from "../../components/Stack/Stack" +import { firstLetter } from "../../util/firstLetter" + +dayjs.extend(relativeTime) + +export const Language = { + createButton: "Create Template", + emptyViewCreate: "to standardize development workspaces for your team.", + emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.", +} + +export interface TemplatesPageViewProps { + loading?: boolean + canCreateTemplate?: boolean + templates?: TypesGen.Template[] + error?: unknown +} + +export const TemplatesPageView: React.FC = (props) => { + const styles = useStyles() + return ( + + +
+ {props.canCreateTemplate && } +
+ + + + Name + Used By + Last Updated + + + + {!props.loading && !props.templates?.length && ( + + +
+ {props.canCreateTemplate ? ( + + + Create a template + +  {Language.emptyViewCreate} + + ) : ( + {Language.emptyViewNoPerms} + )} +
+
+
+ )} + {props.templates?.map((template) => { + return ( + + +
+ + {firstLetter(template.name)} + + + {template.name} + {template.description} + +
+
+ + {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} + + {dayjs().to(dayjs(template.updated_at))} +
+ ) + })} +
+
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + actions: { + marginTop: theme.spacing(3), + marginBottom: theme.spacing(3), + display: "flex", + height: theme.spacing(6), + + "& button": { + marginLeft: "auto", + }, + }, + welcome: { + paddingTop: theme.spacing(12), + paddingBottom: theme.spacing(12), + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + "& span": { + maxWidth: 600, + textAlign: "center", + fontSize: theme.spacing(2), + lineHeight: `${theme.spacing(3)}px`, + }, + }, + templateRow: { + "& > td": { + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + }, + }, + templateAvatar: { + borderRadius: 2, + marginRight: theme.spacing(1), + width: 24, + height: 24, + fontSize: 16, + }, + templateName: { + display: "flex", + alignItems: "center", + }, + templateLink: { + display: "flex", + flexDirection: "column", + color: theme.palette.text.primary, + textDecoration: "none", + "&:hover": { + textDecoration: "underline", + }, + "& span": { + fontSize: 12, + color: theme.palette.text.secondary, + }, + }, +})) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx new file mode 100644 index 0000000000000..c74c07b0d6fbf --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx @@ -0,0 +1,67 @@ +import { screen } from "@testing-library/react" +import { rest } from "msw" +import React from "react" +import { MockTemplate } from "../../testHelpers/entities" +import { history, render } from "../../testHelpers/renderHelpers" +import { server } from "../../testHelpers/server" +import TemplatesPage from "./TemplatesPage" +import { Language } from "./TemplatesPageView" + +describe("TemplatesPage", () => { + beforeEach(() => { + history.replace("/workspaces") + }) + + it("renders an empty templates page", async () => { + // Given + server.use( + rest.get("/api/v2/organizations/:organizationId/templates", (req, res, ctx) => { + return res(ctx.status(200), ctx.json([])) + }), + rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + createTemplates: true, + }), + ) + }), + ) + + // When + render() + + // Then + await screen.findByText(Language.emptyViewCreate) + }) + + it("renders a filled templates page", async () => { + // When + render() + + // Then + await screen.findByText(MockTemplate.name) + }) + + it("shows empty view without permissions to create", async () => { + server.use( + rest.get("/api/v2/organizations/:organizationId/templates", (req, res, ctx) => { + return res(ctx.status(200), ctx.json([])) + }), + rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + createTemplates: false, + }), + ) + }), + ) + + // When + render() + + // Then + await screen.findByText(Language.emptyViewNoPerms) + }) +}) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx new file mode 100644 index 0000000000000..545634172ca22 --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -0,0 +1,21 @@ +import { useActor, useMachine } from "@xstate/react" +import React, { useContext } from "react" +import { XServiceContext } from "../../xServices/StateContext" +import { templatesMachine } from "../../xServices/templates/templatesXService" +import { TemplatesPageView } from "./TemplatesPageView" + +const TemplatesPage: React.FC = () => { + const xServices = useContext(XServiceContext) + const [authState] = useActor(xServices.authXService) + const [templatesState] = useMachine(templatesMachine) + + return ( + + ) +} + +export default TemplatesPage diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx new file mode 100644 index 0000000000000..91fdba645d725 --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -0,0 +1,36 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockTemplate } from "../../testHelpers/entities" +import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView" + +export default { + title: "pages/TemplatesPageView", + component: TemplatesPageView, +} as ComponentMeta + +const Template: Story = (args) => + +export const AllStates = Template.bind({}) +AllStates.args = { + canCreateTemplate: true, + templates: [ + MockTemplate, + { + ...MockTemplate, + description: "🚀 Some magical template that does some magical things!", + }, + { + ...MockTemplate, + workspace_owner_count: 150, + description: "😮 Wow, this one has a bunch of usage!", + }, + ], +} + +export const EmptyCanCreate = Template.bind({}) +EmptyCanCreate.args = { + canCreateTemplate: true, +} + +export const EmptyCannotCreate = Template.bind({}) +EmptyCannotCreate.args = {} diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx new file mode 100644 index 0000000000000..1d41b257b6064 --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -0,0 +1,156 @@ +import Avatar from "@material-ui/core/Avatar" +import Box from "@material-ui/core/Box" +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +import { makeStyles } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import AddCircleOutline from "@material-ui/icons/AddCircleOutline" +import dayjs from "dayjs" +import relativeTime from "dayjs/plugin/relativeTime" +import React from "react" +import { Link as RouterLink } from "react-router-dom" +import * as TypesGen from "../../api/typesGenerated" +import { Margins } from "../../components/Margins/Margins" +import { Stack } from "../../components/Stack/Stack" +import { TableLoader } from "../../components/TableLoader/TableLoader" +import { firstLetter } from "../../util/firstLetter" + +dayjs.extend(relativeTime) + +export const Language = { + createButton: "Create Template", + developerCount: (ownerCount: number): string => { + return `${ownerCount} developer${ownerCount !== 1 ? "s" : ""}` + }, + nameLabel: "Name", + usedByLabel: "Used By", + lastUpdatedLabel: "Last Updated", + emptyViewCreateCTA: "Create a template", + emptyViewCreate: "to standardize development workspaces for your team.", + emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.", +} + +export interface TemplatesPageViewProps { + loading?: boolean + canCreateTemplate?: boolean + templates?: TypesGen.Template[] +} + +export const TemplatesPageView: React.FC = (props) => { + const styles = useStyles() + return ( + + +
+ {props.canCreateTemplate && } +
+ + + + {Language.nameLabel} + {Language.usedByLabel} + {Language.lastUpdatedLabel} + + + + {props.loading && } + {!props.loading && !props.templates?.length && ( + + +
+ {props.canCreateTemplate ? ( + + + {Language.emptyViewCreateCTA} + +  {Language.emptyViewCreate} + + ) : ( + {Language.emptyViewNoPerms} + )} +
+
+
+ )} + {props.templates?.map((template) => ( + + + + + {firstLetter(template.name)} + + + {template.name} + {template.description} + + + + + {Language.developerCount(template.workspace_owner_count)} + + {dayjs().to(dayjs(template.updated_at))} + + ))} +
+
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + actions: { + marginTop: theme.spacing(3), + marginBottom: theme.spacing(3), + display: "flex", + height: theme.spacing(6), + + "& button": { + marginLeft: "auto", + }, + }, + welcome: { + paddingTop: theme.spacing(12), + paddingBottom: theme.spacing(12), + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + "& span": { + maxWidth: 600, + textAlign: "center", + fontSize: theme.spacing(2), + lineHeight: `${theme.spacing(3)}px`, + }, + }, + templateRow: { + "& > td": { + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + }, + }, + templateAvatar: { + borderRadius: 2, + marginRight: theme.spacing(1), + width: 24, + height: 24, + fontSize: 16, + }, + templateLink: { + display: "flex", + flexDirection: "column", + color: theme.palette.text.primary, + textDecoration: "none", + "&:hover": { + textDecoration: "underline", + }, + "& span": { + fontSize: 12, + color: theme.palette.text.secondary, + }, + }, +})) diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index 2ece384d7e41d..c29560ba5dbce 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -6,7 +6,7 @@ import React from "react" import { Route, Routes } from "react-router-dom" import { TextDecoder, TextEncoder } from "util" import { ReconnectingPTYRequest } from "../../api/types" -import { history, MockWorkspaceAgent, render } from "../../testHelpers/renderHelpers" +import { history, MockWorkspace, MockWorkspaceAgent, render } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" import TerminalPage, { Language } from "./TerminalPage" @@ -52,7 +52,7 @@ const expectTerminalText = (container: HTMLElement, text: string) => { describe("TerminalPage", () => { beforeEach(() => { - history.push("/some-user/my-workspace/terminal") + history.push(`/some-user/${MockWorkspace.name}/terminal`) }) it("shows an error if fetching organizations fails", async () => { @@ -146,4 +146,20 @@ describe("TerminalPage", () => { expect(req.width).toBeGreaterThan(0) server.close() }) + + it("supports workspace.agent syntax", async () => { + // Given + const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty") + const text = "something to render" + + // When + history.push(`/some-user/${MockWorkspace.name}.${MockWorkspaceAgent.name}/terminal`) + const { container } = renderTerminal() + + // Then + await server.connected + server.send(text) + await expectTerminalText(container, text) + server.close() + }) }) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 0beaf3017d5ec..093cc7781fc07 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -34,10 +34,14 @@ const TerminalPage: React.FC<{ const search = new URLSearchParams(location.search) return search.get("reconnect") ?? uuidv4() }) + // The workspace name is in the format: + // [.] + const workspaceNameParts = workspace?.split(".") const [terminalState, sendEvent] = useMachine(terminalMachine, { context: { + agentName: workspaceNameParts?.[1], reconnection: reconnectionToken, - workspaceName: workspace, + workspaceName: workspaceNameParts?.[0], username: username, }, actions: { diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 2686316362320..ad23e0cd4ed9c 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,11 +1,11 @@ -/* eslint-disable @typescript-eslint/no-floating-promises */ -import { screen } from "@testing-library/react" +import { fireEvent, screen, waitFor } from "@testing-library/react" import { rest } from "msw" import React from "react" import * as api from "../../api/api" -import { Template, Workspace, WorkspaceBuild } from "../../api/typesGenerated" +import { Workspace } from "../../api/typesGenerated" import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar" import { + MockBuilds, MockCancelingWorkspace, MockDeletedWorkspace, MockDeletingWorkspace, @@ -22,6 +22,12 @@ import { import { server } from "../../testHelpers/server" import { WorkspacePage } from "./WorkspacePage" +// It renders the workspace page and waits for it be loaded +const renderWorkspacePage = async () => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + await screen.findByText(MockWorkspace.name) +} + /** * Requests and responses related to workspace status are unrelated, so we can't test in the usual way. * Instead, test that button clicks produce the correct requests and that responses produce the correct UI. @@ -29,16 +35,11 @@ import { WorkspacePage } from "./WorkspacePage" * workspaceStatus was calculated correctly. */ -const testButton = async ( - label: string, - mock: - | jest.SpyInstance, [workspaceId: string, templateVersionId?: string | undefined]> - | jest.SpyInstance, [templateId: string]>, -) => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) +const testButton = async (label: string, actionMock: jest.SpyInstance) => { + await renderWorkspacePage() const button = await screen.findByText(label) - button.click() - expect(mock).toHaveBeenCalled() + await waitFor(() => fireEvent.click(button)) + expect(actionMock).toBeCalled() } const testStatus = async (mock: Workspace, label: string) => { @@ -47,82 +48,118 @@ const testStatus = async (mock: Workspace, label: string) => { return res(ctx.status(200), ctx.json(mock)) }), ) - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + await renderWorkspacePage() const status = await screen.findByRole("status") expect(status).toHaveTextContent(label) } +beforeEach(() => { + jest.resetAllMocks() +}) + describe("Workspace Page", () => { it("shows a workspace", async () => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - const workspaceName = await screen.findByText(MockWorkspace.name) + await renderWorkspacePage() + const workspaceName = screen.getByText(MockWorkspace.name) expect(workspaceName).toBeDefined() }) it("shows the status of the workspace", async () => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - const status = await screen.findByRole("status") + await renderWorkspacePage() + const status = screen.getByRole("status") expect(status).toHaveTextContent("Running") }) it("requests a stop job when the user presses Stop", async () => { + const stopWorkspaceMock = jest.spyOn(api, "stopWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) + await testButton(Language.stop, stopWorkspaceMock) + }) + it("requests a start job when the user presses Start", async () => { + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockStoppedWorkspace)) + }), + ) + const startWorkspaceMock = jest + .spyOn(api, "startWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + await testButton(Language.start, startWorkspaceMock) + }) + it("requests a start job when the user presses Retry after trying to start", async () => { + // Use a workspace that failed during start + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockFailedWorkspace, + latest_build: { + ...MockFailedWorkspace.latest_build, + transition: "start", + }, + }), + ) + }), + ) + const startWorkSpaceMock = jest.spyOn(api, "startWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) + await testButton(Language.retry, startWorkSpaceMock) + }) + it("requests a stop job when the user presses Retry after trying to stop", async () => { + // Use a workspace that failed during stop + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockFailedWorkspace, + latest_build: { + ...MockFailedWorkspace.latest_build, + transition: "stop", + }, + }), + ) + }), + ) const stopWorkspaceMock = jest .spyOn(api, "stopWorkspace") .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) - testButton(Language.start, stopWorkspaceMock) - }), - it("requests a start job when the user presses Start", async () => { - const startWorkspaceMock = jest - .spyOn(api, "startWorkspace") - .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) - testButton(Language.start, startWorkspaceMock) - }), - it("requests a start job when the user presses Retry after trying to start", async () => { - const startWorkspaceMock = jest - .spyOn(api, "startWorkspace") - .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) - testButton(Language.retry, startWorkspaceMock) - }), - it("requests a stop job when the user presses Retry after trying to stop", async () => { - const stopWorkspaceMock = jest - .spyOn(api, "stopWorkspace") - .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockStoppedWorkspace)) - }), - ) - testButton(Language.start, stopWorkspaceMock) - }), - it("requests a template when the user presses Update", async () => { - const getTemplateMock = jest.spyOn(api, "getTemplate").mockImplementation(() => Promise.resolve(MockTemplate)) - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) - }), - ) - testButton(Language.update, getTemplateMock) - }), - it("shows the Stopping status when the workspace is stopping", async () => { - testStatus(MockStoppingWorkspace, Language.stopping) - }) + await testButton(Language.retry, stopWorkspaceMock) + }) + it("requests a template when the user presses Update", async () => { + const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) + }), + ) + await testButton(Language.update, getTemplateMock) + }) + it("shows the Stopping status when the workspace is stopping", async () => { + await testStatus(MockStoppingWorkspace, Language.stopping) + }) it("shows the Stopped status when the workspace is stopped", async () => { - testStatus(MockStoppedWorkspace, Language.stopped) + await testStatus(MockStoppedWorkspace, Language.stopped) }) it("shows the Building status when the workspace is starting", async () => { - testStatus(MockStartingWorkspace, Language.starting) + await testStatus(MockStartingWorkspace, Language.starting) }) it("shows the Running status when the workspace is started", async () => { - testStatus(MockWorkspace, Language.started) + await testStatus(MockWorkspace, Language.started) }) it("shows the Error status when the workspace is failed or canceled", async () => { - testStatus(MockFailedWorkspace, Language.error) + await testStatus(MockFailedWorkspace, Language.error) }) it("shows the Loading status when the workspace is canceling", async () => { - testStatus(MockCancelingWorkspace, Language.canceling) + await testStatus(MockCancelingWorkspace, Language.canceling) }) it("shows the Deleting status when the workspace is deleting", async () => { - testStatus(MockDeletingWorkspace, Language.canceling) + await testStatus(MockDeletingWorkspace, Language.deleting) }) it("shows the Deleted status when the workspace is deleted", async () => { - testStatus(MockDeletedWorkspace, Language.canceling) + await testStatus(MockDeletedWorkspace, Language.deleted) + }) + it("shows the timeline build", async () => { + await renderWorkspacePage() + const table = await screen.findByRole("table") + const rows = table.querySelectorAll("tbody > tr") + expect(rows).toHaveLength(MockBuilds.length) }) }) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 4b0a412865ab8..fd0c5fe793938 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -16,7 +16,7 @@ export const WorkspacePage: React.FC = () => { const xServices = useContext(XServiceContext) const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService) - const { workspace, getWorkspaceError, getTemplateError, getOrganizationError } = workspaceState.context + const { workspace, getWorkspaceError, getTemplateError, getOrganizationError, builds } = workspaceState.context const workspaceStatus = useSelector(xServices.workspaceXService, (state) => { return getWorkspaceStatus(state.context.workspace?.latest_build) }) @@ -44,6 +44,7 @@ export const WorkspacePage: React.FC = () => { handleRetry={() => workspaceSend("RETRY")} handleUpdate={() => workspaceSend("UPDATE")} workspaceStatus={workspaceStatus} + builds={builds} /> diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index df6151c857380..8c26e1c13cf25 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -15,7 +15,7 @@ describe("WorkspacesPage", () => { it("renders an empty workspaces page", async () => { // Given server.use( - rest.get("/api/v2/users/me/workspaces", async (req, res, ctx) => { + rest.get("/api/v2/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json([])) }), ) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 355d709c7a7b0..1952f5422a6fa 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -14,11 +14,10 @@ import relativeTime from "dayjs/plugin/relativeTime" import React from "react" import { Link as RouterLink } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" -import { WorkspaceBuild } from "../../api/typesGenerated" import { Margins } from "../../components/Margins/Margins" import { Stack } from "../../components/Stack/Stack" import { firstLetter } from "../../util/firstLetter" -import { getWorkspaceStatus } from "../../util/workspace" +import { getDisplayStatus } from "../../util/workspace" dayjs.extend(relativeTime) @@ -40,7 +39,9 @@ export const WorkspacesPageView: React.FC = (props) =>
- + + +
@@ -58,7 +59,7 @@ export const WorkspacesPageView: React.FC = (props) =>
- + Create a workspace  {Language.emptyView} @@ -68,7 +69,7 @@ export const WorkspacesPageView: React.FC = (props) => )} {props.workspaces?.map((workspace) => { - const status = getStatus(theme, workspace.latest_build) + const status = getDisplayStatus(theme, workspace.latest_build) return ( @@ -108,74 +109,6 @@ export const WorkspacesPageView: React.FC = (props) => ) } -const getStatus = ( - theme: Theme, - build: WorkspaceBuild, -): { - color: string - status: string -} => { - const status = getWorkspaceStatus(build) - switch (status) { - case undefined: - return { - color: theme.palette.text.secondary, - status: "Loading...", - } - case "started": - return { - color: theme.palette.success.main, - status: "⦿ Running", - } - case "starting": - return { - color: theme.palette.success.main, - status: "⦿ Starting", - } - case "stopping": - return { - color: theme.palette.text.secondary, - status: "◍ Stopping", - } - case "stopped": - return { - color: theme.palette.text.secondary, - status: "◍ Stopped", - } - case "deleting": - return { - color: theme.palette.text.secondary, - status: "⦸ Deleting", - } - case "deleted": - return { - color: theme.palette.text.secondary, - status: "⦸ Deleted", - } - case "canceling": - return { - color: theme.palette.warning.light, - status: "◍ Canceling", - } - case "canceled": - return { - color: theme.palette.text.secondary, - status: "◍ Canceled", - } - case "error": - return { - color: theme.palette.error.main, - status: "ⓧ Failed", - } - case "queued": - return { - color: theme.palette.text.secondary, - status: "◍ Queued", - } - } - throw new Error("unknown status " + status) -} - const useStyles = makeStyles((theme) => ({ actions: { marginTop: theme.spacing(3), @@ -183,7 +116,7 @@ const useStyles = makeStyles((theme) => ({ display: "flex", height: theme.spacing(6), - "& button": { + "& > *": { marginLeft: "auto", }, }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 5954c69a8de4f..4f42e854c2021 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -80,8 +80,8 @@ export const MockRunningProvisionerJob = { ...MockProvisionerJob, status: "runni export const MockTemplate: TypesGen.Template = { id: "test-template", - created_at: "", - updated_at: "", + created_at: new Date().toString(), + updated_at: new Date().toString(), organization_id: MockOrganization.id, name: "Test Template", provisioner: MockProvisioner.id, @@ -110,29 +110,32 @@ export const MockWorkspaceAutostopEnabled: TypesGen.UpdateWorkspaceAutostartRequ } export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { - after_id: "", - before_id: "", + build_number: 1, created_at: new Date().toString(), - id: "test-workspace-build", + id: "1", initiator_id: "", job: MockProvisionerJob, name: "a-workspace-build", template_version_id: "", transition: "start", - updated_at: "", + updated_at: "2022-05-17T17:39:01.382927298Z", workspace_id: "test-workspace", } export const MockWorkspaceBuildStop = { ...MockWorkspaceBuild, + id: "2", transition: "stop", } export const MockWorkspaceBuildDelete = { ...MockWorkspaceBuild, + id: "3", transition: "delete", } +export const MockBuilds = [MockWorkspaceBuild, MockWorkspaceBuildStop, MockWorkspaceBuildDelete] + export const MockWorkspace: TypesGen.Workspace = { id: "test-workspace", name: "Test-Workspace", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 1f65874616dc1..529995fa68245 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -17,6 +17,9 @@ export const handlers = [ rest.get("/api/v2/organizations/:organizationId/templates/:templateId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockTemplate)) }), + rest.get("/api/v2/organizations/:organizationId/templates", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json([M.MockTemplate])) + }), // templates rest.get("/api/v2/templates/:templateId", async (req, res, ctx) => { @@ -33,7 +36,7 @@ export const handlers = [ rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), - rest.get("/api/v2/users/me/workspaces", async (req, res, ctx) => { + rest.get("/api/v2/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json([M.MockWorkspace])) }), rest.get("/api/v2/users/me/organizations", (req, res, ctx) => { @@ -77,7 +80,16 @@ export const handlers = [ // workspaces rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => { - return res(ctx.status(200), ctx.json(M.MockWorkspace)) + if (req.params.workspaceName !== M.MockWorkspace.name) { + return res( + ctx.status(404), + ctx.json({ + message: "workspace not found", + }), + ) + } else { + return res(ctx.status(200), ctx.json(M.MockWorkspace)) + } }), rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) @@ -98,6 +110,9 @@ export const handlers = [ const result = transitionToBuild[transition as WorkspaceBuildTransition] return res(ctx.status(200), ctx.json(result)) }), + rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockBuilds)) + }), // workspace builds rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index f4b844cdd3665..1c36ce958309a 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -1,3 +1,4 @@ +import { Theme } from "@material-ui/core/styles" import { WorkspaceBuildTransition } from "../api/types" import { WorkspaceBuild } from "../api/typesGenerated" @@ -47,3 +48,71 @@ export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceSt return "error" } } + +export const getDisplayStatus = ( + theme: Theme, + build: WorkspaceBuild, +): { + color: string + status: string +} => { + const status = getWorkspaceStatus(build) + switch (status) { + case undefined: + return { + color: theme.palette.text.secondary, + status: "Loading...", + } + case "started": + return { + color: theme.palette.success.main, + status: "⦿ Running", + } + case "starting": + return { + color: theme.palette.success.main, + status: "⦿ Starting", + } + case "stopping": + return { + color: theme.palette.text.secondary, + status: "◍ Stopping", + } + case "stopped": + return { + color: theme.palette.text.secondary, + status: "◍ Stopped", + } + case "deleting": + return { + color: theme.palette.text.secondary, + status: "⦸ Deleting", + } + case "deleted": + return { + color: theme.palette.text.secondary, + status: "⦸ Deleted", + } + case "canceling": + return { + color: theme.palette.warning.light, + status: "◍ Canceling", + } + case "canceled": + return { + color: theme.palette.text.secondary, + status: "◍ Canceled", + } + case "error": + return { + color: theme.palette.error.main, + status: "ⓧ Failed", + } + case "queued": + return { + color: theme.palette.text.secondary, + status: "◍ Queued", + } + } + throw new Error("unknown status " + status) +} diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index d9e88a3c72f37..6ca72d406ba58 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -11,6 +11,7 @@ export const Language = { export const checks = { readAllUsers: "readAllUsers", + createTemplates: "createTemplates", } as const export const permissionsToCheck = { @@ -20,6 +21,12 @@ export const permissionsToCheck = { }, action: "read", }, + [checks.createTemplates]: { + object: { + resource_type: "template", + }, + action: "write", + }, } as const type Permissions = Record diff --git a/site/src/xServices/templates/templatesXService.ts b/site/src/xServices/templates/templatesXService.ts new file mode 100644 index 0000000000000..68e7a847e9fb7 --- /dev/null +++ b/site/src/xServices/templates/templatesXService.ts @@ -0,0 +1,104 @@ +import { assign, createMachine } from "xstate" +import * as API from "../../api/api" +import * as TypesGen from "../../api/typesGenerated" + +interface TemplatesContext { + organizations?: TypesGen.Organization[] + templates?: TypesGen.Template[] + canCreateTemplate?: boolean + permissionsError?: Error | unknown + organizationsError?: Error | unknown + templatesError?: Error | unknown +} + +export const templatesMachine = createMachine( + { + tsTypes: {} as import("./templatesXService.typegen").Typegen0, + schema: { + context: {} as TemplatesContext, + services: {} as { + getOrganizations: { + data: TypesGen.Organization[] + } + getPermissions: { + data: boolean + } + getTemplates: { + data: TypesGen.Template[] + } + }, + }, + id: "templatesState", + initial: "gettingOrganizations", + states: { + gettingOrganizations: { + entry: "clearOrganizationsError", + invoke: { + src: "getOrganizations", + id: "getOrganizations", + onDone: [ + { + actions: ["assignOrganizations", "clearOrganizationsError"], + target: "gettingTemplates", + }, + ], + onError: [ + { + actions: "assignOrganizationsError", + target: "error", + }, + ], + }, + tags: "loading", + }, + gettingTemplates: { + entry: "clearTemplatesError", + invoke: { + src: "getTemplates", + id: "getTemplates", + onDone: { + target: "done", + actions: ["assignTemplates", "clearTemplatesError"], + }, + onError: { + target: "error", + actions: "assignTemplatesError", + }, + }, + tags: "loading", + }, + done: {}, + error: {}, + }, + }, + { + actions: { + assignOrganizations: assign({ + organizations: (_, event) => event.data, + }), + assignOrganizationsError: assign({ + organizationsError: (_, event) => event.data, + }), + clearOrganizationsError: assign((context) => ({ + ...context, + organizationsError: undefined, + })), + assignTemplates: assign({ + templates: (_, event) => event.data, + }), + assignTemplatesError: assign({ + templatesError: (_, event) => event.data, + }), + clearTemplatesError: (context) => assign({ ...context, getWorkspacesError: undefined }), + }, + services: { + getOrganizations: API.getOrganizations, + getTemplates: async (context) => { + if (!context.organizations || context.organizations.length === 0) { + throw new Error("no organizations") + } + return API.getTemplates(context.organizations[0].id) + }, + }, + }, +) diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts index 3a17618f6d486..056a7ddf7cafe 100644 --- a/site/src/xServices/terminal/terminalXService.ts +++ b/site/src/xServices/terminal/terminalXService.ts @@ -14,13 +14,16 @@ export interface TerminalContext { websocketError?: Error | unknown // Assigned by connecting! + // The workspace agent is entirely optional. If the agent is omitted the + // first agent will be used. + agentName?: string username?: string workspaceName?: string reconnection?: string } export type TerminalEvent = - | { type: "CONNECT"; reconnection?: string; workspaceName?: string; username?: string } + | { type: "CONNECT"; agentName?: string; reconnection?: string; workspaceName?: string; username?: string } | { type: "WRITE"; request: Types.ReconnectingPTYRequest } | { type: "READ"; data: ArrayBuffer } | { type: "DISCONNECT" } @@ -153,7 +156,7 @@ export const terminalMachine = getOrganizations: API.getOrganizations, getWorkspace: async (context) => { if (!context.organizations || !context.workspaceName) { - throw new Error("organizations or workspace not set") + throw new Error("organizations or workspace name not set") } return API.getWorkspaceByOwnerAndName(context.organizations[0].id, context.username, context.workspaceName) }, @@ -161,11 +164,6 @@ export const terminalMachine = if (!context.workspace || !context.workspaceName) { throw new Error("workspace or workspace name is not set") } - // The workspace name is in the format: - // [.] - // The workspace agent is entirely optional. - const workspaceNameParts = context.workspaceName.split(".") - const agentName = workspaceNameParts[1] const resources = await API.getWorkspaceResources(context.workspace.latest_build.id) @@ -174,10 +172,10 @@ export const terminalMachine = if (!resource.agents || resource.agents.length < 1) { return } - if (!agentName) { + if (!context.agentName) { return resource.agents[0] } - return resource.agents.find((agent) => agent.name === agentName) + return resource.agents.find((agent) => agent.name === context.agentName) }) .filter((a) => a)[0] if (!agent) { @@ -218,6 +216,7 @@ export const terminalMachine = actions: { assignConnection: assign((context, event) => ({ ...context, + agentName: event.agentName ?? context.agentName, reconnection: event.reconnection ?? context.reconnection, workspaceName: event.workspaceName ?? context.workspaceName, })), diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index a64633595466c..782d4f847a459 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -1,8 +1,16 @@ -import { assign, createMachine } from "xstate" +import { assign, createMachine, send } from "xstate" +import { pure } from "xstate/lib/actions" import * as API from "../../api/api" import * as TypesGen from "../../api/typesGenerated" import { displayError } from "../../components/GlobalSnackbar/utils" +const latestBuild = (builds: TypesGen.WorkspaceBuild[]) => { + // Cloning builds to not change the origin object with the sort() + return [...builds].sort((a, b) => { + return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + })[0] +} + const Language = { refreshTemplateError: "Error updating workspace: latest template could not be fetched.", buildError: "Workspace action failed.", @@ -21,6 +29,10 @@ export interface WorkspaceContext { // these are separate from getX errors because they don't make the page unusable refreshWorkspaceError: Error | unknown refreshTemplateError: Error | unknown + // Builds + builds?: TypesGen.WorkspaceBuild[] + getBuildsError?: Error | unknown + loadMoreBuildsError?: Error | unknown } export type WorkspaceEvent = @@ -29,6 +41,8 @@ export type WorkspaceEvent = | { type: "STOP" } | { type: "RETRY" } | { type: "UPDATE" } + | { type: "LOAD_MORE_BUILDS" } + | { type: "REFRESH_TIMELINE" } export const workspaceMachine = createMachine( { @@ -55,6 +69,12 @@ export const workspaceMachine = createMachine( refreshWorkspace: { data: TypesGen.Workspace | undefined } + getBuilds: { + data: TypesGen.WorkspaceBuild[] + } + loadMoreBuilds: { + data: TypesGen.WorkspaceBuild[] + } }, }, id: "workspaceState", @@ -94,7 +114,7 @@ export const workspaceMachine = createMachine( invoke: { id: "refreshWorkspace", src: "refreshWorkspace", - onDone: { target: "waiting", actions: "assignWorkspace" }, + onDone: { target: "waiting", actions: ["refreshTimeline", "assignWorkspace"] }, onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, }, }, @@ -160,7 +180,7 @@ export const workspaceMachine = createMachine( src: "startWorkspace", onDone: { target: "idle", - actions: "assignBuild", + actions: ["assignBuild", "refreshTimeline"], }, onError: { target: "idle", @@ -175,7 +195,7 @@ export const workspaceMachine = createMachine( src: "stopWorkspace", onDone: { target: "idle", - actions: "assignBuild", + actions: ["assignBuild", "refreshTimeline"], }, onError: { target: "idle", @@ -200,6 +220,55 @@ export const workspaceMachine = createMachine( }, }, }, + + timeline: { + initial: "gettingBuilds", + states: { + idle: {}, + gettingBuilds: { + entry: "clearGetBuildsError", + invoke: { + src: "getBuilds", + onDone: { + actions: ["assignBuilds"], + target: "loadedBuilds", + }, + onError: { + actions: ["assignGetBuildsError"], + target: "idle", + }, + }, + }, + loadedBuilds: { + initial: "idle", + states: { + idle: { + on: { + LOAD_MORE_BUILDS: { + target: "loadingMoreBuilds", + cond: "hasMoreBuilds", + }, + REFRESH_TIMELINE: "#workspaceState.ready.timeline.gettingBuilds", + }, + }, + loadingMoreBuilds: { + entry: "clearLoadMoreBuildsError", + invoke: { + src: "loadMoreBuilds", + onDone: { + actions: ["assignNewBuilds"], + target: "idle", + }, + onError: { + actions: ["assignLoadMoreBuildsError"], + target: "idle", + }, + }, + }, + }, + }, + }, + }, }, }, error: { @@ -274,9 +343,54 @@ export const workspaceMachine = createMachine( assign({ refreshTemplateError: undefined, }), + // Timeline + assignBuilds: assign({ + builds: (_, event) => event.data, + }), + assignGetBuildsError: assign({ + getBuildsError: (_, event) => event.data, + }), + clearGetBuildsError: assign({ + getBuildsError: (_) => undefined, + }), + assignNewBuilds: assign({ + builds: (context, event) => { + const oldBuilds = context.builds + + if (!oldBuilds) { + throw new Error("Builds not loaded") + } + + return [...oldBuilds, ...event.data] + }, + }), + assignLoadMoreBuildsError: assign({ + loadMoreBuildsError: (_, event) => event.data, + }), + clearLoadMoreBuildsError: assign({ + loadMoreBuildsError: (_) => undefined, + }), + refreshTimeline: pure((context, event) => { + // No need to refresh the timeline if it is not loaded + if (!context.builds) { + return + } + // When it is a refresh workspace event, we want to check if the latest + // build was updated to not over fetch the builds + if (event.type === "done.invoke.refreshWorkspace") { + const latestBuildInTimeline = latestBuild(context.builds) + const isUpdated = event.data?.latest_build.updated_at !== latestBuildInTimeline.updated_at + if (isUpdated) { + return send({ type: "REFRESH_TIMELINE" }) + } + } else { + return send({ type: "REFRESH_TIMELINE" }) + } + }), }, guards: { triedToStart: (context) => context.workspace?.latest_build.transition === "start", + hasMoreBuilds: (_) => false, }, services: { getWorkspace: async (_, event) => { @@ -317,6 +431,20 @@ export const workspaceMachine = createMachine( throw Error("Cannot refresh workspace without id") } }, + getBuilds: async (context) => { + if (context.workspace) { + return await API.getWorkspaceBuilds(context.workspace.id) + } else { + throw Error("Cannot refresh workspace without id") + } + }, + loadMoreBuilds: async (context) => { + if (context.workspace) { + return await API.getWorkspaceBuilds(context.workspace.id) + } else { + throw Error("Cannot refresh workspace without id") + } + }, }, }, ) diff --git a/site/yarn.lock b/site/yarn.lock index 6babd4922c508..cd898202a62f6 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -2658,10 +2658,10 @@ "@testing-library/dom" "^8.0.0" "@types/react-dom" "<18.0.0" -"@testing-library/user-event@14.1.1": - version "14.1.1" - resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.1.1.tgz#e1ff6118896e4b22af31e5ea2f9da956adde23d8" - integrity sha512-XrjH/iEUqNl9lF2HX9YhPNV7Amntkcnpw0Bo1KkRzowNDcgSN9i0nm4Q8Oi5wupgdfPaJNMAWa61A+voD6Kmwg== +"@testing-library/user-event@14.2.0": + version "14.2.0" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.2.0.tgz#8293560f8f80a00383d6c755ec3e0b918acb1683" + integrity sha512-+hIlG4nJS6ivZrKnOP7OGsDu9Fxmryj9vCl8x0ZINtTJcCHs2zLsYif5GzuRiBF2ck5GZG2aQr7Msg+EHlnYVQ== "@tootallnate/once@1": version "1.1.2" @@ -5323,10 +5323,10 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cronstrue@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.4.0.tgz#16c6d10a17b90c37a71c7e8fb3bb67d0243d70e5" - integrity sha512-KDJgE8XoT0Nupt1iljNGAQnxkfITwIYkL7mHrzH4a0AWyrj7Xk6GVCNPN3Avs7tU2yYoNuDculMKp9T3jysbPA== +cronstrue@2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.5.0.tgz#1d69bd53520ce536789fb666d9fd562065b491c6" + integrity sha512-2uhcYEmXEH52Prn1biZ1HSaQwGwUy4fxFiq3U3vKwLYngL14j8f4pZeQt9f1J6tWDaLFeLRgcCHtO45r78ECyw== cross-spawn@^6.0.0: version "6.0.5" From cd4da3188fe74957ed505dfa99bc1b58be4e3c11 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 May 2022 20:40:19 +0000 Subject: [PATCH 20/21] Revert "Merge branch 'main' into bpmct/add-docker-builds" This reverts commit 30136fcac05f250c370c2d7b9a590468928d019f. --- .goreleaser.yaml | 2 +- .vscode/settings.json | 4 - Makefile | 4 - agent/agent.go | 114 ---- agent/agent_test.go | 165 +----- agent/conn.go | 43 +- cli/autostart_test.go | 4 +- cli/autostop_test.go | 4 +- cli/cliui/prompt.go | 6 +- cli/cliui/prompt_test.go | 57 -- cli/list.go | 2 +- cli/portforward.go | 379 ------------- cli/portforward_test.go | 532 ------------------ cli/resetpassword_test.go | 4 +- cli/root.go | 19 +- cli/server_test.go | 5 +- cli/ssh.go | 145 ++--- cli/templates.go | 9 +- cli/tunnel.go | 14 + coderd/audit/table.go | 2 +- coderd/authorize.go | 43 -- .../autobuild/executor/lifecycle_executor.go | 28 +- .../executor/lifecycle_executor_test.go | 9 +- coderd/autobuild/schedule/schedule_test.go | 47 +- coderd/coderd.go | 53 +- coderd/coderd_test.go | 211 +------ coderd/coderdtest/coderdtest.go | 32 +- coderd/database/databasefake/databasefake.go | 186 +++--- coderd/database/dump.sql | 8 +- coderd/database/migrations/000004_jobs.up.sql | 6 +- .../000012_template_version_readme.down.sql | 1 - .../000012_template_version_readme.up.sql | 1 - coderd/database/models.go | 5 +- coderd/database/postgres/postgres_test.go | 4 +- coderd/database/pubsub_test.go | 8 +- coderd/database/querier.go | 10 +- coderd/database/queries.sql.go | 362 ++++++------ coderd/database/queries/templateversions.sql | 11 +- coderd/database/queries/workspacebuilds.sql | 60 +- coderd/database/queries/workspaces.sql | 32 +- coderd/gitsshkey.go | 11 - coderd/httpapi/httpapi.go | 6 - coderd/httpmw/authorize.go | 82 +++ coderd/httpmw/oauth2.go | 4 +- coderd/httpmw/organizationparam.go | 2 +- coderd/httpmw/organizationparam_test.go | 2 +- coderd/organizations.go | 10 +- coderd/organizations_test.go | 8 +- coderd/provisionerdaemons.go | 10 - coderd/rbac/authz.go | 8 +- coderd/rbac/builtin.go | 19 +- coderd/rbac/error.go | 2 +- coderd/rbac/object.go | 59 +- coderd/roles.go | 33 +- coderd/roles_test.go | 4 +- coderd/templateversions.go | 3 +- coderd/users.go | 159 ++---- coderd/users_test.go | 20 +- coderd/workspaceagents.go | 4 +- coderd/workspaceagents_test.go | 25 +- coderd/workspacebuilds.go | 46 +- coderd/workspacebuilds_test.go | 44 +- coderd/workspaceresourceauth.go | 4 +- coderd/workspaces.go | 154 ++--- coderd/workspaces_test.go | 29 +- codersdk/buildinfo.go | 2 +- codersdk/client.go | 4 +- codersdk/files.go | 4 +- codersdk/gitsshkey.go | 6 +- codersdk/organizations.go | 20 +- codersdk/pagination.go | 2 +- codersdk/parameters.go | 6 +- codersdk/provisionerdaemons.go | 4 +- codersdk/roles.go | 6 +- codersdk/templates.go | 10 +- codersdk/templateversions.go | 11 +- codersdk/users.go | 52 +- codersdk/workspaceagents.go | 14 +- codersdk/workspacebuilds.go | 13 +- codersdk/workspaceresources.go | 2 +- codersdk/workspaces.go | 58 +- cryptorand/slices.go | 20 - cryptorand/slices_test.go | 56 -- scripts/develop.sh => develop.sh | 0 docs/CONTRIBUTING.md | 2 +- examples/aws-linux/README.md | 59 -- examples/aws-linux/main.tf | 5 - examples/docker-image-builds/main.tf | 2 - examples/docker/main.tf | 2 - go.mod | 2 +- peer/channel.go | 4 +- peer/conn.go | 19 +- peer/conn_test.go | 14 +- provisioner/echo/serve.go | 1 + provisioner/terraform/provision.go | 6 +- provisionerd/proto/provisionerd.pb.go | 79 ++- provisionerd/proto/provisionerd.proto | 1 - provisionerd/provisionerd.go | 81 +-- provisionerd/provisionerd_test.go | 9 +- pty/pty.go | 31 +- pty/pty_linux.go | 13 - pty/pty_other.go | 9 +- pty/pty_windows.go | 9 +- site/package.json | 4 +- site/src/AppRouter.tsx | 12 - site/src/api/api.ts | 16 +- site/src/api/typesGenerated.ts | 23 +- .../BorderedMenu/BorderedMenu.stories.tsx | 10 +- .../BorderedMenuRow/BorderedMenuRow.tsx | 54 +- .../BuildsTable/BuildsTable.stories.tsx | 21 - .../components/BuildsTable/BuildsTable.tsx | 97 ---- site/src/components/NavbarView/NavbarView.tsx | 5 - .../TerminalLink/TerminalLink.stories.tsx | 16 - .../components/TerminalLink/TerminalLink.tsx | 28 - .../components/UserDropdown/UsersDropdown.tsx | 5 +- site/src/components/Workspace/Workspace.tsx | 18 +- .../WorkspaceSection/WorkspaceSection.tsx | 10 +- .../pages/TemplatePage/TemplatePageView.tsx | 153 ----- .../TemplatesPage/TemplatesPage.test.tsx | 67 --- .../src/pages/TemplatesPage/TemplatesPage.tsx | 21 - .../TemplatesPageView.stories.tsx | 36 -- .../pages/TemplatesPage/TemplatesPageView.tsx | 156 ----- .../pages/TerminalPage/TerminalPage.test.tsx | 20 +- site/src/pages/TerminalPage/TerminalPage.tsx | 6 +- .../WorkspacePage/WorkspacePage.test.tsx | 159 ++---- .../src/pages/WorkspacePage/WorkspacePage.tsx | 3 +- .../WorkspacesPage/WorkspacesPage.test.tsx | 2 +- .../WorkspacesPage/WorkspacesPageView.tsx | 81 ++- site/src/testHelpers/entities.ts | 15 +- site/src/testHelpers/handlers.ts | 19 +- site/src/util/workspace.ts | 69 --- site/src/xServices/auth/authXService.ts | 7 - .../xServices/templates/templatesXService.ts | 104 ---- .../xServices/terminal/terminalXService.ts | 17 +- .../xServices/workspace/workspaceXService.ts | 136 +---- site/yarn.lock | 16 +- 136 files changed, 1141 insertions(+), 4282 deletions(-) delete mode 100644 cli/portforward.go delete mode 100644 cli/portforward_test.go create mode 100644 cli/tunnel.go delete mode 100644 coderd/authorize.go delete mode 100644 coderd/database/migrations/000012_template_version_readme.down.sql delete mode 100644 coderd/database/migrations/000012_template_version_readme.up.sql delete mode 100644 cryptorand/slices.go delete mode 100644 cryptorand/slices_test.go rename scripts/develop.sh => develop.sh (100%) delete mode 100644 pty/pty_linux.go delete mode 100644 site/src/components/BuildsTable/BuildsTable.stories.tsx delete mode 100644 site/src/components/BuildsTable/BuildsTable.tsx delete mode 100644 site/src/components/TerminalLink/TerminalLink.stories.tsx delete mode 100644 site/src/components/TerminalLink/TerminalLink.tsx delete mode 100644 site/src/pages/TemplatePage/TemplatePageView.tsx delete mode 100644 site/src/pages/TemplatesPage/TemplatesPage.test.tsx delete mode 100644 site/src/pages/TemplatesPage/TemplatesPage.tsx delete mode 100644 site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx delete mode 100644 site/src/pages/TemplatesPage/TemplatesPageView.tsx delete mode 100644 site/src/xServices/templates/templatesXService.ts diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 30b7863e69231..36ad5247f38d7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,7 @@ archives: - id: coder-linux builds: [coder-linux] - format: tar.gz + format: tar files: - src: docs/README.md dst: README.md diff --git a/.vscode/settings.json b/.vscode/settings.json index a04dc17791f5f..6f7ea5c69fce3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -73,10 +73,6 @@ { "match": "database/queries/*.sql", "cmd": "make gen" - }, - { - "match": "provisionerd/proto/provisionerd.proto", - "cmd": "make provisionerd/proto/provisionerd.pb.go", } ] }, diff --git a/Makefile b/Makefile index b3d38a5e0e02d..9ea8aa90a13b0 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,6 @@ coderd/database/dump.sql: $(wildcard coderd/database/migrations/*.sql) coderd/database/querier.go: coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql) coderd/database/generate.sh -dev: - ./scripts/develop.sh -.PHONY: dev - dist/artifacts.json: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum goreleaser release --snapshot --rm-dist --skip-sign diff --git a/agent/agent.go b/agent/agent.go index 75787b4cfc5e1..b946166056532 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "net" - "net/url" "os" "os/exec" "os/user" @@ -212,8 +211,6 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { go a.sshServer.HandleConn(channel.NetConn()) case "reconnecting-pty": go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn()) - case "dial": - go a.handleDial(ctx, channel.Label(), channel.NetConn()) default: a.logger.Warn(ctx, "unhandled protocol from channel", slog.F("protocol", channel.Protocol()), @@ -620,70 +617,6 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne } } -// dialResponse is written to datachannels with protocol "dial" by the agent as -// the first packet to signify whether the dial succeeded or failed. -type dialResponse struct { - Error string `json:"error,omitempty"` -} - -func (a *agent) handleDial(ctx context.Context, label string, conn net.Conn) { - defer conn.Close() - - writeError := func(responseError error) error { - msg := "" - if responseError != nil { - msg = responseError.Error() - if !xerrors.Is(responseError, io.EOF) { - a.logger.Warn(ctx, "handle dial", slog.F("label", label), slog.Error(responseError)) - } - } - b, err := json.Marshal(dialResponse{ - Error: msg, - }) - if err != nil { - a.logger.Warn(ctx, "write dial response", slog.F("label", label), slog.Error(err)) - return xerrors.Errorf("marshal agent webrtc dial response: %w", err) - } - - _, err = conn.Write(b) - return err - } - - u, err := url.Parse(label) - if err != nil { - _ = writeError(xerrors.Errorf("parse URL %q: %w", label, err)) - return - } - - network := u.Scheme - addr := u.Host + u.Path - if strings.HasPrefix(network, "unix") { - if runtime.GOOS == "windows" { - _ = writeError(xerrors.New("Unix forwarding is not supported from Windows workspaces")) - return - } - addr, err = ExpandRelativeHomePath(addr) - if err != nil { - _ = writeError(xerrors.Errorf("expand path %q: %w", addr, err)) - return - } - } - - d := net.Dialer{Timeout: 3 * time.Second} - nconn, err := d.DialContext(ctx, network, addr) - if err != nil { - _ = writeError(xerrors.Errorf("dial '%v://%v': %w", network, addr, err)) - return - } - - err = writeError(nil) - if err != nil { - return - } - - Bicopy(ctx, conn, nconn) -} - // isClosed returns whether the API is closed or not. func (a *agent) isClosed() bool { select { @@ -729,50 +662,3 @@ func (r *reconnectingPTY) Close() { r.circularBuffer.Reset() r.timeout.Stop() } - -// Bicopy copies all of the data between the two connections and will close them -// after one or both of them are done writing. If the context is canceled, both -// of the connections will be closed. -func Bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) { - defer c1.Close() - defer c2.Close() - - var wg sync.WaitGroup - copyFunc := func(dst io.WriteCloser, src io.Reader) { - defer wg.Done() - _, _ = io.Copy(dst, src) - } - - wg.Add(2) - go copyFunc(c1, c2) - go copyFunc(c2, c1) - - // Convert waitgroup to a channel so we can also wait on the context. - done := make(chan struct{}) - go func() { - defer close(done) - wg.Wait() - }() - - select { - case <-ctx.Done(): - case <-done: - } -} - -// ExpandRelativeHomePath expands the tilde at the beginning of a path to the -// current user's home directory and returns a full absolute path. -func ExpandRelativeHomePath(in string) (string, error) { - usr, err := user.Current() - if err != nil { - return "", xerrors.Errorf("get current user details: %w", err) - } - - if in == "~" { - in = usr.HomeDir - } else if strings.HasPrefix(in, "~/") { - in = filepath.Join(usr.HomeDir, in[2:]) - } - - return filepath.Abs(in) -} diff --git a/agent/agent_test.go b/agent/agent_test.go index db3235417960e..bd26fae7f0a69 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1,7 +1,6 @@ package agent_test import ( - "bufio" "context" "encoding/json" "fmt" @@ -17,7 +16,6 @@ import ( "time" "github.com/google/uuid" - "github.com/pion/udp" "github.com/pion/webrtc/v3" "github.com/pkg/sftp" "github.com/stretchr/testify/require" @@ -205,11 +203,6 @@ func TestAgent(t *testing.T) { id := uuid.NewString() netConn, err := conn.ReconnectingPTY(id, 100, 100) require.NoError(t, err) - bufRead := bufio.NewReader(netConn) - - // Brief pause to reduce the likelihood that we send keystrokes while - // the shell is simultaneously sending a prompt. - time.Sleep(100 * time.Millisecond) data, err := json.Marshal(agent.ReconnectingPTYRequest{ Data: "echo test\r\n", @@ -218,141 +211,28 @@ func TestAgent(t *testing.T) { _, err = netConn.Write(data) require.NoError(t, err) - expectLine := func(matcher func(string) bool) { + findEcho := func() { for { - line, err := bufRead.ReadString('\n') + read, err := netConn.Read(data) require.NoError(t, err) - if matcher(line) { + if strings.Contains(string(data[:read]), "test") { break } } } - matchEchoCommand := func(line string) bool { - return strings.Contains(line, "echo test") - } - matchEchoOutput := func(line string) bool { - return strings.Contains(line, "test") && !strings.Contains(line, "echo") - } // Once for typing the command... - expectLine(matchEchoCommand) + findEcho() // And another time for the actual output. - expectLine(matchEchoOutput) + findEcho() _ = netConn.Close() netConn, err = conn.ReconnectingPTY(id, 100, 100) require.NoError(t, err) - bufRead = bufio.NewReader(netConn) // Same output again! - expectLine(matchEchoCommand) - expectLine(matchEchoOutput) - }) - - t.Run("Dial", func(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - setup func(t *testing.T) net.Listener - }{ - { - name: "TCP", - setup: func(t *testing.T) net.Listener { - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err, "create TCP listener") - return l - }, - }, - { - name: "UDP", - setup: func(t *testing.T) net.Listener { - addr := net.UDPAddr{ - IP: net.ParseIP("127.0.0.1"), - Port: 0, - } - l, err := udp.Listen("udp", &addr) - require.NoError(t, err, "create UDP listener") - return l - }, - }, - { - name: "Unix", - setup: func(t *testing.T) net.Listener { - if runtime.GOOS == "windows" { - t.Skip("Unix socket forwarding isn't supported on Windows") - } - - tmpDir, err := os.MkdirTemp("", "coderd_agent_test_") - require.NoError(t, err, "create temp dir for unix listener") - t.Cleanup(func() { - _ = os.RemoveAll(tmpDir) - }) - - l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock")) - require.NoError(t, err, "create UDP listener") - return l - }, - }, - } - - for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { - t.Parallel() - - // Setup listener - l := c.setup(t) - defer l.Close() - go func() { - for { - c, err := l.Accept() - if err != nil { - return - } - - go testAccept(t, c) - } - }() - - // Dial the listener over WebRTC twice and test out of order - conn := setupAgent(t, agent.Metadata{}, 0) - conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String()) - require.NoError(t, err) - defer conn1.Close() - conn2, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String()) - require.NoError(t, err) - defer conn2.Close() - testDial(t, conn2) - testDial(t, conn1) - }) - } - }) - - t.Run("DialError", func(t *testing.T) { - t.Parallel() - - if runtime.GOOS == "windows" { - // This test uses Unix listeners so we can very easily ensure that - // no other tests decide to listen on the same random port we - // picked. - t.Skip("this test is unsupported on Windows") - return - } - - tmpDir, err := os.MkdirTemp("", "coderd_agent_test_") - require.NoError(t, err, "create temp dir") - t.Cleanup(func() { - _ = os.RemoveAll(tmpDir) - }) - - // Try to dial the non-existent Unix socket over WebRTC - conn := setupAgent(t, agent.Metadata{}, 0) - netConn, err := conn.DialContext(context.Background(), "unix", filepath.Join(tmpDir, "test.sock")) - require.Error(t, err) - require.ErrorContains(t, err, "remote dial error") - require.ErrorContains(t, err, "no such file") - require.Nil(t, netConn) + findEcho() + findEcho() }) } @@ -423,34 +303,3 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) Conn: conn, } } - -var dialTestPayload = []byte("dean-was-here123") - -func testDial(t *testing.T, c net.Conn) { - t.Helper() - - assertWritePayload(t, c, dialTestPayload) - assertReadPayload(t, c, dialTestPayload) -} - -func testAccept(t *testing.T, c net.Conn) { - t.Helper() - defer c.Close() - - assertReadPayload(t, c, dialTestPayload) - assertWritePayload(t, c, dialTestPayload) -} - -func assertReadPayload(t *testing.T, r io.Reader, payload []byte) { - b := make([]byte, len(payload)+16) - n, err := r.Read(b) - require.NoError(t, err, "read payload") - require.Equal(t, len(payload), n, "read payload length does not match") - require.Equal(t, payload, b[:n]) -} - -func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { - n, err := w.Write(payload) - require.NoError(t, err, "write payload") - require.Equal(t, len(payload), n, "payload length does not match") -} diff --git a/agent/conn.go b/agent/conn.go index 56d3d42ea1784..81a6315af26de 100644 --- a/agent/conn.go +++ b/agent/conn.go @@ -2,11 +2,8 @@ package agent import ( "context" - "encoding/json" "fmt" "net" - "net/url" - "strings" "golang.org/x/crypto/ssh" "golang.org/x/xerrors" @@ -35,7 +32,7 @@ type Conn struct { // ReconnectingPTY returns a connection serving a TTY that can // be reconnected to via ID. func (c *Conn) ReconnectingPTY(id string, height, width uint16) (net.Conn, error) { - channel, err := c.CreateChannel(context.Background(), fmt.Sprintf("%s:%d:%d", id, height, width), &peer.ChannelOptions{ + channel, err := c.Dial(context.Background(), fmt.Sprintf("%s:%d:%d", id, height, width), &peer.ChannelOptions{ Protocol: "reconnecting-pty", }) if err != nil { @@ -46,7 +43,7 @@ func (c *Conn) ReconnectingPTY(id string, height, width uint16) (net.Conn, error // SSH dials the built-in SSH server. func (c *Conn) SSH() (net.Conn, error) { - channel, err := c.CreateChannel(context.Background(), "ssh", &peer.ChannelOptions{ + channel, err := c.Dial(context.Background(), "ssh", &peer.ChannelOptions{ Protocol: "ssh", }) if err != nil { @@ -74,42 +71,6 @@ func (c *Conn) SSHClient() (*ssh.Client, error) { return ssh.NewClient(sshConn, channels, requests), nil } -// DialContext dials an arbitrary protocol+address from inside the workspace and -// proxies it through the provided net.Conn. -func (c *Conn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) { - u := &url.URL{ - Scheme: network, - } - if strings.HasPrefix(network, "unix") { - u.Path = addr - } else { - u.Host = addr - } - - channel, err := c.CreateChannel(ctx, u.String(), &peer.ChannelOptions{ - Protocol: "dial", - Unordered: strings.HasPrefix(network, "udp"), - }) - if err != nil { - return nil, xerrors.Errorf("create datachannel: %w", err) - } - - // The first message written from the other side is a JSON payload - // containing the dial error. - dec := json.NewDecoder(channel) - var res dialResponse - err = dec.Decode(&res) - if err != nil { - return nil, xerrors.Errorf("failed to decode initial packet: %w", err) - } - if res.Error != "" { - _ = channel.Close() - return nil, xerrors.Errorf("remote dial error: %v", res.Error) - } - - return channel.NetConn(), nil -} - func (c *Conn) Close() error { _ = c.Negotiator.DRPCConn().Close() return c.Conn.Close() diff --git a/cli/autostart_test.go b/cli/autostart_test.go index 8c9ff40ee25d0..78f11a5380145 100644 --- a/cli/autostart_test.go +++ b/cli/autostart_test.go @@ -110,7 +110,7 @@ func TestAutostart(t *testing.T) { clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error") + require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") }) t.Run("Disable_NotFound", func(t *testing.T) { @@ -128,7 +128,7 @@ func TestAutostart(t *testing.T) { clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error") + require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") }) t.Run("Enable_DefaultSchedule", func(t *testing.T) { diff --git a/cli/autostop_test.go b/cli/autostop_test.go index 14447ac037ee4..2d5205ad9c732 100644 --- a/cli/autostop_test.go +++ b/cli/autostop_test.go @@ -109,7 +109,7 @@ func TestAutostop(t *testing.T) { clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error") + require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") }) t.Run("Disable_NotFound", func(t *testing.T) { @@ -127,7 +127,7 @@ func TestAutostop(t *testing.T) { clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error") + require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") }) t.Run("Enable_DefaultSchedule", func(t *testing.T) { diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index ac39404e27d3f..3e4c0689da162 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -34,6 +34,8 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { _, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") ")) } interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + defer signal.Stop(interrupt) errCh := make(chan error, 1) lineCh := make(chan string) @@ -43,12 +45,8 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { inFile, isInputFile := cmd.InOrStdin().(*os.File) if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { - // we don't install a signal handler here because speakeasy has its own line, err = speakeasy.Ask("") } else { - signal.Notify(interrupt, os.Interrupt) - defer signal.Stop(interrupt) - reader := bufio.NewReader(cmd.InOrStdin()) line, err = reader.ReadString('\n') diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index 1926349c2d1fc..dc14925cc16bc 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -2,16 +2,12 @@ package cliui_test import ( "context" - "os" - "os/exec" "testing" - "time" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/pty" "github.com/coder/coder/pty/ptytest" ) @@ -114,56 +110,3 @@ func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) { cmd.SetIn(ptty.Input()) return value, cmd.ExecuteContext(context.Background()) } - -func TestPasswordTerminalState(t *testing.T) { - if os.Getenv("TEST_SUBPROCESS") == "1" { - passwordHelper() - return - } - t.Parallel() - - ptty := ptytest.New(t) - ptyWithFlags, ok := ptty.PTY.(pty.WithFlags) - if !ok { - t.Skip("unable to check PTY local echo on this platform") - } - - cmd := exec.Command(os.Args[0], "-test.run=TestPasswordTerminalState") //nolint:gosec - cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1") - // connect the child process's stdio to the PTY directly, not via a pipe - cmd.Stdin = ptty.Input().Reader - cmd.Stdout = ptty.Output().Writer - cmd.Stderr = os.Stderr - err := cmd.Start() - require.NoError(t, err) - process := cmd.Process - defer process.Kill() - - ptty.ExpectMatch("Password: ") - time.Sleep(100 * time.Millisecond) // wait for child process to turn off echo and start reading input - - echo, err := ptyWithFlags.EchoEnabled() - require.NoError(t, err) - require.False(t, echo, "echo is on while reading password") - - err = process.Signal(os.Interrupt) - require.NoError(t, err) - _, err = process.Wait() - require.NoError(t, err) - - echo, err = ptyWithFlags.EchoEnabled() - require.NoError(t, err) - require.True(t, echo, "echo is off after reading password") -} - -func passwordHelper() { - cmd := &cobra.Command{ - Run: func(cmd *cobra.Command, args []string) { - cliui.Prompt(cmd, cliui.PromptOptions{ - Text: "Password:", - Secret: true, - }) - }, - } - cmd.ExecuteContext(context.Background()) -} diff --git a/cli/list.go b/cli/list.go index 23454bc85674e..6fb2a369d63fd 100644 --- a/cli/list.go +++ b/cli/list.go @@ -29,7 +29,7 @@ func list() *cobra.Command { if err != nil { return err } - workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{}) + workspaces, err := client.WorkspacesByUser(cmd.Context(), codersdk.Me) if err != nil { return err } diff --git a/cli/portforward.go b/cli/portforward.go deleted file mode 100644 index 51206687f9cdb..0000000000000 --- a/cli/portforward.go +++ /dev/null @@ -1,379 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "net" - "os" - "os/signal" - "runtime" - "strconv" - "strings" - "sync" - "syscall" - - "github.com/pion/udp" - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - coderagent "github.com/coder/coder/agent" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" -) - -func portForward() *cobra.Command { - var ( - tcpForwards []string // : - udpForwards []string // : - unixForwards []string // : OR : - ) - cmd := &cobra.Command{ - Use: "port-forward ", - Aliases: []string{"tunnel"}, - Args: cobra.ExactArgs(1), - Example: ` - - Port forward a single TCP port from 1234 in the workspace to port 5678 on - your local machine - - ` + cliui.Styles.Code.Render("$ coder port-forward --tcp 5678:1234") + ` - - - Port forward a single UDP port from port 9000 to port 9000 on your local - machine - - ` + cliui.Styles.Code.Render("$ coder port-forward --udp 9000") + ` - - - Forward a Unix socket in the workspace to a local Unix socket - - ` + cliui.Styles.Code.Render("$ coder port-forward --unix ./local.sock:~/remote.sock") + ` - - - Forward a Unix socket in the workspace to a local TCP port - - ` + cliui.Styles.Code.Render("$ coder port-forward --unix 8080:~/remote.sock") + ` - - - Port forward multiple TCP ports and a UDP port - - ` + cliui.Styles.Code.Render("$ coder port-forward --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53"), - RunE: func(cmd *cobra.Command, args []string) error { - specs, err := parsePortForwards(tcpForwards, udpForwards, unixForwards) - if err != nil { - return xerrors.Errorf("parse port-forward specs: %w", err) - } - if len(specs) == 0 { - err = cmd.Help() - if err != nil { - return xerrors.Errorf("generate help output: %w", err) - } - return xerrors.New("no port-forwards requested") - } - - client, err := createClient(cmd) - if err != nil { - return err - } - organization, err := currentOrganization(cmd, client) - if err != nil { - return err - } - - workspace, agent, err := getWorkspaceAndAgent(cmd, client, organization.ID, codersdk.Me, args[0], false) - if err != nil { - return err - } - if workspace.LatestBuild.Transition != database.WorkspaceTransitionStart { - return xerrors.New("workspace must be in start transition to port-forward") - } - if workspace.LatestBuild.Job.CompletedAt == nil { - err = cliui.WorkspaceBuild(cmd.Context(), cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt) - if err != nil { - return err - } - } - - err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{ - WorkspaceName: workspace.Name, - Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) { - return client.WorkspaceAgent(ctx, agent.ID) - }, - }) - if err != nil { - return xerrors.Errorf("await agent: %w", err) - } - - conn, err := client.DialWorkspaceAgent(cmd.Context(), agent.ID, nil) - if err != nil { - return xerrors.Errorf("dial workspace agent: %w", err) - } - defer conn.Close() - - // Start all listeners. - var ( - ctx, cancel = context.WithCancel(cmd.Context()) - wg = new(sync.WaitGroup) - listeners = make([]net.Listener, len(specs)) - closeAllListeners = func() { - for _, l := range listeners { - if l == nil { - continue - } - _ = l.Close() - } - } - ) - defer cancel() - for i, spec := range specs { - l, err := listenAndPortForward(ctx, cmd, conn, wg, spec) - if err != nil { - closeAllListeners() - return err - } - listeners[i] = l - } - - // Wait for the context to be canceled or for a signal and close - // all listeners. - var closeErr error - go func() { - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - - select { - case <-ctx.Done(): - closeErr = ctx.Err() - case <-sigs: - _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Received signal, closing all listeners and active connections") - closeErr = xerrors.New("signal received") - } - - cancel() - closeAllListeners() - }() - - _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Ready!") - wg.Wait() - return closeErr - }, - } - - cmd.Flags().StringArrayVarP(&tcpForwards, "tcp", "p", []string{}, "Forward a TCP port from the workspace to the local machine") - cmd.Flags().StringArrayVar(&udpForwards, "udp", []string{}, "Forward a UDP port from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols") - cmd.Flags().StringArrayVar(&unixForwards, "unix", []string{}, "Forward a Unix socket in the workspace to a local Unix socket or TCP port") - - return cmd -} - -func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderagent.Conn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) { - _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress) - - var ( - l net.Listener - err error - ) - switch spec.listenNetwork { - case "tcp": - l, err = net.Listen(spec.listenNetwork, spec.listenAddress) - case "udp": - var host, port string - host, port, err = net.SplitHostPort(spec.listenAddress) - if err != nil { - return nil, xerrors.Errorf("split %q: %w", spec.listenAddress, err) - } - - var portInt int - portInt, err = strconv.Atoi(port) - if err != nil { - return nil, xerrors.Errorf("parse port %v from %q as int: %w", port, spec.listenAddress, err) - } - - l, err = udp.Listen(spec.listenNetwork, &net.UDPAddr{ - IP: net.ParseIP(host), - Port: portInt, - }) - case "unix": - l, err = net.Listen(spec.listenNetwork, spec.listenAddress) - default: - return nil, xerrors.Errorf("unknown listen network %q", spec.listenNetwork) - } - if err != nil { - return nil, xerrors.Errorf("listen '%v://%v': %w", spec.listenNetwork, spec.listenAddress, err) - } - - wg.Add(1) - go func(spec portForwardSpec) { - defer wg.Done() - for { - netConn, err := l.Accept() - if err != nil { - _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %+v\n", spec.listenNetwork, spec.listenAddress, err) - _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Killing listener") - return - } - - go func(netConn net.Conn) { - defer netConn.Close() - remoteConn, err := conn.DialContext(ctx, spec.dialNetwork, spec.dialAddress) - if err != nil { - _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Failed to dial '%v://%v' in workspace: %s\n", spec.dialNetwork, spec.dialAddress, err) - return - } - defer remoteConn.Close() - - coderagent.Bicopy(ctx, netConn, remoteConn) - }(netConn) - } - }(spec) - - return l, nil -} - -type portForwardSpec struct { - listenNetwork string // tcp, udp, unix - listenAddress string // : or path - - dialNetwork string // tcp, udp, unix - dialAddress string // : or path -} - -func parsePortForwards(tcpSpecs, udpSpecs, unixSpecs []string) ([]portForwardSpec, error) { - specs := []portForwardSpec{} - - for _, spec := range tcpSpecs { - local, remote, err := parsePortPort(spec) - if err != nil { - return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err) - } - - specs = append(specs, portForwardSpec{ - listenNetwork: "tcp", - listenAddress: fmt.Sprintf("127.0.0.1:%v", local), - dialNetwork: "tcp", - dialAddress: fmt.Sprintf("127.0.0.1:%v", remote), - }) - } - - for _, spec := range udpSpecs { - local, remote, err := parsePortPort(spec) - if err != nil { - return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err) - } - - specs = append(specs, portForwardSpec{ - listenNetwork: "udp", - listenAddress: fmt.Sprintf("127.0.0.1:%v", local), - dialNetwork: "udp", - dialAddress: fmt.Sprintf("127.0.0.1:%v", remote), - }) - } - - for _, specStr := range unixSpecs { - localPath, localTCP, remotePath, err := parseUnixUnix(specStr) - if err != nil { - return nil, xerrors.Errorf("failed to parse Unix port-forward specification %q: %w", specStr, err) - } - - spec := portForwardSpec{ - dialNetwork: "unix", - dialAddress: remotePath, - } - if localPath == "" { - spec.listenNetwork = "tcp" - spec.listenAddress = fmt.Sprintf("127.0.0.1:%v", localTCP) - } else { - if runtime.GOOS == "windows" { - return nil, xerrors.Errorf("Unix port-forwarding is not supported on Windows") - } - spec.listenNetwork = "unix" - spec.listenAddress = localPath - } - specs = append(specs, spec) - } - - // Check for duplicate entries. - locals := map[string]struct{}{} - for _, spec := range specs { - localStr := fmt.Sprintf("%v:%v", spec.listenNetwork, spec.listenAddress) - if _, ok := locals[localStr]; ok { - return nil, xerrors.Errorf("local %v %v is specified twice", spec.listenNetwork, spec.listenAddress) - } - locals[localStr] = struct{}{} - } - - return specs, nil -} - -func parsePort(in string) (uint16, error) { - port, err := strconv.ParseUint(strings.TrimSpace(in), 10, 16) - if err != nil { - return 0, xerrors.Errorf("parse port %q: %w", in, err) - } - if port == 0 { - return 0, xerrors.New("port cannot be 0") - } - - return uint16(port), nil -} - -func parseUnixPath(in string) (string, error) { - path, err := coderagent.ExpandRelativeHomePath(strings.TrimSpace(in)) - if err != nil { - return "", xerrors.Errorf("tidy path %q: %w", in, err) - } - - return path, nil -} - -func parsePortPort(in string) (local uint16, remote uint16, err error) { - parts := strings.Split(in, ":") - if len(parts) > 2 { - return 0, 0, xerrors.Errorf("invalid port specification %q", in) - } - if len(parts) == 1 { - // Duplicate the single part - parts = append(parts, parts[0]) - } - - local, err = parsePort(parts[0]) - if err != nil { - return 0, 0, xerrors.Errorf("parse local port from %q: %w", in, err) - } - remote, err = parsePort(parts[1]) - if err != nil { - return 0, 0, xerrors.Errorf("parse remote port from %q: %w", in, err) - } - - return local, remote, nil -} - -func parsePortOrUnixPath(in string) (string, uint16, error) { - port, err := parsePort(in) - if err == nil { - return "", port, nil - } - - path, err := parseUnixPath(in) - if err != nil { - return "", 0, xerrors.Errorf("could not parse port or unix path %q: %w", in, err) - } - - return path, 0, nil -} - -func parseUnixUnix(in string) (string, uint16, string, error) { - parts := strings.Split(in, ":") - if len(parts) > 2 { - return "", 0, "", xerrors.Errorf("invalid port-forward specification %q", in) - } - if len(parts) == 1 { - // Duplicate the single part - parts = append(parts, parts[0]) - } - - localPath, localPort, err := parsePortOrUnixPath(parts[0]) - if err != nil { - return "", 0, "", xerrors.Errorf("parse local part of spec %q: %w", in, err) - } - - // We don't really touch the remote path at all since it gets cleaned - // up/expanded on the remote. - return localPath, localPort, parts[1], nil -} diff --git a/cli/portforward_test.go b/cli/portforward_test.go deleted file mode 100644 index 0c0d3ddc5fa08..0000000000000 --- a/cli/portforward_test.go +++ /dev/null @@ -1,532 +0,0 @@ -package cli_test - -import ( - "bytes" - "context" - "fmt" - "io" - "net" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" - - "github.com/google/uuid" - "github.com/pion/udp" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" -) - -func TestPortForward(t *testing.T) { - t.Parallel() - - t.Run("None", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - _ = coderdtest.CreateFirstUser(t, client) - - cmd, root := clitest.New(t, "port-forward", "blah") - clitest.SetupConfig(t, client, root) - buf := newThreadSafeBuffer() - cmd.SetOut(buf) - - err := cmd.Execute() - require.Error(t, err) - require.ErrorContains(t, err, "no port-forwards") - - // Check that the help was printed. - require.Contains(t, buf.String(), "port-forward ") - }) - - cases := []struct { - name string - network string - // The flag to pass to `coder port-forward X` to port-forward this type - // of connection. Has two format args (both strings), the first is the - // local address and the second is the remote address. - flag string - // setupRemote creates a "remote" listener to emulate a service in the - // workspace. - setupRemote func(t *testing.T) net.Listener - // setupLocal returns an available port or Unix socket path that the - // port-forward command will listen on "locally". Returns the address - // you pass to net.Dial, and the port/path you pass to `coder - // port-forward`. - setupLocal func(t *testing.T) (string, string) - }{ - { - name: "TCP", - network: "tcp", - flag: "--tcp=%v:%v", - setupRemote: func(t *testing.T) net.Listener { - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err, "create TCP listener") - return l - }, - setupLocal: func(t *testing.T) (string, string) { - l, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err, "create TCP listener to generate random port") - defer l.Close() - - _, port, err := net.SplitHostPort(l.Addr().String()) - require.NoErrorf(t, err, "split TCP address %q", l.Addr().String()) - return l.Addr().String(), port - }, - }, - { - name: "UDP", - network: "udp", - flag: "--udp=%v:%v", - setupRemote: func(t *testing.T) net.Listener { - addr := net.UDPAddr{ - IP: net.ParseIP("127.0.0.1"), - Port: 0, - } - l, err := udp.Listen("udp", &addr) - require.NoError(t, err, "create UDP listener") - return l - }, - setupLocal: func(t *testing.T) (string, string) { - addr := net.UDPAddr{ - IP: net.ParseIP("127.0.0.1"), - Port: 0, - } - l, err := udp.Listen("udp", &addr) - require.NoError(t, err, "create UDP listener to generate random port") - defer l.Close() - - _, port, err := net.SplitHostPort(l.Addr().String()) - require.NoErrorf(t, err, "split UDP address %q", l.Addr().String()) - return l.Addr().String(), port - }, - }, - { - name: "Unix", - network: "unix", - flag: "--unix=%v:%v", - setupRemote: func(t *testing.T) net.Listener { - if runtime.GOOS == "windows" { - t.Skip("Unix socket forwarding isn't supported on Windows") - } - - tmpDir, err := os.MkdirTemp("", "coderd_agent_test_") - require.NoError(t, err, "create temp dir for unix listener") - t.Cleanup(func() { - _ = os.RemoveAll(tmpDir) - }) - - l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock")) - require.NoError(t, err, "create UDP listener") - return l - }, - setupLocal: func(t *testing.T) (string, string) { - tmpDir, err := os.MkdirTemp("", "coderd_agent_test_") - require.NoError(t, err, "create temp dir for unix listener") - t.Cleanup(func() { - _ = os.RemoveAll(tmpDir) - }) - - path := filepath.Join(tmpDir, "test.sock") - return path, path - }, - }, - } - - for _, c := range cases { //nolint:paralleltest // the `c := c` confuses the linter - c := c - t.Run(c.name, func(t *testing.T) { - t.Parallel() - - t.Run("OnePort", func(t *testing.T) { - t.Parallel() - var ( - client = coderdtest.New(t, nil) - user = coderdtest.CreateFirstUser(t, client) - _, workspace = runAgent(t, client, user.UserID) - l1, p1 = setupTestListener(t, c.setupRemote(t)) - ) - t.Cleanup(func() { - _ = l1.Close() - }) - - // Create a flag that forwards from local to listener 1. - localAddress, localFlag := c.setupLocal(t) - flag := fmt.Sprintf(c.flag, localFlag, p1) - - // Launch port-forward in a goroutine so we can start dialing - // the "local" listener. - cmd, root := clitest.New(t, "port-forward", workspace.Name, flag) - clitest.SetupConfig(t, client, root) - buf := newThreadSafeBuffer() - cmd.SetOut(io.MultiWriter(buf, os.Stderr)) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - err := cmd.ExecuteContext(ctx) - require.Error(t, err) - require.ErrorIs(t, err, context.Canceled) - }() - waitForPortForwardReady(t, buf) - - // Open two connections simultaneously and test them out of - // sync. - d := net.Dialer{Timeout: 3 * time.Second} - c1, err := d.DialContext(ctx, c.network, localAddress) - require.NoError(t, err, "open connection 1 to 'local' listener") - defer c1.Close() - c2, err := d.DialContext(ctx, c.network, localAddress) - require.NoError(t, err, "open connection 2 to 'local' listener") - defer c2.Close() - testDial(t, c2) - testDial(t, c1) - }) - - t.Run("TwoPorts", func(t *testing.T) { - t.Parallel() - var ( - client = coderdtest.New(t, nil) - user = coderdtest.CreateFirstUser(t, client) - _, workspace = runAgent(t, client, user.UserID) - l1, p1 = setupTestListener(t, c.setupRemote(t)) - l2, p2 = setupTestListener(t, c.setupRemote(t)) - ) - t.Cleanup(func() { - _ = l1.Close() - _ = l2.Close() - }) - - // Create a flags for listener 1 and listener 2. - localAddress1, localFlag1 := c.setupLocal(t) - localAddress2, localFlag2 := c.setupLocal(t) - flag1 := fmt.Sprintf(c.flag, localFlag1, p1) - flag2 := fmt.Sprintf(c.flag, localFlag2, p2) - - // Launch port-forward in a goroutine so we can start dialing - // the "local" listeners. - cmd, root := clitest.New(t, "port-forward", workspace.Name, flag1, flag2) - clitest.SetupConfig(t, client, root) - buf := newThreadSafeBuffer() - cmd.SetOut(io.MultiWriter(buf, os.Stderr)) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - err := cmd.ExecuteContext(ctx) - require.Error(t, err) - require.ErrorIs(t, err, context.Canceled) - }() - waitForPortForwardReady(t, buf) - - // Open a connection to both listener 1 and 2 simultaneously and - // then test them out of order. - d := net.Dialer{Timeout: 3 * time.Second} - c1, err := d.DialContext(ctx, c.network, localAddress1) - require.NoError(t, err, "open connection 1 to 'local' listener 1") - defer c1.Close() - c2, err := d.DialContext(ctx, c.network, localAddress2) - require.NoError(t, err, "open connection 2 to 'local' listener 2") - defer c2.Close() - testDial(t, c2) - testDial(t, c1) - }) - }) - } - - // Test doing a TCP -> Unix forward. - t.Run("TCP2Unix", func(t *testing.T) { - t.Parallel() - var ( - client = coderdtest.New(t, nil) - user = coderdtest.CreateFirstUser(t, client) - _, workspace = runAgent(t, client, user.UserID) - - // Find the TCP and Unix cases so we can use their setupLocal and - // setupRemote methods respectively. - tcpCase = cases[0] - unixCase = cases[2] - - // Setup remote Unix listener. - l1, p1 = setupTestListener(t, unixCase.setupRemote(t)) - ) - t.Cleanup(func() { - _ = l1.Close() - }) - - // Create a flag that forwards from local TCP to Unix listener 1. - // Notably this is a --unix flag. - localAddress, localFlag := tcpCase.setupLocal(t) - flag := fmt.Sprintf(unixCase.flag, localFlag, p1) - - // Launch port-forward in a goroutine so we can start dialing - // the "local" listener. - cmd, root := clitest.New(t, "port-forward", workspace.Name, flag) - clitest.SetupConfig(t, client, root) - buf := newThreadSafeBuffer() - cmd.SetOut(io.MultiWriter(buf, os.Stderr)) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - err := cmd.ExecuteContext(ctx) - require.Error(t, err) - require.ErrorIs(t, err, context.Canceled) - }() - waitForPortForwardReady(t, buf) - - // Open two connections simultaneously and test them out of - // sync. - d := net.Dialer{Timeout: 3 * time.Second} - c1, err := d.DialContext(ctx, tcpCase.network, localAddress) - require.NoError(t, err, "open connection 1 to 'local' listener") - defer c1.Close() - c2, err := d.DialContext(ctx, tcpCase.network, localAddress) - require.NoError(t, err, "open connection 2 to 'local' listener") - defer c2.Close() - testDial(t, c2) - testDial(t, c1) - }) - - // Test doing TCP, UDP and Unix at the same time. - t.Run("All", func(t *testing.T) { - t.Parallel() - var ( - client = coderdtest.New(t, nil) - user = coderdtest.CreateFirstUser(t, client) - _, workspace = runAgent(t, client, user.UserID) - // These aren't fixed size because we exclude Unix on Windows. - dials = []addr{} - flags = []string{} - ) - - // Start listeners and populate arrays with the cases. - for _, c := range cases { - if strings.HasPrefix(c.network, "unix") && runtime.GOOS == "windows" { - // Unix isn't supported on Windows, but we can still - // test other protocols together. - continue - } - - l, p := setupTestListener(t, c.setupRemote(t)) - t.Cleanup(func() { - _ = l.Close() - }) - - localAddress, localFlag := c.setupLocal(t) - dials = append(dials, addr{ - network: c.network, - addr: localAddress, - }) - flags = append(flags, fmt.Sprintf(c.flag, localFlag, p)) - } - - // Launch port-forward in a goroutine so we can start dialing - // the "local" listeners. - cmd, root := clitest.New(t, append([]string{"port-forward", workspace.Name}, flags...)...) - clitest.SetupConfig(t, client, root) - buf := newThreadSafeBuffer() - cmd.SetOut(io.MultiWriter(buf, os.Stderr)) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - err := cmd.ExecuteContext(ctx) - require.Error(t, err) - require.ErrorIs(t, err, context.Canceled) - }() - waitForPortForwardReady(t, buf) - - // Open connections to all items in the "dial" array. - var ( - d = net.Dialer{Timeout: 3 * time.Second} - conns = make([]net.Conn, len(dials)) - ) - for i, a := range dials { - c, err := d.DialContext(ctx, a.network, a.addr) - require.NoErrorf(t, err, "open connection %v to 'local' listener %v", i+1, i+1) - t.Cleanup(func() { - _ = c.Close() - }) - conns[i] = c - } - - // Test each connection in reverse order. - for i := len(conns) - 1; i >= 0; i-- { - testDial(t, conns[i]) - } - }) -} - -// runAgent creates a fake workspace and starts an agent locally for that -// workspace. The agent will be cleaned up on test completion. -func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]codersdk.WorkspaceResource, codersdk.Workspace) { - ctx := context.Background() - user, err := client.User(ctx, userID.String()) - require.NoError(t, err, "specified user does not exist") - require.Greater(t, len(user.OrganizationIDs), 0, "user has no organizations") - orgID := user.OrganizationIDs[0] - - // Setup echo provisioner - agentToken := uuid.NewString() - coderdtest.NewProvisionerDaemon(t, client) - version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionDryRun: echo.ProvisionComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "somename", - Type: "someinstance", - Agents: []*proto.Agent{{ - Auth: &proto.Agent_Token{ - Token: agentToken, - }, - }}, - }}, - }, - }, - }}, - }) - - // Create template and workspace - template := coderdtest.CreateTemplate(t, client, orgID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - // Start workspace agent in a goroutine - cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String()) - clitest.SetupConfig(t, client, root) - agentCtx, agentCancel := context.WithCancel(ctx) - t.Cleanup(agentCancel) - go func() { - err := cmd.ExecuteContext(agentCtx) - require.NoError(t, err) - }() - - coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) - resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID) - require.NoError(t, err) - - return resources, workspace -} - -// setupTestListener starts accepting connections and echoing a single packet. -// Returns the listener and the listen port or Unix path. -func setupTestListener(t *testing.T, l net.Listener) (net.Listener, string) { - t.Cleanup(func() { - _ = l.Close() - }) - go func() { - for { - c, err := l.Accept() - if err != nil { - return - } - - go testAccept(t, c) - } - }() - - addr := l.Addr().String() - if !strings.HasPrefix(l.Addr().Network(), "unix") { - _, port, err := net.SplitHostPort(addr) - require.NoErrorf(t, err, "split non-Unix listen path %q", addr) - addr = port - } - - return l, addr -} - -var dialTestPayload = []byte("dean-was-here123") - -func testDial(t *testing.T, c net.Conn) { - t.Helper() - - assertWritePayload(t, c, dialTestPayload) - assertReadPayload(t, c, dialTestPayload) -} - -func testAccept(t *testing.T, c net.Conn) { - t.Helper() - defer c.Close() - - assertReadPayload(t, c, dialTestPayload) - assertWritePayload(t, c, dialTestPayload) -} - -func assertReadPayload(t *testing.T, r io.Reader, payload []byte) { - b := make([]byte, len(payload)+16) - n, err := r.Read(b) - require.NoError(t, err, "read payload") - require.Equal(t, len(payload), n, "read payload length does not match") - require.Equal(t, payload, b[:n]) -} - -func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { - n, err := w.Write(payload) - require.NoError(t, err, "write payload") - require.Equal(t, len(payload), n, "payload length does not match") -} - -func waitForPortForwardReady(t *testing.T, output *threadSafeBuffer) { - for i := 0; i < 100; i++ { - time.Sleep(250 * time.Millisecond) - - data := output.String() - if strings.Contains(data, "Ready!") { - return - } - } - - t.Fatal("port-forward command did not become ready in time") -} - -type addr struct { - network string - addr string -} - -type threadSafeBuffer struct { - b *bytes.Buffer - mut *sync.RWMutex -} - -func newThreadSafeBuffer() *threadSafeBuffer { - return &threadSafeBuffer{ - b: bytes.NewBuffer(nil), - mut: new(sync.RWMutex), - } -} - -var _ io.Reader = &threadSafeBuffer{} -var _ io.Writer = &threadSafeBuffer{} - -// Read implements io.Reader. -func (b *threadSafeBuffer) Read(p []byte) (int, error) { - b.mut.RLock() - defer b.mut.RUnlock() - - return b.b.Read(p) -} - -// Write implements io.Writer. -func (b *threadSafeBuffer) Write(p []byte) (int, error) { - b.mut.Lock() - defer b.mut.Unlock() - - return b.b.Write(p) -} - -func (b *threadSafeBuffer) String() string { - b.mut.RLock() - defer b.mut.RUnlock() - - return b.b.String() -} diff --git a/cli/resetpassword_test.go b/cli/resetpassword_test.go index 20f69cda7e93f..eafa097e3e842 100644 --- a/cli/resetpassword_test.go +++ b/cli/resetpassword_test.go @@ -15,10 +15,8 @@ import ( "github.com/coder/coder/pty/ptytest" ) -// nolint:paralleltest func TestResetPassword(t *testing.T) { - // postgres.Open() seems to be creating race conditions when run in parallel. - // t.Parallel() + t.Parallel() if runtime.GOOS != "linux" || testing.Short() { // Skip on non-Linux because it spawns a PostgreSQL instance. diff --git a/cli/root.go b/cli/root.go index 424ec54155c03..9e96be9be3f53 100644 --- a/cli/root.go +++ b/cli/root.go @@ -36,15 +36,6 @@ const ( varForceTty = "force-tty" ) -func init() { - // Customizes the color of headings to make subcommands more visually - // appealing. - header := cliui.Styles.Placeholder - cobra.AddTemplateFunc("usageHeader", func(s string) string { - return header.Render(s) - }) -} - func Root() *cobra.Command { cmd := &cobra.Command{ Use: "coder", @@ -80,7 +71,7 @@ func Root() *cobra.Command { templates(), update(), users(), - portForward(), + tunnel(), workspaceAgent(), ) @@ -188,7 +179,13 @@ func isTTY(cmd *cobra.Command) bool { } func usageTemplate() string { - // usageHeader is defined in init(). + // Customizes the color of headings to make subcommands + // more visually appealing. + header := cliui.Styles.Placeholder + cobra.AddTemplateFunc("usageHeader", func(s string) string { + return header.Render(s) + }) + return `{{usageHeader "Usage:"}} {{- if .Runnable}} {{.UseLine}} diff --git a/cli/server_test.go b/cli/server_test.go index ef0d72a1cd493..f20944367853f 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -32,11 +32,10 @@ import ( ) // This cannot be ran in parallel because it uses a signal. -// nolint:paralleltest +// nolint:tparallel func TestServer(t *testing.T) { t.Run("Production", func(t *testing.T) { - // postgres.Open() seems to be creating race conditions when run in parallel. - // t.Parallel() + t.Parallel() if runtime.GOOS != "linux" || testing.Short() { // Skip on non-Linux because it spawns a PostgreSQL instance. t.SkipNow() diff --git a/cli/ssh.go b/cli/ssh.go index 4dfd68463aa64..4ccaa241d5902 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -24,7 +24,6 @@ import ( "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" ) var autostopPollInterval = 30 * time.Second @@ -32,14 +31,13 @@ var autostopNotifyCountdown = []time.Duration{30 * time.Minute} func ssh() *cobra.Command { var ( - stdio bool - shuffle bool + stdio bool ) cmd := &cobra.Command{ Annotations: workspaceCommand, Use: "ssh ", Short: "SSH into a workspace", - Args: cobra.ArbitraryArgs, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { @@ -50,23 +48,58 @@ func ssh() *cobra.Command { return err } - if shuffle { - err := cobra.ExactArgs(0)(cmd, args) - if err != nil { - return err - } - } else { - err := cobra.MinimumNArgs(1)(cmd, args) + workspaceParts := strings.Split(args[0], ".") + workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, workspaceParts[0]) + if err != nil { + return err + } + + if workspace.LatestBuild.Transition != database.WorkspaceTransitionStart { + return xerrors.New("workspace must be in start transition to ssh") + } + + if workspace.LatestBuild.Job.CompletedAt == nil { + err = cliui.WorkspaceBuild(cmd.Context(), cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt) if err != nil { return err } } - workspace, agent, err := getWorkspaceAndAgent(cmd, client, organization.ID, codersdk.Me, args[0], shuffle) + if workspace.LatestBuild.Transition == database.WorkspaceTransitionDelete { + return xerrors.New("workspace is deleting...") + } + + resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) if err != nil { return err } + agents := make([]codersdk.WorkspaceAgent, 0) + for _, resource := range resources { + agents = append(agents, resource.Agents...) + } + if len(agents) == 0 { + return xerrors.New("workspace has no agents") + } + var agent codersdk.WorkspaceAgent + if len(workspaceParts) >= 2 { + for _, otherAgent := range agents { + if otherAgent.Name != workspaceParts[1] { + continue + } + agent = otherAgent + break + } + if agent.ID == uuid.Nil { + return xerrors.Errorf("agent not found by name %q", workspaceParts[1]) + } + } + if agent.ID == uuid.Nil { + if len(agents) > 1 { + return xerrors.New("you must specify the name of an agent") + } + agent = agents[0] + } // OpenSSH passes stderr directly to the calling TTY. // This is required in "stdio" mode so a connecting indicator can be displayed. err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{ @@ -156,98 +189,10 @@ func ssh() *cobra.Command { }, } cliflag.BoolVarP(cmd.Flags(), &stdio, "stdio", "", "CODER_SSH_STDIO", false, "Specifies whether to emit SSH output over stdin/stdout.") - cliflag.BoolVarP(cmd.Flags(), &shuffle, "shuffle", "", "CODER_SSH_SHUFFLE", false, "Specifies whether to choose a random workspace") - _ = cmd.Flags().MarkHidden("shuffle") return cmd } -// getWorkspaceAgent returns the workspace and agent selected using either the -// `[.]` syntax via `in` or picks a random workspace and agent -// if `shuffle` is true. -func getWorkspaceAndAgent(cmd *cobra.Command, client *codersdk.Client, orgID uuid.UUID, userID string, in string, shuffle bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive - ctx := cmd.Context() - - var ( - workspace codersdk.Workspace - workspaceParts = strings.Split(in, ".") - err error - ) - if shuffle { - workspaces, err := client.WorkspacesByOwner(cmd.Context(), orgID, userID) - if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err - } - if len(workspaces) == 0 { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("no workspaces to shuffle") - } - - workspace, err = cryptorand.Element(workspaces) - if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err - } - } else { - workspace, err = client.WorkspaceByOwnerAndName(cmd.Context(), orgID, userID, workspaceParts[0]) - if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err - } - } - - if workspace.LatestBuild.Transition != database.WorkspaceTransitionStart { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("workspace must be in start transition to ssh") - } - if workspace.LatestBuild.Job.CompletedAt == nil { - err := cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt) - if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err - } - } - if workspace.LatestBuild.Transition == database.WorkspaceTransitionDelete { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is being deleted", workspace.Name) - } - - resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) - if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("fetch workspace resources: %w", err) - } - - agents := make([]codersdk.WorkspaceAgent, 0) - for _, resource := range resources { - agents = append(agents, resource.Agents...) - } - if len(agents) == 0 { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name) - } - var agent codersdk.WorkspaceAgent - if len(workspaceParts) >= 2 { - for _, otherAgent := range agents { - if otherAgent.Name != workspaceParts[1] { - continue - } - agent = otherAgent - break - } - if agent.ID == uuid.Nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q", workspaceParts[1]) - } - } - if agent.ID == uuid.Nil { - if len(agents) > 1 { - if !shuffle { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("you must specify the name of an agent") - } - agent, err = cryptorand.Element(agents) - if err != nil { - return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err - } - } else { - agent = agents[0] - } - } - - return workspace, agent, nil -} - // Attempt to poll workspace autostop. We write a per-workspace lockfile to // avoid spamming the user with notifications in case of multiple instances // of the CLI running simultaneously. diff --git a/cli/templates.go b/cli/templates.go index 2e5b179c900a4..45f6224369ba8 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -1,9 +1,8 @@ package cli import ( + "github.com/fatih/color" "github.com/spf13/cobra" - - "github.com/coder/coder/cli/cliui" ) func templates() *cobra.Command { @@ -14,15 +13,15 @@ func templates() *cobra.Command { Example: ` - Create a template for developers to create workspaces - ` + cliui.Styles.Code.Render("$ coder templates create") + ` + ` + color.New(color.FgHiMagenta).Sprint("$ coder templates create") + ` - Make changes to your template, and plan the changes - ` + cliui.Styles.Code.Render("$ coder templates plan ") + ` + ` + color.New(color.FgHiMagenta).Sprint("$ coder templates plan ") + ` - Update the template. Your developers can update their workspaces - ` + cliui.Styles.Code.Render("$ coder templates update "), + ` + color.New(color.FgHiMagenta).Sprint("$ coder templates update "), } cmd.AddCommand( templateCreate(), diff --git a/cli/tunnel.go b/cli/tunnel.go new file mode 100644 index 0000000000000..887d766a090b3 --- /dev/null +++ b/cli/tunnel.go @@ -0,0 +1,14 @@ +package cli + +import "github.com/spf13/cobra" + +func tunnel() *cobra.Command { + return &cobra.Command{ + Annotations: workspaceCommand, + Use: "tunnel", + Short: "Forward ports to your local machine", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } +} diff --git a/coderd/audit/table.go b/coderd/audit/table.go index f7edadbcf21f2..1556d9d3a3909 100644 --- a/coderd/audit/table.go +++ b/coderd/audit/table.go @@ -78,7 +78,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff. "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. "name": ActionTrack, - "readme": ActionTrack, + "description": ActionTrack, "job_id": ActionIgnore, // Not helpful in a diff because jobs aren't tracked in audit logs. }, &database.User{}: { diff --git a/coderd/authorize.go b/coderd/authorize.go deleted file mode 100644 index 69b9cf8c596c9..0000000000000 --- a/coderd/authorize.go +++ /dev/null @@ -1,43 +0,0 @@ -package coderd - -import ( - "net/http" - - "golang.org/x/xerrors" - - "cdr.dev/slog" - - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" -) - -func (api *api) Authorize(rw http.ResponseWriter, r *http.Request, action rbac.Action, object rbac.Object) bool { - roles := httpmw.UserRoles(r) - err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object) - if err != nil { - httpapi.Write(rw, http.StatusForbidden, httpapi.Response{ - Message: err.Error(), - }) - - // Log the errors for debugging - internalError := new(rbac.UnauthorizedError) - logger := api.Logger - if xerrors.As(err, internalError) { - logger = api.Logger.With(slog.F("internal", internalError.Internal())) - } - // Log information for debugging. This will be very helpful - // in the early days - logger.Warn(r.Context(), "unauthorized", - slog.F("roles", roles.Roles), - slog.F("user_id", roles.ID), - slog.F("username", roles.Username), - slog.F("route", r.URL.Path), - slog.F("action", action), - slog.F("object", object), - ) - - return false - } - return true -} diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index f402e7cedcc51..3fd1eb7fc28af 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -57,7 +57,7 @@ func (e *Executor) runOnce(t time.Time) error { for _, ws := range eligibleWorkspaces { // Determine the workspace state based on its latest build. - priorHistory, err := db.GetLatestWorkspaceBuildByWorkspaceID(e.ctx, ws.ID) + priorHistory, err := db.GetWorkspaceBuildByWorkspaceIDWithoutAfter(e.ctx, ws.ID) if err != nil { e.log.Warn(e.ctx, "get latest workspace build", slog.F("workspace_id", ws.ID), @@ -152,8 +152,12 @@ func build(ctx context.Context, store database.Store, workspace database.Workspa return xerrors.Errorf("get workspace template: %w", err) } - priorBuildNumber := priorHistory.BuildNumber + priorHistoryID := uuid.NullUUID{ + UUID: priorHistory.ID, + Valid: true, + } + var newWorkspaceBuild database.WorkspaceBuild // This must happen in a transaction to ensure history can be inserted, and // the prior history can update it's "after" column to point at the new. workspaceBuildID := uuid.New() @@ -182,13 +186,13 @@ func build(ctx context.Context, store database.Store, workspace database.Workspa if err != nil { return xerrors.Errorf("insert provisioner job: %w", err) } - _, err = store.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + newWorkspaceBuild, err = store.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ ID: workspaceBuildID, CreatedAt: now, UpdatedAt: now, WorkspaceID: workspace.ID, TemplateVersionID: priorHistory.TemplateVersionID, - BuildNumber: priorBuildNumber + 1, + BeforeID: priorHistoryID, Name: namesgenerator.GetRandomName(1), ProvisionerState: priorHistory.ProvisionerState, InitiatorID: workspace.OwnerID, @@ -198,5 +202,21 @@ func build(ctx context.Context, store database.Store, workspace database.Workspa if err != nil { return xerrors.Errorf("insert workspace build: %w", err) } + + if priorHistoryID.Valid { + // Update the prior history entries "after" column. + err = store.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + ID: priorHistory.ID, + ProvisionerState: priorHistory.ProvisionerState, + UpdatedAt: now, + AfterID: uuid.NullUUID{ + UUID: newWorkspaceBuild.ID, + Valid: true, + }, + }) + if err != nil { + return xerrors.Errorf("update prior workspace build: %w", err) + } + } return nil } diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index 38642cd54158b..445be2d8889c3 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -419,17 +419,10 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur") require.Equal(t, codersdk.ProvisionerJobSucceeded, ws.LatestBuild.Job.Status, "expected provisioner job to have succeeded") require.Equal(t, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected latest transition to be start") - builds, err := client.WorkspaceBuilds(ctx, codersdk.WorkspaceBuildsRequest{WorkspaceID: ws.ID}) + builds, err := client.WorkspaceBuilds(ctx, ws.ID) require.NoError(t, err, "fetch list of workspace builds from primary") // One build to start, one stop transition, and one autostart. No more. - require.Equal(t, database.WorkspaceTransitionStart, builds[0].Transition) - require.Equal(t, database.WorkspaceTransitionStop, builds[1].Transition) - require.Equal(t, database.WorkspaceTransitionStart, builds[2].Transition) require.Len(t, builds, 3, "unexpected number of builds for workspace from primary") - - // Builds are returned most recent first. - require.True(t, builds[0].CreatedAt.After(builds[1].CreatedAt)) - require.True(t, builds[1].CreatedAt.After(builds[2].CreatedAt)) } func mustProvisionWorkspace(t *testing.T, client *codersdk.Client) codersdk.Workspace { diff --git a/coderd/autobuild/schedule/schedule_test.go b/coderd/autobuild/schedule/schedule_test.go index 0af54e04a3e0c..0dd9884fb3b2a 100644 --- a/coderd/autobuild/schedule/schedule_test.go +++ b/coderd/autobuild/schedule/schedule_test.go @@ -12,34 +12,31 @@ import ( func Test_Weekly(t *testing.T) { t.Parallel() testCases := []struct { - name string - spec string - at time.Time - expectedNext time.Time - expectedError string - expectedCron string - expectedTz string - expectedString string + name string + spec string + at time.Time + expectedNext time.Time + expectedError string + expectedCron string + expectedTz string }{ { - name: "with timezone", - spec: "CRON_TZ=US/Central 30 9 * * 1-5", - at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC), - expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC), - expectedError: "", - expectedCron: "30 9 * * 1-5", - expectedTz: "US/Central", - expectedString: "CRON_TZ=US/Central 30 9 * * 1-5", + name: "with timezone", + spec: "CRON_TZ=US/Central 30 9 * * 1-5", + at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC), + expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC), + expectedError: "", + expectedCron: "30 9 * * 1-5", + expectedTz: "US/Central", }, { - name: "without timezone", - spec: "30 9 * * 1-5", - at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.UTC), - expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.UTC), - expectedError: "", - expectedCron: "30 9 * * 1-5", - expectedTz: "UTC", - expectedString: "CRON_TZ=UTC 30 9 * * 1-5", + name: "without timezone", + spec: "CRON_TZ=UTC 30 9 * * 1-5", + at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.Local), + expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.Local), + expectedError: "", + expectedCron: "30 9 * * 1-5", + expectedTz: "UTC", }, { name: "invalid schedule", @@ -94,9 +91,9 @@ func Test_Weekly(t *testing.T) { nextTime := actual.Next(testCase.at) require.NoError(t, err) require.Equal(t, testCase.expectedNext, nextTime) + require.Equal(t, testCase.spec, actual.String()) require.Equal(t, testCase.expectedCron, actual.Cron()) require.Equal(t, testCase.expectedTz, actual.Timezone()) - require.Equal(t, testCase.expectedString, actual.String()) } else { require.EqualError(t, err, testCase.expectedError) require.Nil(t, actual) diff --git a/coderd/coderd.go b/coderd/coderd.go index 1f1a4ff18dfac..27e5a667cb75f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -50,7 +50,7 @@ type Options struct { SecureAuthCookie bool SSHKeygenAlgorithm gitsshkey.Algorithm TURNServer *turnconn.Server - Authorizer rbac.Authorizer + Authorizer *rbac.RegoAuthorizer } // New constructs the Coder API into an HTTP handler. @@ -83,6 +83,10 @@ func New(options *Options) (http.Handler, func()) { // TODO: @emyrk we should just move this into 'ExtractAPIKey'. authRolesMiddleware := httpmw.ExtractUserRoles(options.Database) + authorize := func(f http.HandlerFunc, actions rbac.Action) http.HandlerFunc { + return httpmw.Authorize(api.Logger, api.Authorizer, actions)(f).ServeHTTP + } + r := chi.NewRouter() r.Use( @@ -154,7 +158,10 @@ func New(options *Options) (http.Handler, func()) { }) }) r.Route("/members", func(r chi.Router) { - r.Get("/roles", api.assignableOrgRoles) + r.Route("/roles", func(r chi.Router) { + r.Use(httpmw.WithRBACObject(rbac.ResourceUserRole)) + r.Get("/", authorize(api.assignableOrgRoles, rbac.ActionRead)) + }) r.Route("/{user}", func(r chi.Router) { r.Use( httpmw.ExtractUserParam(options.Database), @@ -175,8 +182,8 @@ func New(options *Options) (http.Handler, func()) { r.Use( apiKeyMiddleware, httpmw.ExtractTemplateParam(options.Database), + httpmw.ExtractOrganizationParam(options.Database), ) - r.Get("/", api.template) r.Delete("/", api.deleteTemplate) r.Route("/versions", func(r chi.Router) { @@ -189,6 +196,7 @@ func New(options *Options) (http.Handler, func()) { r.Use( apiKeyMiddleware, httpmw.ExtractTemplateVersionParam(options.Database), + httpmw.ExtractOrganizationParam(options.Database), ) r.Get("/", api.templateVersion) @@ -224,7 +232,8 @@ func New(options *Options) (http.Handler, func()) { r.Get("/", api.users) // These routes query information about site wide roles. r.Route("/roles", func(r chi.Router) { - r.Get("/", api.assignableSiteRoles) + r.Use(httpmw.WithRBACObject(rbac.ResourceUserRole)) + r.Get("/", authorize(api.assignableSiteRoles, rbac.ActionRead)) }) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) @@ -235,7 +244,8 @@ func New(options *Options) (http.Handler, func()) { r.Put("/active", api.putUserStatus(database.UserStatusActive)) }) r.Route("/password", func(r chi.Router) { - r.Put("/", api.putUserPassword) + r.Use(httpmw.WithRBACObject(rbac.ResourceUserPasswordRole)) + r.Put("/", authorize(api.putUserPassword, rbac.ActionUpdate)) }) r.Get("/organizations", api.organizationsByUser) r.Post("/organizations", api.postOrganizationsByUser) @@ -253,6 +263,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Get("/gitsshkey", api.gitSSHKey) r.Put("/gitsshkey", api.regenerateGitSSHKey) + r.Get("/workspaces", api.workspacesByUser) }) }) }) @@ -288,28 +299,22 @@ func New(options *Options) (http.Handler, func()) { ) r.Get("/", api.workspaceResource) }) - r.Route("/workspaces", func(r chi.Router) { + r.Route("/workspaces/{workspace}", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, + httpmw.ExtractWorkspaceParam(options.Database), ) - r.Get("/", api.workspaces) - r.Route("/{workspace}", func(r chi.Router) { - r.Use( - httpmw.ExtractWorkspaceParam(options.Database), - ) - r.Get("/", api.workspace) - r.Route("/builds", func(r chi.Router) { - r.Get("/", api.workspaceBuilds) - r.Post("/", api.postWorkspaceBuilds) - r.Get("/{workspacebuildname}", api.workspaceBuildByName) - }) - r.Route("/autostart", func(r chi.Router) { - r.Put("/", api.putWorkspaceAutostart) - }) - r.Route("/autostop", func(r chi.Router) { - r.Put("/", api.putWorkspaceAutostop) - }) + r.Get("/", api.workspace) + r.Route("/builds", func(r chi.Router) { + r.Get("/", api.workspaceBuilds) + r.Post("/", api.postWorkspaceBuilds) + r.Get("/{workspacebuildname}", api.workspaceBuildByName) + }) + r.Route("/autostart", func(r chi.Router) { + r.Put("/", api.putWorkspaceAutostart) + }) + r.Route("/autostop", func(r chi.Router) { + r.Put("/", api.putWorkspaceAutostop) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index cc34d89ed6bee..73d3c3d308def 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -2,19 +2,14 @@ package coderd_test import ( "context" - "net/http" - "strings" "testing" - "github.com/go-chi/chi/v5" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "go.uber.org/goleak" - "golang.org/x/xerrors" + + "github.com/stretchr/testify/require" "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/rbac" ) func TestMain(m *testing.M) { @@ -29,205 +24,3 @@ func TestBuildInfo(t *testing.T) { require.Equal(t, buildinfo.ExternalURL(), buildInfo.ExternalURL, "external URL") require.Equal(t, buildinfo.Version(), buildInfo.Version, "version") } - -// TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered. -func TestAuthorizeAllEndpoints(t *testing.T) { - t.Parallel() - - authorizer := &fakeAuthorizer{} - srv, client := coderdtest.NewMemoryCoderd(t, &coderdtest.Options{ - Authorizer: authorizer, - }) - admin := coderdtest.CreateFirstUser(t, client) - organization, err := client.Organization(context.Background(), admin.OrganizationID) - require.NoError(t, err, "fetch org") - - // Setup some data in the database. - coderdtest.NewProvisionerDaemon(t, client) - version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, admin.OrganizationID, template.ID) - - // Always fail auth from this point forward - authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil) - - // skipRoutes allows skipping routes from being checked. - type routeCheck struct { - NoAuthorize bool - AssertObject rbac.Object - StatusCode int - } - assertRoute := map[string]routeCheck{ - // These endpoints do not require auth - "GET:/api/v2": {NoAuthorize: true}, - "GET:/api/v2/buildinfo": {NoAuthorize: true}, - "GET:/api/v2/users/first": {NoAuthorize: true}, - "POST:/api/v2/users/first": {NoAuthorize: true}, - "POST:/api/v2/users/login": {NoAuthorize: true}, - "POST:/api/v2/users/logout": {NoAuthorize: true}, - "GET:/api/v2/users/authmethods": {NoAuthorize: true}, - - // All workspaceagents endpoints do not use rbac - "POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true}, - "POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true}, - "POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/iceservers": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/listen": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/dial": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/pty": {NoAuthorize: true}, - "GET:/api/v2/workspaceagents/{workspaceagent}/turn": {NoAuthorize: true}, - - // TODO: @emyrk these need to be fixed by adding authorize calls - "GET:/api/v2/workspaceresources/{workspaceresource}": {NoAuthorize: true}, - "GET:/api/v2/workspacebuilds/{workspacebuild}": {NoAuthorize: true}, - "GET:/api/v2/workspacebuilds/{workspacebuild}/logs": {NoAuthorize: true}, - "GET:/api/v2/workspacebuilds/{workspacebuild}/resources": {NoAuthorize: true}, - "GET:/api/v2/workspacebuilds/{workspacebuild}/state": {NoAuthorize: true}, - "PATCH:/api/v2/workspacebuilds/{workspacebuild}/cancel": {NoAuthorize: true}, - "GET:/api/v2/workspaces/{workspace}/builds/{workspacebuildname}": {NoAuthorize: true}, - - "GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true}, - - "PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true}, - "GET:/api/v2/organizations/{organization}/provisionerdaemons": {NoAuthorize: true}, - "POST:/api/v2/organizations/{organization}/templates": {NoAuthorize: true}, - "GET:/api/v2/organizations/{organization}/templates": {NoAuthorize: true}, - "GET:/api/v2/organizations/{organization}/templates/{templatename}": {NoAuthorize: true}, - "POST:/api/v2/organizations/{organization}/templateversions": {NoAuthorize: true}, - "POST:/api/v2/organizations/{organization}/workspaces": {NoAuthorize: true}, - - "POST:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true}, - "GET:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true}, - "DELETE:/api/v2/parameters/{scope}/{id}/{name}": {NoAuthorize: true}, - - "GET:/api/v2/provisionerdaemons/me/listen": {NoAuthorize: true}, - - "DELETE:/api/v2/templates/{template}": {NoAuthorize: true}, - "GET:/api/v2/templates/{template}": {NoAuthorize: true}, - "GET:/api/v2/templates/{template}/versions": {NoAuthorize: true}, - "PATCH:/api/v2/templates/{template}/versions": {NoAuthorize: true}, - "GET:/api/v2/templates/{template}/versions/{templateversionname}": {NoAuthorize: true}, - - "GET:/api/v2/templateversions/{templateversion}": {NoAuthorize: true}, - "PATCH:/api/v2/templateversions/{templateversion}/cancel": {NoAuthorize: true}, - "GET:/api/v2/templateversions/{templateversion}/logs": {NoAuthorize: true}, - "GET:/api/v2/templateversions/{templateversion}/parameters": {NoAuthorize: true}, - "GET:/api/v2/templateversions/{templateversion}/resources": {NoAuthorize: true}, - "GET:/api/v2/templateversions/{templateversion}/schema": {NoAuthorize: true}, - - "POST:/api/v2/users/{user}/organizations": {NoAuthorize: true}, - - "GET:/api/v2/workspaces/{workspace}": {NoAuthorize: true}, - "PUT:/api/v2/workspaces/{workspace}/autostart": {NoAuthorize: true}, - "PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true}, - "GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, - "POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, - - "POST:/api/v2/files": {NoAuthorize: true}, - "GET:/api/v2/files/{hash}": {NoAuthorize: true}, - - // These endpoints have more assertions. This is good, add more endpoints to assert if you can! - "GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(admin.OrganizationID)}, - "GET:/api/v2/users/{user}/organizations": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceOrganization}, - "GET:/api/v2/users/{user}/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, - "GET:/api/v2/organizations/{organization}/workspaces/{user}": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, - "GET:/api/v2/organizations/{organization}/workspaces/{user}/{workspace}": { - AssertObject: rbac.ResourceWorkspace.InOrg(organization.ID).WithID(workspace.ID.String()).WithOwner(workspace.OwnerID.String()), - }, - "GET:/api/v2/organizations/{organization}/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, - "GET:/api/v2/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, - - // These endpoints need payloads to get to the auth part. - "PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - } - - for k, v := range assertRoute { - noTrailSlash := strings.TrimRight(k, "/") - if _, ok := assertRoute[noTrailSlash]; ok && noTrailSlash != k { - t.Errorf("route %q & %q is declared twice", noTrailSlash, k) - t.FailNow() - } - assertRoute[noTrailSlash] = v - } - - c, _ := srv.Config.Handler.(*chi.Mux) - err = chi.Walk(c, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { - name := method + ":" + route - t.Run(name, func(t *testing.T) { - authorizer.reset() - routeAssertions, ok := assertRoute[strings.TrimRight(name, "/")] - if !ok { - // By default, all omitted routes check for just "authorize" called - routeAssertions = routeCheck{} - } - if routeAssertions.StatusCode == 0 { - routeAssertions.StatusCode = http.StatusForbidden - } - - // Replace all url params with known values - route = strings.ReplaceAll(route, "{organization}", admin.OrganizationID.String()) - route = strings.ReplaceAll(route, "{user}", admin.UserID.String()) - route = strings.ReplaceAll(route, "{organizationname}", organization.Name) - route = strings.ReplaceAll(route, "{workspace}", workspace.Name) - - resp, err := client.Request(context.Background(), method, route, nil) - require.NoError(t, err, "do req") - _ = resp.Body.Close() - - if !routeAssertions.NoAuthorize { - assert.NotNil(t, authorizer.Called, "authorizer expected") - assert.Equal(t, routeAssertions.StatusCode, resp.StatusCode, "expect unauthorized") - if authorizer.Called != nil { - if routeAssertions.AssertObject.Type != "" { - assert.Equal(t, routeAssertions.AssertObject.Type, authorizer.Called.Object.Type, "resource type") - } - if routeAssertions.AssertObject.Owner != "" { - assert.Equal(t, routeAssertions.AssertObject.Owner, authorizer.Called.Object.Owner, "resource owner") - } - if routeAssertions.AssertObject.OrgID != "" { - assert.Equal(t, routeAssertions.AssertObject.OrgID, authorizer.Called.Object.OrgID, "resource org") - } - if routeAssertions.AssertObject.ResourceID != "" { - assert.Equal(t, routeAssertions.AssertObject.ResourceID, authorizer.Called.Object.ResourceID, "resource ID") - } - } - } else { - assert.Nil(t, authorizer.Called, "authorize not expected") - } - }) - return nil - }) - require.NoError(t, err) -} - -type authCall struct { - SubjectID string - Roles []string - Action rbac.Action - Object rbac.Object -} - -type fakeAuthorizer struct { - Called *authCall - AlwaysReturn error -} - -func (f *fakeAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error { - f.Called = &authCall{ - SubjectID: subjectID, - Roles: roleNames, - Action: action, - Object: object, - } - return f.AlwaysReturn -} - -func (f *fakeAuthorizer) reset() { - f.Called = nil -} diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 117e96e6ac04f..f2cf11cfc02ae 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -56,7 +56,6 @@ import ( type Options struct { AWSCertificates awsidentity.Certificates - Authorizer rbac.Authorizer AzureCertificates x509.VerifyOptions GithubOAuth2Config *coderd.GithubOAuth2Config GoogleTokenValidator *idtoken.Validator @@ -67,7 +66,7 @@ type Options struct { // New constructs an in-memory coderd instance and returns // the connected client. -func NewMemoryCoderd(t *testing.T, options *Options) (*httptest.Server, *codersdk.Client) { +func New(t *testing.T, options *Options) *codersdk.Client { if options == nil { options = &Options{} } @@ -148,7 +147,6 @@ func NewMemoryCoderd(t *testing.T, options *Options) (*httptest.Server, *codersd SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, TURNServer: turnServer, APIRateLimit: options.APIRateLimit, - Authorizer: options.Authorizer, }) t.Cleanup(func() { cancelFunc() @@ -157,14 +155,7 @@ func NewMemoryCoderd(t *testing.T, options *Options) (*httptest.Server, *codersd closeWait() }) - return srv, codersdk.New(serverURL) -} - -// New constructs an in-memory coderd instance and returns -// the connected client. -func New(t *testing.T, options *Options) *codersdk.Client { - _, cli := NewMemoryCoderd(t, options) - return cli + return codersdk.New(serverURL) } // NewProvisionerDaemon launches a provisionerd instance configured to work @@ -261,8 +252,9 @@ func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uui for _, r := range user.Roles { siteRoles = append(siteRoles, r.Name) } - - _, err := client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: siteRoles}) + // TODO: @emyrk switch "other" to "client" when we support updating other + // users. + _, err := other.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: siteRoles}) require.NoError(t, err, "update site roles") // Update org roles @@ -295,20 +287,6 @@ func CreateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID return templateVersion } -// CreateWorkspaceBuild creates a workspace build for the given workspace and transition. -func CreateWorkspaceBuild( - t *testing.T, - client *codersdk.Client, - workspace codersdk.Workspace, - transition database.WorkspaceTransition) codersdk.WorkspaceBuild { - req := codersdk.CreateWorkspaceBuildRequest{ - Transition: transition, - } - build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, req) - require.NoError(t, err) - return build -} - // CreateTemplate creates a template with the "echo" provisioner for // compatibility with testing. The name assigned is randomly generated. func CreateTemplate(t *testing.T, client *codersdk.Client, organization uuid.UUID, version uuid.UUID) codersdk.Template { diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index e876a54fdad55..8455f522cc605 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -6,7 +6,6 @@ import ( "sort" "strings" "sync" - "time" "github.com/google/uuid" "golang.org/x/exp/slices" @@ -291,27 +290,6 @@ func (q *fakeQuerier) GetAllUserRoles(_ context.Context, userID uuid.UUID) (data }, nil } -func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.GetWorkspacesWithFilterParams) ([]database.Workspace, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - workspaces := make([]database.Workspace, 0) - for _, workspace := range q.workspaces { - if arg.OrganizationID != uuid.Nil && workspace.OrganizationID != arg.OrganizationID { - continue - } - if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID { - continue - } - if !arg.Deleted && workspace.Deleted { - continue - } - workspaces = append(workspaces, workspace) - } - - return workspaces, nil -} - func (q *fakeQuerier) GetWorkspacesByTemplateID(_ context.Context, arg database.GetWorkspacesByTemplateIDParams) ([]database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -441,100 +419,50 @@ func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUI return database.WorkspaceBuild{}, sql.ErrNoRows } -func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() - var row database.WorkspaceBuild - var buildNum int32 for _, workspaceBuild := range q.workspaceBuilds { - if workspaceBuild.WorkspaceID.String() == workspaceID.String() && workspaceBuild.BuildNumber > buildNum { - row = workspaceBuild - buildNum = workspaceBuild.BuildNumber + if workspaceBuild.WorkspaceID.String() != workspaceID.String() { + continue + } + if !workspaceBuild.AfterID.Valid { + return workspaceBuild, nil } } - if buildNum == 0 { - return database.WorkspaceBuild{}, sql.ErrNoRows - } - return row, nil + return database.WorkspaceBuild{}, sql.ErrNoRows } -func (q *fakeQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() - builds := make(map[uuid.UUID]database.WorkspaceBuild) - buildNumbers := make(map[uuid.UUID]int32) + builds := make([]database.WorkspaceBuild, 0) for _, workspaceBuild := range q.workspaceBuilds { for _, id := range ids { - if id.String() == workspaceBuild.WorkspaceID.String() && workspaceBuild.BuildNumber > buildNumbers[id] { - builds[id] = workspaceBuild - buildNumbers[id] = workspaceBuild.BuildNumber + if id.String() != workspaceBuild.WorkspaceID.String() { + continue } + builds = append(builds, workspaceBuild) } } - var returnBuilds []database.WorkspaceBuild - for i, n := range buildNumbers { - if n > 0 { - b := builds[i] - returnBuilds = append(returnBuilds, b) - } - } - if len(returnBuilds) == 0 { + if len(builds) == 0 { return nil, sql.ErrNoRows } - return returnBuilds, nil + return builds, nil } -func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceID(_ context.Context, - params database.GetWorkspaceBuildByWorkspaceIDParams) ([]database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceID(_ context.Context, workspaceID uuid.UUID) ([]database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() history := make([]database.WorkspaceBuild, 0) for _, workspaceBuild := range q.workspaceBuilds { - if workspaceBuild.WorkspaceID.String() == params.WorkspaceID.String() { + if workspaceBuild.WorkspaceID.String() == workspaceID.String() { history = append(history, workspaceBuild) } } - - // Order by build_number - slices.SortFunc(history, func(a, b database.WorkspaceBuild) bool { - // use greater than since we want descending order - return a.BuildNumber > b.BuildNumber - }) - - if params.AfterID != uuid.Nil { - found := false - for i, v := range history { - if v.ID == params.AfterID { - // We want to return all builds after index i. - history = history[i+1:] - found = true - break - } - } - - // If no builds after the time, then we return an empty list. - if !found { - return nil, sql.ErrNoRows - } - } - - if params.OffsetOpt > 0 { - if int(params.OffsetOpt) > len(history)-1 { - return nil, sql.ErrNoRows - } - history = history[params.OffsetOpt:] - } - - if params.LimitOpt > 0 { - if int(params.LimitOpt) > len(history) { - params.LimitOpt = int32(len(history)) - } - history = history[:params.LimitOpt] - } - if len(history) == 0 { return nil, sql.ErrNoRows } @@ -557,6 +485,26 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndName(_ context.Context, a return database.WorkspaceBuild{}, sql.ErrNoRows } +func (q *fakeQuerier) GetWorkspacesByOrganizationID(_ context.Context, req database.GetWorkspacesByOrganizationIDParams) ([]database.Workspace, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspaces { + if workspace.OrganizationID != req.OrganizationID { + continue + } + if workspace.Deleted != req.Deleted { + continue + } + workspaces = append(workspaces, workspace) + } + if len(workspaces) == 0 { + return nil, sql.ErrNoRows + } + return workspaces, nil +} + func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req database.GetWorkspacesByOrganizationIDsParams) ([]database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -576,6 +524,23 @@ func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req data return workspaces, nil } +func (q *fakeQuerier) GetWorkspacesByOwnerID(_ context.Context, req database.GetWorkspacesByOwnerIDParams) ([]database.Workspace, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspaces { + if workspace.OwnerID != req.OwnerID { + continue + } + if workspace.Deleted != req.Deleted { + continue + } + workspaces = append(workspaces, workspace) + } + return workspaces, nil +} + func (q *fakeQuerier) GetOrganizations(_ context.Context) ([]database.Organization, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1224,7 +1189,7 @@ func (q *fakeQuerier) InsertTemplateVersion(_ context.Context, arg database.Inse CreatedAt: arg.CreatedAt, UpdatedAt: arg.UpdatedAt, Name: arg.Name, - Readme: arg.Readme, + Description: arg.Description, JobID: arg.JobID, } q.templateVersions = append(q.templateVersions, version) @@ -1479,7 +1444,7 @@ func (q *fakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.Inser WorkspaceID: arg.WorkspaceID, Name: arg.Name, TemplateVersionID: arg.TemplateVersionID, - BuildNumber: arg.BuildNumber, + BeforeID: arg.BeforeID, Transition: arg.Transition, InitiatorID: arg.InitiatorID, JobID: arg.JobID, @@ -1513,7 +1478,7 @@ func (q *fakeQuerier) UpdateTemplateActiveVersionByID(_ context.Context, arg dat defer q.mutex.Unlock() for index, template := range q.templates { - if template.ID != arg.ID { + if template.ID.String() != arg.ID.String() { continue } template.ActiveVersionID = arg.ActiveVersionID @@ -1528,7 +1493,7 @@ func (q *fakeQuerier) UpdateTemplateDeletedByID(_ context.Context, arg database. defer q.mutex.Unlock() for index, template := range q.templates { - if template.ID != arg.ID { + if template.ID.String() != arg.ID.String() { continue } template.Deleted = arg.Deleted @@ -1543,7 +1508,7 @@ func (q *fakeQuerier) UpdateTemplateVersionByID(_ context.Context, arg database. defer q.mutex.Unlock() for index, templateVersion := range q.templateVersions { - if templateVersion.ID != arg.ID { + if templateVersion.ID.String() != arg.ID.String() { continue } templateVersion.TemplateID = arg.TemplateID @@ -1554,28 +1519,12 @@ func (q *fakeQuerier) UpdateTemplateVersionByID(_ context.Context, arg database. return sql.ErrNoRows } -func (q *fakeQuerier) UpdateTemplateVersionDescriptionByJobID(_ context.Context, arg database.UpdateTemplateVersionDescriptionByJobIDParams) error { - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, templateVersion := range q.templateVersions { - if templateVersion.JobID != arg.JobID { - continue - } - templateVersion.Readme = arg.Readme - templateVersion.UpdatedAt = time.Now() - q.templateVersions[index] = templateVersion - return nil - } - return sql.ErrNoRows -} - func (q *fakeQuerier) UpdateProvisionerDaemonByID(_ context.Context, arg database.UpdateProvisionerDaemonByIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() for index, daemon := range q.provisionerDaemons { - if arg.ID != daemon.ID { + if arg.ID.String() != daemon.ID.String() { continue } daemon.UpdatedAt = arg.UpdatedAt @@ -1591,7 +1540,7 @@ func (q *fakeQuerier) UpdateWorkspaceAgentConnectionByID(_ context.Context, arg defer q.mutex.Unlock() for index, agent := range q.provisionerJobAgents { - if agent.ID != arg.ID { + if agent.ID.String() != arg.ID.String() { continue } agent.FirstConnectedAt = arg.FirstConnectedAt @@ -1608,7 +1557,7 @@ func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.U defer q.mutex.Unlock() for index, job := range q.provisionerJobs { - if arg.ID != job.ID { + if arg.ID.String() != job.ID.String() { continue } job.UpdatedAt = arg.UpdatedAt @@ -1623,7 +1572,7 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCancelByID(_ context.Context, arg defer q.mutex.Unlock() for index, job := range q.provisionerJobs { - if arg.ID != job.ID { + if arg.ID.String() != job.ID.String() { continue } job.CanceledAt = arg.CanceledAt @@ -1638,7 +1587,7 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar defer q.mutex.Unlock() for index, job := range q.provisionerJobs { - if arg.ID != job.ID { + if arg.ID.String() != job.ID.String() { continue } job.UpdatedAt = arg.UpdatedAt @@ -1655,7 +1604,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.U defer q.mutex.Unlock() for index, workspace := range q.workspaces { - if workspace.ID != arg.ID { + if workspace.ID.String() != arg.ID.String() { continue } workspace.AutostartSchedule = arg.AutostartSchedule @@ -1671,7 +1620,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.Up defer q.mutex.Unlock() for index, workspace := range q.workspaces { - if workspace.ID != arg.ID { + if workspace.ID.String() != arg.ID.String() { continue } workspace.AutostopSchedule = arg.AutostopSchedule @@ -1687,10 +1636,11 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U defer q.mutex.Unlock() for index, workspaceBuild := range q.workspaceBuilds { - if workspaceBuild.ID != arg.ID { + if workspaceBuild.ID.String() != arg.ID.String() { continue } workspaceBuild.UpdatedAt = arg.UpdatedAt + workspaceBuild.AfterID = arg.AfterID workspaceBuild.ProvisionerState = arg.ProvisionerState q.workspaceBuilds[index] = workspaceBuild return nil @@ -1703,7 +1653,7 @@ func (q *fakeQuerier) UpdateWorkspaceDeletedByID(_ context.Context, arg database defer q.mutex.Unlock() for index, workspace := range q.workspaces { - if workspace.ID != arg.ID { + if workspace.ID.String() != arg.ID.String() { continue } workspace.Deleted = arg.Deleted diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f1041e3519475..89ca22acab100 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -234,7 +234,7 @@ CREATE TABLE template_versions ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, name character varying(64) NOT NULL, - readme character varying(1048576) NOT NULL, + description character varying(1048576) NOT NULL, job_id uuid NOT NULL ); @@ -288,7 +288,8 @@ CREATE TABLE workspace_builds ( workspace_id uuid NOT NULL, template_version_id uuid NOT NULL, name character varying(64) NOT NULL, - build_number integer NOT NULL, + before_id uuid, + after_id uuid, transition workspace_transition NOT NULL, initiator_id uuid NOT NULL, provisioner_state bytea, @@ -388,9 +389,6 @@ ALTER TABLE ONLY workspace_builds ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_pkey PRIMARY KEY (id); -ALTER TABLE ONLY workspace_builds - ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number); - ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_name_key UNIQUE (workspace_id, name); diff --git a/coderd/database/migrations/000004_jobs.up.sql b/coderd/database/migrations/000004_jobs.up.sql index cc87d95b95b59..d1c6633f0996e 100644 --- a/coderd/database/migrations/000004_jobs.up.sql +++ b/coderd/database/migrations/000004_jobs.up.sql @@ -165,7 +165,8 @@ CREATE TABLE workspace_builds ( workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, template_version_id uuid NOT NULL REFERENCES template_versions (id) ON DELETE CASCADE, name varchar(64) NOT NULL, - build_number integer NOT NULL, + before_id uuid, + after_id uuid, transition workspace_transition NOT NULL, initiator_id uuid NOT NULL, -- State stored by the provisioner @@ -173,6 +174,5 @@ CREATE TABLE workspace_builds ( -- Job ID of the action job_id uuid NOT NULL UNIQUE REFERENCES provisioner_jobs (id) ON DELETE CASCADE, PRIMARY KEY (id), - UNIQUE(workspace_id, name), - UNIQUE(workspace_id, build_number) + UNIQUE(workspace_id, name) ); diff --git a/coderd/database/migrations/000012_template_version_readme.down.sql b/coderd/database/migrations/000012_template_version_readme.down.sql deleted file mode 100644 index 9f090f164993b..0000000000000 --- a/coderd/database/migrations/000012_template_version_readme.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE template_versions RENAME README TO description; diff --git a/coderd/database/migrations/000012_template_version_readme.up.sql b/coderd/database/migrations/000012_template_version_readme.up.sql deleted file mode 100644 index 684b3b90a715e..0000000000000 --- a/coderd/database/migrations/000012_template_version_readme.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE template_versions RENAME description TO readme; diff --git a/coderd/database/models.go b/coderd/database/models.go index a705a02410411..1fc101a16ee1a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -446,7 +446,7 @@ type TemplateVersion struct { CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` - Readme string `db:"readme" json:"readme"` + Description string `db:"description" json:"description"` JobID uuid.UUID `db:"job_id" json:"job_id"` } @@ -501,7 +501,8 @@ type WorkspaceBuild struct { WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` Name string `db:"name" json:"name"` - BuildNumber int32 `db:"build_number" json:"build_number"` + BeforeID uuid.NullUUID `db:"before_id" json:"before_id"` + AfterID uuid.NullUUID `db:"after_id" json:"after_id"` Transition WorkspaceTransition `db:"transition" json:"transition"` InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` diff --git a/coderd/database/postgres/postgres_test.go b/coderd/database/postgres/postgres_test.go index d11fdb21e89c0..178a434c3be69 100644 --- a/coderd/database/postgres/postgres_test.go +++ b/coderd/database/postgres/postgres_test.go @@ -17,10 +17,8 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain(m) } -// nolint:paralleltest func TestPostgres(t *testing.T) { - // postgres.Open() seems to be creating race conditions when run in parallel. - // t.Parallel() + t.Parallel() if testing.Short() { t.Skip() diff --git a/coderd/database/pubsub_test.go b/coderd/database/pubsub_test.go index 73bd96dd1597a..1312326a20a73 100644 --- a/coderd/database/pubsub_test.go +++ b/coderd/database/pubsub_test.go @@ -22,10 +22,8 @@ func TestPubsub(t *testing.T) { return } - // nolint:paralleltest t.Run("Postgres", func(t *testing.T) { - // postgres.Open() seems to be creating race conditions when run in parallel. - // t.Parallel() + t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() @@ -54,10 +52,8 @@ func TestPubsub(t *testing.T) { assert.Equal(t, string(message), data) }) - // nolint:paralleltest t.Run("PostgresCloseCancel", func(t *testing.T) { - // postgres.Open() seems to be creating race conditions when run in parallel. - // t.Parallel() + t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() connectionURL, closePg, err := postgres.Open() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 387a2c9a06698..9787aca37e017 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -27,8 +27,6 @@ type querier interface { GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBeforeParams) ([]AuditLog, error) GetFileByHash(ctx context.Context, hash string) (File, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) - GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) - GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) 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) @@ -63,17 +61,20 @@ type querier interface { GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) - GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDParams) ([]WorkspaceBuild, error) + GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndNameParams) (WorkspaceBuild, error) + GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) + GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error) + GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error) + GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) - GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) @@ -102,7 +103,6 @@ type querier interface { UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error - UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5985afbf33d65..d4abbefd040f5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1830,7 +1830,7 @@ func (q *sqlQuerier) UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTe const getTemplateVersionByID = `-- name: GetTemplateVersionByID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id + id, template_id, organization_id, created_at, updated_at, name, description, job_id FROM template_versions WHERE @@ -1847,7 +1847,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Readme, + &i.Description, &i.JobID, ) return i, err @@ -1855,7 +1855,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( const getTemplateVersionByJobID = `-- name: GetTemplateVersionByJobID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id + id, template_id, organization_id, created_at, updated_at, name, description, job_id FROM template_versions WHERE @@ -1872,7 +1872,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Readme, + &i.Description, &i.JobID, ) return i, err @@ -1880,7 +1880,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U const getTemplateVersionByTemplateIDAndName = `-- name: GetTemplateVersionByTemplateIDAndName :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id + id, template_id, organization_id, created_at, updated_at, name, description, job_id FROM template_versions WHERE @@ -1903,7 +1903,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Readme, + &i.Description, &i.JobID, ) return i, err @@ -1911,7 +1911,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, const getTemplateVersionsByTemplateID = `-- name: GetTemplateVersionsByTemplateID :many SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id + id, template_id, organization_id, created_at, updated_at, name, description, job_id FROM template_versions WHERE @@ -1972,7 +1972,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Readme, + &i.Description, &i.JobID, ); err != nil { return nil, err @@ -1997,11 +1997,11 @@ INSERT INTO created_at, updated_at, "name", - readme, + description, job_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, template_id, organization_id, created_at, updated_at, name, readme, job_id + ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, template_id, organization_id, created_at, updated_at, name, description, job_id ` type InsertTemplateVersionParams struct { @@ -2011,7 +2011,7 @@ type InsertTemplateVersionParams struct { CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` - Readme string `db:"readme" json:"readme"` + Description string `db:"description" json:"description"` JobID uuid.UUID `db:"job_id" json:"job_id"` } @@ -2023,7 +2023,7 @@ func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTempla arg.CreatedAt, arg.UpdatedAt, arg.Name, - arg.Readme, + arg.Description, arg.JobID, ) var i TemplateVersion @@ -2034,7 +2034,7 @@ func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTempla &i.CreatedAt, &i.UpdatedAt, &i.Name, - &i.Readme, + &i.Description, &i.JobID, ) return i, err @@ -2061,26 +2061,6 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe return err } -const updateTemplateVersionDescriptionByJobID = `-- name: UpdateTemplateVersionDescriptionByJobID :exec -UPDATE - template_versions -SET - readme = $2, - updated_at = now() -WHERE - job_id = $1 -` - -type UpdateTemplateVersionDescriptionByJobIDParams struct { - JobID uuid.UUID `db:"job_id" json:"job_id"` - Readme string `db:"readme" json:"readme"` -} - -func (q *sqlQuerier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error { - _, err := q.db.ExecContext(ctx, updateTemplateVersionDescriptionByJobID, arg.JobID, arg.Readme) - return err -} - const getAllUserRoles = `-- name: GetAllUserRoles :one SELECT -- username is returned just to help for logging purposes @@ -2749,21 +2729,50 @@ func (q *sqlQuerier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg return err } -const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one +const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id + id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id FROM workspace_builds WHERE - workspace_id = $1 -ORDER BY - build_number desc + id = $1 LIMIT 1 ` -func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getLatestWorkspaceBuildByWorkspaceID, workspaceID) +func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceBuildByID, id) + var i WorkspaceBuild + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.Name, + &i.BeforeID, + &i.AfterID, + &i.Transition, + &i.InitiatorID, + &i.ProvisionerState, + &i.JobID, + ) + return i, err +} + +const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one +SELECT + id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id +FROM + workspace_builds +WHERE + job_id = $1 +LIMIT + 1 +` + +func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceBuildByJobID, jobID) var i WorkspaceBuild err := row.Scan( &i.ID, @@ -2772,7 +2781,8 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BuildNumber, + &i.BeforeID, + &i.AfterID, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2781,25 +2791,17 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w return i, err } -const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.name, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_builds - WHERE - workspace_id = ANY($1 :: uuid [ ]) - GROUP BY - workspace_id -) m -JOIN - workspace_builds wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number -` - -func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getLatestWorkspaceBuildsByWorkspaceIDs, pq.Array(ids)) +const getWorkspaceBuildByWorkspaceID = `-- name: GetWorkspaceBuildByWorkspaceID :many +SELECT + id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id +FROM + workspace_builds +WHERE + workspace_id = $1 +` + +func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceBuild, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceBuildByWorkspaceID, workspaceID) if err != nil { return nil, err } @@ -2814,7 +2816,8 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BuildNumber, + &i.BeforeID, + &i.AfterID, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2833,19 +2836,23 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, return items, nil } -const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one +const getWorkspaceBuildByWorkspaceIDAndName = `-- name: GetWorkspaceBuildByWorkspaceIDAndName :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id + id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id FROM workspace_builds WHERE - id = $1 -LIMIT - 1 + workspace_id = $1 + AND "name" = $2 ` -func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByID, id) +type GetWorkspaceBuildByWorkspaceIDAndNameParams struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndNameParams) (WorkspaceBuild, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceBuildByWorkspaceIDAndName, arg.WorkspaceID, arg.Name) var i WorkspaceBuild err := row.Scan( &i.ID, @@ -2854,7 +2861,8 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BuildNumber, + &i.BeforeID, + &i.AfterID, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2863,19 +2871,20 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W return i, err } -const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one +const getWorkspaceBuildByWorkspaceIDWithoutAfter = `-- name: GetWorkspaceBuildByWorkspaceIDWithoutAfter :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id + id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id FROM workspace_builds WHERE - job_id = $1 + workspace_id = $1 + AND after_id IS NULL LIMIT 1 ` -func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByJobID, jobID) +func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceBuildByWorkspaceIDWithoutAfter, workspaceID) var i WorkspaceBuild err := row.Scan( &i.ID, @@ -2884,7 +2893,8 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BuildNumber, + &i.BeforeID, + &i.AfterID, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2893,53 +2903,18 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU return i, err } -const getWorkspaceBuildByWorkspaceID = `-- name: GetWorkspaceBuildByWorkspaceID :many +const getWorkspaceBuildsByWorkspaceIDsWithoutAfter = `-- name: GetWorkspaceBuildsByWorkspaceIDsWithoutAfter :many SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id + id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id FROM workspace_builds WHERE - workspace_builds.workspace_id = $1 - AND CASE - -- This allows using the last element on a page as effectively a cursor. - -- This is an important option for scripts that need to paginate without - -- duplicating or missing data. - WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN ( - -- The pagination cursor is the last ID of the previous page. - -- The query is ordered by the build_number field, so select all - -- rows after the cursor. - build_number > ( - SELECT - build_number - FROM - workspace_builds - WHERE - id = $2 - ) - ) - ELSE true -END -ORDER BY - build_number desc OFFSET $3 -LIMIT - -- A null limit means "no limit", so -1 means return all - NULLIF($4 :: int, -1) + workspace_id = ANY($1 :: uuid [ ]) + AND after_id IS NULL ` -type GetWorkspaceBuildByWorkspaceIDParams struct { - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - AfterID uuid.UUID `db:"after_id" json:"after_id"` - OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` - LimitOpt int32 `db:"limit_opt" json:"limit_opt"` -} - -func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDParams) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaceBuildByWorkspaceID, - arg.WorkspaceID, - arg.AfterID, - arg.OffsetOpt, - arg.LimitOpt, - ) +func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceBuildsByWorkspaceIDsWithoutAfter, pq.Array(ids)) if err != nil { return nil, err } @@ -2954,7 +2929,8 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg Get &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BuildNumber, + &i.BeforeID, + &i.AfterID, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -2973,40 +2949,6 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg Get return items, nil } -const getWorkspaceBuildByWorkspaceIDAndName = `-- name: GetWorkspaceBuildByWorkspaceIDAndName :one -SELECT - id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id -FROM - workspace_builds -WHERE - workspace_id = $1 - AND "name" = $2 -` - -type GetWorkspaceBuildByWorkspaceIDAndNameParams struct { - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - Name string `db:"name" json:"name"` -} - -func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndNameParams) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByWorkspaceIDAndName, arg.WorkspaceID, arg.Name) - var i WorkspaceBuild - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.Name, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - ) - return i, err -} - const insertWorkspaceBuild = `-- name: InsertWorkspaceBuild :one INSERT INTO workspace_builds ( @@ -3015,7 +2957,7 @@ INSERT INTO updated_at, workspace_id, template_version_id, - "build_number", + before_id, "name", transition, initiator_id, @@ -3023,7 +2965,7 @@ INSERT INTO provisioner_state ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, workspace_id, template_version_id, name, before_id, after_id, transition, initiator_id, provisioner_state, job_id ` type InsertWorkspaceBuildParams struct { @@ -3032,7 +2974,7 @@ type InsertWorkspaceBuildParams struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - BuildNumber int32 `db:"build_number" json:"build_number"` + BeforeID uuid.NullUUID `db:"before_id" json:"before_id"` Name string `db:"name" json:"name"` Transition WorkspaceTransition `db:"transition" json:"transition"` InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` @@ -3047,7 +2989,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa arg.UpdatedAt, arg.WorkspaceID, arg.TemplateVersionID, - arg.BuildNumber, + arg.BeforeID, arg.Name, arg.Transition, arg.InitiatorID, @@ -3062,7 +3004,8 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa &i.WorkspaceID, &i.TemplateVersionID, &i.Name, - &i.BuildNumber, + &i.BeforeID, + &i.AfterID, &i.Transition, &i.InitiatorID, &i.ProvisionerState, @@ -3076,19 +3019,26 @@ UPDATE workspace_builds SET updated_at = $2, - provisioner_state = $3 + after_id = $3, + provisioner_state = $4 WHERE id = $1 ` type UpdateWorkspaceBuildByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + AfterID uuid.NullUUID `db:"after_id" json:"after_id"` + ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` } func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceBuildByID, arg.ID, arg.UpdatedAt, arg.ProvisionerState) + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildByID, + arg.ID, + arg.UpdatedAt, + arg.AfterID, + arg.ProvisionerState, + ) return err } @@ -3344,6 +3294,49 @@ func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Work return items, nil } +const getWorkspacesByOrganizationID = `-- name: GetWorkspacesByOrganizationID :many +SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = $1 AND deleted = $2 +` + +type GetWorkspacesByOrganizationIDParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Deleted bool `db:"deleted" json:"deleted"` +} + +func (q *sqlQuerier) GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesByOrganizationID, arg.OrganizationID, arg.Deleted) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, + ); 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 getWorkspacesByOrganizationIDs = `-- name: GetWorkspacesByOrganizationIDs :many SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2 ` @@ -3387,23 +3380,23 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg Get return items, nil } -const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many +const getWorkspacesByOwnerID = `-- name: GetWorkspacesByOwnerID :many SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE - template_id = $1 + owner_id = $1 AND deleted = $2 ` -type GetWorkspacesByTemplateIDParams struct { - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` +type GetWorkspacesByOwnerIDParams struct { + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + Deleted bool `db:"deleted" json:"deleted"` } -func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesByTemplateID, arg.TemplateID, arg.Deleted) +func (q *sqlQuerier) GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesByOwnerID, arg.OwnerID, arg.Deleted) if err != nil { return nil, err } @@ -3436,36 +3429,23 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks return items, nil } -const getWorkspacesWithFilter = `-- name: GetWorkspacesWithFilter :many +const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM - workspaces + workspaces WHERE - -- Optionally include deleted workspaces - deleted = $1 - -- Filter by organization_id - AND CASE - WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN - organization_id = $2 - ELSE true - END - -- Filter by owner_id - AND CASE - WHEN $3 :: uuid != '00000000-00000000-00000000-00000000' THEN - owner_id = $3 - ELSE true - END + template_id = $1 + AND deleted = $2 ` -type GetWorkspacesWithFilterParams struct { - Deleted bool `db:"deleted" json:"deleted"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` +type GetWorkspacesByTemplateIDParams struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` } -func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesWithFilter, arg.Deleted, arg.OrganizationID, arg.OwnerID) +func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesByTemplateID, arg.TemplateID, arg.Deleted) if err != nil { return nil, err } diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index 148c8856deeb0..b6a3550d5f2bd 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -66,7 +66,7 @@ INSERT INTO created_at, updated_at, "name", - readme, + description, job_id ) VALUES @@ -80,12 +80,3 @@ SET updated_at = $3 WHERE id = $1; - --- name: UpdateTemplateVersionDescriptionByJobID :exec -UPDATE - template_versions -SET - readme = $2, - updated_at = now() -WHERE - job_id = $1; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 733725131eb9f..8b9220e72520e 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -33,60 +33,27 @@ SELECT FROM workspace_builds WHERE - workspace_builds.workspace_id = $1 - AND CASE - -- This allows using the last element on a page as effectively a cursor. - -- This is an important option for scripts that need to paginate without - -- duplicating or missing data. - WHEN @after_id :: uuid != '00000000-00000000-00000000-00000000' THEN ( - -- The pagination cursor is the last ID of the previous page. - -- The query is ordered by the build_number field, so select all - -- rows after the cursor. - build_number > ( - SELECT - build_number - FROM - workspace_builds - WHERE - id = @after_id - ) - ) - ELSE true -END -ORDER BY - build_number desc OFFSET @offset_opt -LIMIT - -- A null limit means "no limit", so -1 means return all - NULLIF(@limit_opt :: int, -1); + workspace_id = $1; --- name: GetLatestWorkspaceBuildByWorkspaceID :one +-- name: GetWorkspaceBuildByWorkspaceIDWithoutAfter :one SELECT * FROM workspace_builds WHERE workspace_id = $1 -ORDER BY - build_number desc + AND after_id IS NULL LIMIT 1; --- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.* -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_builds - WHERE - workspace_id = ANY(@ids :: uuid [ ]) - GROUP BY - workspace_id -) m -JOIN - workspace_builds wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; - +-- name: GetWorkspaceBuildsByWorkspaceIDsWithoutAfter :many +SELECT + * +FROM + workspace_builds +WHERE + workspace_id = ANY(@ids :: uuid [ ]) + AND after_id IS NULL; -- name: InsertWorkspaceBuild :one INSERT INTO @@ -96,7 +63,7 @@ INSERT INTO updated_at, workspace_id, template_version_id, - "build_number", + before_id, "name", transition, initiator_id, @@ -111,6 +78,7 @@ UPDATE workspace_builds SET updated_at = $2, - provisioner_state = $3 + after_id = $3, + provisioner_state = $4 WHERE id = $1; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index eb87ad9a51d41..f8e68b110656d 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -8,27 +8,8 @@ WHERE LIMIT 1; --- name: GetWorkspacesWithFilter :many -SELECT - * -FROM - workspaces -WHERE - -- Optionally include deleted workspaces - deleted = @deleted - -- Filter by organization_id - AND CASE - WHEN @organization_id :: uuid != '00000000-00000000-00000000-00000000' THEN - organization_id = @organization_id - ELSE true - END - -- Filter by owner_id - AND CASE - WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN - owner_id = @owner_id - ELSE true - END -; +-- name: GetWorkspacesByOrganizationID :many +SELECT * FROM workspaces WHERE organization_id = $1 AND deleted = $2; -- name: GetWorkspacesByOrganizationIDs :many SELECT * FROM workspaces WHERE organization_id = ANY(@ids :: uuid [ ]) AND deleted = @deleted; @@ -56,6 +37,15 @@ WHERE template_id = $1 AND deleted = $2; +-- name: GetWorkspacesByOwnerID :many +SELECT + * +FROM + workspaces +WHERE + owner_id = $1 + AND deleted = $2; + -- name: GetWorkspaceByOwnerIDAndName :one SELECT * diff --git a/coderd/gitsshkey.go b/coderd/gitsshkey.go index d5b2b049f892c..1543980ab6eb2 100644 --- a/coderd/gitsshkey.go +++ b/coderd/gitsshkey.go @@ -8,17 +8,11 @@ import ( "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) func (api *api) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - - if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceUserData.WithOwner(user.ID.String())) { - return - } - privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ @@ -59,11 +53,6 @@ func (api *api) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) { func (api *api) gitSSHKey(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - - if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUserData.WithOwner(user.ID.String())) { - return - } - gitSSHKey, err := api.Database.GetGitSSHKey(r.Context(), user.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index afdb4685ed063..331ec527e4328 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -62,12 +62,6 @@ type Error struct { Detail string `json:"detail" validate:"required"` } -func Forbidden(rw http.ResponseWriter) { - Write(rw, http.StatusForbidden, Response{ - Message: "forbidden", - }) -} - // Write outputs a standardized format to an HTTP response body. func Write(rw http.ResponseWriter, status int, response interface{}) { buf := &bytes.Buffer{} diff --git a/coderd/httpmw/authorize.go b/coderd/httpmw/authorize.go index 84bf7cbfa04b4..2eb221f1893eb 100644 --- a/coderd/httpmw/authorize.go +++ b/coderd/httpmw/authorize.go @@ -4,10 +4,92 @@ import ( "context" "net/http" + "golang.org/x/xerrors" + + "cdr.dev/slog" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/rbac" ) +// Authorize will enforce if the user roles can complete the action on the AuthObject. +// The organization and owner are found using the ExtractOrganization and +// ExtractUser middleware if present. +func Authorize(logger slog.Logger, auth *rbac.RegoAuthorizer, action rbac.Action) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + roles := UserRoles(r) + object := rbacObject(r) + + if object.Type == "" { + panic("developer error: auth object has no type") + } + + // First extract the object's owner and organization if present. + unknownOrg := r.Context().Value(organizationParamContextKey{}) + if organization, castOK := unknownOrg.(database.Organization); unknownOrg != nil { + if !castOK { + panic("developer error: organization param middleware not provided for authorize") + } + object = object.InOrg(organization.ID) + } + + unknownOwner := r.Context().Value(userParamContextKey{}) + if owner, castOK := unknownOwner.(database.User); unknownOwner != nil { + if !castOK { + panic("developer error: user param middleware not provided for authorize") + } + object = object.WithOwner(owner.ID.String()) + } + + err := auth.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object) + if err != nil { + internalError := new(rbac.UnauthorizedError) + if xerrors.As(err, internalError) { + logger = logger.With(slog.F("internal", internalError.Internal())) + } + // Log information for debugging. This will be very helpful + // in the early days if we over secure endpoints. + logger.Warn(r.Context(), "unauthorized", + slog.F("roles", roles.Roles), + slog.F("user_id", roles.ID), + slog.F("username", roles.Username), + slog.F("route", r.URL.Path), + slog.F("action", action), + slog.F("object", object), + ) + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: err.Error(), + }) + return + } + next.ServeHTTP(rw, r) + }) + } +} + +type authObjectKey struct{} + +// APIKey returns the API key from the ExtractAPIKey handler. +func rbacObject(r *http.Request) rbac.Object { + obj, ok := r.Context().Value(authObjectKey{}).(rbac.Object) + if !ok { + panic("developer error: auth object middleware not provided") + } + return obj +} + +// WithRBACObject sets the object for 'Authorize()' for all routes handled +// by this middleware. The important field to set is 'Type' +func WithRBACObject(object rbac.Object) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), authObjectKey{}, object) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} + // User roles are the 'subject' field of Authorize() type userRolesKey struct{} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 92ba5da38162b..ba14f8dafdfc0 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "reflect" "golang.org/x/oauth2" @@ -47,8 +46,7 @@ func OAuth2(r *http.Request) OAuth2State { func ExtractOAuth2(config OAuth2Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - // Interfaces can hold a nil value - if config == nil || reflect.ValueOf(config).IsNil() { + if config == nil { httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{ Message: "The oauth2 method requested is not configured!", }) diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index 8b088ee76b4d8..d66e49236672e 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -63,7 +63,7 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler UserID: apiKey.UserID, }) if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusForbidden, httpapi.Response{ + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ Message: "not a member of the organization", }) return diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index b062e63bc3819..2e4a8eddf4414 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -141,7 +141,7 @@ func TestOrganizationParam(t *testing.T) { rtr.ServeHTTP(rw, r) res := rw.Result() defer res.Body.Close() - require.Equal(t, http.StatusForbidden, res.StatusCode) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) }) t.Run("Success", func(t *testing.T) { diff --git a/coderd/organizations.go b/coderd/organizations.go index b0b57f748ccd6..feb7a7ba9dc18 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -6,19 +6,11 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) -func (api *api) organization(rw http.ResponseWriter, r *http.Request) { +func (*api) organization(rw http.ResponseWriter, r *http.Request) { organization := httpmw.OrganizationParam(r) - - if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceOrganization. - InOrg(organization.ID). - WithID(organization.ID.String())) { - return - } - httpapi.Write(rw, http.StatusOK, convertOrganization(organization)) } diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index 01b5822a15611..e6338f61cb7fe 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -30,7 +30,7 @@ func TestOrganizationByUserAndName(t *testing.T) { _, err := client.OrganizationByName(context.Background(), codersdk.Me, "nothing") var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) }) t.Run("NoMember", func(t *testing.T) { @@ -38,14 +38,14 @@ func TestOrganizationByUserAndName(t *testing.T) { client := coderdtest.New(t, nil) first := coderdtest.CreateFirstUser(t, client) other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) - org, err := client.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{ + org, err := other.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{ Name: "another", }) require.NoError(t, err) - _, err = other.OrganizationByName(context.Background(), codersdk.Me, org.Name) + _, err = client.OrganizationByName(context.Background(), codersdk.Me, org.Name) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) t.Run("Valid", func(t *testing.T) { diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 5cdebc1c942f2..9a27fbe6e4857 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -348,16 +348,6 @@ func (server *provisionerdServer) UpdateJob(ctx context.Context, request *proto. } } - if len(request.Readme) > 0 { - err := server.Database.UpdateTemplateVersionDescriptionByJobID(ctx, database.UpdateTemplateVersionDescriptionByJobIDParams{ - JobID: job.ID, - Readme: string(request.Readme), - }) - if err != nil { - return nil, xerrors.Errorf("update template version description: %w", err) - } - } - if len(request.ParameterSchemas) > 0 { for _, protoParameter := range request.ParameterSchemas { validationTypeSystem, err := convertValidationTypeSystem(protoParameter.ValidationTypeSystem) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 6325a4b8c506b..39cd7ed102906 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -9,10 +9,6 @@ import ( "github.com/open-policy-agent/opa/rego" ) -type Authorizer interface { - ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error -} - // RegoAuthorizer will use a prepared rego query for performing authorize() type RegoAuthorizer struct { query rego.PreparedEvalQuery @@ -42,10 +38,10 @@ type authSubject struct { Roles []Role `json:"roles"` } -// ByRoleName will expand all roleNames into roles before calling Authorize(). +// AuthorizeByRoleName will expand all roleNames into roles before calling Authorize(). // This is the function intended to be used outside this package. // The role is fetched from the builtin map located in memory. -func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error { +func (a RegoAuthorizer) AuthorizeByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error { roles := make([]Role, 0, len(roleNames)) for _, n := range roleNames { r, err := RoleByName(n) diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 71b0f059f628d..6a85cfe3256a2 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -64,10 +64,6 @@ var ( return Role{ Name: member, DisplayName: "Member", - Site: permissions(map[Object][]Action{ - // All users can read all other users and know they exist. - ResourceUser: {ActionRead}, - }), User: permissions(map[Object][]Action{ ResourceWildcard: {WildcardSymbol}, }), @@ -115,20 +111,7 @@ var ( Name: roleName(orgMember, organizationID), DisplayName: "Organization Member", Org: map[string][]Permission{ - organizationID: { - { - // All org members can read the other members in their org. - ResourceType: ResourceOrganizationMember.Type, - Action: ActionRead, - ResourceID: "*", - }, - { - // All org members can read the organization - ResourceType: ResourceOrganization.Type, - Action: ActionRead, - ResourceID: "*", - }, - }, + organizationID: {}, }, } }, diff --git a/coderd/rbac/error.go b/coderd/rbac/error.go index 6b63bb88602db..593ca4d0fc23a 100644 --- a/coderd/rbac/error.go +++ b/coderd/rbac/error.go @@ -6,7 +6,7 @@ const ( // errUnauthorized is the error message that should be returned to // clients when an action is forbidden. It is intentionally vague to prevent // disclosing information that a client should not have access to. - errUnauthorized = "forbidden" + errUnauthorized = "unauthorized" ) // UnauthorizedError is the error type for authorization errors diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 862653f50286e..e4fa5013a16ce 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -9,10 +9,6 @@ const WildcardSymbol = "*" // Resources are just typed objects. Making resources this way allows directly // passing them into an Authorize function and use the chaining api. var ( - // ResourceWorkspace CRUD. Org + User owner - // create/delete = make or delete workspaces - // read = access workspace - // update = edit workspace variables ResourceWorkspace = Object{ Type: "workspace", } @@ -21,60 +17,19 @@ var ( Type: "template", } - ResourceFile = Object{ - Type: "file", - } - - // ResourceOrganization CRUD. Has an org owner on all but 'create'. - // create/delete = make or delete organizations - // read = view org information (Can add user owner for read) - // update = ?? - ResourceOrganization = Object{ - Type: "organization", - } - - // ResourceRoleAssignment might be expanded later to allow more granular permissions - // to modifying roles. For now, this covers all possible roles, so having this permission - // allows granting/deleting **ALL** roles. - // create = Assign roles - // update = ?? - // read = View available roles to assign - // delete = Remove role - ResourceRoleAssignment = Object{ - Type: "assign_role", - } - - // ResourceAPIKey is owned by a user. - // create = Create a new api key for user - // update = ?? - // read = View api key - // delete = Delete api key - ResourceAPIKey = Object{ - Type: "api_key", - } - - // ResourceUser is the user in the 'users' table. - // ResourceUser never has any owners or in an org, as it's site wide. - // create/delete = make or delete a new user. - // read = view all 'user' table data - // update = update all 'user' table data ResourceUser = Object{ Type: "user", } - // ResourceUserData is any data associated with a user. A user has control - // over their data (profile, password, etc). So this resource has an owner. - ResourceUserData = Object{ - Type: "user_data", + // ResourceUserRole might be expanded later to allow more granular permissions + // to modifying roles. For now, this covers all possible roles, so having this permission + // allows granting/deleting **ALL** roles. + ResourceUserRole = Object{ + Type: "user_role", } - // ResourceOrganizationMember is a user's membership in an organization. - // Has ONLY an organization owner. The resource ID is the user's ID - // create/delete = Create/delete member from org. - // update = Update organization member - // read = View member - ResourceOrganizationMember = Object{ - Type: "organization_member", + ResourceUserPasswordRole = Object{ + Type: "user_password", } // ResourceWildcard represents all resource types diff --git a/coderd/roles.go b/coderd/roles.go index 308b1bf791984..205e8633b4bbe 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -11,43 +11,32 @@ import ( ) // assignableSiteRoles returns all site wide roles that can be assigned. -func (api *api) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) { +func (*api) assignableSiteRoles(rw http.ResponseWriter, _ *http.Request) { // TODO: @emyrk in the future, allow granular subsets of roles to be returned based on the // role of the user. - - if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceRoleAssignment) { - return - } - roles := rbac.SiteRoles() httpapi.Write(rw, http.StatusOK, convertRoles(roles)) } // assignableSiteRoles returns all site wide roles that can be assigned. -func (api *api) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { +func (*api) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { // TODO: @emyrk in the future, allow granular subsets of roles to be returned based on the // role of the user. organization := httpmw.OrganizationParam(r) - - if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceRoleAssignment.InOrg(organization.ID)) { - return - } - roles := rbac.OrganizationRoles(organization.ID) httpapi.Write(rw, http.StatusOK, convertRoles(roles)) } func (api *api) checkPermissions(rw http.ResponseWriter, r *http.Request) { + roles := httpmw.UserRoles(r) user := httpmw.UserParam(r) - - if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUser.WithOwner(user.ID.String())) { - return - } - - // use the roles of the user specified, not the person making the request. - roles, err := api.Database.GetAllUserRoles(r.Context(), user.ID) - if err != nil { - httpapi.Forbidden(rw) + if user.ID != roles.ID { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + // TODO: @Emyrk in the future we could have an rbac check here. + // If the user can masquerade/impersonate as the user passed in, + // we could allow this or something like that. + Message: "only allowed to check permissions on yourself", + }) return } @@ -68,7 +57,7 @@ func (api *api) checkPermissions(rw http.ResponseWriter, r *http.Request) { if v.Object.OwnerID == "me" { v.Object.OwnerID = roles.ID.String() } - err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.Action(v.Action), + err := api.Authorizer.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.Action(v.Action), rbac.Object{ ResourceID: v.Object.ResourceID, Owner: v.Object.OwnerID, diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 0350bcf835377..83d4f1f23d83a 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -112,7 +112,7 @@ func TestListRoles(t *testing.T) { }) require.NoError(t, err, "create org") - const unauth = "forbidden" + const unauth = "unauthorized" const notMember = "not a member of the organization" testCases := []struct { @@ -191,7 +191,7 @@ func TestListRoles(t *testing.T) { if c.AuthorizedError != "" { var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) require.Contains(t, apiErr.Message, c.AuthorizedError) } else { require.NoError(t, err) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index f1d70cb4c9b97..99c6c62383811 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -357,7 +357,7 @@ func (api *api) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht CreatedAt: database.Now(), UpdatedAt: database.Now(), Name: namesgenerator.GetRandomName(1), - Readme: "", + Description: "", JobID: provisionerJob.ID, }) if err != nil { @@ -407,6 +407,5 @@ func convertTemplateVersion(version database.TemplateVersion, job codersdk.Provi UpdatedAt: version.UpdatedAt, Name: version.Name, Job: job, - Readme: version.Readme, } } diff --git a/coderd/users.go b/coderd/users.go index fbbbde5e250c1..5f34b951223f6 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -109,11 +109,6 @@ func (api *api) users(rw http.ResponseWriter, r *http.Request) { statusFilter = r.URL.Query().Get("status") ) - // Reading all users across the site - if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUser) { - return - } - paginationParams, ok := parsePagination(rw, r) if !ok { return @@ -162,24 +157,12 @@ func (api *api) users(rw http.ResponseWriter, r *http.Request) { // Creates a new user. func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { - // Create the user on the site - if !api.Authorize(rw, r, rbac.ActionCreate, rbac.ResourceUser) { - return - } + apiKey := httpmw.APIKey(r) var createUser codersdk.CreateUserRequest if !httpapi.Read(rw, r, &createUser) { return } - - // Create the organization member in the org. - if !api.Authorize(rw, r, rbac.ActionCreate, - rbac.ResourceOrganizationMember.InOrg(createUser.OrganizationID)) { - return - } - - // TODO: @emyrk Authorize the organization create if the createUser will do that. - _, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ Username: createUser.Username, Email: createUser.Email, @@ -197,7 +180,7 @@ func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { return } - _, err = api.Database.GetOrganizationByID(r.Context(), createUser.OrganizationID) + organization, err := api.Database.GetOrganizationByID(r.Context(), createUser.OrganizationID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ Message: "organization does not exist with the provided id", @@ -210,6 +193,23 @@ func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { }) return } + // Check if the caller has permissions to the organization requested. + _, err = api.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ + OrganizationID: organization.ID, + UserID: apiKey.UserID, + }) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "you are not authorized to add members to that organization", + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get organization member: %s", err), + }) + return + } user, _, err := api.createUser(r.Context(), createUser) if err != nil { @@ -228,10 +228,6 @@ func (api *api) userByName(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) organizationIDs, err := userOrganizationIDs(r.Context(), api, user) - if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUser.WithID(user.ID.String())) { - return - } - if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get organization IDs: %s", err.Error()), @@ -245,10 +241,6 @@ func (api *api) userByName(rw http.ResponseWriter, r *http.Request) { func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceUser.WithOwner(user.ID.String())) { - return - } - var params codersdk.UpdateUserProfileRequest if !httpapi.Read(rw, r, ¶ms) { return @@ -315,11 +307,6 @@ func (api *api) putUserStatus(status database.UserStatus) func(rw http.ResponseW return func(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) apiKey := httpmw.APIKey(r) - - if !api.Authorize(rw, r, rbac.ActionDelete, rbac.ResourceUser.WithID(user.ID.String())) { - return - } - if status == database.UserStatusSuspended && user.ID == apiKey.UserID { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: "You cannot suspend yourself", @@ -357,11 +344,6 @@ func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) { user = httpmw.UserParam(r) params codersdk.UpdateUserPasswordRequest ) - - if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceUserData.WithOwner(user.ID.String())) { - return - } - if !httpapi.Read(rw, r, ¶ms) { return } @@ -389,12 +371,6 @@ func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) { func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - roles := httpmw.UserRoles(r) - - if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUserData. - WithOwner(user.ID.String())) { - return - } resp := codersdk.UserRoles{ Roles: user.RBACRoles, @@ -410,16 +386,7 @@ func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { } for _, mem := range memberships { - err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, - rbac.ResourceOrganizationMember. - WithID(user.ID.String()). - InOrg(mem.OrganizationID), - ) - - // If we can read the org member, include the roles - if err == nil { - resp.OrganizationRoles[mem.OrganizationID] = mem.Roles - } + resp.OrganizationRoles[mem.OrganizationID] = mem.Roles } httpapi.Write(rw, http.StatusOK, resp) @@ -427,41 +394,22 @@ func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) { func (api *api) putUserRoles(rw http.ResponseWriter, r *http.Request) { // User is the user to modify + // TODO: Until rbac authorize is implemented, only be able to change your + // own roles. This also means you can grant yourself whatever roles you want. user := httpmw.UserParam(r) - roles := httpmw.UserRoles(r) + apiKey := httpmw.APIKey(r) + if apiKey.UserID != user.ID { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "modifying other users is not supported at this time", + }) + return + } var params codersdk.UpdateRoles if !httpapi.Read(rw, r, ¶ms) { return } - has := make(map[string]struct{}) - for _, exists := range roles.Roles { - has[exists] = struct{}{} - } - - for _, roleName := range params.Roles { - // If the user already has the role assigned, we don't need to check the permission - // to reassign it. Only run permission checks on the difference in the set of - // roles. - if _, ok := has[roleName]; ok { - delete(has, roleName) - continue - } - - // Assigning a role requires the create permission. - if !api.Authorize(rw, r, rbac.ActionCreate, rbac.ResourceRoleAssignment.WithID(roleName)) { - return - } - } - - // Any roles that were removed also need to be checked. - for roleName := range has { - if !api.Authorize(rw, r, rbac.ActionDelete, rbac.ResourceRoleAssignment.WithID(roleName)) { - return - } - } - updatedUser, err := api.updateSiteUserRoles(r.Context(), database.UpdateUserRolesParams{ GrantedRoles: params.Roles, ID: user.ID, @@ -484,8 +432,6 @@ func (api *api) putUserRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusOK, convertUser(updatedUser, organizationIDs)) } -// updateSiteUserRoles will ensure only site wide roles are passed in as arguments. -// If an organization role is included, an error is returned. func (api *api) updateSiteUserRoles(ctx context.Context, args database.UpdateUserRolesParams) (database.User, error) { // Enforce only site wide roles for _, r := range args.GrantedRoles { @@ -508,7 +454,6 @@ func (api *api) updateSiteUserRoles(ctx context.Context, args database.UpdateUse // Returns organizations the parameterized user has access to. func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - roles := httpmw.UserRoles(r) organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), user.ID) if errors.Is(err, sql.ErrNoRows) { @@ -524,38 +469,42 @@ func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { publicOrganizations := make([]codersdk.Organization, 0, len(organizations)) for _, organization := range organizations { - err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, - rbac.ResourceOrganization. - WithID(organization.ID.String()). - InOrg(organization.ID), - ) - if err == nil { - // Only return orgs the user can read - publicOrganizations = append(publicOrganizations, convertOrganization(organization)) - } + publicOrganizations = append(publicOrganizations, convertOrganization(organization)) } httpapi.Write(rw, http.StatusOK, publicOrganizations) } func (api *api) organizationByUserAndName(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) organizationName := chi.URLParam(r, "organizationname") organization, err := api.Database.GetOrganizationByName(r.Context(), organizationName) if errors.Is(err, sql.ErrNoRows) { - // Return unauthorized rather than a 404 to not leak if the organization - // exists. - httpapi.Forbidden(rw) + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: fmt.Sprintf("no organization found by name %q", organizationName), + }) return } if err != nil { - httpapi.Forbidden(rw) + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get organization by name: %s", err), + }) return } - - if !api.Authorize(rw, r, rbac.ActionRead, - rbac.ResourceOrganization. - InOrg(organization.ID). - WithID(organization.ID.String())) { + _, err = api.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ + OrganizationID: organization.ID, + UserID: user.ID, + }) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: fmt.Sprintf("no organization found by name %q", organizationName), + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get organization member: %s", err), + }) return } @@ -668,8 +617,12 @@ func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) { // Creates a new session key, used for logging in via the CLI func (api *api) postAPIKey(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) + apiKey := httpmw.APIKey(r) - if !api.Authorize(rw, r, rbac.ActionCreate, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { + if user.ID != apiKey.UserID { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "Keys can only be generated for the authenticated user", + }) return } diff --git a/coderd/users_test.go b/coderd/users_test.go index 9c2846ed96cbd..ef4eabe74972e 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -172,14 +172,13 @@ func TestPostUsers(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) first := coderdtest.CreateFirstUser(t, client) - notInOrg := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) org, err := other.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{ Name: "another", }) require.NoError(t, err) - _, err = notInOrg.CreateUser(context.Background(), codersdk.CreateUserRequest{ + _, err = client.CreateUser(context.Background(), codersdk.CreateUserRequest{ Email: "some@domain.com", Username: "anotheruser", Password: "testing", @@ -187,7 +186,7 @@ func TestPostUsers(t *testing.T) { }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) t.Run("Create", func(t *testing.T) { @@ -402,11 +401,10 @@ func TestGrantRoles(t *testing.T) { []string{rbac.RoleOrgMember(first.OrganizationID)}, ) - memberUser, err := member.User(ctx, codersdk.Me) - require.NoError(t, err, "fetch member") - // Grant - _, err = admin.UpdateUserRoles(ctx, memberUser.ID.String(), codersdk.UpdateRoles{ + // TODO: @emyrk this should be 'admin.UpdateUserRoles' once proper authz + // is enforced. + _, err = member.UpdateUserRoles(ctx, codersdk.Me, codersdk.UpdateRoles{ Roles: []string{ // Promote to site admin rbac.RoleMember(), @@ -599,9 +597,7 @@ func TestWorkspacesByUser(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - workspaces, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{ - Owner: codersdk.Me, - }) + workspaces, err := client.WorkspacesByUser(context.Background(), codersdk.Me) require.NoError(t, err) require.Len(t, workspaces, 0) }) @@ -630,11 +626,11 @@ func TestWorkspacesByUser(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - workspaces, err := newUserClient.Workspaces(context.Background(), codersdk.WorkspaceFilter{Owner: codersdk.Me}) + workspaces, err := newUserClient.WorkspacesByUser(context.Background(), codersdk.Me) require.NoError(t, err) require.Len(t, workspaces, 0) - workspaces, err = client.Workspaces(context.Background(), codersdk.WorkspaceFilter{Owner: codersdk.Me}) + workspaces, err = client.WorkspacesByUser(context.Background(), codersdk.Me) require.NoError(t, err) require.Len(t, workspaces, 1) }) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index ec26001b99ff1..d2a7baeab560d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -212,11 +212,11 @@ func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { // Ensure the resource is still valid! // We only accept agents for resources on the latest build. ensureLatestBuild := func() error { - latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), build.WorkspaceID) + latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), build.WorkspaceID) if err != nil { return err } - if build.ID != latestBuild.ID { + if build.ID.String() != latestBuild.ID.String() { return xerrors.New("build is outdated") } return nil diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 15bf01d065bad..09d3af4500148 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1,13 +1,11 @@ package coderd_test import ( - "bufio" "context" "encoding/json" "runtime" "strings" "testing" - "time" "github.com/google/uuid" "github.com/pion/webrtc/v3" @@ -232,11 +230,6 @@ func TestWorkspaceAgentPTY(t *testing.T) { require.NoError(t, err) _, err = conn.Write(data) require.NoError(t, err) - bufRead := bufio.NewReader(conn) - - // Brief pause to reduce the likelihood that we send keystrokes while - // the shell is simultaneously sending a prompt. - time.Sleep(100 * time.Millisecond) data, err = json.Marshal(agent.ReconnectingPTYRequest{ Data: "echo test\r\n", @@ -245,22 +238,16 @@ func TestWorkspaceAgentPTY(t *testing.T) { _, err = conn.Write(data) require.NoError(t, err) - expectLine := func(matcher func(string) bool) { + findEcho := func() { for { - line, err := bufRead.ReadString('\n') + read, err := conn.Read(data) require.NoError(t, err) - if matcher(line) { - break + if strings.Contains(string(data[:read]), "test") { + return } } } - matchEchoCommand := func(line string) bool { - return strings.Contains(line, "echo test") - } - matchEchoOutput := func(line string) bool { - return strings.Contains(line, "test") && !strings.Contains(line, "echo") - } - expectLine(matchEchoCommand) - expectLine(matchEchoOutput) + findEcho() + findEcho() } diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index b3cf97462d969..e9e908bc660eb 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -34,17 +34,7 @@ func (api *api) workspaceBuild(rw http.ResponseWriter, r *http.Request) { func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) - paginationParams, ok := parsePagination(rw, r) - if !ok { - return - } - req := database.GetWorkspaceBuildByWorkspaceIDParams{ - WorkspaceID: workspace.ID, - AfterID: paginationParams.AfterID, - OffsetOpt: int32(paginationParams.Offset), - LimitOpt: int32(paginationParams.Limit), - } - builds, err := api.Database.GetWorkspaceBuildByWorkspaceID(r.Context(), req) + builds, err := api.Database.GetWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) if xerrors.Is(err, sql.ErrNoRows) { err = nil } @@ -126,7 +116,7 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } if createBuild.TemplateVersionID == uuid.Nil { - latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) + latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get latest workspace build: %s", err), @@ -186,9 +176,9 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - // Store prior build number to compute new build number - var priorBuildNum int32 - priorHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) + // Store prior history ID if it exists to update it after we create new! + priorHistoryID := uuid.NullUUID{} + priorHistory, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) if err == nil { priorJob, err := api.Database.GetProvisionerJobByID(r.Context(), priorHistory.JobID) if err == nil && convertProvisionerJob(priorJob).Status.Active() { @@ -198,7 +188,10 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - priorBuildNum = priorHistory.BuildNumber + priorHistoryID = uuid.NullUUID{ + UUID: priorHistory.ID, + Valid: true, + } } else if !errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get prior workspace build: %s", err), @@ -244,7 +237,7 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { UpdatedAt: database.Now(), WorkspaceID: workspace.ID, TemplateVersionID: templateVersion.ID, - BuildNumber: priorBuildNum + 1, + BeforeID: priorHistoryID, Name: namesgenerator.GetRandomName(1), ProvisionerState: state, InitiatorID: apiKey.UserID, @@ -255,6 +248,22 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("insert workspace build: %w", err) } + if priorHistoryID.Valid { + // Update the prior history entries "after" column. + err = db.UpdateWorkspaceBuildByID(r.Context(), database.UpdateWorkspaceBuildByIDParams{ + ID: priorHistory.ID, + ProvisionerState: priorHistory.ProvisionerState, + UpdatedAt: database.Now(), + AfterID: uuid.NullUUID{ + UUID: workspaceBuild.ID, + Valid: true, + }, + }) + if err != nil { + return xerrors.Errorf("update prior workspace build: %w", err) + } + } + return nil }) if err != nil { @@ -346,7 +355,8 @@ func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job codersdk. UpdatedAt: workspaceBuild.UpdatedAt, WorkspaceID: workspaceBuild.WorkspaceID, TemplateVersionID: workspaceBuild.TemplateVersionID, - BuildNumber: workspaceBuild.BuildNumber, + BeforeID: workspaceBuild.BeforeID.UUID, + AfterID: workspaceBuild.AfterID.UUID, Name: workspaceBuild.Name, Transition: workspaceBuild.Transition, InitiatorID: workspaceBuild.InitiatorID, diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index e50c2281f7e13..a9cec2cf3355c 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -39,50 +38,9 @@ func TestWorkspaceBuilds(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - builds, err := client.WorkspaceBuilds(context.Background(), - codersdk.WorkspaceBuildsRequest{WorkspaceID: workspace.ID}) - require.Len(t, builds, 1) - require.Equal(t, int32(1), builds[0].BuildNumber) + _, err := client.WorkspaceBuilds(context.Background(), workspace.ID) require.NoError(t, err) }) - - t.Run("PaginateLimitOffset", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - coderdtest.NewProvisionerDaemon(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - var expectedBuilds []codersdk.WorkspaceBuild - extraBuilds := 4 - for i := 0; i < extraBuilds; i++ { - b := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) - expectedBuilds = append(expectedBuilds, b) - coderdtest.AwaitWorkspaceBuildJob(t, client, b.ID) - } - - pageSize := 3 - firstPage, err := client.WorkspaceBuilds(context.Background(), codersdk.WorkspaceBuildsRequest{ - WorkspaceID: workspace.ID, - Pagination: codersdk.Pagination{Limit: pageSize, Offset: 0}, - }) - require.NoError(t, err) - require.Len(t, firstPage, pageSize) - for i := 0; i < pageSize; i++ { - require.Equal(t, expectedBuilds[extraBuilds-i-1].ID, firstPage[i].ID) - } - secondPage, err := client.WorkspaceBuilds(context.Background(), codersdk.WorkspaceBuildsRequest{ - WorkspaceID: workspace.ID, - Pagination: codersdk.Pagination{Limit: pageSize, Offset: pageSize}, - }) - require.NoError(t, err) - require.Len(t, secondPage, 2) - require.Equal(t, expectedBuilds[0].ID, secondPage[0].ID) - require.Equal(t, workspace.LatestBuild.ID, secondPage[1].ID) // build created while creating workspace - }) } func TestPatchCancelWorkspaceBuild(t *testing.T) { diff --git a/coderd/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index 17a713b651be5..f9d8f701f8daf 100644 --- a/coderd/workspaceresourceauth.go +++ b/coderd/workspaceresourceauth.go @@ -137,14 +137,14 @@ func (api *api) handleAuthInstanceID(rw http.ResponseWriter, r *http.Request, in // This token should only be exchanged if the instance ID is valid // for the latest history. If an instance ID is recycled by a cloud, // we'd hate to leak access to a user's workspace. - latestHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), resourceHistory.WorkspaceID) + latestHistory, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), resourceHistory.WorkspaceID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get latest workspace build: %s", err), }) return } - if latestHistory.ID != resourceHistory.ID { + if latestHistory.ID.String() != resourceHistory.ID.String() { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: fmt.Sprintf("resource found for id %q, but isn't registered on the latest history", instanceID), }) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 4ef3b61301fe6..7176f3d20198a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -25,7 +25,7 @@ import ( func (api *api) workspace(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) - build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) + build, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get workspace build: %s", err), @@ -58,19 +58,13 @@ func (api *api) workspace(rw http.ResponseWriter, r *http.Request) { return } - if !api.Authorize(rw, r, rbac.ActionRead, - rbac.ResourceWorkspace.InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { - return - } - httpapi.Write(rw, http.StatusOK, convertWorkspace(workspace, convertWorkspaceBuild(build, convertProvisionerJob(job)), template, owner)) } func (api *api) workspacesByOrganization(rw http.ResponseWriter, r *http.Request) { organization := httpmw.OrganizationParam(r) - roles := httpmw.UserRoles(r) - workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), database.GetWorkspacesWithFilterParams{ + workspaces, err := api.Database.GetWorkspacesByOrganizationID(r.Context(), database.GetWorkspacesByOrganizationIDParams{ OrganizationID: organization.ID, Deleted: false, }) @@ -83,18 +77,7 @@ func (api *api) workspacesByOrganization(rw http.ResponseWriter, r *http.Request }) return } - - allowedWorkspaces := make([]database.Workspace, 0) - for _, ws := range workspaces { - ws := ws - err = api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, - rbac.ResourceWorkspace.InOrg(ws.OrganizationID).WithOwner(ws.OwnerID.String()).WithID(ws.ID.String())) - if err == nil { - allowedWorkspaces = append(allowedWorkspaces, ws) - } - } - - apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, allowedWorkspaces) + apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, workspaces) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("convert workspaces: %s", err), @@ -104,67 +87,64 @@ func (api *api) workspacesByOrganization(rw http.ResponseWriter, r *http.Request httpapi.Write(rw, http.StatusOK, apiWorkspaces) } -// workspaces returns all workspaces a user can read. -// Optional filters with query params -func (api *api) workspaces(rw http.ResponseWriter, r *http.Request) { +func (api *api) workspacesByUser(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) roles := httpmw.UserRoles(r) - apiKey := httpmw.APIKey(r) - // Empty strings mean no filter - orgFilter := r.URL.Query().Get("organization_id") - ownerFilter := r.URL.Query().Get("owner_id") - - filter := database.GetWorkspacesWithFilterParams{Deleted: false} - if orgFilter != "" { - orgID, err := uuid.Parse(orgFilter) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("organization_id must be a uuid: %s", err.Error()), - }) - return - } - filter.OrganizationID = orgID + organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), user.ID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get organizations: %s", err), + }) + return } - if ownerFilter == "me" { - filter.OwnerID = apiKey.UserID - } else if ownerFilter != "" { - userID, err := uuid.Parse(ownerFilter) + organizationIDs := make([]uuid.UUID, 0) + for _, organization := range organizations { + err = api.Authorizer.AuthorizeByRoleName(r.Context(), user.ID.String(), roles.Roles, rbac.ActionRead, rbac.ResourceWorkspace.All().InOrg(organization.ID)) + var apiErr *rbac.UnauthorizedError + if xerrors.As(err, &apiErr) { + continue + } if err != nil { - // Maybe it's a username - user, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ - // Why not just accept 1 arg and use it for both in the sql? - Username: ownerFilter, - Email: ownerFilter, + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("authorize: %s", err), }) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "owner must be a uuid or username", - }) - return - } - userID = user.ID + return } - filter.OwnerID = userID + organizationIDs = append(organizationIDs, organization.ID) } - allowedWorkspaces := make([]database.Workspace, 0) - allWorkspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), filter) + workspaceIDs := map[uuid.UUID]struct{}{} + allWorkspaces, err := api.Database.GetWorkspacesByOrganizationIDs(r.Context(), database.GetWorkspacesByOrganizationIDsParams{ + Ids: organizationIDs, + }) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get workspaces for user: %s", err), + Message: fmt.Sprintf("get workspaces for organizations: %s", err), }) return } for _, ws := range allWorkspaces { - ws := ws - err = api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, - rbac.ResourceWorkspace.InOrg(ws.OrganizationID).WithOwner(ws.OwnerID.String()).WithID(ws.ID.String())) - if err == nil { - allowedWorkspaces = append(allowedWorkspaces, ws) + workspaceIDs[ws.ID] = struct{}{} + } + userWorkspaces, err := api.Database.GetWorkspacesByOwnerID(r.Context(), database.GetWorkspacesByOwnerIDParams{ + OwnerID: user.ID, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspaces for user: %s", err), + }) + return + } + for _, ws := range userWorkspaces { + _, exists := workspaceIDs[ws.ID] + if exists { + continue } + allWorkspaces = append(allWorkspaces, ws) } - apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, allowedWorkspaces) + apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, allWorkspaces) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("convert workspaces: %s", err), @@ -176,10 +156,8 @@ func (api *api) workspaces(rw http.ResponseWriter, r *http.Request) { func (api *api) workspacesByOwner(rw http.ResponseWriter, r *http.Request) { owner := httpmw.UserParam(r) - roles := httpmw.UserRoles(r) - workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), database.GetWorkspacesWithFilterParams{ + workspaces, err := api.Database.GetWorkspacesByOwnerID(r.Context(), database.GetWorkspacesByOwnerIDParams{ OwnerID: owner.ID, - Deleted: false, }) if errors.Is(err, sql.ErrNoRows) { err = nil @@ -190,18 +168,7 @@ func (api *api) workspacesByOwner(rw http.ResponseWriter, r *http.Request) { }) return } - - allowedWorkspaces := make([]database.Workspace, 0) - for _, ws := range workspaces { - ws := ws - err = api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ActionRead, - rbac.ResourceWorkspace.InOrg(ws.OrganizationID).WithOwner(ws.OwnerID.String()).WithID(ws.ID.String())) - if err == nil { - allowedWorkspaces = append(allowedWorkspaces, ws) - } - } - - apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, allowedWorkspaces) + apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, workspaces) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("convert workspaces: %s", err), @@ -221,8 +188,9 @@ func (api *api) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) Name: workspaceName, }) if errors.Is(err, sql.ErrNoRows) { - // Do not leak information if the workspace exists or not - httpapi.Forbidden(rw) + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: fmt.Sprintf("no workspace found by name %q", workspaceName), + }) return } if err != nil { @@ -239,12 +207,7 @@ func (api *api) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) return } - if !api.Authorize(rw, r, rbac.ActionRead, - rbac.ResourceWorkspace.InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { - return - } - - build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) + build, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get workspace build: %s", err), @@ -446,7 +409,6 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req InitiatorID: apiKey.UserID, Transition: database.WorkspaceTransitionStart, JobID: provisionerJob.ID, - BuildNumber: 1, // First build! }) if err != nil { return xerrors.Errorf("insert workspace build: %w", err) @@ -544,7 +506,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data templateIDs = append(templateIDs, workspace.TemplateID) ownerIDs = append(ownerIDs, workspace.OwnerID) } - workspaceBuilds, err := db.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, workspaceIDs) + workspaceBuilds, err := db.GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx, workspaceIDs) if errors.Is(err, sql.ErrNoRows) { err = nil } @@ -576,19 +538,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data buildByWorkspaceID := map[uuid.UUID]database.WorkspaceBuild{} for _, workspaceBuild := range workspaceBuilds { - buildByWorkspaceID[workspaceBuild.WorkspaceID] = database.WorkspaceBuild{ - ID: workspaceBuild.ID, - CreatedAt: workspaceBuild.CreatedAt, - UpdatedAt: workspaceBuild.UpdatedAt, - WorkspaceID: workspaceBuild.WorkspaceID, - TemplateVersionID: workspaceBuild.TemplateVersionID, - Name: workspaceBuild.Name, - BuildNumber: workspaceBuild.BuildNumber, - Transition: workspaceBuild.Transition, - InitiatorID: workspaceBuild.InitiatorID, - ProvisionerState: workspaceBuild.ProvisionerState, - JobID: workspaceBuild.JobID, - } + buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild } templateByID := map[uuid.UUID]database.Template{} for _, template := range templates { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 58140b1d00e1d..3e4d8b57244c5 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -134,29 +134,16 @@ func TestWorkspacesByOwner(t *testing.T) { _, err := client.WorkspacesByOwner(context.Background(), user.OrganizationID, codersdk.Me) require.NoError(t, err) }) - - t.Run("ListMine", func(t *testing.T) { + t.Run("List", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) coderdtest.NewProvisionerDaemon(t, client) user := coderdtest.CreateFirstUser(t, client) - me, err := client.User(context.Background(), codersdk.Me) - require.NoError(t, err) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - - // Create noise workspace that should be filtered out - other := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) - _ = coderdtest.CreateWorkspace(t, other, user.OrganizationID, template.ID) - - // Use a username - workspaces, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{ - OrganizationID: user.OrganizationID, - Owner: me.Username, - }) + workspaces, err := client.WorkspacesByOwner(context.Background(), user.OrganizationID, codersdk.Me) require.NoError(t, err) require.Len(t, workspaces, 1) }) @@ -171,7 +158,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { _, err := client.WorkspaceByOwnerAndName(context.Background(), user.OrganizationID, codersdk.Me, "something") var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) }) t.Run("Get", func(t *testing.T) { t.Parallel() @@ -248,7 +235,7 @@ func TestPostWorkspaceBuild(t *testing.T) { require.Equal(t, http.StatusConflict, apiErr.StatusCode()) }) - t.Run("IncrementBuildNumber", func(t *testing.T) { + t.Run("UpdatePriorAfterField", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -263,7 +250,11 @@ func TestPostWorkspaceBuild(t *testing.T) { Transition: database.WorkspaceTransitionStart, }) require.NoError(t, err) - require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) + require.Equal(t, workspace.LatestBuild.ID.String(), build.BeforeID.String()) + + firstBuild, err := client.WorkspaceBuild(context.Background(), workspace.LatestBuild.ID) + require.NoError(t, err) + require.Equal(t, build.ID.String(), firstBuild.AfterID.String()) }) t.Run("WithState", func(t *testing.T) { @@ -303,7 +294,7 @@ func TestPostWorkspaceBuild(t *testing.T) { Transition: database.WorkspaceTransitionDelete, }) require.NoError(t, err) - require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) + require.Equal(t, workspace.LatestBuild.ID.String(), build.BeforeID.String()) coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) workspaces, err := client.WorkspacesByOwner(context.Background(), user.OrganizationID, user.UserID.String()) diff --git a/codersdk/buildinfo.go b/codersdk/buildinfo.go index 0233047caf98c..a3aecc1ffdfed 100644 --- a/codersdk/buildinfo.go +++ b/codersdk/buildinfo.go @@ -18,7 +18,7 @@ type BuildInfoResponse struct { // BuildInfo returns build information for this instance of Coder. func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) + res, err := c.request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) if err != nil { return BuildInfoResponse{}, err } diff --git a/codersdk/client.go b/codersdk/client.go index 48571adff5d0b..1654c141e6827 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -35,9 +35,9 @@ type Client struct { type requestOption func(*http.Request) -// Request performs an HTTP request with the body provided. +// request performs an HTTP request with the body provided. // The caller is responsible for closing the response body. -func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...requestOption) (*http.Response, error) { +func (c *Client) request(ctx context.Context, method, path string, body interface{}, opts ...requestOption) (*http.Response, error) { serverURL, err := c.URL.Parse(path) if err != nil { return nil, xerrors.Errorf("parse url: %w", err) diff --git a/codersdk/files.go b/codersdk/files.go index 52fcf0215081b..15c30f30f87f7 100644 --- a/codersdk/files.go +++ b/codersdk/files.go @@ -20,7 +20,7 @@ type UploadResponse struct { // Upload uploads an arbitrary file with the content type provided. // This is used to upload a source-code archive. func (c *Client) Upload(ctx context.Context, contentType string, content []byte) (UploadResponse, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/v2/files", content, func(r *http.Request) { + res, err := c.request(ctx, http.MethodPost, "/api/v2/files", content, func(r *http.Request) { r.Header.Set("Content-Type", contentType) }) if err != nil { @@ -36,7 +36,7 @@ func (c *Client) Upload(ctx context.Context, contentType string, content []byte) // Download fetches a file by uploaded hash. func (c *Client) Download(ctx context.Context, hash string) ([]byte, string, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/files/%s", hash), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/files/%s", hash), nil) if err != nil { return nil, "", err } diff --git a/codersdk/gitsshkey.go b/codersdk/gitsshkey.go index e345a2733ab02..f20c0666caf0f 100644 --- a/codersdk/gitsshkey.go +++ b/codersdk/gitsshkey.go @@ -25,7 +25,7 @@ type AgentGitSSHKey struct { // GitSSHKey returns the user's git SSH public key. func (c *Client) GitSSHKey(ctx context.Context, user string) (GitSSHKey, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil) if err != nil { return GitSSHKey{}, xerrors.Errorf("execute request: %w", err) } @@ -41,7 +41,7 @@ func (c *Client) GitSSHKey(ctx context.Context, user string) (GitSSHKey, error) // RegenerateGitSSHKey will create a new SSH key pair for the user and return it. func (c *Client) RegenerateGitSSHKey(ctx context.Context, user string) (GitSSHKey, error) { - res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil) + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/gitsshkey", user), nil) if err != nil { return GitSSHKey{}, xerrors.Errorf("execute request: %w", err) } @@ -57,7 +57,7 @@ func (c *Client) RegenerateGitSSHKey(ctx context.Context, user string) (GitSSHKe // AgentGitSSHKey will return the user's SSH key pair for the workspace. func (c *Client) AgentGitSSHKey(ctx context.Context) (AgentGitSSHKey, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil) + res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil) if err != nil { return AgentGitSSHKey{}, xerrors.Errorf("execute request: %w", err) } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 7ebb16aedca97..0843e6ddbcaa6 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -62,7 +62,7 @@ type CreateWorkspaceRequest struct { } func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", id.String()), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", id.String()), nil) if err != nil { return Organization{}, xerrors.Errorf("execute request: %w", err) } @@ -78,7 +78,7 @@ func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, // ProvisionerDaemonsByOrganization returns provisioner daemons available for an organization. func (c *Client) ProvisionerDaemonsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerDaemon, error) { - res, err := c.Request(ctx, http.MethodGet, + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons", organizationID.String()), nil, ) @@ -98,7 +98,7 @@ func (c *Client) ProvisionerDaemonsByOrganization(ctx context.Context, organizat // CreateTemplateVersion processes source-code and optionally associates the version with a template. // Executing without a template is useful for validating source-code. func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid.UUID, req CreateTemplateVersionRequest) (TemplateVersion, error) { - res, err := c.Request(ctx, http.MethodPost, + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/templateversions", organizationID.String()), req, ) @@ -117,7 +117,7 @@ func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid. // CreateTemplate creates a new template inside an organization. func (c *Client) CreateTemplate(ctx context.Context, organizationID uuid.UUID, request CreateTemplateRequest) (Template, error) { - res, err := c.Request(ctx, http.MethodPost, + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/templates", organizationID.String()), request, ) @@ -136,7 +136,7 @@ func (c *Client) CreateTemplate(ctx context.Context, organizationID uuid.UUID, r // TemplatesByOrganization lists all templates inside of an organization. func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uuid.UUID) ([]Template, error) { - res, err := c.Request(ctx, http.MethodGet, + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/templates", organizationID.String()), nil, ) @@ -155,7 +155,7 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui // TemplateByName finds a template inside the organization provided with a case-insensitive name. func (c *Client) TemplateByName(ctx context.Context, organizationID uuid.UUID, name string) (Template, error) { - res, err := c.Request(ctx, http.MethodGet, + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/templates/%s", organizationID.String(), name), nil, ) @@ -174,7 +174,7 @@ func (c *Client) TemplateByName(ctx context.Context, organizationID uuid.UUID, n // CreateWorkspace creates a new workspace for the template specified. func (c *Client) CreateWorkspace(ctx context.Context, organizationID uuid.UUID, request CreateWorkspaceRequest) (Workspace, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/workspaces", organizationID), request) + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/workspaces", organizationID), request) if err != nil { return Workspace{}, err } @@ -190,7 +190,7 @@ func (c *Client) CreateWorkspace(ctx context.Context, organizationID uuid.UUID, // WorkspacesByOrganization returns all workspaces in the specified organization. func (c *Client) WorkspacesByOrganization(ctx context.Context, organizationID uuid.UUID) ([]Workspace, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces", organizationID), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces", organizationID), nil) if err != nil { return nil, err } @@ -206,7 +206,7 @@ func (c *Client) WorkspacesByOrganization(ctx context.Context, organizationID uu // WorkspacesByOwner returns all workspaces contained in the organization owned by the user. func (c *Client) WorkspacesByOwner(ctx context.Context, organizationID uuid.UUID, user string) ([]Workspace, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces/%s", organizationID, user), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces/%s", organizationID, user), nil) if err != nil { return nil, err } @@ -222,7 +222,7 @@ func (c *Client) WorkspacesByOwner(ctx context.Context, organizationID uuid.UUID // WorkspaceByOwnerAndName returns a workspace by the owner's UUID and the workspace's name. func (c *Client) WorkspaceByOwnerAndName(ctx context.Context, organization uuid.UUID, owner string, name string) (Workspace, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces/%s/%s", organization, owner, name), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/workspaces/%s/%s", organization, owner, name), nil) if err != nil { return Workspace{}, err } diff --git a/codersdk/pagination.go b/codersdk/pagination.go index c059266dd34c4..a4adee6b6e567 100644 --- a/codersdk/pagination.go +++ b/codersdk/pagination.go @@ -26,7 +26,7 @@ type Pagination struct { Offset int `json:"offset,omitempty"` } -// asRequestOption returns a function that can be used in (*Client).Request. +// asRequestOption returns a function that can be used in (*Client).request. // It modifies the request query parameters. func (p Pagination) asRequestOption() requestOption { return func(r *http.Request) { diff --git a/codersdk/parameters.go b/codersdk/parameters.go index 9fd9d5d9cad8d..4697d07c51190 100644 --- a/codersdk/parameters.go +++ b/codersdk/parameters.go @@ -43,7 +43,7 @@ type CreateParameterRequest struct { } func (c *Client) CreateParameter(ctx context.Context, scope ParameterScope, id uuid.UUID, req CreateParameterRequest) (Parameter, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id.String()), req) + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id.String()), req) if err != nil { return Parameter{}, err } @@ -58,7 +58,7 @@ func (c *Client) CreateParameter(ctx context.Context, scope ParameterScope, id u } func (c *Client) DeleteParameter(ctx context.Context, scope ParameterScope, id uuid.UUID, name string) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/parameters/%s/%s/%s", scope, id.String(), name), nil) + res, err := c.request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/parameters/%s/%s/%s", scope, id.String(), name), nil) if err != nil { return err } @@ -73,7 +73,7 @@ func (c *Client) DeleteParameter(ctx context.Context, scope ParameterScope, id u } func (c *Client) Parameters(ctx context.Context, scope ParameterScope, id uuid.UUID) ([]Parameter, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id.String()), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id.String()), nil) if err != nil { return nil, err } diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 1c906fa8c23b8..c726f8f255eef 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -99,7 +99,7 @@ func (c *Client) provisionerJobLogsBefore(ctx context.Context, path string, befo if !before.IsZero() { values["before"] = []string{strconv.FormatInt(before.UTC().UnixMilli(), 10)} } - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("%s?%s", path, values.Encode()), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("%s?%s", path, values.Encode()), nil) if err != nil { return nil, err } @@ -118,7 +118,7 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after if !after.IsZero() { afterQuery = fmt.Sprintf("&after=%d", after.UTC().UnixMilli()) } - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("%s?follow%s", path, afterQuery), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("%s?follow%s", path, afterQuery), nil) if err != nil { return nil, err } diff --git a/codersdk/roles.go b/codersdk/roles.go index 377565c06d404..09aa19b806ccd 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -17,7 +17,7 @@ type Role struct { // ListSiteRoles lists all available site wide roles. // This is not user specific. func (c *Client) ListSiteRoles(ctx context.Context) ([]Role, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/roles", nil) + res, err := c.request(ctx, http.MethodGet, "/api/v2/users/roles", nil) if err != nil { return nil, err } @@ -32,7 +32,7 @@ func (c *Client) ListSiteRoles(ctx context.Context) ([]Role, error) { // ListOrganizationRoles lists all available roles for a given organization. // This is not user specific. func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Role, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/roles", org.String()), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/roles/", org.String()), nil) if err != nil { return nil, err } @@ -45,7 +45,7 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Ro } func (c *Client) CheckPermissions(ctx context.Context, checks UserAuthorizationRequest) (UserAuthorizationResponse, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/authorization", Me), checks) + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/authorization", Me), checks) if err != nil { return nil, err } diff --git a/codersdk/templates.go b/codersdk/templates.go index 972a8a5b2b8dd..14a14ed976650 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -32,7 +32,7 @@ type UpdateActiveTemplateVersion struct { // Template returns a single template. func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil) if err != nil { return Template{}, nil } @@ -45,7 +45,7 @@ func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, er } func (c *Client) DeleteTemplate(ctx context.Context, template uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/templates/%s", template), nil) + res, err := c.request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/templates/%s", template), nil) if err != nil { return err } @@ -59,7 +59,7 @@ func (c *Client) DeleteTemplate(ctx context.Context, template uuid.UUID) error { // UpdateActiveTemplateVersion updates the active template version to the ID provided. // The template version must be attached to the template. func (c *Client) UpdateActiveTemplateVersion(ctx context.Context, template uuid.UUID, req UpdateActiveTemplateVersion) error { - res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templates/%s/versions", template), req) + res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templates/%s/versions", template), req) if err != nil { return nil } @@ -79,7 +79,7 @@ type TemplateVersionsByTemplateRequest struct { // TemplateVersionsByTemplate lists versions associated with a template. func (c *Client) TemplateVersionsByTemplate(ctx context.Context, req TemplateVersionsByTemplateRequest) ([]TemplateVersion, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/versions", req.TemplateID), nil, req.Pagination.asRequestOption()) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/versions", req.TemplateID), nil, req.Pagination.asRequestOption()) if err != nil { return nil, err } @@ -94,7 +94,7 @@ func (c *Client) TemplateVersionsByTemplate(ctx context.Context, req TemplateVer // TemplateVersionByName returns a template version by it's friendly name. // This is used for path-based routing. Like: /templates/example/versions/helloworld func (c *Client) TemplateVersionByName(ctx context.Context, template uuid.UUID, name string) (TemplateVersion, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/versions/%s", template, name), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/versions/%s", template, name), nil) if err != nil { return TemplateVersion{}, err } diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index 64e3934c8732a..f7cf29006e514 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -21,7 +21,6 @@ type TemplateVersion struct { UpdatedAt time.Time `json:"updated_at"` Name string `json:"name"` Job ProvisionerJob `json:"job"` - Readme string `json:"readme"` } // TemplateVersionParameterSchema represents a parameter parsed from template version source. @@ -32,7 +31,7 @@ type TemplateVersionParameter parameter.ComputedValue // TemplateVersion returns a template version by ID. func (c *Client) TemplateVersion(ctx context.Context, id uuid.UUID) (TemplateVersion, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s", id), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s", id), nil) if err != nil { return TemplateVersion{}, err } @@ -46,7 +45,7 @@ func (c *Client) TemplateVersion(ctx context.Context, id uuid.UUID) (TemplateVer // CancelTemplateVersion marks a template version job as canceled. func (c *Client) CancelTemplateVersion(ctx context.Context, version uuid.UUID) error { - res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templateversions/%s/cancel", version), nil) + res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templateversions/%s/cancel", version), nil) if err != nil { return err } @@ -59,7 +58,7 @@ func (c *Client) CancelTemplateVersion(ctx context.Context, version uuid.UUID) e // TemplateVersionSchema returns schemas for a template version by ID. func (c *Client) TemplateVersionSchema(ctx context.Context, version uuid.UUID) ([]TemplateVersionParameterSchema, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/schema", version), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/schema", version), nil) if err != nil { return nil, err } @@ -73,7 +72,7 @@ func (c *Client) TemplateVersionSchema(ctx context.Context, version uuid.UUID) ( // TemplateVersionParameters returns computed parameters for a template version. func (c *Client) TemplateVersionParameters(ctx context.Context, version uuid.UUID) ([]TemplateVersionParameter, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/parameters", version), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/parameters", version), nil) if err != nil { return nil, err } @@ -87,7 +86,7 @@ func (c *Client) TemplateVersionParameters(ctx context.Context, version uuid.UUI // TemplateVersionResources returns resources a template version declares. func (c *Client) TemplateVersionResources(ctx context.Context, version uuid.UUID) ([]WorkspaceResource, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/resources", version), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/resources", version), nil) if err != nil { return nil, err } diff --git a/codersdk/users.go b/codersdk/users.go index 61faa398d0fa6..8af79d720fff0 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -155,7 +155,7 @@ type AuthMethods struct { // HasFirstUser returns whether the first user has been created. func (c *Client) HasFirstUser(ctx context.Context) (bool, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/first", nil) + res, err := c.request(ctx, http.MethodGet, "/api/v2/users/first", nil) if err != nil { return false, err } @@ -172,7 +172,7 @@ func (c *Client) HasFirstUser(ctx context.Context) (bool, error) { // CreateFirstUser attempts to create the first user on a Coder deployment. // This initial user has superadmin privileges. If >0 users exist, this request will fail. func (c *Client) CreateFirstUser(ctx context.Context, req CreateFirstUserRequest) (CreateFirstUserResponse, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/first", req) + res, err := c.request(ctx, http.MethodPost, "/api/v2/users/first", req) if err != nil { return CreateFirstUserResponse{}, err } @@ -186,7 +186,7 @@ func (c *Client) CreateFirstUser(ctx context.Context, req CreateFirstUserRequest // CreateUser creates a new user. func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/v2/users", req) + res, err := c.request(ctx, http.MethodPost, "/api/v2/users", req) if err != nil { return User{}, err } @@ -200,7 +200,7 @@ func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, e // UpdateUserProfile enables callers to update profile information func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateUserProfileRequest) (User, error) { - res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/profile", user), req) + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/profile", user), req) if err != nil { return User{}, err } @@ -224,7 +224,7 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS return User{}, xerrors.Errorf("status %q is not supported", status) } - res, err := c.Request(ctx, http.MethodPut, path, nil) + res, err := c.request(ctx, http.MethodPut, path, nil) if err != nil { return User{}, err } @@ -240,7 +240,7 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS // UpdateUserPassword updates a user password. // It calls PUT /users/{user}/password func (c *Client) UpdateUserPassword(ctx context.Context, user string, req UpdateUserPasswordRequest) error { - res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/password", user), req) + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/password", user), req) if err != nil { return err } @@ -254,7 +254,7 @@ func (c *Client) UpdateUserPassword(ctx context.Context, user string, req Update // 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) { - res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/roles", user), req) + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/roles", user), req) if err != nil { return User{}, err } @@ -269,7 +269,7 @@ func (c *Client) UpdateUserRoles(ctx context.Context, user string, req UpdateRol // UpdateOrganizationMemberRoles grants the userID the specified roles in an org. // Include ALL roles the user has. func (c *Client) UpdateOrganizationMemberRoles(ctx context.Context, organizationID uuid.UUID, user string, req UpdateRoles) (OrganizationMember, error) { - res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/organizations/%s/members/%s/roles", organizationID, user), req) + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/organizations/%s/members/%s/roles", organizationID, user), req) if err != nil { return OrganizationMember{}, err } @@ -283,7 +283,7 @@ func (c *Client) UpdateOrganizationMemberRoles(ctx context.Context, organization // GetUserRoles returns all roles the user has func (c *Client) GetUserRoles(ctx context.Context, user string) (UserRoles, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/roles", user), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/roles", user), nil) if err != nil { return UserRoles{}, err } @@ -297,7 +297,7 @@ func (c *Client) GetUserRoles(ctx context.Context, user string) (UserRoles, erro // CreateAPIKey generates an API key for the user ID provided. func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKeyResponse, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", user), nil) + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", user), nil) if err != nil { return nil, err } @@ -312,7 +312,7 @@ func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKey // LoginWithPassword creates a session token authenticating with an email and password. // Call `SetSessionToken()` to apply the newly acquired token to the client. func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordRequest) (LoginWithPasswordResponse, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/login", req) + res, err := c.request(ctx, http.MethodPost, "/api/v2/users/login", req) if err != nil { return LoginWithPasswordResponse{}, err } @@ -333,7 +333,7 @@ func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordReq func (c *Client) Logout(ctx context.Context) error { // Since `LoginWithPassword` doesn't actually set a SessionToken // (it requires a call to SetSessionToken), this is essentially a no-op - res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/logout", nil) + res, err := c.request(ctx, http.MethodPost, "/api/v2/users/logout", nil) if err != nil { return err } @@ -343,7 +343,7 @@ func (c *Client) Logout(ctx context.Context) error { // User returns a user for the ID/username provided. func (c *Client) User(ctx context.Context, userIdent string) (User, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s", userIdent), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s", userIdent), nil) if err != nil { return User{}, err } @@ -358,7 +358,7 @@ func (c *Client) User(ctx context.Context, userIdent string) (User, error) { // Users returns all users according to the request parameters. If no parameters are set, // the default behavior is to return all users in a single page. func (c *Client) Users(ctx context.Context, req UsersRequest) ([]User, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/users", nil, + res, err := c.request(ctx, http.MethodGet, "/api/v2/users", nil, req.Pagination.asRequestOption(), func(r *http.Request) { q := r.URL.Query() @@ -382,7 +382,7 @@ func (c *Client) Users(ctx context.Context, req UsersRequest) ([]User, error) { // OrganizationsByUser returns all organizations the user is a member of. func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organization, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations", user), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations", user), nil) if err != nil { return nil, err } @@ -395,7 +395,7 @@ func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organi } func (c *Client) OrganizationByName(ctx context.Context, user string, name string) (Organization, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations/%s", user, name), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations/%s", user, name), nil) if err != nil { return Organization{}, err } @@ -409,7 +409,7 @@ func (c *Client) OrganizationByName(ctx context.Context, user string, name strin // CreateOrganization creates an organization and adds the provided user as an admin. func (c *Client) CreateOrganization(ctx context.Context, user string, req CreateOrganizationRequest) (Organization, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/organizations", user), req) + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/organizations", user), req) if err != nil { return Organization{}, err } @@ -425,7 +425,7 @@ func (c *Client) CreateOrganization(ctx context.Context, user string, req Create // AuthMethods returns types of authentication available to the user. func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/authmethods", nil) + res, err := c.request(ctx, http.MethodGet, "/api/v2/users/authmethods", nil) if err != nil { return AuthMethods{}, err } @@ -438,3 +438,19 @@ func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) { var userAuth AuthMethods return userAuth, json.NewDecoder(res.Body).Decode(&userAuth) } + +// WorkspacesByUser returns all workspaces a user has access to. +func (c *Client) WorkspacesByUser(ctx context.Context, user string) ([]Workspace, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspaces", user), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + + var workspaces []Workspace + return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) +} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index d64b42bc5faaa..be98b4696fd8b 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -65,7 +65,7 @@ func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, servic if err != nil { return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err) } - res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{ + res, err := c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{ JSONWebToken: jwt, }) if err != nil { @@ -129,7 +129,7 @@ func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (Workspac return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err) } - res, err = c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{ + res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{ Signature: string(signature), Document: string(document), }) @@ -164,7 +164,7 @@ func (c *Client) AuthWorkspaceAzureInstanceIdentity(ctx context.Context) (Worksp return WorkspaceAgentAuthenticateResponse{}, err } - res, err = c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token) + res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token) if err != nil { return WorkspaceAgentAuthenticateResponse{}, err } @@ -213,7 +213,7 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) ( } listener, err := peerbroker.Listen(session, func(ctx context.Context) ([]webrtc.ICEServer, *peer.ConnOptions, error) { // This can be cached if it adds to latency too much. - res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/iceservers", nil) + res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/iceservers", nil) if err != nil { return nil, nil, err } @@ -240,7 +240,7 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) ( if err != nil { return agent.Metadata{}, nil, xerrors.Errorf("listen peerbroker: %w", err) } - res, err = c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil) + res, err = c.request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil) if err != nil { return agent.Metadata{}, nil, err } @@ -292,7 +292,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti return nil, xerrors.Errorf("negotiate connection: %w", err) } - res, err = c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/iceservers", agentID.String()), nil) + res, err = c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/iceservers", agentID.String()), nil) if err != nil { return nil, err } @@ -326,7 +326,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti // WorkspaceAgent returns an agent by ID. func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s", id), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s", id), nil) if err != nil { return WorkspaceAgent{}, err } diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 83e2a4b535cf0..ef6e68d6bab8f 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -14,14 +14,15 @@ import ( ) // WorkspaceBuild is an at-point representation of a workspace state. -// BuildNumbers start at 1 and increase by 1 for each subsequent build +// Iterate on before/after to determine a chronological history. type WorkspaceBuild struct { ID uuid.UUID `json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` WorkspaceID uuid.UUID `json:"workspace_id"` TemplateVersionID uuid.UUID `json:"template_version_id"` - BuildNumber int32 `json:"build_number"` + BeforeID uuid.UUID `json:"before_id"` + AfterID uuid.UUID `json:"after_id"` Name string `json:"name"` Transition database.WorkspaceTransition `json:"transition"` InitiatorID uuid.UUID `json:"initiator_id"` @@ -31,7 +32,7 @@ type WorkspaceBuild struct { // WorkspaceBuild returns a single workspace build for a workspace. // If history is "", the latest version is returned. func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s", id), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s", id), nil) if err != nil { return WorkspaceBuild{}, err } @@ -45,7 +46,7 @@ func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBui // CancelWorkspaceBuild marks a workspace build job as canceled. func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error { - res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspacebuilds/%s/cancel", id), nil) + res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspacebuilds/%s/cancel", id), nil) if err != nil { return err } @@ -58,7 +59,7 @@ func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error { // WorkspaceResourcesByBuild returns resources for a workspace build. func (c *Client) WorkspaceResourcesByBuild(ctx context.Context, build uuid.UUID) ([]WorkspaceResource, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/resources", build), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/resources", build), nil) if err != nil { return nil, err } @@ -82,7 +83,7 @@ func (c *Client) WorkspaceBuildLogsAfter(ctx context.Context, build uuid.UUID, a // WorkspaceBuildState returns the provisioner state of the build. func (c *Client) WorkspaceBuildState(ctx context.Context, build uuid.UUID) ([]byte, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/state", build), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/state", build), nil) if err != nil { return nil, err } diff --git a/codersdk/workspaceresources.go b/codersdk/workspaceresources.go index f1dc5d74a04f9..b21451bbc63ea 100644 --- a/codersdk/workspaceresources.go +++ b/codersdk/workspaceresources.go @@ -69,7 +69,7 @@ type WorkspaceAgentInstanceMetadata struct { } func (c *Client) WorkspaceResource(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceresources/%s", id), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceresources/%s", id), nil) if err != nil { return WorkspaceResource{}, err } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 6e4ab7afd6e57..644f90422ff07 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -40,7 +40,7 @@ type CreateWorkspaceBuildRequest struct { // Workspace returns a single workspace. func (c *Client) Workspace(ctx context.Context, id uuid.UUID) (Workspace, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s", id), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s", id), nil) if err != nil { return Workspace{}, err } @@ -52,14 +52,8 @@ func (c *Client) Workspace(ctx context.Context, id uuid.UUID) (Workspace, error) return workspace, json.NewDecoder(res.Body).Decode(&workspace) } -type WorkspaceBuildsRequest struct { - WorkspaceID uuid.UUID - Pagination -} - -func (c *Client) WorkspaceBuilds(ctx context.Context, req WorkspaceBuildsRequest) ([]WorkspaceBuild, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds", req.WorkspaceID), - nil, req.Pagination.asRequestOption()) +func (c *Client) WorkspaceBuilds(ctx context.Context, workspace uuid.UUID) ([]WorkspaceBuild, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds", workspace), nil) if err != nil { return nil, err } @@ -73,7 +67,7 @@ func (c *Client) WorkspaceBuilds(ctx context.Context, req WorkspaceBuildsRequest // CreateWorkspaceBuild queues a new build to occur for a workspace. func (c *Client) CreateWorkspaceBuild(ctx context.Context, workspace uuid.UUID, request CreateWorkspaceBuildRequest) (WorkspaceBuild, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s/builds", workspace), request) + res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s/builds", workspace), request) if err != nil { return WorkspaceBuild{}, err } @@ -86,7 +80,7 @@ func (c *Client) CreateWorkspaceBuild(ctx context.Context, workspace uuid.UUID, } func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, name string) (WorkspaceBuild, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds/%s", workspace, name), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds/%s", workspace, name), nil) if err != nil { return WorkspaceBuild{}, err } @@ -107,7 +101,7 @@ type UpdateWorkspaceAutostartRequest struct { // If the provided schedule is empty, autostart is disabled for the workspace. func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostartRequest) error { path := fmt.Sprintf("/api/v2/workspaces/%s/autostart", id.String()) - res, err := c.Request(ctx, http.MethodPut, path, req) + res, err := c.request(ctx, http.MethodPut, path, req) if err != nil { return xerrors.Errorf("update workspace autostart: %w", err) } @@ -127,7 +121,7 @@ type UpdateWorkspaceAutostopRequest struct { // If the provided schedule is empty, autostop is disabled for the workspace. func (c *Client) UpdateWorkspaceAutostop(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostopRequest) error { path := fmt.Sprintf("/api/v2/workspaces/%s/autostop", id.String()) - res, err := c.Request(ctx, http.MethodPut, path, req) + res, err := c.request(ctx, http.MethodPut, path, req) if err != nil { return xerrors.Errorf("update workspace autostop: %w", err) } @@ -137,41 +131,3 @@ func (c *Client) UpdateWorkspaceAutostop(ctx context.Context, id uuid.UUID, req } return nil } - -type WorkspaceFilter struct { - OrganizationID uuid.UUID - // Owner can be a user_id (uuid), "me", or a username - Owner string -} - -// asRequestOption returns a function that can be used in (*Client).Request. -// It modifies the request query parameters. -func (f WorkspaceFilter) asRequestOption() requestOption { - return func(r *http.Request) { - q := r.URL.Query() - if f.OrganizationID != uuid.Nil { - q.Set("organization_id", f.OrganizationID.String()) - } - if f.Owner != "" { - q.Set("owner_id", f.Owner) - } - r.URL.RawQuery = q.Encode() - } -} - -// Workspaces returns all workspaces the authenticated user has access to. -func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaces", nil, filter.asRequestOption()) - - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, readBodyAsError(res) - } - - var workspaces []Workspace - return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) -} diff --git a/cryptorand/slices.go b/cryptorand/slices.go deleted file mode 100644 index 90bc6a9b20368..0000000000000 --- a/cryptorand/slices.go +++ /dev/null @@ -1,20 +0,0 @@ -package cryptorand - -import ( - "golang.org/x/xerrors" -) - -// Element returns a random element of the slice. An error will be returned if -// the slice has no elements in it. -func Element[T any](s []T) (out T, err error) { - if len(s) == 0 { - return out, xerrors.New("slice must have at least one element") - } - - i, err := Intn(len(s)) - if err != nil { - return out, xerrors.Errorf("generate random integer from 0-%v: %w", len(s), err) - } - - return s[i], nil -} diff --git a/cryptorand/slices_test.go b/cryptorand/slices_test.go deleted file mode 100644 index f4c7be248c0cc..0000000000000 --- a/cryptorand/slices_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package cryptorand_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/cryptorand" -) - -func TestRandomElement(t *testing.T) { - t.Parallel() - - t.Run("Empty", func(t *testing.T) { - t.Parallel() - - s := []string{} - v, err := cryptorand.Element(s) - require.Error(t, err) - require.ErrorContains(t, err, "slice must have at least one element") - require.Empty(t, v) - }) - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - // Generate random slices of ints and strings - var ( - ints = make([]int, 20) - strings = make([]string, 20) - ) - for i := range ints { - v, err := cryptorand.Intn(1024) - require.NoError(t, err, "generate random int for test slice") - ints[i] = v - } - for i := range strings { - v, err := cryptorand.String(10) - require.NoError(t, err, "generate random string for test slice") - strings[i] = v - } - - // Get a random value from each 20 times. - for i := 0; i < 20; i++ { - iv, err := cryptorand.Element(ints) - require.NoError(t, err, "unexpected error from Element(ints)") - t.Logf("random int slice element: %v", iv) - require.Contains(t, ints, iv) - - sv, err := cryptorand.Element(strings) - require.NoError(t, err, "unexpected error from Element(strings)") - t.Logf("random string slice element: %v", sv) - require.Contains(t, strings, sv) - } - }) -} diff --git a/scripts/develop.sh b/develop.sh similarity index 100% rename from scripts/develop.sh rename to develop.sh diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 4164727df51fb..0658262128fc6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -9,10 +9,10 @@ Coder requires Go 1.18+, Node 14+, and GNU Make. Use the following `make` commands and scripts in development: -- `make dev` runs the frontend and backend development server - `make build` compiles binaries and release packages - `make install` installs binaries to `$GOPATH/bin` - `make test` +- `./develop.sh` hot reloads for front-end development ## Styling diff --git a/examples/aws-linux/README.md b/examples/aws-linux/README.md index bf50e661334bc..6bc248d3ba837 100644 --- a/examples/aws-linux/README.md +++ b/examples/aws-linux/README.md @@ -3,62 +3,3 @@ name: Develop in Linux on AWS EC2 description: Get started with Linux development on AWS EC2. tags: [cloud, aws] --- - -# aws-linux - -## Getting started - -Pick this template in `coder templates init` and follow instructions. - -## Required permissions / policy - -This example policy allows Coder to create EC2 instances and modify instances provisioned by Coder. - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "VisualEditor0", - "Effect": "Allow", - "Action": [ - "ec2:GetDefaultCreditSpecification", - "ec2:DescribeIamInstanceProfileAssociations", - "ec2:DescribeTags", - "ec2:CreateTags", - "ec2:RunInstances", - "ec2:DescribeInstanceCreditSpecifications", - "ec2:DescribeImages", - "ec2:ModifyDefaultCreditSpecification", - "ec2:DescribeVolumes" - ], - "Resource": "*" - }, - { - "Sid": "CoderResouces", - "Effect": "Allow", - "Action": [ - "ec2:DescribeInstances", - "ec2:DescribeInstanceAttribute", - "ec2:UnmonitorInstances", - "ec2:TerminateInstances", - "ec2:StartInstances", - "ec2:StopInstances", - "ec2:DeleteTags", - "ec2:MonitorInstances", - "ec2:CreateTags", - "ec2:RunInstances", - "ec2:ModifyInstanceAttribute", - "ec2:ModifyInstanceCreditSpecification" - ], - "Resource": "arn:aws:ec2:*:*:instance/*", - "Condition": { - "StringEquals": { - "aws:ResourceTag/Coder_Provisioned": "true" - } - } - } - ] -} -``` - diff --git a/examples/aws-linux/main.tf b/examples/aws-linux/main.tf index d6eb41a2da6ac..b5fc1f3283ea4 100644 --- a/examples/aws-linux/main.tf +++ b/examples/aws-linux/main.tf @@ -11,9 +11,6 @@ variable "access_key" { description = < 0 { + if len(update.Logs) != 0 { didLog.Store(true) } - if len(update.Readme) > 0 { - didReadme.Store(true) - } return &proto.UpdateJobResponse{}, nil }, completeJob: func(ctx context.Context, job *proto.CompletedJob) (*proto.Empty, error) { diff --git a/pty/pty.go b/pty/pty.go index 7a8fe6c99edb6..00eb7d33ea4c1 100644 --- a/pty/pty.go +++ b/pty/pty.go @@ -2,7 +2,6 @@ package pty import ( "io" - "os" ) // PTY is a minimal interface for interacting with a TTY. @@ -15,7 +14,7 @@ type PTY interface { // uses the output stream for writing. // // The same stream could be read to validate output. - Output() ReadWriter + Output() io.ReadWriter // Input handles TTY input. // @@ -23,38 +22,18 @@ type PTY interface { // uses the PTY input for reading. // // The same stream would be used to provide user input: pty.Input().Write(...) - Input() ReadWriter + Input() io.ReadWriter // Resize sets the size of the PTY. Resize(height uint16, width uint16) error } -// WithFlags represents a PTY whose flags can be inspected, in particular -// to determine whether local echo is enabled. -type WithFlags interface { - PTY - - // EchoEnabled determines whether local echo is currently enabled for this terminal. - EchoEnabled() (bool, error) -} - // New constructs a new Pty. func New() (PTY, error) { return newPty() } -// ReadWriter is an implementation of io.ReadWriter that wraps two separate -// underlying file descriptors, one for reading and one for writing, and allows -// them to be accessed separately. -type ReadWriter struct { - Reader *os.File - Writer *os.File -} - -func (rw ReadWriter) Read(p []byte) (int, error) { - return rw.Reader.Read(p) -} - -func (rw ReadWriter) Write(p []byte) (int, error) { - return rw.Writer.Write(p) +type readWriter struct { + io.Reader + io.Writer } diff --git a/pty/pty_linux.go b/pty/pty_linux.go deleted file mode 100644 index b18d801c228e8..0000000000000 --- a/pty/pty_linux.go +++ /dev/null @@ -1,13 +0,0 @@ -// go:build linux - -package pty - -import "golang.org/x/sys/unix" - -func (p *otherPty) EchoEnabled() (bool, error) { - termios, err := unix.IoctlGetTermios(int(p.pty.Fd()), unix.TCGETS) - if err != nil { - return false, err - } - return (termios.Lflag & unix.ECHO) != 0, nil -} diff --git a/pty/pty_other.go b/pty/pty_other.go index d6e21d4d3ffe1..b826bd3a3398f 100644 --- a/pty/pty_other.go +++ b/pty/pty_other.go @@ -4,6 +4,7 @@ package pty import ( + "io" "os" "sync" @@ -27,15 +28,15 @@ type otherPty struct { pty, tty *os.File } -func (p *otherPty) Input() ReadWriter { - return ReadWriter{ +func (p *otherPty) Input() io.ReadWriter { + return readWriter{ Reader: p.tty, Writer: p.pty, } } -func (p *otherPty) Output() ReadWriter { - return ReadWriter{ +func (p *otherPty) Output() io.ReadWriter { + return readWriter{ Reader: p.pty, Writer: p.tty, } diff --git a/pty/pty_windows.go b/pty/pty_windows.go index 93e58c4405772..854ecfe36eeda 100644 --- a/pty/pty_windows.go +++ b/pty/pty_windows.go @@ -4,6 +4,7 @@ package pty import ( + "io" "os" "sync" "unsafe" @@ -66,15 +67,15 @@ type ptyWindows struct { closed bool } -func (p *ptyWindows) Output() ReadWriter { - return ReadWriter{ +func (p *ptyWindows) Output() io.ReadWriter { + return readWriter{ Reader: p.outputRead, Writer: p.outputWrite, } } -func (p *ptyWindows) Input() ReadWriter { - return ReadWriter{ +func (p *ptyWindows) Input() io.ReadWriter { + return readWriter{ Reader: p.inputRead, Writer: p.inputWrite, } diff --git a/site/package.json b/site/package.json index 98b0ee16e29bd..dafb4078a732b 100644 --- a/site/package.json +++ b/site/package.json @@ -34,7 +34,7 @@ "@xstate/inspect": "0.6.5", "@xstate/react": "3.0.0", "axios": "0.26.1", - "cronstrue": "2.5.0", + "cronstrue": "2.4.0", "dayjs": "1.11.2", "formik": "2.2.9", "history": "5.3.0", @@ -60,7 +60,7 @@ "@storybook/react": "6.4.22", "@testing-library/jest-dom": "5.16.4", "@testing-library/react": "12.1.5", - "@testing-library/user-event": "14.2.0", + "@testing-library/user-event": "14.1.1", "@types/express": "4.17.13", "@types/jest": "27.4.1", "@types/node": "14.18.16", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index f0145fdde8b03..b3b8f59981ae9 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -12,7 +12,6 @@ import { OrgsPage } from "./pages/OrgsPage/OrgsPage" import { SettingsPage } from "./pages/SettingsPage/SettingsPage" import { AccountPage } from "./pages/SettingsPages/AccountPage/AccountPage" import { SSHKeysPage } from "./pages/SettingsPages/SSHKeysPage/SSHKeysPage" -import TemplatesPage from "./pages/TemplatesPage/TemplatesPage" import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage" import { UsersPage } from "./pages/UsersPage/UsersPage" import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage" @@ -74,17 +73,6 @@ export const AppRouter: React.FC = () => ( - - - - - } - /> - - => { - const response = await axios.get(`/api/v2/organizations/${organizationId}/templates`) - return response.data -} - export const getWorkspace = async (workspaceId: string): Promise => { const response = await axios.get(`/api/v2/workspaces/${workspaceId}`) return response.data } -// TODO: @emyrk add query params as arguments. Supports 'organization_id' and 'owner' -// 'owner' can be a username, user_id, or 'me' -export const getWorkspaces = async (): Promise => { - const response = await axios.get(`/api/v2/workspaces`) +export const getWorkspaces = async (userID = "me"): Promise => { + const response = await axios.get(`/api/v2/users/${userID}/workspaces`) return response.data } @@ -228,8 +221,3 @@ export const regenerateUserSSHKey = async (userId = "me"): Promise(`/api/v2/users/${userId}/gitsshkey`) return response.data } - -export const getWorkspaceBuilds = async (workspaceId: string): Promise => { - const response = await axios.get(`/api/v2/workspaces/${workspaceId}/builds`) - return response.data -} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 792ef3961313c..da8c18eee2d2c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -231,10 +231,9 @@ export interface TemplateVersion { readonly updated_at: string readonly name: string readonly job: ProvisionerJob - readonly readme: string } -// From codersdk/templateversions.go:31:6 +// From codersdk/templateversions.go:30:6 export interface TemplateVersionParameter { // Named type "github.com/coder/coder/coderd/database.ParameterValue" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -243,7 +242,7 @@ export interface TemplateVersionParameter { readonly default_source_value: boolean } -// From codersdk/templateversions.go:28:6 +// From codersdk/templateversions.go:27:6 export interface TemplateVersionParameterSchema { readonly id: string readonly created_at: string @@ -292,12 +291,12 @@ export interface UpdateUserProfileRequest { readonly username: string } -// From codersdk/workspaces.go:102:6 +// From codersdk/workspaces.go:96:6 export interface UpdateWorkspaceAutostartRequest { readonly schedule: string } -// From codersdk/workspaces.go:122:6 +// From codersdk/workspaces.go:116:6 export interface UpdateWorkspaceAutostopRequest { readonly schedule: string } @@ -421,7 +420,8 @@ export interface WorkspaceBuild { readonly updated_at: string readonly workspace_id: string readonly template_version_id: string - readonly build_number: number + readonly before_id: string + readonly after_id: string readonly name: string // This is likely an enum in an external package ("github.com/coder/coder/coderd/database.WorkspaceTransition") readonly transition: string @@ -429,17 +429,6 @@ export interface WorkspaceBuild { readonly job: ProvisionerJob } -// From codersdk/workspaces.go:55:6 -export interface WorkspaceBuildsRequest extends Pagination { - readonly WorkspaceID: string -} - -// From codersdk/workspaces.go:141:6 -export interface WorkspaceFilter { - readonly OrganizationID: string - readonly Owner: string -} - // From codersdk/workspaceresources.go:23:6 export interface WorkspaceResource { readonly id: string diff --git a/site/src/components/BorderedMenu/BorderedMenu.stories.tsx b/site/src/components/BorderedMenu/BorderedMenu.stories.tsx index e53deab3fab06..73186fd77faec 100644 --- a/site/src/components/BorderedMenu/BorderedMenu.stories.tsx +++ b/site/src/components/BorderedMenu/BorderedMenu.stories.tsx @@ -12,14 +12,8 @@ export default { const Template: Story = (args: BorderedMenuProps) => ( - - + + ) diff --git a/site/src/components/BorderedMenuRow/BorderedMenuRow.tsx b/site/src/components/BorderedMenuRow/BorderedMenuRow.tsx index 32b218b282853..d83bebdf52166 100644 --- a/site/src/components/BorderedMenuRow/BorderedMenuRow.tsx +++ b/site/src/components/BorderedMenuRow/BorderedMenuRow.tsx @@ -17,7 +17,7 @@ interface BorderedMenuRowProps { /** An SvgIcon that will be rendered to the left of the title */ Icon: typeof SvgIcon /** URL path */ - path: string + path?: string /** Required title of this row */ title: string /** Defaults to `"wide"` */ @@ -37,30 +37,38 @@ export const BorderedMenuRow: React.FC = ({ }) => { const styles = useStyles() - return ( - - -
-
- - {title} - {active && } -
- - {description && ( - - {ellipsizeText(description)} - - )} + const Component = () => ( + +
+
+ + {title} + {active && }
- - + + {description && ( + + {ellipsizeText(description)} + + )} +
+
) + + if (path) { + return ( + + + + ) + } + + return } const iconSize = 20 diff --git a/site/src/components/BuildsTable/BuildsTable.stories.tsx b/site/src/components/BuildsTable/BuildsTable.stories.tsx deleted file mode 100644 index 4626b8723cd87..0000000000000 --- a/site/src/components/BuildsTable/BuildsTable.stories.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentMeta, Story } from "@storybook/react" -import React from "react" -import { MockBuilds } from "../../testHelpers/entities" -import { BuildsTable, BuildsTableProps } from "./BuildsTable" - -export default { - title: "components/BuildsTable", - component: BuildsTable, -} as ComponentMeta - -const Template: Story = (args) => - -export const Example = Template.bind({}) -Example.args = { - builds: MockBuilds, -} - -export const Empty = Template.bind({}) -Empty.args = { - builds: [], -} diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx deleted file mode 100644 index 3e26910894e75..0000000000000 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import Box from "@material-ui/core/Box" -import { Theme } from "@material-ui/core/styles" -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" -import useTheme from "@material-ui/styles/useTheme" -import dayjs from "dayjs" -import duration from "dayjs/plugin/duration" -import relativeTime from "dayjs/plugin/relativeTime" -import React from "react" -import * as TypesGen from "../../api/typesGenerated" -import { getDisplayStatus } from "../../util/workspace" -import { EmptyState } from "../EmptyState/EmptyState" -import { TableLoader } from "../TableLoader/TableLoader" - -dayjs.extend(relativeTime) -dayjs.extend(duration) - -export const Language = { - emptyMessage: "No builds found", - inProgressLabel: "In progress", - actionLabel: "Action", - durationLabel: "Duration", - startedAtLabel: "Started at", - statusLabel: "Status", -} - -const getDurationInSeconds = (build: TypesGen.WorkspaceBuild) => { - let display = Language.inProgressLabel - - if (build.job.started_at && build.job.completed_at) { - const startedAt = dayjs(build.job.started_at) - const completedAt = dayjs(build.job.completed_at) - const diff = completedAt.diff(startedAt, "seconds") - display = `${diff} seconds` - } - - return display -} - -export interface BuildsTableProps { - builds?: TypesGen.WorkspaceBuild[] - className?: string -} - -export const BuildsTable: React.FC = ({ builds, className }) => { - const isLoading = !builds - const theme: Theme = useTheme() - - return ( -
- - - {Language.actionLabel} - {Language.durationLabel} - {Language.startedAtLabel} - {Language.statusLabel} - - - - {isLoading && } - {builds && - builds.map((b) => { - const status = getDisplayStatus(theme, b) - const duration = getDurationInSeconds(b) - - return ( - - {b.transition} - - {duration} - - - {new Date(b.created_at).toLocaleString()} - - - {status.status} - - - ) - })} - - {builds && builds.length === 0 && ( - - - - - - - - )} - -
- ) -} diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 1a471dc8bcb28..2938240744b0f 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -30,11 +30,6 @@ export const NavbarView: React.FC = ({ user, onSignOut, display Workspaces - - - Templates - -
{displayAdminDropdown && } diff --git a/site/src/components/TerminalLink/TerminalLink.stories.tsx b/site/src/components/TerminalLink/TerminalLink.stories.tsx deleted file mode 100644 index 417821f53d30b..0000000000000 --- a/site/src/components/TerminalLink/TerminalLink.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Story } from "@storybook/react" -import React from "react" -import { MockWorkspace } from "../../testHelpers/renderHelpers" -import { TerminalLink, TerminalLinkProps } from "./TerminalLink" - -export default { - title: "components/TerminalLink", - component: TerminalLink, -} - -const Template: Story = (args) => - -export const Example = Template.bind({}) -Example.args = { - workspaceName: MockWorkspace.name, -} diff --git a/site/src/components/TerminalLink/TerminalLink.tsx b/site/src/components/TerminalLink/TerminalLink.tsx deleted file mode 100644 index f8c93212103c7..0000000000000 --- a/site/src/components/TerminalLink/TerminalLink.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Link from "@material-ui/core/Link" -import React from "react" -import * as TypesGen from "../../api/typesGenerated" - -export const Language = { - linkText: "Open in terminal", -} - -export interface TerminalLinkProps { - agentName?: TypesGen.WorkspaceAgent["name"] - userName?: TypesGen.User["username"] - workspaceName: TypesGen.Workspace["name"] -} - -/** - * Generate a link to a terminal connected to the provided workspace agent. If - * no agent is provided connect to the first agent. - * - * If no user name is provided "me" is used however it makes the link not - * shareable. - */ -export const TerminalLink: React.FC = ({ agentName, userName = "me", workspaceName }) => { - return ( - - {Language.linkText} - - ) -} diff --git a/site/src/components/UserDropdown/UsersDropdown.tsx b/site/src/components/UserDropdown/UsersDropdown.tsx index 3e1de2c41162a..dc61cd6215302 100644 --- a/site/src/components/UserDropdown/UsersDropdown.tsx +++ b/site/src/components/UserDropdown/UsersDropdown.tsx @@ -8,7 +8,6 @@ import AccountIcon from "@material-ui/icons/AccountCircleOutlined" import React, { useState } from "react" import { Link } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" -import { navHeight } from "../../theme/constants" import { BorderedMenu } from "../BorderedMenu/BorderedMenu" import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows" import { DocsIcon } from "../Icons/DocsIcon" @@ -39,7 +38,7 @@ export const UserDropdown: React.FC = ({ user, onSignOut }: U return ( <> - +
@@ -122,7 +121,7 @@ export const useStyles = makeStyles((theme) => ({ }, menuItem: { - height: navHeight, + height: 44, padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`, "&:hover": { diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 5f94b749463ce..ab880a1d4169d 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -3,7 +3,6 @@ import Typography from "@material-ui/core/Typography" import React from "react" import * as TypesGen from "../../api/typesGenerated" import { WorkspaceStatus } from "../../util/workspace" -import { BuildsTable } from "../BuildsTable/BuildsTable" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" @@ -17,7 +16,6 @@ export interface WorkspaceProps { handleRetry: () => void handleUpdate: () => void workspaceStatus: WorkspaceStatus - builds?: TypesGen.WorkspaceBuild[] } /** @@ -30,7 +28,6 @@ export const Workspace: React.FC = ({ handleRetry, handleUpdate, workspaceStatus, - builds, }) => { const styles = useStyles() @@ -59,8 +56,13 @@ export const Workspace: React.FC = ({
- - + +
+ +
@@ -103,11 +105,5 @@ export const useStyles = makeStyles(() => { timelineContainer: { flex: 1, }, - timelineContents: { - margin: 0, - }, - timelineTable: { - border: 0, - }, } }) diff --git a/site/src/components/WorkspaceSection/WorkspaceSection.tsx b/site/src/components/WorkspaceSection/WorkspaceSection.tsx index 73dac822eb8d6..bcdb90c03463c 100644 --- a/site/src/components/WorkspaceSection/WorkspaceSection.tsx +++ b/site/src/components/WorkspaceSection/WorkspaceSection.tsx @@ -1,16 +1,14 @@ import Paper from "@material-ui/core/Paper" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" -import React, { HTMLProps } from "react" +import React from "react" import { CardPadding, CardRadius } from "../../theme/constants" -import { combineClasses } from "../../util/combineClasses" export interface WorkspaceSectionProps { title?: string - contentsProps?: HTMLProps } -export const WorkspaceSection: React.FC = ({ title, children, contentsProps }) => { +export const WorkspaceSection: React.FC = ({ title, children }) => { const styles = useStyles() return ( @@ -23,9 +21,7 @@ export const WorkspaceSection: React.FC = ({ title, child )} -
- {children} -
+
{children}
) } diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx deleted file mode 100644 index 03e18499f12c9..0000000000000 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import Avatar from "@material-ui/core/Avatar" -import Button from "@material-ui/core/Button" -import Link from "@material-ui/core/Link" -import { makeStyles } from "@material-ui/core/styles" -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" -import AddCircleOutline from "@material-ui/icons/AddCircleOutline" -import dayjs from "dayjs" -import relativeTime from "dayjs/plugin/relativeTime" -import React from "react" -import { Link as RouterLink } from "react-router-dom" -import * as TypesGen from "../../api/typesGenerated" -import { Margins } from "../../components/Margins/Margins" -import { Stack } from "../../components/Stack/Stack" -import { firstLetter } from "../../util/firstLetter" - -dayjs.extend(relativeTime) - -export const Language = { - createButton: "Create Template", - emptyViewCreate: "to standardize development workspaces for your team.", - emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.", -} - -export interface TemplatesPageViewProps { - loading?: boolean - canCreateTemplate?: boolean - templates?: TypesGen.Template[] - error?: unknown -} - -export const TemplatesPageView: React.FC = (props) => { - const styles = useStyles() - return ( - - -
- {props.canCreateTemplate && } -
- - - - Name - Used By - Last Updated - - - - {!props.loading && !props.templates?.length && ( - - -
- {props.canCreateTemplate ? ( - - - Create a template - -  {Language.emptyViewCreate} - - ) : ( - {Language.emptyViewNoPerms} - )} -
-
-
- )} - {props.templates?.map((template) => { - return ( - - -
- - {firstLetter(template.name)} - - - {template.name} - {template.description} - -
-
- - {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} - - {dayjs().to(dayjs(template.updated_at))} -
- ) - })} -
-
-
-
- ) -} - -const useStyles = makeStyles((theme) => ({ - actions: { - marginTop: theme.spacing(3), - marginBottom: theme.spacing(3), - display: "flex", - height: theme.spacing(6), - - "& button": { - marginLeft: "auto", - }, - }, - welcome: { - paddingTop: theme.spacing(12), - paddingBottom: theme.spacing(12), - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - "& span": { - maxWidth: 600, - textAlign: "center", - fontSize: theme.spacing(2), - lineHeight: `${theme.spacing(3)}px`, - }, - }, - templateRow: { - "& > td": { - paddingTop: theme.spacing(2), - paddingBottom: theme.spacing(2), - }, - }, - templateAvatar: { - borderRadius: 2, - marginRight: theme.spacing(1), - width: 24, - height: 24, - fontSize: 16, - }, - templateName: { - display: "flex", - alignItems: "center", - }, - templateLink: { - display: "flex", - flexDirection: "column", - color: theme.palette.text.primary, - textDecoration: "none", - "&:hover": { - textDecoration: "underline", - }, - "& span": { - fontSize: 12, - color: theme.palette.text.secondary, - }, - }, -})) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx deleted file mode 100644 index c74c07b0d6fbf..0000000000000 --- a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { screen } from "@testing-library/react" -import { rest } from "msw" -import React from "react" -import { MockTemplate } from "../../testHelpers/entities" -import { history, render } from "../../testHelpers/renderHelpers" -import { server } from "../../testHelpers/server" -import TemplatesPage from "./TemplatesPage" -import { Language } from "./TemplatesPageView" - -describe("TemplatesPage", () => { - beforeEach(() => { - history.replace("/workspaces") - }) - - it("renders an empty templates page", async () => { - // Given - server.use( - rest.get("/api/v2/organizations/:organizationId/templates", (req, res, ctx) => { - return res(ctx.status(200), ctx.json([])) - }), - rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - createTemplates: true, - }), - ) - }), - ) - - // When - render() - - // Then - await screen.findByText(Language.emptyViewCreate) - }) - - it("renders a filled templates page", async () => { - // When - render() - - // Then - await screen.findByText(MockTemplate.name) - }) - - it("shows empty view without permissions to create", async () => { - server.use( - rest.get("/api/v2/organizations/:organizationId/templates", (req, res, ctx) => { - return res(ctx.status(200), ctx.json([])) - }), - rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - createTemplates: false, - }), - ) - }), - ) - - // When - render() - - // Then - await screen.findByText(Language.emptyViewNoPerms) - }) -}) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx deleted file mode 100644 index 545634172ca22..0000000000000 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useActor, useMachine } from "@xstate/react" -import React, { useContext } from "react" -import { XServiceContext } from "../../xServices/StateContext" -import { templatesMachine } from "../../xServices/templates/templatesXService" -import { TemplatesPageView } from "./TemplatesPageView" - -const TemplatesPage: React.FC = () => { - const xServices = useContext(XServiceContext) - const [authState] = useActor(xServices.authXService) - const [templatesState] = useMachine(templatesMachine) - - return ( - - ) -} - -export default TemplatesPage diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx deleted file mode 100644 index 91fdba645d725..0000000000000 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { ComponentMeta, Story } from "@storybook/react" -import React from "react" -import { MockTemplate } from "../../testHelpers/entities" -import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView" - -export default { - title: "pages/TemplatesPageView", - component: TemplatesPageView, -} as ComponentMeta - -const Template: Story = (args) => - -export const AllStates = Template.bind({}) -AllStates.args = { - canCreateTemplate: true, - templates: [ - MockTemplate, - { - ...MockTemplate, - description: "🚀 Some magical template that does some magical things!", - }, - { - ...MockTemplate, - workspace_owner_count: 150, - description: "😮 Wow, this one has a bunch of usage!", - }, - ], -} - -export const EmptyCanCreate = Template.bind({}) -EmptyCanCreate.args = { - canCreateTemplate: true, -} - -export const EmptyCannotCreate = Template.bind({}) -EmptyCannotCreate.args = {} diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx deleted file mode 100644 index 1d41b257b6064..0000000000000 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import Avatar from "@material-ui/core/Avatar" -import Box from "@material-ui/core/Box" -import Button from "@material-ui/core/Button" -import Link from "@material-ui/core/Link" -import { makeStyles } from "@material-ui/core/styles" -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" -import AddCircleOutline from "@material-ui/icons/AddCircleOutline" -import dayjs from "dayjs" -import relativeTime from "dayjs/plugin/relativeTime" -import React from "react" -import { Link as RouterLink } from "react-router-dom" -import * as TypesGen from "../../api/typesGenerated" -import { Margins } from "../../components/Margins/Margins" -import { Stack } from "../../components/Stack/Stack" -import { TableLoader } from "../../components/TableLoader/TableLoader" -import { firstLetter } from "../../util/firstLetter" - -dayjs.extend(relativeTime) - -export const Language = { - createButton: "Create Template", - developerCount: (ownerCount: number): string => { - return `${ownerCount} developer${ownerCount !== 1 ? "s" : ""}` - }, - nameLabel: "Name", - usedByLabel: "Used By", - lastUpdatedLabel: "Last Updated", - emptyViewCreateCTA: "Create a template", - emptyViewCreate: "to standardize development workspaces for your team.", - emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.", -} - -export interface TemplatesPageViewProps { - loading?: boolean - canCreateTemplate?: boolean - templates?: TypesGen.Template[] -} - -export const TemplatesPageView: React.FC = (props) => { - const styles = useStyles() - return ( - - -
- {props.canCreateTemplate && } -
- - - - {Language.nameLabel} - {Language.usedByLabel} - {Language.lastUpdatedLabel} - - - - {props.loading && } - {!props.loading && !props.templates?.length && ( - - -
- {props.canCreateTemplate ? ( - - - {Language.emptyViewCreateCTA} - -  {Language.emptyViewCreate} - - ) : ( - {Language.emptyViewNoPerms} - )} -
-
-
- )} - {props.templates?.map((template) => ( - - - - - {firstLetter(template.name)} - - - {template.name} - {template.description} - - - - - {Language.developerCount(template.workspace_owner_count)} - - {dayjs().to(dayjs(template.updated_at))} - - ))} -
-
-
-
- ) -} - -const useStyles = makeStyles((theme) => ({ - actions: { - marginTop: theme.spacing(3), - marginBottom: theme.spacing(3), - display: "flex", - height: theme.spacing(6), - - "& button": { - marginLeft: "auto", - }, - }, - welcome: { - paddingTop: theme.spacing(12), - paddingBottom: theme.spacing(12), - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - "& span": { - maxWidth: 600, - textAlign: "center", - fontSize: theme.spacing(2), - lineHeight: `${theme.spacing(3)}px`, - }, - }, - templateRow: { - "& > td": { - paddingTop: theme.spacing(2), - paddingBottom: theme.spacing(2), - }, - }, - templateAvatar: { - borderRadius: 2, - marginRight: theme.spacing(1), - width: 24, - height: 24, - fontSize: 16, - }, - templateLink: { - display: "flex", - flexDirection: "column", - color: theme.palette.text.primary, - textDecoration: "none", - "&:hover": { - textDecoration: "underline", - }, - "& span": { - fontSize: 12, - color: theme.palette.text.secondary, - }, - }, -})) diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index c29560ba5dbce..2ece384d7e41d 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -6,7 +6,7 @@ import React from "react" import { Route, Routes } from "react-router-dom" import { TextDecoder, TextEncoder } from "util" import { ReconnectingPTYRequest } from "../../api/types" -import { history, MockWorkspace, MockWorkspaceAgent, render } from "../../testHelpers/renderHelpers" +import { history, MockWorkspaceAgent, render } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" import TerminalPage, { Language } from "./TerminalPage" @@ -52,7 +52,7 @@ const expectTerminalText = (container: HTMLElement, text: string) => { describe("TerminalPage", () => { beforeEach(() => { - history.push(`/some-user/${MockWorkspace.name}/terminal`) + history.push("/some-user/my-workspace/terminal") }) it("shows an error if fetching organizations fails", async () => { @@ -146,20 +146,4 @@ describe("TerminalPage", () => { expect(req.width).toBeGreaterThan(0) server.close() }) - - it("supports workspace.agent syntax", async () => { - // Given - const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty") - const text = "something to render" - - // When - history.push(`/some-user/${MockWorkspace.name}.${MockWorkspaceAgent.name}/terminal`) - const { container } = renderTerminal() - - // Then - await server.connected - server.send(text) - await expectTerminalText(container, text) - server.close() - }) }) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 093cc7781fc07..0beaf3017d5ec 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -34,14 +34,10 @@ const TerminalPage: React.FC<{ const search = new URLSearchParams(location.search) return search.get("reconnect") ?? uuidv4() }) - // The workspace name is in the format: - // [.] - const workspaceNameParts = workspace?.split(".") const [terminalState, sendEvent] = useMachine(terminalMachine, { context: { - agentName: workspaceNameParts?.[1], reconnection: reconnectionToken, - workspaceName: workspaceNameParts?.[0], + workspaceName: workspace, username: username, }, actions: { diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index ad23e0cd4ed9c..2686316362320 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,11 +1,11 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react" +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { screen } from "@testing-library/react" import { rest } from "msw" import React from "react" import * as api from "../../api/api" -import { Workspace } from "../../api/typesGenerated" +import { Template, Workspace, WorkspaceBuild } from "../../api/typesGenerated" import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar" import { - MockBuilds, MockCancelingWorkspace, MockDeletedWorkspace, MockDeletingWorkspace, @@ -22,12 +22,6 @@ import { import { server } from "../../testHelpers/server" import { WorkspacePage } from "./WorkspacePage" -// It renders the workspace page and waits for it be loaded -const renderWorkspacePage = async () => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - await screen.findByText(MockWorkspace.name) -} - /** * Requests and responses related to workspace status are unrelated, so we can't test in the usual way. * Instead, test that button clicks produce the correct requests and that responses produce the correct UI. @@ -35,11 +29,16 @@ const renderWorkspacePage = async () => { * workspaceStatus was calculated correctly. */ -const testButton = async (label: string, actionMock: jest.SpyInstance) => { - await renderWorkspacePage() +const testButton = async ( + label: string, + mock: + | jest.SpyInstance, [workspaceId: string, templateVersionId?: string | undefined]> + | jest.SpyInstance, [templateId: string]>, +) => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) const button = await screen.findByText(label) - await waitFor(() => fireEvent.click(button)) - expect(actionMock).toBeCalled() + button.click() + expect(mock).toHaveBeenCalled() } const testStatus = async (mock: Workspace, label: string) => { @@ -48,118 +47,82 @@ const testStatus = async (mock: Workspace, label: string) => { return res(ctx.status(200), ctx.json(mock)) }), ) - await renderWorkspacePage() + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) const status = await screen.findByRole("status") expect(status).toHaveTextContent(label) } -beforeEach(() => { - jest.resetAllMocks() -}) - describe("Workspace Page", () => { it("shows a workspace", async () => { - await renderWorkspacePage() - const workspaceName = screen.getByText(MockWorkspace.name) + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const workspaceName = await screen.findByText(MockWorkspace.name) expect(workspaceName).toBeDefined() }) it("shows the status of the workspace", async () => { - await renderWorkspacePage() - const status = screen.getByRole("status") + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const status = await screen.findByRole("status") expect(status).toHaveTextContent("Running") }) it("requests a stop job when the user presses Stop", async () => { - const stopWorkspaceMock = jest.spyOn(api, "stopWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) - await testButton(Language.stop, stopWorkspaceMock) - }) - it("requests a start job when the user presses Start", async () => { - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockStoppedWorkspace)) - }), - ) - const startWorkspaceMock = jest - .spyOn(api, "startWorkspace") - .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) - await testButton(Language.start, startWorkspaceMock) - }) - it("requests a start job when the user presses Retry after trying to start", async () => { - // Use a workspace that failed during start - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - ...MockFailedWorkspace, - latest_build: { - ...MockFailedWorkspace.latest_build, - transition: "start", - }, - }), - ) - }), - ) - const startWorkSpaceMock = jest.spyOn(api, "startWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) - await testButton(Language.retry, startWorkSpaceMock) - }) - it("requests a stop job when the user presses Retry after trying to stop", async () => { - // Use a workspace that failed during stop - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - ...MockFailedWorkspace, - latest_build: { - ...MockFailedWorkspace.latest_build, - transition: "stop", - }, - }), - ) - }), - ) const stopWorkspaceMock = jest .spyOn(api, "stopWorkspace") .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) - await testButton(Language.retry, stopWorkspaceMock) - }) - it("requests a template when the user presses Update", async () => { - const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate) - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) - }), - ) - await testButton(Language.update, getTemplateMock) - }) - it("shows the Stopping status when the workspace is stopping", async () => { - await testStatus(MockStoppingWorkspace, Language.stopping) - }) + testButton(Language.start, stopWorkspaceMock) + }), + it("requests a start job when the user presses Start", async () => { + const startWorkspaceMock = jest + .spyOn(api, "startWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + testButton(Language.start, startWorkspaceMock) + }), + it("requests a start job when the user presses Retry after trying to start", async () => { + const startWorkspaceMock = jest + .spyOn(api, "startWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + testButton(Language.retry, startWorkspaceMock) + }), + it("requests a stop job when the user presses Retry after trying to stop", async () => { + const stopWorkspaceMock = jest + .spyOn(api, "stopWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockStoppedWorkspace)) + }), + ) + testButton(Language.start, stopWorkspaceMock) + }), + it("requests a template when the user presses Update", async () => { + const getTemplateMock = jest.spyOn(api, "getTemplate").mockImplementation(() => Promise.resolve(MockTemplate)) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) + }), + ) + testButton(Language.update, getTemplateMock) + }), + it("shows the Stopping status when the workspace is stopping", async () => { + testStatus(MockStoppingWorkspace, Language.stopping) + }) it("shows the Stopped status when the workspace is stopped", async () => { - await testStatus(MockStoppedWorkspace, Language.stopped) + testStatus(MockStoppedWorkspace, Language.stopped) }) it("shows the Building status when the workspace is starting", async () => { - await testStatus(MockStartingWorkspace, Language.starting) + testStatus(MockStartingWorkspace, Language.starting) }) it("shows the Running status when the workspace is started", async () => { - await testStatus(MockWorkspace, Language.started) + testStatus(MockWorkspace, Language.started) }) it("shows the Error status when the workspace is failed or canceled", async () => { - await testStatus(MockFailedWorkspace, Language.error) + testStatus(MockFailedWorkspace, Language.error) }) it("shows the Loading status when the workspace is canceling", async () => { - await testStatus(MockCancelingWorkspace, Language.canceling) + testStatus(MockCancelingWorkspace, Language.canceling) }) it("shows the Deleting status when the workspace is deleting", async () => { - await testStatus(MockDeletingWorkspace, Language.deleting) + testStatus(MockDeletingWorkspace, Language.canceling) }) it("shows the Deleted status when the workspace is deleted", async () => { - await testStatus(MockDeletedWorkspace, Language.deleted) - }) - it("shows the timeline build", async () => { - await renderWorkspacePage() - const table = await screen.findByRole("table") - const rows = table.querySelectorAll("tbody > tr") - expect(rows).toHaveLength(MockBuilds.length) + testStatus(MockDeletedWorkspace, Language.canceling) }) }) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index fd0c5fe793938..4b0a412865ab8 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -16,7 +16,7 @@ export const WorkspacePage: React.FC = () => { const xServices = useContext(XServiceContext) const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService) - const { workspace, getWorkspaceError, getTemplateError, getOrganizationError, builds } = workspaceState.context + const { workspace, getWorkspaceError, getTemplateError, getOrganizationError } = workspaceState.context const workspaceStatus = useSelector(xServices.workspaceXService, (state) => { return getWorkspaceStatus(state.context.workspace?.latest_build) }) @@ -44,7 +44,6 @@ export const WorkspacePage: React.FC = () => { handleRetry={() => workspaceSend("RETRY")} handleUpdate={() => workspaceSend("UPDATE")} workspaceStatus={workspaceStatus} - builds={builds} />
diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 8c26e1c13cf25..df6151c857380 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -15,7 +15,7 @@ describe("WorkspacesPage", () => { it("renders an empty workspaces page", async () => { // Given server.use( - rest.get("/api/v2/workspaces", async (req, res, ctx) => { + rest.get("/api/v2/users/me/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json([])) }), ) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 1952f5422a6fa..355d709c7a7b0 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -14,10 +14,11 @@ import relativeTime from "dayjs/plugin/relativeTime" import React from "react" import { Link as RouterLink } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" +import { WorkspaceBuild } from "../../api/typesGenerated" import { Margins } from "../../components/Margins/Margins" import { Stack } from "../../components/Stack/Stack" import { firstLetter } from "../../util/firstLetter" -import { getDisplayStatus } from "../../util/workspace" +import { getWorkspaceStatus } from "../../util/workspace" dayjs.extend(relativeTime) @@ -39,9 +40,7 @@ export const WorkspacesPageView: React.FC = (props) =>
- - - +
@@ -59,7 +58,7 @@ export const WorkspacesPageView: React.FC = (props) =>
- + Create a workspace  {Language.emptyView} @@ -69,7 +68,7 @@ export const WorkspacesPageView: React.FC = (props) => )} {props.workspaces?.map((workspace) => { - const status = getDisplayStatus(theme, workspace.latest_build) + const status = getStatus(theme, workspace.latest_build) return ( @@ -109,6 +108,74 @@ export const WorkspacesPageView: React.FC = (props) => ) } +const getStatus = ( + theme: Theme, + build: WorkspaceBuild, +): { + color: string + status: string +} => { + const status = getWorkspaceStatus(build) + switch (status) { + case undefined: + return { + color: theme.palette.text.secondary, + status: "Loading...", + } + case "started": + return { + color: theme.palette.success.main, + status: "⦿ Running", + } + case "starting": + return { + color: theme.palette.success.main, + status: "⦿ Starting", + } + case "stopping": + return { + color: theme.palette.text.secondary, + status: "◍ Stopping", + } + case "stopped": + return { + color: theme.palette.text.secondary, + status: "◍ Stopped", + } + case "deleting": + return { + color: theme.palette.text.secondary, + status: "⦸ Deleting", + } + case "deleted": + return { + color: theme.palette.text.secondary, + status: "⦸ Deleted", + } + case "canceling": + return { + color: theme.palette.warning.light, + status: "◍ Canceling", + } + case "canceled": + return { + color: theme.palette.text.secondary, + status: "◍ Canceled", + } + case "error": + return { + color: theme.palette.error.main, + status: "ⓧ Failed", + } + case "queued": + return { + color: theme.palette.text.secondary, + status: "◍ Queued", + } + } + throw new Error("unknown status " + status) +} + const useStyles = makeStyles((theme) => ({ actions: { marginTop: theme.spacing(3), @@ -116,7 +183,7 @@ const useStyles = makeStyles((theme) => ({ display: "flex", height: theme.spacing(6), - "& > *": { + "& button": { marginLeft: "auto", }, }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 4f42e854c2021..5954c69a8de4f 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -80,8 +80,8 @@ export const MockRunningProvisionerJob = { ...MockProvisionerJob, status: "runni export const MockTemplate: TypesGen.Template = { id: "test-template", - created_at: new Date().toString(), - updated_at: new Date().toString(), + created_at: "", + updated_at: "", organization_id: MockOrganization.id, name: "Test Template", provisioner: MockProvisioner.id, @@ -110,32 +110,29 @@ export const MockWorkspaceAutostopEnabled: TypesGen.UpdateWorkspaceAutostartRequ } export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { - build_number: 1, + after_id: "", + before_id: "", created_at: new Date().toString(), - id: "1", + id: "test-workspace-build", initiator_id: "", job: MockProvisionerJob, name: "a-workspace-build", template_version_id: "", transition: "start", - updated_at: "2022-05-17T17:39:01.382927298Z", + updated_at: "", workspace_id: "test-workspace", } export const MockWorkspaceBuildStop = { ...MockWorkspaceBuild, - id: "2", transition: "stop", } export const MockWorkspaceBuildDelete = { ...MockWorkspaceBuild, - id: "3", transition: "delete", } -export const MockBuilds = [MockWorkspaceBuild, MockWorkspaceBuildStop, MockWorkspaceBuildDelete] - export const MockWorkspace: TypesGen.Workspace = { id: "test-workspace", name: "Test-Workspace", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 529995fa68245..1f65874616dc1 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -17,9 +17,6 @@ export const handlers = [ rest.get("/api/v2/organizations/:organizationId/templates/:templateId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockTemplate)) }), - rest.get("/api/v2/organizations/:organizationId/templates", async (req, res, ctx) => { - return res(ctx.status(200), ctx.json([M.MockTemplate])) - }), // templates rest.get("/api/v2/templates/:templateId", async (req, res, ctx) => { @@ -36,7 +33,7 @@ export const handlers = [ rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), - rest.get("/api/v2/workspaces", async (req, res, ctx) => { + rest.get("/api/v2/users/me/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json([M.MockWorkspace])) }), rest.get("/api/v2/users/me/organizations", (req, res, ctx) => { @@ -80,16 +77,7 @@ export const handlers = [ // workspaces rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => { - if (req.params.workspaceName !== M.MockWorkspace.name) { - return res( - ctx.status(404), - ctx.json({ - message: "workspace not found", - }), - ) - } else { - return res(ctx.status(200), ctx.json(M.MockWorkspace)) - } + return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) @@ -110,9 +98,6 @@ export const handlers = [ const result = transitionToBuild[transition as WorkspaceBuildTransition] return res(ctx.status(200), ctx.json(result)) }), - rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => { - return res(ctx.status(200), ctx.json(M.MockBuilds)) - }), // workspace builds rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 1c36ce958309a..f4b844cdd3665 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -1,4 +1,3 @@ -import { Theme } from "@material-ui/core/styles" import { WorkspaceBuildTransition } from "../api/types" import { WorkspaceBuild } from "../api/typesGenerated" @@ -48,71 +47,3 @@ export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceSt return "error" } } - -export const getDisplayStatus = ( - theme: Theme, - build: WorkspaceBuild, -): { - color: string - status: string -} => { - const status = getWorkspaceStatus(build) - switch (status) { - case undefined: - return { - color: theme.palette.text.secondary, - status: "Loading...", - } - case "started": - return { - color: theme.palette.success.main, - status: "⦿ Running", - } - case "starting": - return { - color: theme.palette.success.main, - status: "⦿ Starting", - } - case "stopping": - return { - color: theme.palette.text.secondary, - status: "◍ Stopping", - } - case "stopped": - return { - color: theme.palette.text.secondary, - status: "◍ Stopped", - } - case "deleting": - return { - color: theme.palette.text.secondary, - status: "⦸ Deleting", - } - case "deleted": - return { - color: theme.palette.text.secondary, - status: "⦸ Deleted", - } - case "canceling": - return { - color: theme.palette.warning.light, - status: "◍ Canceling", - } - case "canceled": - return { - color: theme.palette.text.secondary, - status: "◍ Canceled", - } - case "error": - return { - color: theme.palette.error.main, - status: "ⓧ Failed", - } - case "queued": - return { - color: theme.palette.text.secondary, - status: "◍ Queued", - } - } - throw new Error("unknown status " + status) -} diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 6ca72d406ba58..d9e88a3c72f37 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -11,7 +11,6 @@ export const Language = { export const checks = { readAllUsers: "readAllUsers", - createTemplates: "createTemplates", } as const export const permissionsToCheck = { @@ -21,12 +20,6 @@ export const permissionsToCheck = { }, action: "read", }, - [checks.createTemplates]: { - object: { - resource_type: "template", - }, - action: "write", - }, } as const type Permissions = Record diff --git a/site/src/xServices/templates/templatesXService.ts b/site/src/xServices/templates/templatesXService.ts deleted file mode 100644 index 68e7a847e9fb7..0000000000000 --- a/site/src/xServices/templates/templatesXService.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { assign, createMachine } from "xstate" -import * as API from "../../api/api" -import * as TypesGen from "../../api/typesGenerated" - -interface TemplatesContext { - organizations?: TypesGen.Organization[] - templates?: TypesGen.Template[] - canCreateTemplate?: boolean - permissionsError?: Error | unknown - organizationsError?: Error | unknown - templatesError?: Error | unknown -} - -export const templatesMachine = createMachine( - { - tsTypes: {} as import("./templatesXService.typegen").Typegen0, - schema: { - context: {} as TemplatesContext, - services: {} as { - getOrganizations: { - data: TypesGen.Organization[] - } - getPermissions: { - data: boolean - } - getTemplates: { - data: TypesGen.Template[] - } - }, - }, - id: "templatesState", - initial: "gettingOrganizations", - states: { - gettingOrganizations: { - entry: "clearOrganizationsError", - invoke: { - src: "getOrganizations", - id: "getOrganizations", - onDone: [ - { - actions: ["assignOrganizations", "clearOrganizationsError"], - target: "gettingTemplates", - }, - ], - onError: [ - { - actions: "assignOrganizationsError", - target: "error", - }, - ], - }, - tags: "loading", - }, - gettingTemplates: { - entry: "clearTemplatesError", - invoke: { - src: "getTemplates", - id: "getTemplates", - onDone: { - target: "done", - actions: ["assignTemplates", "clearTemplatesError"], - }, - onError: { - target: "error", - actions: "assignTemplatesError", - }, - }, - tags: "loading", - }, - done: {}, - error: {}, - }, - }, - { - actions: { - assignOrganizations: assign({ - organizations: (_, event) => event.data, - }), - assignOrganizationsError: assign({ - organizationsError: (_, event) => event.data, - }), - clearOrganizationsError: assign((context) => ({ - ...context, - organizationsError: undefined, - })), - assignTemplates: assign({ - templates: (_, event) => event.data, - }), - assignTemplatesError: assign({ - templatesError: (_, event) => event.data, - }), - clearTemplatesError: (context) => assign({ ...context, getWorkspacesError: undefined }), - }, - services: { - getOrganizations: API.getOrganizations, - getTemplates: async (context) => { - if (!context.organizations || context.organizations.length === 0) { - throw new Error("no organizations") - } - return API.getTemplates(context.organizations[0].id) - }, - }, - }, -) diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts index 056a7ddf7cafe..3a17618f6d486 100644 --- a/site/src/xServices/terminal/terminalXService.ts +++ b/site/src/xServices/terminal/terminalXService.ts @@ -14,16 +14,13 @@ export interface TerminalContext { websocketError?: Error | unknown // Assigned by connecting! - // The workspace agent is entirely optional. If the agent is omitted the - // first agent will be used. - agentName?: string username?: string workspaceName?: string reconnection?: string } export type TerminalEvent = - | { type: "CONNECT"; agentName?: string; reconnection?: string; workspaceName?: string; username?: string } + | { type: "CONNECT"; reconnection?: string; workspaceName?: string; username?: string } | { type: "WRITE"; request: Types.ReconnectingPTYRequest } | { type: "READ"; data: ArrayBuffer } | { type: "DISCONNECT" } @@ -156,7 +153,7 @@ export const terminalMachine = getOrganizations: API.getOrganizations, getWorkspace: async (context) => { if (!context.organizations || !context.workspaceName) { - throw new Error("organizations or workspace name not set") + throw new Error("organizations or workspace not set") } return API.getWorkspaceByOwnerAndName(context.organizations[0].id, context.username, context.workspaceName) }, @@ -164,6 +161,11 @@ export const terminalMachine = if (!context.workspace || !context.workspaceName) { throw new Error("workspace or workspace name is not set") } + // The workspace name is in the format: + // [.] + // The workspace agent is entirely optional. + const workspaceNameParts = context.workspaceName.split(".") + const agentName = workspaceNameParts[1] const resources = await API.getWorkspaceResources(context.workspace.latest_build.id) @@ -172,10 +174,10 @@ export const terminalMachine = if (!resource.agents || resource.agents.length < 1) { return } - if (!context.agentName) { + if (!agentName) { return resource.agents[0] } - return resource.agents.find((agent) => agent.name === context.agentName) + return resource.agents.find((agent) => agent.name === agentName) }) .filter((a) => a)[0] if (!agent) { @@ -216,7 +218,6 @@ export const terminalMachine = actions: { assignConnection: assign((context, event) => ({ ...context, - agentName: event.agentName ?? context.agentName, reconnection: event.reconnection ?? context.reconnection, workspaceName: event.workspaceName ?? context.workspaceName, })), diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 782d4f847a459..a64633595466c 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -1,16 +1,8 @@ -import { assign, createMachine, send } from "xstate" -import { pure } from "xstate/lib/actions" +import { assign, createMachine } from "xstate" import * as API from "../../api/api" import * as TypesGen from "../../api/typesGenerated" import { displayError } from "../../components/GlobalSnackbar/utils" -const latestBuild = (builds: TypesGen.WorkspaceBuild[]) => { - // Cloning builds to not change the origin object with the sort() - return [...builds].sort((a, b) => { - return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() - })[0] -} - const Language = { refreshTemplateError: "Error updating workspace: latest template could not be fetched.", buildError: "Workspace action failed.", @@ -29,10 +21,6 @@ export interface WorkspaceContext { // these are separate from getX errors because they don't make the page unusable refreshWorkspaceError: Error | unknown refreshTemplateError: Error | unknown - // Builds - builds?: TypesGen.WorkspaceBuild[] - getBuildsError?: Error | unknown - loadMoreBuildsError?: Error | unknown } export type WorkspaceEvent = @@ -41,8 +29,6 @@ export type WorkspaceEvent = | { type: "STOP" } | { type: "RETRY" } | { type: "UPDATE" } - | { type: "LOAD_MORE_BUILDS" } - | { type: "REFRESH_TIMELINE" } export const workspaceMachine = createMachine( { @@ -69,12 +55,6 @@ export const workspaceMachine = createMachine( refreshWorkspace: { data: TypesGen.Workspace | undefined } - getBuilds: { - data: TypesGen.WorkspaceBuild[] - } - loadMoreBuilds: { - data: TypesGen.WorkspaceBuild[] - } }, }, id: "workspaceState", @@ -114,7 +94,7 @@ export const workspaceMachine = createMachine( invoke: { id: "refreshWorkspace", src: "refreshWorkspace", - onDone: { target: "waiting", actions: ["refreshTimeline", "assignWorkspace"] }, + onDone: { target: "waiting", actions: "assignWorkspace" }, onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, }, }, @@ -180,7 +160,7 @@ export const workspaceMachine = createMachine( src: "startWorkspace", onDone: { target: "idle", - actions: ["assignBuild", "refreshTimeline"], + actions: "assignBuild", }, onError: { target: "idle", @@ -195,7 +175,7 @@ export const workspaceMachine = createMachine( src: "stopWorkspace", onDone: { target: "idle", - actions: ["assignBuild", "refreshTimeline"], + actions: "assignBuild", }, onError: { target: "idle", @@ -220,55 +200,6 @@ export const workspaceMachine = createMachine( }, }, }, - - timeline: { - initial: "gettingBuilds", - states: { - idle: {}, - gettingBuilds: { - entry: "clearGetBuildsError", - invoke: { - src: "getBuilds", - onDone: { - actions: ["assignBuilds"], - target: "loadedBuilds", - }, - onError: { - actions: ["assignGetBuildsError"], - target: "idle", - }, - }, - }, - loadedBuilds: { - initial: "idle", - states: { - idle: { - on: { - LOAD_MORE_BUILDS: { - target: "loadingMoreBuilds", - cond: "hasMoreBuilds", - }, - REFRESH_TIMELINE: "#workspaceState.ready.timeline.gettingBuilds", - }, - }, - loadingMoreBuilds: { - entry: "clearLoadMoreBuildsError", - invoke: { - src: "loadMoreBuilds", - onDone: { - actions: ["assignNewBuilds"], - target: "idle", - }, - onError: { - actions: ["assignLoadMoreBuildsError"], - target: "idle", - }, - }, - }, - }, - }, - }, - }, }, }, error: { @@ -343,54 +274,9 @@ export const workspaceMachine = createMachine( assign({ refreshTemplateError: undefined, }), - // Timeline - assignBuilds: assign({ - builds: (_, event) => event.data, - }), - assignGetBuildsError: assign({ - getBuildsError: (_, event) => event.data, - }), - clearGetBuildsError: assign({ - getBuildsError: (_) => undefined, - }), - assignNewBuilds: assign({ - builds: (context, event) => { - const oldBuilds = context.builds - - if (!oldBuilds) { - throw new Error("Builds not loaded") - } - - return [...oldBuilds, ...event.data] - }, - }), - assignLoadMoreBuildsError: assign({ - loadMoreBuildsError: (_, event) => event.data, - }), - clearLoadMoreBuildsError: assign({ - loadMoreBuildsError: (_) => undefined, - }), - refreshTimeline: pure((context, event) => { - // No need to refresh the timeline if it is not loaded - if (!context.builds) { - return - } - // When it is a refresh workspace event, we want to check if the latest - // build was updated to not over fetch the builds - if (event.type === "done.invoke.refreshWorkspace") { - const latestBuildInTimeline = latestBuild(context.builds) - const isUpdated = event.data?.latest_build.updated_at !== latestBuildInTimeline.updated_at - if (isUpdated) { - return send({ type: "REFRESH_TIMELINE" }) - } - } else { - return send({ type: "REFRESH_TIMELINE" }) - } - }), }, guards: { triedToStart: (context) => context.workspace?.latest_build.transition === "start", - hasMoreBuilds: (_) => false, }, services: { getWorkspace: async (_, event) => { @@ -431,20 +317,6 @@ export const workspaceMachine = createMachine( throw Error("Cannot refresh workspace without id") } }, - getBuilds: async (context) => { - if (context.workspace) { - return await API.getWorkspaceBuilds(context.workspace.id) - } else { - throw Error("Cannot refresh workspace without id") - } - }, - loadMoreBuilds: async (context) => { - if (context.workspace) { - return await API.getWorkspaceBuilds(context.workspace.id) - } else { - throw Error("Cannot refresh workspace without id") - } - }, }, }, ) diff --git a/site/yarn.lock b/site/yarn.lock index cd898202a62f6..6babd4922c508 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -2658,10 +2658,10 @@ "@testing-library/dom" "^8.0.0" "@types/react-dom" "<18.0.0" -"@testing-library/user-event@14.2.0": - version "14.2.0" - resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.2.0.tgz#8293560f8f80a00383d6c755ec3e0b918acb1683" - integrity sha512-+hIlG4nJS6ivZrKnOP7OGsDu9Fxmryj9vCl8x0ZINtTJcCHs2zLsYif5GzuRiBF2ck5GZG2aQr7Msg+EHlnYVQ== +"@testing-library/user-event@14.1.1": + version "14.1.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.1.1.tgz#e1ff6118896e4b22af31e5ea2f9da956adde23d8" + integrity sha512-XrjH/iEUqNl9lF2HX9YhPNV7Amntkcnpw0Bo1KkRzowNDcgSN9i0nm4Q8Oi5wupgdfPaJNMAWa61A+voD6Kmwg== "@tootallnate/once@1": version "1.1.2" @@ -5323,10 +5323,10 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cronstrue@2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.5.0.tgz#1d69bd53520ce536789fb666d9fd562065b491c6" - integrity sha512-2uhcYEmXEH52Prn1biZ1HSaQwGwUy4fxFiq3U3vKwLYngL14j8f4pZeQt9f1J6tWDaLFeLRgcCHtO45r78ECyw== +cronstrue@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.4.0.tgz#16c6d10a17b90c37a71c7e8fb3bb67d0243d70e5" + integrity sha512-KDJgE8XoT0Nupt1iljNGAQnxkfITwIYkL7mHrzH4a0AWyrj7Xk6GVCNPN3Avs7tU2yYoNuDculMKp9T3jysbPA== cross-spawn@^6.0.0: version "6.0.5" From 13cdff5961767fb85acd4824c77dd118bbbe5a99 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 May 2022 20:43:17 +0000 Subject: [PATCH 21/21] restore docker warning --- examples/docker-image-builds/main.tf | 2 ++ examples/docker/main.tf | 2 ++ 2 files changed, 4 insertions(+) diff --git a/examples/docker-image-builds/main.tf b/examples/docker-image-builds/main.tf index 766b1b92c64dc..122bd20c3abcc 100644 --- a/examples/docker-image-builds/main.tf +++ b/examples/docker-image-builds/main.tf @@ -42,6 +42,8 @@ provider "docker" { } provider "coder" { + # The below assumes your Coder deployment is running in docker-compose. + # If this is not the case, either comment or edit the below. url = "http://host.docker.internal:7080" } diff --git a/examples/docker/main.tf b/examples/docker/main.tf index 218b48890583b..44d56e38d1151 100644 --- a/examples/docker/main.tf +++ b/examples/docker/main.tf @@ -49,6 +49,8 @@ provider "docker" { } provider "coder" { + # The below assumes your Coder deployment is running in docker-compose. + # If this is not the case, either comment or edit the below. url = "http://host.docker.internal:7080" }