diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 907287634c2c4..d88d947d962c5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,18 +1,57 @@ { "name": "Development environments on your infrastructure", "image": "codercom/oss-dogfood:latest", - "features": { // See all possible options here https://github.com/devcontainers/features/tree/main/src/docker-in-docker "ghcr.io/devcontainers/features/docker-in-docker:2": { "moby": "false" - } + }, + "ghcr.io/coder/devcontainer-features/code-server:1": { + "auth": "none", + "port": 13337 + }, + "./filebrowser": {} }, // SYS_PTRACE to enable go debugging "runArgs": ["--cap-add=SYS_PTRACE"], "customizations": { "vscode": { "extensions": ["biomejs.biome"] + }, + "coder": { + "apps": [ + { + "slug": "cursor", + "displayName": "Cursor Desktop", + "url": "cursor://coder.coder-remote/openDevContainer?owner=${localEnv:CODER_WORKSPACE_OWNER_NAME}&workspace=${localEnv:CODER_WORKSPACE_NAME}&agent=${localEnv:CODER_WORKSPACE_PARENT_AGENT_NAME}&url=${localEnv:CODER_URL}&token=$SESSION_TOKEN&devContainerName=${localEnv:CONTAINER_ID}&devContainerFolder=${containerWorkspaceFolder}", + "external": true, + "icon": "/icon/cursor.svg", + "order": 1 + }, + { + "slug": "windsurf", + "displayName": "Windsurf Editor", + "url": "windsurf://coder.coder-remote/openDevContainer?owner=${localEnv:CODER_WORKSPACE_OWNER_NAME}&workspace=${localEnv:CODER_WORKSPACE_NAME}&agent=${localEnv:CODER_WORKSPACE_PARENT_AGENT_NAME}&url=${localEnv:CODER_URL}&token=$SESSION_TOKEN&devContainerName=${localEnv:CONTAINER_ID}&devContainerFolder=${containerWorkspaceFolder}", + "external": true, + "icon": "/icon/windsurf.svg", + "order": 4 + }, + { + "slug": "zed", + "displayName": "Zed Editor", + "url": "zed://ssh/${localEnv:CODER_WORKSPACE_AGENT_NAME}.${localEnv:CODER_WORKSPACE_NAME}.${localEnv:CODER_WORKSPACE_OWNER_NAME}.coder${containerWorkspaceFolder}", + "external": true, + "icon": "/icon/zed.svg", + "order": 5 + } + ] } - } + }, + "mounts": [ + // Mount the entire home because conditional mounts are not supported. + // See: https://github.com/devcontainers/spec/issues/132 + "source=${localEnv:HOME},target=/mnt/home/coder,type=bind,readonly" + ], + "postCreateCommand": "./.devcontainer/postCreateCommand.sh", + "postStartCommand": "sudo service docker start" } diff --git a/.devcontainer/filebrowser/devcontainer-feature.json b/.devcontainer/filebrowser/devcontainer-feature.json new file mode 100644 index 0000000000000..3829139cf3143 --- /dev/null +++ b/.devcontainer/filebrowser/devcontainer-feature.json @@ -0,0 +1,50 @@ +{ + "id": "filebrowser", + "version": "0.0.1", + "name": "File Browser", + "description": "A web-based file browser for your development container", + "options": { + "port": { + "type": "string", + "default": "13339", + "description": "The port to run filebrowser on" + }, + // "folder": { + // "type": "string", + // "default": "${containerWorkspaceFolder}", + // "description": "The root directory for filebrowser to serve" + // }, + "auth": { + "type": "string", + "enum": [ + "none", + "password" + ], + "default": "none", + "description": "Authentication method (none or password)" + } + }, + "entrypoint": "/usr/local/bin/filebrowser-entrypoint", + "dependsOn": { + "ghcr.io/devcontainers/features/common-utils:2": {} + }, + "customizations": { + "coder": { + "apps": [ + { + "slug": "filebrowser", + "displayName": "File Browser", + "url": "http://localhost:${localEnv:FEATURE_FILEBROWSER_OPTION_PORT:13339}", + "icon": "/icon/filebrowser.svg", + "order": 3, + "subdomain": true, + "healthcheck": { + "url": "http://localhost:${localEnv:FEATURE_FILEBROWSER_OPTION_PORT:13339}/health", + "interval": 5, + "threshold": 6 + } + } + ] + } + } +} diff --git a/.devcontainer/filebrowser/install.sh b/.devcontainer/filebrowser/install.sh new file mode 100644 index 0000000000000..1f8390f63864c --- /dev/null +++ b/.devcontainer/filebrowser/install.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[0;1m' + +printf "%sInstalling filebrowser\n\n" "${BOLD}" + +# Check if filebrowser is installed. +if ! command -v filebrowser &>/dev/null; then + curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash +fi + +printf "πŸ₯³ Installation complete!\n\n" + +# Create run script. +cat >/usr/local/bin/filebrowser-entrypoint <>\${LOG_PATH} 2>&1 & + +printf "πŸ“ Logs at \${LOG_PATH}\n\n" +EOF + +chmod +x /usr/local/bin/filebrowser-entrypoint + +printf "βœ… File Browser installed!\n\n" +printf "πŸš€ Run 'filebrowser-entrypoint' to start the service\n\n" diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh new file mode 100755 index 0000000000000..8799908311431 --- /dev/null +++ b/.devcontainer/postCreateCommand.sh @@ -0,0 +1,59 @@ +#!/bin/sh + +install_devcontainer_cli() { + npm install -g @devcontainers/cli +} + +install_ssh_config() { + echo "πŸ”‘ Installing SSH configuration..." + rsync -a /mnt/home/coder/.ssh/ ~/.ssh/ + chmod 0700 ~/.ssh +} + +install_git_config() { + echo "πŸ“‚ Installing Git configuration..." + if [ -f /mnt/home/coder/git/config ]; then + rsync -a /mnt/home/coder/git/ ~/.config/git/ + elif [ -d /mnt/home/coder/.gitconfig ]; then + rsync -a /mnt/home/coder/.gitconfig ~/.gitconfig + else + echo "⚠️ Git configuration directory not found." + fi +} + +install_dotfiles() { + if [ ! -d /mnt/home/coder/.config/coderv2/dotfiles ]; then + echo "⚠️ Dotfiles directory not found." + return + fi + + cd /mnt/home/coder/.config/coderv2/dotfiles || return + for script in install.sh install bootstrap.sh bootstrap script/bootstrap setup.sh setup script/setup; do + if [ -x $script ]; then + echo "πŸ“¦ Installing dotfiles..." + ./$script || { + echo "❌ Error running $script. Please check the script for issues." + return + } + echo "βœ… Dotfiles installed successfully." + return + fi + done + echo "⚠️ No install script found in dotfiles directory." +} + +personalize() { + # Allow script to continue as Coder dogfood utilizes a hack to + # synchronize startup script execution. + touch /tmp/.coder-startup-script.done + + if [ -x /mnt/home/coder/personalize ]; then + echo "🎨 Personalizing environment..." + /mnt/home/coder/personalize + fi +} + +install_devcontainer_cli +install_ssh_config +install_dotfiles +personalize diff --git a/.github/.linkspector.yml b/.github/.linkspector.yml index 6cbd17c3c0816..1bbf60c200175 100644 --- a/.github/.linkspector.yml +++ b/.github/.linkspector.yml @@ -24,5 +24,6 @@ ignorePatterns: - pattern: "mutagen.io" - pattern: "docs.github.com" - pattern: "claude.ai" + - pattern: "splunk.com" aliveStatusCodes: - 200 diff --git a/.github/actions/embedded-pg-cache/download/action.yml b/.github/actions/embedded-pg-cache/download/action.yml new file mode 100644 index 0000000000000..c2c3c0c0b299c --- /dev/null +++ b/.github/actions/embedded-pg-cache/download/action.yml @@ -0,0 +1,47 @@ +name: "Download Embedded Postgres Cache" +description: | + Downloads the embedded postgres cache and outputs today's cache key. + A PR job can use a cache if it was created by its base branch, its current + branch, or the default branch. + https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache +outputs: + cache-key: + description: "Today's cache key" + value: ${{ steps.vars.outputs.cache-key }} +inputs: + key-prefix: + description: "Prefix for the cache key" + required: true + cache-path: + description: "Path to the cache directory" + required: true +runs: + using: "composite" + steps: + - name: Get date values and cache key + id: vars + shell: bash + run: | + export YEAR_MONTH=$(date +'%Y-%m') + export PREV_YEAR_MONTH=$(date -d 'last month' +'%Y-%m') + export DAY=$(date +'%d') + echo "year-month=$YEAR_MONTH" >> $GITHUB_OUTPUT + echo "prev-year-month=$PREV_YEAR_MONTH" >> $GITHUB_OUTPUT + echo "cache-key=${{ inputs.key-prefix }}-${YEAR_MONTH}-${DAY}" >> $GITHUB_OUTPUT + + # By default, depot keeps caches for 14 days. This is plenty for embedded + # postgres, which changes infrequently. + # https://depot.dev/docs/github-actions/overview#cache-retention-policy + - name: Download embedded Postgres cache + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ${{ inputs.cache-path }} + key: ${{ steps.vars.outputs.cache-key }} + # > If there are multiple partial matches for a restore key, the action returns the most recently created cache. + # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#matching-a-cache-key + # The second restore key allows non-main branches to use the cache from the previous month. + # This prevents PRs from rebuilding the cache on the first day of the month. + # It also makes sure that once a month, the cache is fully reset. + restore-keys: | + ${{ inputs.key-prefix }}-${{ steps.vars.outputs.year-month }}- + ${{ github.ref != 'refs/heads/main' && format('{0}-{1}-', inputs.key-prefix, steps.vars.outputs.prev-year-month) || '' }} diff --git a/.github/actions/embedded-pg-cache/upload/action.yml b/.github/actions/embedded-pg-cache/upload/action.yml new file mode 100644 index 0000000000000..19b37bb65665b --- /dev/null +++ b/.github/actions/embedded-pg-cache/upload/action.yml @@ -0,0 +1,18 @@ +name: "Upload Embedded Postgres Cache" +description: Uploads the embedded Postgres cache. This only runs on the main branch. +inputs: + cache-key: + description: "Cache key" + required: true + cache-path: + description: "Path to the cache directory" + required: true +runs: + using: "composite" + steps: + - name: Upload Embedded Postgres cache + if: ${{ github.ref == 'refs/heads/main' }} + uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ${{ inputs.cache-path }} + key: ${{ inputs.cache-key }} diff --git a/.github/actions/setup-embedded-pg-cache-paths/action.yml b/.github/actions/setup-embedded-pg-cache-paths/action.yml new file mode 100644 index 0000000000000..019ff4e6dc746 --- /dev/null +++ b/.github/actions/setup-embedded-pg-cache-paths/action.yml @@ -0,0 +1,33 @@ +name: "Setup Embedded Postgres Cache Paths" +description: Sets up a path for cached embedded postgres binaries. +outputs: + embedded-pg-cache: + description: "Value of EMBEDDED_PG_CACHE_DIR" + value: ${{ steps.paths.outputs.embedded-pg-cache }} + cached-dirs: + description: "directories that should be cached between CI runs" + value: ${{ steps.paths.outputs.cached-dirs }} +runs: + using: "composite" + steps: + - name: Override Go paths + id: paths + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const path = require('path'); + + // RUNNER_TEMP should be backed by a RAM disk on Windows if + // coder/setup-ramdisk-action was used + const runnerTemp = process.env.RUNNER_TEMP; + const embeddedPgCacheDir = path.join(runnerTemp, 'embedded-pg-cache'); + core.exportVariable('EMBEDDED_PG_CACHE_DIR', embeddedPgCacheDir); + core.setOutput('embedded-pg-cache', embeddedPgCacheDir); + const cachedDirs = `${embeddedPgCacheDir}`; + core.setOutput('cached-dirs', cachedDirs); + + - name: Create directories + shell: bash + run: | + set -e + mkdir -p "$EMBEDDED_PG_CACHE_DIR" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0537cc16b7f0c..0855cba8126f7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -473,6 +473,17 @@ jobs: with: key-prefix: test-go-pg-${{ runner.os }}-${{ runner.arch }} + - name: Setup Embedded Postgres Cache Paths + id: embedded-pg-cache + uses: ./.github/actions/setup-embedded-pg-cache-paths + + - name: Download Embedded Postgres Cache + id: download-embedded-pg-cache + uses: ./.github/actions/embedded-pg-cache/download + with: + key-prefix: embedded-pg-${{ runner.os }}-${{ runner.arch }} + cache-path: ${{ steps.embedded-pg-cache.outputs.cached-dirs }} + - name: Normalize File and Directory Timestamps shell: bash # Normalize file modification timestamps so that go test can use the @@ -497,12 +508,12 @@ jobs: # Create a temp dir on the R: ramdisk drive for Windows. The default # C: drive is extremely slow: https://github.com/actions/runner-images/issues/8755 mkdir -p "R:/temp/embedded-pg" - go run scripts/embedded-pg/main.go -path "R:/temp/embedded-pg" + go run scripts/embedded-pg/main.go -path "R:/temp/embedded-pg" -cache "${EMBEDDED_PG_CACHE_DIR}" elif [ "${{ runner.os }}" == "macOS" ]; then # Postgres runs faster on a ramdisk on macOS too mkdir -p /tmp/tmpfs sudo mount_tmpfs -o noowners -s 8g /tmp/tmpfs - go run scripts/embedded-pg/main.go -path /tmp/tmpfs/embedded-pg + go run scripts/embedded-pg/main.go -path /tmp/tmpfs/embedded-pg -cache "${EMBEDDED_PG_CACHE_DIR}" elif [ "${{ runner.os }}" == "Linux" ]; then make test-postgres-docker fi @@ -571,6 +582,14 @@ jobs: with: cache-key: ${{ steps.download-cache.outputs.cache-key }} + - name: Upload Embedded Postgres Cache + uses: ./.github/actions/embedded-pg-cache/upload + # We only use the embedded Postgres cache on macOS and Windows runners. + if: runner.OS == 'macOS' || runner.OS == 'Windows' + with: + cache-key: ${{ steps.download-embedded-pg-cache.outputs.cache-key }} + cache-path: "${{ steps.embedded-pg-cache.outputs.embedded-pg-cache }}" + - name: Upload test stats to Datadog timeout-minutes: 1 continue-on-error: true @@ -902,7 +921,7 @@ jobs: # the check to pass. This is desired in PRs, but not in mainline. - name: Publish to Chromatic (non-mainline) if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder' - uses: chromaui/action@c50adf8eaa8c2878af3263499a73077854de39d4 # v12.2.0 + uses: chromaui/action@b5848056bb67ce5f1cccca8e62a37cbd9dd42871 # v13.0.1 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true @@ -934,7 +953,7 @@ jobs: # infinitely "in progress" in mainline unless we re-review each build. - name: Publish to Chromatic (mainline) if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder' - uses: chromaui/action@c50adf8eaa8c2878af3263499a73077854de39d4 # v12.2.0 + uses: chromaui/action@b5848056bb67ce5f1cccca8e62a37cbd9dd42871 # v13.0.1 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true diff --git a/.github/workflows/docker-base.yaml b/.github/workflows/docker-base.yaml index 01e24c62d9bf7..f74e71ba2909b 100644 --- a/.github/workflows/docker-base.yaml +++ b/.github/workflows/docker-base.yaml @@ -60,7 +60,7 @@ jobs: # This uses OIDC authentication, so no auth variables are required. - name: Build base Docker image via depot.dev - uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0 + uses: depot/build-push-action@2583627a84956d07561420dcc1d0eb1f2af3fac0 # v1.15.0 with: project: wl5hnrrkns context: base-build-context diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml index 3994f8e69d24c..30ac97706d9f8 100644 --- a/.github/workflows/docs-ci.yaml +++ b/.github/workflows/docs-ci.yaml @@ -28,7 +28,7 @@ jobs: - name: Setup Node uses: ./.github/actions/setup-node - - uses: tj-actions/changed-files@d52d20fa3f981cb852b861fd8f55308b5fe29637 # v45.0.7 + - uses: tj-actions/changed-files@666c9d29007687c52e3c7aa2aac6c0ffcadeadc3 # v45.0.7 id: changed-files with: files: | diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index df73479994516..952e31b7c98ec 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -35,7 +35,11 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@5bb6a3b3abe66fd09bbf250dce8ada94f856a703 # v30 + uses: nixbuild/nix-quick-install-action@63ca48f939ee3b8d835f4126562537df0fee5b91 # v32 + with: + # Pinning to 2.28 here, as Nix gets a "error: [json.exception.type_error.302] type must be array, but is string" + # on version 2.29 and above. + nix_version: "2.28.4" - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 with: @@ -72,7 +76,7 @@ jobs: uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Login to DockerHub if: github.ref == 'refs/heads/main' @@ -82,7 +86,7 @@ jobs: password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build and push Non-Nix image - uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0 + uses: depot/build-push-action@2583627a84956d07561420dcc1d0eb1f2af3fac0 # v1.15.0 with: project: b4q6ltmpzh token: ${{ secrets.DEPOT_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 032961dd2e5c5..69043dc833362 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -364,7 +364,7 @@ jobs: # This uses OIDC authentication, so no auth variables are required. - name: Build base Docker image via depot.dev if: steps.image-base-tag.outputs.tag != '' - uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0 + uses: depot/build-push-action@2583627a84956d07561420dcc1d0eb1f2af3fac0 # v1.15.0 with: project: wl5hnrrkns context: base-build-context diff --git a/CLAUDE.md b/CLAUDE.md index 8f0f5a8a99a4e..5462f9c3018e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,5 +103,4 @@ Read [cursor rules](.cursorrules). The frontend is contained in the site folder. -For building Frontend refer to [this document](docs/contributing/frontend.md) For building Frontend refer to [this document](docs/about/contributing/frontend.md) diff --git a/agent/agent.go b/agent/agent.go index e142f8662f641..3c02b5f2790f0 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -89,9 +89,9 @@ type Options struct { ServiceBannerRefreshInterval time.Duration BlockFileTransfer bool Execer agentexec.Execer - - ExperimentalDevcontainersEnabled bool - ContainerAPIOptions []agentcontainers.Option // Enable ExperimentalDevcontainersEnabled for these to be effective. + Devcontainers bool + DevcontainerAPIOptions []agentcontainers.Option // Enable Devcontainers for these to be effective. + Clock quartz.Clock } type Client interface { @@ -145,6 +145,9 @@ func New(options Options) Agent { if options.PortCacheDuration == 0 { options.PortCacheDuration = 1 * time.Second } + if options.Clock == nil { + options.Clock = quartz.NewReal() + } prometheusRegistry := options.PrometheusRegistry if prometheusRegistry == nil { @@ -158,6 +161,7 @@ func New(options Options) Agent { hardCtx, hardCancel := context.WithCancel(context.Background()) gracefulCtx, gracefulCancel := context.WithCancel(hardCtx) a := &agent{ + clock: options.Clock, tailnetListenPort: options.TailnetListenPort, reconnectingPTYTimeout: options.ReconnectingPTYTimeout, logger: options.Logger, @@ -190,8 +194,8 @@ func New(options Options) Agent { metrics: newAgentMetrics(prometheusRegistry), execer: options.Execer, - experimentalDevcontainersEnabled: options.ExperimentalDevcontainersEnabled, - containerAPIOptions: options.ContainerAPIOptions, + devcontainers: options.Devcontainers, + containerAPIOptions: options.DevcontainerAPIOptions, } // Initially, we have a closed channel, reflecting the fact that we are not initially connected. // Each time we connect we replace the channel (while holding the closeMutex) with a new one @@ -205,6 +209,7 @@ func New(options Options) Agent { } type agent struct { + clock quartz.Clock logger slog.Logger client Client exchangeToken func(ctx context.Context) (string, error) @@ -272,9 +277,9 @@ type agent struct { metrics *agentMetrics execer agentexec.Execer - experimentalDevcontainersEnabled bool - containerAPIOptions []agentcontainers.Option - containerAPI atomic.Pointer[agentcontainers.API] // Set by apiHandler. + devcontainers bool + containerAPIOptions []agentcontainers.Option + containerAPI *agentcontainers.API } func (a *agent) TailnetConn() *tailnet.Conn { @@ -311,7 +316,7 @@ func (a *agent) init() { return a.reportConnection(id, connectionType, ip) }, - ExperimentalDevContainersEnabled: a.experimentalDevcontainersEnabled, + ExperimentalContainers: a.devcontainers, }) if err != nil { panic(err) @@ -331,6 +336,19 @@ func (a *agent) init() { // will not report anywhere. a.scriptRunner.RegisterMetrics(a.prometheusRegistry) + if a.devcontainers { + containerAPIOpts := []agentcontainers.Option{ + agentcontainers.WithExecer(a.execer), + agentcontainers.WithCommandEnv(a.sshServer.CommandEnv), + agentcontainers.WithScriptLogger(func(logSourceID uuid.UUID) agentcontainers.ScriptLogger { + return a.logSender.GetScriptLogger(logSourceID) + }), + } + containerAPIOpts = append(containerAPIOpts, a.containerAPIOptions...) + + a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...) + } + a.reconnectingPTYServer = reconnectingpty.NewServer( a.logger.Named("reconnecting-pty"), a.sshServer, @@ -340,7 +358,7 @@ func (a *agent) init() { a.metrics.connectionsTotal, a.metrics.reconnectingPTYErrors, a.reconnectingPTYTimeout, func(s *reconnectingpty.Server) { - s.ExperimentalDevcontainersEnabled = a.experimentalDevcontainersEnabled + s.ExperimentalContainers = a.devcontainers }, ) go a.runLoop() @@ -547,7 +565,6 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient26 // channel to synchronize the results and avoid both messy // mutex logic and overloading the API. for _, md := range manifest.Metadata { - md := md // We send the result to the channel in the goroutine to avoid // sending the same result multiple times. So, we don't care about // the return values. @@ -1087,9 +1104,9 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, slog.F("parent_id", manifest.ParentID), slog.F("agent_id", manifest.AgentID), ) - if a.experimentalDevcontainersEnabled { + if a.devcontainers { a.logger.Info(ctx, "devcontainers are not supported on sub agents, disabling feature") - a.experimentalDevcontainersEnabled = false + a.devcontainers = false } } a.client.RewriteDERPMap(manifest.DERPMap) @@ -1142,17 +1159,27 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, } var ( - scripts = manifest.Scripts - scriptRunnerOpts []agentscripts.InitOption + scripts = manifest.Scripts + devcontainerScripts map[uuid.UUID]codersdk.WorkspaceAgentScript ) - if a.experimentalDevcontainersEnabled { - var dcScripts []codersdk.WorkspaceAgentScript - scripts, dcScripts = agentcontainers.ExtractAndInitializeDevcontainerScripts(manifest.Devcontainers, scripts) - // See ExtractAndInitializeDevcontainerScripts for motivation - // behind running dcScripts as post start scripts. - scriptRunnerOpts = append(scriptRunnerOpts, agentscripts.WithPostStartScripts(dcScripts...)) + if a.containerAPI != nil { + // Init the container API with the manifest and client so that + // we can start accepting requests. The final start of the API + // happens after the startup scripts have been executed to + // ensure the presence of required tools. This means we can + // return existing devcontainers but actual container detection + // and creation will be deferred. + a.containerAPI.Init( + agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName), + agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts), + agentcontainers.WithSubAgentClient(agentcontainers.NewSubAgentClientFromAPI(a.logger, aAPI)), + ) + + // Since devcontainer are enabled, remove devcontainer scripts + // from the main scripts list to avoid showing an error. + scripts, devcontainerScripts = agentcontainers.ExtractDevcontainerScripts(manifest.Devcontainers, scripts) } - err = a.scriptRunner.Init(scripts, aAPI.ScriptCompleted, scriptRunnerOpts...) + err = a.scriptRunner.Init(scripts, aAPI.ScriptCompleted) if err != nil { return xerrors.Errorf("init script runner: %w", err) } @@ -1169,7 +1196,18 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, // finished (both start and post start). For instance, an // autostarted devcontainer will be included in this time. err := a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecuteStartScripts) - err = errors.Join(err, a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecutePostStartScripts)) + + if a.containerAPI != nil { + // Start the container API after the startup scripts have + // been executed to ensure that the required tools can be + // installed. + a.containerAPI.Start() + for _, dc := range manifest.Devcontainers { + cErr := a.createDevcontainer(ctx, aAPI, dc, devcontainerScripts[dc.ID]) + err = errors.Join(err, cErr) + } + } + dur := time.Since(start).Seconds() if err != nil { a.logger.Warn(ctx, "startup script(s) failed", slog.Error(err)) @@ -1188,14 +1226,6 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, } a.metrics.startupScriptSeconds.WithLabelValues(label).Set(dur) a.scriptRunner.StartCron() - - // If the container API is enabled, trigger an immediate refresh - // for quick sub agent injection. - if cAPI := a.containerAPI.Load(); cAPI != nil { - if err := cAPI.RefreshContainers(ctx); err != nil { - a.logger.Error(ctx, "failed to refresh containers", slog.Error(err)) - } - } }) if err != nil { return xerrors.Errorf("track conn goroutine: %w", err) @@ -1205,6 +1235,38 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, } } +func (a *agent) createDevcontainer( + ctx context.Context, + aAPI proto.DRPCAgentClient26, + dc codersdk.WorkspaceAgentDevcontainer, + script codersdk.WorkspaceAgentScript, +) (err error) { + var ( + exitCode = int32(0) + startTime = a.clock.Now() + status = proto.Timing_OK + ) + if err = a.containerAPI.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath); err != nil { + exitCode = 1 + status = proto.Timing_EXIT_FAILURE + } + endTime := a.clock.Now() + + if _, scriptErr := aAPI.ScriptCompleted(ctx, &proto.WorkspaceAgentScriptCompletedRequest{ + Timing: &proto.Timing{ + ScriptId: script.ID[:], + Start: timestamppb.New(startTime), + End: timestamppb.New(endTime), + ExitCode: exitCode, + Stage: proto.Timing_START, + Status: status, + }, + }); scriptErr != nil { + a.logger.Warn(ctx, "reporting script completed failed", slog.Error(scriptErr)) + } + return err +} + // createOrUpdateNetwork waits for the manifest to be set using manifestOK, then creates or updates // the tailnet using the information in the manifest func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(context.Context, proto.DRPCAgentClient26) error { @@ -1228,7 +1290,6 @@ func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(co // agent API. network, err = a.createTailnet( a.gracefulCtx, - aAPI, manifest.AgentID, manifest.DERPMap, manifest.DERPForceWebSockets, @@ -1263,9 +1324,9 @@ func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(co network.SetBlockEndpoints(manifest.DisableDirectConnections) // Update the subagent client if the container API is available. - if cAPI := a.containerAPI.Load(); cAPI != nil { + if a.containerAPI != nil { client := agentcontainers.NewSubAgentClientFromAPI(a.logger, aAPI) - cAPI.UpdateSubAgentClient(client) + a.containerAPI.UpdateSubAgentClient(client) } } return nil @@ -1383,7 +1444,6 @@ func (a *agent) trackGoroutine(fn func()) error { func (a *agent) createTailnet( ctx context.Context, - aAPI proto.DRPCAgentClient26, agentID uuid.UUID, derpMap *tailcfg.DERPMap, derpForceWebSockets, disableDirectConnections bool, @@ -1516,10 +1576,7 @@ func (a *agent) createTailnet( }() if err = a.trackGoroutine(func() { defer apiListener.Close() - apiHandler, closeAPIHAndler := a.apiHandler(aAPI) - defer func() { - _ = closeAPIHAndler() - }() + apiHandler := a.apiHandler() server := &http.Server{ BaseContext: func(net.Listener) context.Context { return ctx }, Handler: apiHandler, @@ -1533,7 +1590,6 @@ func (a *agent) createTailnet( case <-ctx.Done(): case <-a.hardCtx.Done(): } - _ = closeAPIHAndler() _ = server.Close() }() @@ -1872,6 +1928,12 @@ func (a *agent) Close() error { a.logger.Error(a.hardCtx, "script runner close", slog.Error(err)) } + if a.containerAPI != nil { + if err := a.containerAPI.Close(); err != nil { + a.logger.Error(a.hardCtx, "container API close", slog.Error(err)) + } + } + // Wait for the graceful shutdown to complete, but don't wait forever so // that we don't break user expectations. go func() { diff --git a/agent/agent_test.go b/agent/agent_test.go index 1f049f08b65f9..4a9141bd37f9e 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -130,7 +130,6 @@ func TestAgent_Stats_SSH(t *testing.T) { t.Parallel() for _, port := range sshPorts { - port := port t.Run(fmt.Sprintf("(:%d)", port), func(t *testing.T) { t.Parallel() @@ -342,7 +341,6 @@ func TestAgent_SessionExec(t *testing.T) { t.Parallel() for _, port := range sshPorts { - port := port t.Run(fmt.Sprintf("(:%d)", port), func(t *testing.T) { t.Parallel() @@ -468,7 +466,6 @@ func TestAgent_SessionTTYShell(t *testing.T) { } for _, port := range sshPorts { - port := port t.Run(fmt.Sprintf("(%d)", port), func(t *testing.T) { t.Parallel() @@ -611,7 +608,6 @@ func TestAgent_Session_TTY_MOTD(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() session := setupSSHSession(t, test.manifest, test.banner, func(fs afero.Fs) { @@ -688,8 +684,6 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) { //nolint:paralleltest // These tests need to swap the banner func. for _, port := range sshPorts { - port := port - sshClient, err := conn.SSHClientOnPort(ctx, port) require.NoError(t, err) t.Cleanup(func() { @@ -697,7 +691,6 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) { }) for i, test := range tests { - test := test t.Run(fmt.Sprintf("(:%d)/%d", port, i), func(t *testing.T) { // Set new banner func and wait for the agent to call it to update the // banner. @@ -1210,7 +1203,6 @@ func TestAgent_CoderEnvVars(t *testing.T) { t.Parallel() for _, key := range []string{"CODER", "CODER_WORKSPACE_NAME", "CODER_WORKSPACE_OWNER_NAME", "CODER_WORKSPACE_AGENT_NAME"} { - key := key t.Run(key, func(t *testing.T) { t.Parallel() @@ -1233,7 +1225,6 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) { // For some reason this test produces a TTY locally and a non-TTY in CI // so we don't test for the absence of SSH_TTY. for _, key := range []string{"SSH_CONNECTION", "SSH_CLIENT"} { - key := key t.Run(key, func(t *testing.T) { t.Parallel() @@ -1276,7 +1267,6 @@ func TestAgent_SSHConnectionLoginVars(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.key, func(t *testing.T) { t.Parallel() @@ -1796,7 +1786,6 @@ func TestAgent_ReconnectingPTY(t *testing.T) { t.Setenv("LANG", "C") for _, backendType := range backends { - backendType := backendType t.Run(backendType, func(t *testing.T) { if backendType == "Screen" { if runtime.GOOS != "linux" { @@ -1965,8 +1954,8 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) { // nolint: dogsled conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) }) @@ -2141,7 +2130,7 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { "name": "mywork", "image": "ubuntu:latest", "cmd": ["sleep", "infinity"], - "runArgs": ["--network=host"] + "runArgs": ["--network=host", "--label=`+agentcontainers.DevcontainerIsTestRunLabel+`=true"] }`), 0o600) require.NoError(t, err, "write devcontainer.json") @@ -2172,12 +2161,13 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { //nolint:dogsled _, agentClient, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append( - o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append( + o.DevcontainerAPIOptions, // Only match this specific dev container. agentcontainers.WithClock(mClock), agentcontainers.WithContainerLabelIncludeFilter("devcontainer.local_folder", tempWorkspaceFolder), + agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerIsTestRunLabel, "true"), agentcontainers.WithSubAgentURL(srv.URL), // The agent will copy "itself", but in the case of this test, the // agent is actually this test binary. So we'll tell the test binary @@ -2299,7 +2289,8 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { err = os.WriteFile(devcontainerFile, []byte(`{ "name": "mywork", "image": "busybox:latest", - "cmd": ["sleep", "infinity"] + "cmd": ["sleep", "infinity"], + "runArgs": ["--label=`+agentcontainers.DevcontainerIsTestRunLabel+`=true"] }`), 0o600) require.NoError(t, err, "write devcontainer.json") @@ -2323,9 +2314,10 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { //nolint:dogsled conn, client, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter("devcontainer.local_folder", workspaceFolder), + agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerIsTestRunLabel, "true"), ) }) @@ -2380,7 +2372,7 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { // devcontainer, we do it in a goroutine so we can process logs // concurrently. go func(container codersdk.WorkspaceAgentContainer) { - _, err := conn.RecreateDevcontainer(ctx, container.ID) + _, err := conn.RecreateDevcontainer(ctx, devcontainerID.String()) assert.NoError(t, err, "recreate devcontainer should succeed") }(container) @@ -2449,8 +2441,7 @@ func TestAgent_DevcontainersDisabledForSubAgent(t *testing.T) { // Setup the agent with devcontainers enabled initially. //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true + conn, _, _, _, _ := setupAgent(t, manifest, 0, func(*agenttest.Client, *agent.Options) { }) // Query the containers API endpoint. This should fail because @@ -2496,7 +2487,6 @@ func TestAgent_Dial(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index ddf98e38bdb48..3faa97c3e0511 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -1,15 +1,14 @@ package agentcontainers import ( - "bytes" "context" "errors" "fmt" - "io" "net/http" "os" "path" "path/filepath" + "regexp" "runtime" "slices" "strings" @@ -25,6 +24,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/agent/agentcontainers/watcher" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/usershell" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -41,6 +41,9 @@ const ( // by tmpfs or other mounts. This assumes the container root filesystem is // read-write, which seems sensible for devcontainers. coderPathInsideContainer = "/.coder-agent/coder" + + maxAgentNameLength = 64 + maxAttemptsToNameAgent = 5 ) // API is responsible for container-related operations in the agent. @@ -50,12 +53,12 @@ type API struct { cancel context.CancelFunc watcherDone chan struct{} updaterDone chan struct{} - initialUpdateDone chan struct{} // Closed after first update in updaterLoop. updateTrigger chan chan error // Channel to trigger manual refresh. updateInterval time.Duration // Interval for periodic container updates. logger slog.Logger watcher watcher.Watcher execer agentexec.Execer + commandEnv CommandEnv ccli ContainerCLI containerLabelIncludeFilter map[string]string // Labels to filter containers by. dccli DevcontainerCLI @@ -67,20 +70,23 @@ type API struct { ownerName string workspaceName string - - mu sync.RWMutex - closed bool - containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation. - containersErr error // Error from the last list operation. - devcontainerNames map[string]bool // By devcontainer name. - knownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer // By workspace folder. - configFileModifiedTimes map[string]time.Time // By config file path. - recreateSuccessTimes map[string]time.Time // By workspace folder. - recreateErrorTimes map[string]time.Time // By workspace folder. - injectedSubAgentProcs map[string]subAgentProcess // By workspace folder. - asyncWg sync.WaitGroup - - devcontainerLogSourceIDs map[string]uuid.UUID // By workspace folder. + parentAgent string + + mu sync.RWMutex // Protects the following fields. + initDone chan struct{} // Closed by Init. + closed bool + containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation. + containersErr error // Error from the last list operation. + devcontainerNames map[string]bool // By devcontainer name. + knownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer // By workspace folder. + devcontainerLogSourceIDs map[string]uuid.UUID // By workspace folder. + configFileModifiedTimes map[string]time.Time // By config file path. + recreateSuccessTimes map[string]time.Time // By workspace folder. + recreateErrorTimes map[string]time.Time // By workspace folder. + injectedSubAgentProcs map[string]subAgentProcess // By workspace folder. + usingWorkspaceFolderName map[string]bool // By workspace folder. + ignoredDevcontainers map[string]bool // By workspace folder. Tracks three states (true, false and not checked). + asyncWg sync.WaitGroup } type subAgentProcess struct { @@ -108,6 +114,29 @@ func WithExecer(execer agentexec.Execer) Option { } } +// WithCommandEnv sets the CommandEnv implementation to use. +func WithCommandEnv(ce CommandEnv) Option { + return func(api *API) { + api.commandEnv = func(ei usershell.EnvInfoer, preEnv []string) (string, string, []string, error) { + shell, dir, env, err := ce(ei, preEnv) + if err != nil { + return shell, dir, env, err + } + env = slices.DeleteFunc(env, func(s string) bool { + // Ensure we filter out environment variables that come + // from the parent agent and are incorrect or not + // relevant for the devcontainer. + return strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_NAME=") || + strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_URL=") || + strings.HasPrefix(s, "CODER_AGENT_TOKEN=") || + strings.HasPrefix(s, "CODER_AGENT_AUTH=") || + strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_ENABLE=") + }) + return shell, dir, env, nil + } + } +} + // WithContainerCLI sets the agentcontainers.ContainerCLI implementation // to use. The default implementation uses the Docker CLI. func WithContainerCLI(ccli ContainerCLI) Option { @@ -150,7 +179,7 @@ func WithSubAgentURL(url string) Option { } } -// WithSubAgent sets the environment variables for the sub-agent. +// WithSubAgentEnv sets the environment variables for the sub-agent. func WithSubAgentEnv(env ...string) Option { return func(api *API) { api.subAgentEnv = env @@ -159,10 +188,11 @@ func WithSubAgentEnv(env ...string) Option { // WithManifestInfo sets the owner name, and workspace name // for the sub-agent. -func WithManifestInfo(owner, workspace string) Option { +func WithManifestInfo(owner, workspace, parentAgent string) Option { return func(api *API) { api.ownerName = owner api.workspaceName = workspace + api.parentAgent = parentAgent } } @@ -178,6 +208,10 @@ func WithDevcontainers(devcontainers []codersdk.WorkspaceAgentDevcontainer, scri api.devcontainerNames = make(map[string]bool, len(devcontainers)) api.devcontainerLogSourceIDs = make(map[string]uuid.UUID) for _, dc := range devcontainers { + if dc.Status == "" { + dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStarting + } + api.knownDevcontainers[dc.WorkspaceFolder] = dc api.devcontainerNames[dc.Name] = true for _, script := range scripts { @@ -236,9 +270,7 @@ func NewAPI(logger slog.Logger, options ...Option) *API { api := &API{ ctx: ctx, cancel: cancel, - watcherDone: make(chan struct{}), - updaterDone: make(chan struct{}), - initialUpdateDone: make(chan struct{}), + initDone: make(chan struct{}), updateTrigger: make(chan chan error), updateInterval: defaultUpdateInterval, logger: logger, @@ -248,16 +280,25 @@ func NewAPI(logger slog.Logger, options ...Option) *API { devcontainerNames: make(map[string]bool), knownDevcontainers: make(map[string]codersdk.WorkspaceAgentDevcontainer), configFileModifiedTimes: make(map[string]time.Time), + ignoredDevcontainers: make(map[string]bool), recreateSuccessTimes: make(map[string]time.Time), recreateErrorTimes: make(map[string]time.Time), scriptLogger: func(uuid.UUID) ScriptLogger { return noopScriptLogger{} }, injectedSubAgentProcs: make(map[string]subAgentProcess), + usingWorkspaceFolderName: make(map[string]bool), } // The ctx and logger must be set before applying options to avoid // nil pointer dereference. for _, opt := range options { opt(api) } + if api.commandEnv != nil { + api.execer = newCommandEnvExecer( + api.logger, + api.commandEnv, + api.execer, + ) + } if api.ccli == nil { api.ccli = NewDockerCLI(api.execer) } @@ -277,10 +318,47 @@ func NewAPI(logger slog.Logger, options ...Option) *API { api.subAgentClient.Store(&c) } + return api +} + +// Init applies a final set of options to the API and then +// closes initDone. This method can only be called once. +func (api *API) Init(opts ...Option) { + api.mu.Lock() + defer api.mu.Unlock() + if api.closed { + return + } + select { + case <-api.initDone: + return + default: + } + defer close(api.initDone) + + for _, opt := range opts { + opt(api) + } +} + +// Start starts the API by initializing the watcher and updater loops. +// This method calls Init, if it is desired to apply options after +// the API has been created, it should be done by calling Init before +// Start. This method must only be called once. +func (api *API) Start() { + api.Init() + + api.mu.Lock() + defer api.mu.Unlock() + if api.closed { + return + } + + api.watcherDone = make(chan struct{}) + api.updaterDone = make(chan struct{}) + go api.watcherLoop() go api.updaterLoop() - - return api } func (api *API) watcherLoop() { @@ -345,28 +423,38 @@ func (api *API) updaterLoop() { // and anyone looking to interact with the API. api.logger.Debug(api.ctx, "performing initial containers update") if err := api.updateContainers(api.ctx); err != nil { - api.logger.Error(api.ctx, "initial containers update failed", slog.Error(err)) + if errors.Is(err, context.Canceled) { + api.logger.Warn(api.ctx, "initial containers update canceled", slog.Error(err)) + } else { + api.logger.Error(api.ctx, "initial containers update failed", slog.Error(err)) + } } else { api.logger.Debug(api.ctx, "initial containers update complete") } - // Signal that the initial update attempt (successful or not) is done. - // Other services can wait on this if they need the first data to be available. - close(api.initialUpdateDone) // We utilize a TickerFunc here instead of a regular Ticker so that // we can guarantee execution of the updateContainers method after // advancing the clock. ticker := api.clock.TickerFunc(api.ctx, api.updateInterval, func() error { done := make(chan error, 1) - defer close(done) - + var sent bool + defer func() { + if !sent { + close(done) + } + }() select { case <-api.ctx.Done(): return api.ctx.Err() case api.updateTrigger <- done: + sent = true err := <-done if err != nil { - api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err)) + if errors.Is(err, context.Canceled) { + api.logger.Warn(api.ctx, "updater loop ticker canceled", slog.Error(err)) + } else { + api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err)) + } } default: api.logger.Debug(api.ctx, "updater loop ticker skipped, update in progress") @@ -388,6 +476,7 @@ func (api *API) updaterLoop() { // Note that although we pass api.ctx here, updateContainers // has an internal timeout to prevent long blocking calls. done <- api.updateContainers(api.ctx) + close(done) } } } @@ -401,7 +490,7 @@ func (api *API) UpdateSubAgentClient(client SubAgentClient) { func (api *API) Routes() http.Handler { r := chi.NewRouter() - ensureInitialUpdateDoneMW := func(next http.Handler) http.Handler { + ensureInitDoneMW := func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { select { case <-api.ctx.Done(): @@ -412,9 +501,8 @@ func (api *API) Routes() http.Handler { return case <-r.Context().Done(): return - case <-api.initialUpdateDone: - // Initial update is done, we can start processing - // requests. + case <-api.initDone: + // API init is done, we can start processing requests. } next.ServeHTTP(rw, r) }) @@ -423,13 +511,13 @@ func (api *API) Routes() http.Handler { // For now, all endpoints require the initial update to be done. // If we want to allow some endpoints to be available before // the initial update, we can enable this per-route. - r.Use(ensureInitialUpdateDoneMW) + r.Use(ensureInitDoneMW) r.Get("/", api.handleList) // TODO(mafredri): Simplify this route as the previous /devcontainers // /-route was dropped. We can drop the /devcontainers prefix here too. - r.Route("/devcontainers", func(r chi.Router) { - r.Post("/container/{container}/recreate", api.handleDevcontainerRecreate) + r.Route("/devcontainers/{devcontainer}", func(r chi.Router) { + r.Post("/recreate", api.handleDevcontainerRecreate) }) return r @@ -462,7 +550,6 @@ func (api *API) updateContainers(ctx context.Context) error { // will clear up on the next update. if !errors.Is(err, context.Canceled) { api.mu.Lock() - api.containers = codersdk.WorkspaceAgentListContainersResponse{} api.containersErr = err api.mu.Unlock() } @@ -525,7 +612,8 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code slog.F("config_file", configFile), ) - if len(api.containerLabelIncludeFilter) > 0 { + // Filter out devcontainer tests, unless explicitly set in include filters. + if len(api.containerLabelIncludeFilter) > 0 || container.Labels[DevcontainerIsTestRunLabel] == "true" { var ok bool for label, value := range api.containerLabelIncludeFilter { if v, found := container.Labels[label]; found && v == value { @@ -585,10 +673,11 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code if dc.Container != nil { if !api.devcontainerNames[dc.Name] { // If the devcontainer name wasn't set via terraform, we - // use the containers friendly name as a fallback which - // will keep changing as the devcontainer is recreated. - // TODO(mafredri): Parse the container label (i.e. devcontainer.json) for customization. - dc.Name = safeFriendlyName(dc.Container.FriendlyName) + // will attempt to create an agent name based on the workspace + // folder's name. If it is not possible to generate a valid + // agent name based off of the folder name (i.e. no valid characters), + // we will instead fall back to using the container's friendly name. + dc.Name, api.usingWorkspaceFolderName[dc.WorkspaceFolder] = api.makeAgentName(dc.WorkspaceFolder, dc.Container.FriendlyName) } } @@ -633,6 +722,40 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code api.containersErr = nil } +var consecutiveHyphenRegex = regexp.MustCompile("-+") + +// `safeAgentName` returns a safe agent name derived from a folder name, +// falling back to the container’s friendly name if needed. The second +// return value will be `true` if it succeeded and `false` if it had +// to fallback to the friendly name. +func safeAgentName(name string, friendlyName string) (string, bool) { + // Keep only ASCII letters and digits, replacing everything + // else with a hyphen. + var sb strings.Builder + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + _, _ = sb.WriteRune(r) + } else { + _, _ = sb.WriteRune('-') + } + } + + // Remove any consecutive hyphens, and then trim any leading + // and trailing hyphens. + name = consecutiveHyphenRegex.ReplaceAllString(sb.String(), "-") + name = strings.Trim(name, "-") + + // Ensure the name of the agent doesn't exceed the maximum agent + // name length. + name = name[:min(len(name), maxAgentNameLength)] + + if provisioner.AgentNameRegex.Match([]byte(name)) { + return name, true + } + + return safeFriendlyName(friendlyName), false +} + // safeFriendlyName returns a API safe version of the container's // friendly name. // @@ -645,6 +768,47 @@ func safeFriendlyName(name string) string { return name } +// expandedAgentName creates an agent name by including parent directories +// from the workspace folder path to avoid name collisions. Like `safeAgentName`, +// the second returned value will be true if using the workspace folder name, +// and false if it fell back to the friendly name. +func expandedAgentName(workspaceFolder string, friendlyName string, depth int) (string, bool) { + var parts []string + for part := range strings.SplitSeq(filepath.ToSlash(workspaceFolder), "/") { + if part = strings.TrimSpace(part); part != "" { + parts = append(parts, part) + } + } + if len(parts) == 0 { + return safeFriendlyName(friendlyName), false + } + + components := parts[max(0, len(parts)-depth-1):] + expanded := strings.Join(components, "-") + + return safeAgentName(expanded, friendlyName) +} + +// makeAgentName attempts to create an agent name. It will first attempt to create an +// agent name based off of the workspace folder, and will eventually fallback to a +// friendly name. Like `safeAgentName`, the second returned value will be true if the +// agent name utilizes the workspace folder, and false if it falls back to the +// friendly name. +func (api *API) makeAgentName(workspaceFolder string, friendlyName string) (string, bool) { + for attempt := 0; attempt <= maxAttemptsToNameAgent; attempt++ { + agentName, usingWorkspaceFolder := expandedAgentName(workspaceFolder, friendlyName, attempt) + if !usingWorkspaceFolder { + return agentName, false + } + + if !api.devcontainerNames[agentName] { + return agentName, true + } + } + + return safeFriendlyName(friendlyName), false +} + // RefreshContainers triggers an immediate update of the container list // and waits for it to complete. func (api *API) RefreshContainers(ctx context.Context) (err error) { @@ -655,12 +819,19 @@ func (api *API) RefreshContainers(ctx context.Context) (err error) { }() done := make(chan error, 1) + var sent bool + defer func() { + if !sent { + close(done) + } + }() select { case <-api.ctx.Done(): return xerrors.Errorf("API closed: %w", api.ctx.Err()) case <-ctx.Done(): return ctx.Err() case api.updateTrigger <- done: + sent = true select { case <-api.ctx.Done(): return xerrors.Errorf("API closed: %w", api.ctx.Err()) @@ -684,6 +855,10 @@ func (api *API) getContainers() (codersdk.WorkspaceAgentListContainersResponse, if len(api.knownDevcontainers) > 0 { devcontainers = make([]codersdk.WorkspaceAgentDevcontainer, 0, len(api.knownDevcontainers)) for _, dc := range api.knownDevcontainers { + if api.ignoredDevcontainers[dc.WorkspaceFolder] { + continue + } + // Include the agent if it's running (we're iterating over // copies, so mutating is fine). if proc := api.injectedSubAgentProcs[dc.WorkspaceFolder]; proc.agent.ID != uuid.Nil { @@ -712,68 +887,40 @@ func (api *API) getContainers() (codersdk.WorkspaceAgentListContainersResponse, // devcontainer by referencing the container. func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - containerID := chi.URLParam(r, "container") - - if containerID == "" { - httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ - Message: "Missing container ID or name", - Detail: "Container ID or name is required to recreate a devcontainer.", - }) - return - } - - containers, err := api.getContainers() - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Could not list containers", - Detail: err.Error(), - }) - return - } + devcontainerID := chi.URLParam(r, "devcontainer") - containerIdx := slices.IndexFunc(containers.Containers, func(c codersdk.WorkspaceAgentContainer) bool { return c.Match(containerID) }) - if containerIdx == -1 { - httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{ - Message: "Container not found", - Detail: "Container ID or name not found in the list of containers.", - }) - return - } - - container := containers.Containers[containerIdx] - workspaceFolder := container.Labels[DevcontainerLocalFolderLabel] - configPath := container.Labels[DevcontainerConfigFileLabel] - - // Workspace folder is required to recreate a container, we don't verify - // the config path here because it's optional. - if workspaceFolder == "" { + if devcontainerID == "" { httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ - Message: "Missing workspace folder label", - Detail: "The container is not a devcontainer, the container must have the workspace folder label to support recreation.", + Message: "Missing devcontainer ID", + Detail: "Devcontainer ID is required to recreate a devcontainer.", }) return } api.mu.Lock() - dc, ok := api.knownDevcontainers[workspaceFolder] - switch { - case !ok: + var dc codersdk.WorkspaceAgentDevcontainer + for _, knownDC := range api.knownDevcontainers { + if knownDC.ID.String() == devcontainerID { + dc = knownDC + break + } + } + if dc.ID == uuid.Nil { api.mu.Unlock() - // This case should not happen if the container is a valid devcontainer. - api.logger.Error(ctx, "devcontainer not found for workspace folder", slog.F("workspace_folder", workspaceFolder)) - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{ Message: "Devcontainer not found.", - Detail: fmt.Sprintf("Could not find devcontainer for workspace folder: %q", workspaceFolder), + Detail: fmt.Sprintf("Could not find devcontainer with ID: %q", devcontainerID), }) return - case dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting: + } + if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting { api.mu.Unlock() httpapi.Write(ctx, w, http.StatusConflict, codersdk.Response{ Message: "Devcontainer recreation already in progress", - Detail: fmt.Sprintf("Recreation for workspace folder %q is already underway.", dc.WorkspaceFolder), + Detail: fmt.Sprintf("Recreation for devcontainer %q is already underway.", dc.Name), }) return } @@ -783,51 +930,65 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStarting dc.Container = nil api.knownDevcontainers[dc.WorkspaceFolder] = dc - api.asyncWg.Add(1) - go api.recreateDevcontainer(dc, configPath) + go func() { + _ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath, WithRemoveExistingContainer()) + }() api.mu.Unlock() httpapi.Write(ctx, w, http.StatusAccepted, codersdk.Response{ Message: "Devcontainer recreation initiated", - Detail: fmt.Sprintf("Recreation process for workspace folder %q has started.", dc.WorkspaceFolder), + Detail: fmt.Sprintf("Recreation process for devcontainer %q has started.", dc.Name), }) } -// recreateDevcontainer should run in its own goroutine and is responsible for +// createDevcontainer should run in its own goroutine and is responsible for // recreating a devcontainer based on the provided devcontainer configuration. // It updates the devcontainer status and logs the process. The configPath is // passed as a parameter for the odd chance that the container being recreated // has a different config file than the one stored in the devcontainer state. // The devcontainer state must be set to starting and the asyncWg must be // incremented before calling this function. -func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, configPath string) { - defer api.asyncWg.Done() +func (api *API) CreateDevcontainer(workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) error { + api.mu.Lock() + if api.closed { + api.mu.Unlock() + return nil + } + + dc, found := api.knownDevcontainers[workspaceFolder] + if !found { + api.mu.Unlock() + return xerrors.Errorf("devcontainer not found") + } var ( - err error ctx = api.ctx logger = api.logger.With( slog.F("devcontainer_id", dc.ID), slog.F("devcontainer_name", dc.Name), slog.F("workspace_folder", dc.WorkspaceFolder), - slog.F("config_path", configPath), + slog.F("config_path", dc.ConfigPath), ) ) - if dc.ConfigPath != configPath { - logger.Warn(ctx, "devcontainer config path mismatch", - slog.F("config_path_param", configPath), - ) - } - // Send logs via agent logging facilities. logSourceID := api.devcontainerLogSourceIDs[dc.WorkspaceFolder] if logSourceID == uuid.Nil { - // Fallback to the external log source ID if not found. + api.logger.Debug(api.ctx, "devcontainer log source ID not found, falling back to external log source ID") logSourceID = agentsdk.ExternalLogSourceID } + api.asyncWg.Add(1) + defer api.asyncWg.Done() + api.mu.Unlock() + + if dc.ConfigPath != configPath { + logger.Warn(ctx, "devcontainer config path mismatch", + slog.F("config_path_param", configPath), + ) + } + scriptLogger := api.scriptLogger(logSourceID) defer func() { flushCtx, cancel := context.WithTimeout(api.ctx, 5*time.Second) @@ -843,12 +1004,15 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con logger.Debug(ctx, "starting devcontainer recreation") - _, err = api.dccli.Up(ctx, dc.WorkspaceFolder, configPath, WithUpOutput(infoW, errW), WithRemoveExistingContainer()) + upOptions := []DevcontainerCLIUpOptions{WithUpOutput(infoW, errW)} + upOptions = append(upOptions, opts...) + + _, err := api.dccli.Up(ctx, dc.WorkspaceFolder, configPath, upOptions...) if err != nil { // No need to log if the API is closing (context canceled), as this // is expected behavior when the API is shutting down. if !errors.Is(err, context.Canceled) { - logger.Error(ctx, "devcontainer recreation failed", slog.Error(err)) + logger.Error(ctx, "devcontainer creation failed", slog.Error(err)) } api.mu.Lock() @@ -857,10 +1021,11 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con api.knownDevcontainers[dc.WorkspaceFolder] = dc api.recreateErrorTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "errorTimes") api.mu.Unlock() - return + + return xerrors.Errorf("start devcontainer: %w", err) } - logger.Info(ctx, "devcontainer recreated successfully") + logger.Info(ctx, "devcontainer created successfully") api.mu.Lock() dc = api.knownDevcontainers[dc.WorkspaceFolder] @@ -883,8 +1048,11 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con // Ensure an immediate refresh to accurately reflect the // devcontainer state after recreation. if err := api.RefreshContainers(ctx); err != nil { - logger.Error(ctx, "failed to trigger immediate refresh after devcontainer recreation", slog.Error(err)) + logger.Error(ctx, "failed to trigger immediate refresh after devcontainer creation", slog.Error(err)) + return xerrors.Errorf("refresh containers: %w", err) } + + return nil } // markDevcontainerDirty finds the devcontainer with the given config file path @@ -916,6 +1084,10 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) { logger.Info(api.ctx, "marking devcontainer as dirty") dc.Dirty = true } + if _, ok := api.ignoredDevcontainers[dc.WorkspaceFolder]; ok { + logger.Debug(api.ctx, "clearing devcontainer ignored state") + delete(api.ignoredDevcontainers, dc.WorkspaceFolder) // Allow re-reading config. + } api.knownDevcontainers[dc.WorkspaceFolder] = dc } @@ -972,6 +1144,10 @@ func (api *API) cleanupSubAgents(ctx context.Context) error { // This method uses an internal timeout to prevent blocking indefinitely // if something goes wrong with the injection. func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc codersdk.WorkspaceAgentDevcontainer) (err error) { + if api.ignoredDevcontainers[dc.WorkspaceFolder] { + return nil + } + ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout) defer cancel() @@ -993,6 +1169,42 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c maybeRecreateSubAgent := false proc, injected := api.injectedSubAgentProcs[dc.WorkspaceFolder] if injected { + if _, ignoreChecked := api.ignoredDevcontainers[dc.WorkspaceFolder]; !ignoreChecked { + // If ignore status has not yet been checked, or cleared by + // modifications to the devcontainer.json, we must read it + // to determine the current status. This can happen while + // the devcontainer subagent is already running or before + // we've had a chance to inject it. + // + // Note, for simplicity, we do not try to optimize to reduce + // ReadConfig calls here. + config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath, nil) + if err != nil { + return xerrors.Errorf("read devcontainer config: %w", err) + } + + dcIgnored := config.Configuration.Customizations.Coder.Ignore + if dcIgnored { + proc.stop() + if proc.agent.ID != uuid.Nil { + // Unlock while doing the delete operation. + api.mu.Unlock() + client := *api.subAgentClient.Load() + if err := client.Delete(ctx, proc.agent.ID); err != nil { + api.mu.Lock() + return xerrors.Errorf("delete subagent: %w", err) + } + api.mu.Lock() + } + // Reset agent and containerID to force config re-reading if ignore is toggled. + proc.agent = SubAgent{} + proc.containerID = "" + api.injectedSubAgentProcs[dc.WorkspaceFolder] = proc + api.ignoredDevcontainers[dc.WorkspaceFolder] = dcIgnored + return nil + } + } + if proc.containerID == container.ID && proc.ctx.Err() == nil { // Same container and running, no need to reinject. return nil @@ -1011,7 +1223,8 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c // Container ID changed or the subagent process is not running, // stop the existing subagent context to replace it. proc.stop() - } else { + } + if proc.agent.OperatingSystem == "" { // Set SubAgent defaults. proc.agent.OperatingSystem = "linux" // Assuming Linux for devcontainers. } @@ -1030,7 +1243,11 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c ranSubAgent := false // Clean up if injection fails. + var dcIgnored, setDCIgnored bool defer func() { + if setDCIgnored { + api.ignoredDevcontainers[dc.WorkspaceFolder] = dcIgnored + } if !ranSubAgent { proc.stop() if !api.closed { @@ -1068,73 +1285,10 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c proc.agent.Architecture = arch } - agentBinaryPath, err := os.Executable() - if err != nil { - return xerrors.Errorf("get agent binary path: %w", err) - } - agentBinaryPath, err = filepath.EvalSymlinks(agentBinaryPath) - if err != nil { - return xerrors.Errorf("resolve agent binary path: %w", err) - } - - // If we scripted this as a `/bin/sh` script, we could reduce these - // steps to one instruction, speeding up the injection process. - // - // Note: We use `path` instead of `filepath` here because we are - // working with Unix-style paths inside the container. - if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "mkdir", "-p", path.Dir(coderPathInsideContainer)); err != nil { - return xerrors.Errorf("create agent directory in container: %w", err) - } - - if err := api.ccli.Copy(ctx, container.ID, agentBinaryPath, coderPathInsideContainer); err != nil { - return xerrors.Errorf("copy agent binary: %w", err) - } - - logger.Info(ctx, "copied agent binary to container") - - // Make sure the agent binary is executable so we can run it (the - // user doesn't matter since we're making it executable for all). - if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "chmod", "0755", path.Dir(coderPathInsideContainer), coderPathInsideContainer); err != nil { - return xerrors.Errorf("set agent binary executable: %w", err) - } - - // Attempt to add CAP_NET_ADMIN to the binary to improve network - // performance (optional, allow to fail). See `bootstrap_linux.sh`. - // TODO(mafredri): Disable for now until we can figure out why this - // causes the following error on some images: - // - // Image: mcr.microsoft.com/devcontainers/base:ubuntu - // Error: /.coder-agent/coder: Operation not permitted - // - // if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "setcap", "cap_net_admin+ep", coderPathInsideContainer); err != nil { - // logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err)) - // } - subAgentConfig := proc.agent.CloneConfig(dc) if proc.agent.ID == uuid.Nil || maybeRecreateSubAgent { subAgentConfig.Architecture = arch - // Detect workspace folder by executing `pwd` in the container. - // NOTE(mafredri): This is a quick and dirty way to detect the - // workspace folder inside the container. In the future we will - // rely more on `devcontainer read-configuration`. - var pwdBuf bytes.Buffer - err = api.dccli.Exec(ctx, dc.WorkspaceFolder, dc.ConfigPath, "pwd", []string{}, - WithExecOutput(&pwdBuf, io.Discard), - WithExecContainerID(container.ID), - ) - if err != nil { - return xerrors.Errorf("check workspace folder in container: %w", err) - } - directory := strings.TrimSpace(pwdBuf.String()) - if directory == "" { - logger.Warn(ctx, "detected workspace folder is empty, using default workspace folder", - slog.F("default_workspace_folder", DevcontainerDefaultContainerWorkspaceFolder), - ) - directory = DevcontainerDefaultContainerWorkspaceFolder - } - subAgentConfig.Directory = directory - displayAppsMap := map[codersdk.DisplayApp]bool{ // NOTE(DanielleMaywood): // We use the same defaults here as set in terraform-provider-coder. @@ -1146,7 +1300,11 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c codersdk.DisplayAppPortForward: true, } - var appsWithPossibleDuplicates []SubAgentApp + var ( + featureOptionsAsEnvs []string + appsWithPossibleDuplicates []SubAgentApp + workspaceFolder = DevcontainerDefaultContainerWorkspaceFolder + ) if err := func() error { var ( @@ -1155,18 +1313,36 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c ) readConfig := func() (DevcontainerConfig, error) { - return api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath, []string{ - fmt.Sprintf("CODER_WORKSPACE_AGENT_NAME=%s", subAgentConfig.Name), - fmt.Sprintf("CODER_WORKSPACE_OWNER_NAME=%s", api.ownerName), - fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName), - fmt.Sprintf("CODER_URL=%s", api.subAgentURL), - }) + return api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath, + append(featureOptionsAsEnvs, []string{ + fmt.Sprintf("CODER_WORKSPACE_AGENT_NAME=%s", subAgentConfig.Name), + fmt.Sprintf("CODER_WORKSPACE_OWNER_NAME=%s", api.ownerName), + fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName), + fmt.Sprintf("CODER_WORKSPACE_PARENT_AGENT_NAME=%s", api.parentAgent), + fmt.Sprintf("CODER_URL=%s", api.subAgentURL), + fmt.Sprintf("CONTAINER_ID=%s", container.ID), + }...), + ) } if config, err = readConfig(); err != nil { return err } + // We only allow ignore to be set in the root customization layer to + // prevent weird interactions with devcontainer features. + dcIgnored, setDCIgnored = config.Configuration.Customizations.Coder.Ignore, true + if dcIgnored { + return nil + } + + workspaceFolder = config.Workspace.WorkspaceFolder + + featureOptionsAsEnvs = config.MergedConfiguration.Features.OptionsAsEnvs() + if len(featureOptionsAsEnvs) > 0 { + configOutdated = true + } + // NOTE(DanielleMaywood): // We only want to take an agent name specified in the root customization layer. // This restricts the ability for a feature to specify the agent name. We may revisit @@ -1176,6 +1352,7 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c if provisioner.AgentNameRegex.Match([]byte(name)) { subAgentConfig.Name = name configOutdated = true + delete(api.usingWorkspaceFolderName, dc.WorkspaceFolder) } else { logger.Warn(ctx, "invalid name in devcontainer customization, ignoring", slog.F("name", name), @@ -1212,6 +1389,22 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) } + if dcIgnored { + proc.stop() + if proc.agent.ID != uuid.Nil { + // If we stop the subagent, we also need to delete it. + client := *api.subAgentClient.Load() + if err := client.Delete(ctx, proc.agent.ID); err != nil { + return xerrors.Errorf("delete subagent: %w", err) + } + } + // Reset agent and containerID to force config re-reading if + // ignore is toggled. + proc.agent = SubAgent{} + proc.containerID = "" + return nil + } + displayApps := make([]codersdk.DisplayApp, 0, len(displayAppsMap)) for app, enabled := range displayAppsMap { if enabled { @@ -1241,8 +1434,56 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c subAgentConfig.DisplayApps = displayApps subAgentConfig.Apps = apps + subAgentConfig.Directory = workspaceFolder + } + + agentBinaryPath, err := os.Executable() + if err != nil { + return xerrors.Errorf("get agent binary path: %w", err) + } + agentBinaryPath, err = filepath.EvalSymlinks(agentBinaryPath) + if err != nil { + return xerrors.Errorf("resolve agent binary path: %w", err) + } + + // If we scripted this as a `/bin/sh` script, we could reduce these + // steps to one instruction, speeding up the injection process. + // + // Note: We use `path` instead of `filepath` here because we are + // working with Unix-style paths inside the container. + if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "mkdir", "-p", path.Dir(coderPathInsideContainer)); err != nil { + return xerrors.Errorf("create agent directory in container: %w", err) + } + + if err := api.ccli.Copy(ctx, container.ID, agentBinaryPath, coderPathInsideContainer); err != nil { + return xerrors.Errorf("copy agent binary: %w", err) + } + + logger.Info(ctx, "copied agent binary to container") + + // Make sure the agent binary is executable so we can run it (the + // user doesn't matter since we're making it executable for all). + if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "chmod", "0755", path.Dir(coderPathInsideContainer), coderPathInsideContainer); err != nil { + return xerrors.Errorf("set agent binary executable: %w", err) } + // Make sure the agent binary is owned by a valid user so we can run it. + if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "/bin/sh", "-c", fmt.Sprintf("chown $(id -u):$(id -g) %s", coderPathInsideContainer)); err != nil { + return xerrors.Errorf("set agent binary ownership: %w", err) + } + + // Attempt to add CAP_NET_ADMIN to the binary to improve network + // performance (optional, allow to fail). See `bootstrap_linux.sh`. + // TODO(mafredri): Disable for now until we can figure out why this + // causes the following error on some images: + // + // Image: mcr.microsoft.com/devcontainers/base:ubuntu + // Error: /.coder-agent/coder: Operation not permitted + // + // if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "setcap", "cap_net_admin+ep", coderPathInsideContainer); err != nil { + // logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err)) + // } + deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig) if deleteSubAgent { logger.Debug(ctx, "deleting existing subagent for recreation", slog.F("agent_id", proc.agent.ID)) @@ -1261,12 +1502,56 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c ) // Create new subagent record in the database to receive the auth token. + // If we get a unique constraint violation, try with expanded names that + // include parent directories to avoid collisions. client := *api.subAgentClient.Load() - newSubAgent, err := client.Create(ctx, subAgentConfig) - if err != nil { - return xerrors.Errorf("create subagent failed: %w", err) + + originalName := subAgentConfig.Name + + for attempt := 1; attempt <= maxAttemptsToNameAgent; attempt++ { + agent, err := client.Create(ctx, subAgentConfig) + if err == nil { + proc.agent = agent // Only reassign on success. + if api.usingWorkspaceFolderName[dc.WorkspaceFolder] { + api.devcontainerNames[dc.Name] = true + delete(api.usingWorkspaceFolderName, dc.WorkspaceFolder) + } + + break + } + // NOTE(DanielleMaywood): + // Ordinarily we'd use `errors.As` here, but it didn't appear to work. Not + // sure if this is because of the communication protocol? Instead I've opted + // for a slightly more janky string contains approach. + // + // We only care if sub agent creation has failed due to a unique constraint + // violation on the agent name, as we can _possibly_ rectify this. + if !strings.Contains(err.Error(), "workspace agent name") { + return xerrors.Errorf("create subagent failed: %w", err) + } + + // If there has been a unique constraint violation but the user is *not* + // using an auto-generated name, then we should error. This is because + // we do not want to surprise the user with a name they did not ask for. + if usingFolderName := api.usingWorkspaceFolderName[dc.WorkspaceFolder]; !usingFolderName { + return xerrors.Errorf("create subagent failed: %w", err) + } + + if attempt == maxAttemptsToNameAgent { + return xerrors.Errorf("create subagent failed after %d attempts: %w", attempt, err) + } + + // We increase how much of the workspace folder is used for generating + // the agent name. With each iteration there is greater chance of this + // being successful. + subAgentConfig.Name, api.usingWorkspaceFolderName[dc.WorkspaceFolder] = expandedAgentName(dc.WorkspaceFolder, dc.Container.FriendlyName, attempt) + + logger.Debug(ctx, "retrying subagent creation with expanded name", + slog.F("original_name", originalName), + slog.F("expanded_name", subAgentConfig.Name), + slog.F("attempt", attempt+1), + ) } - proc.agent = newSubAgent logger.Info(ctx, "created new subagent", slog.F("agent_id", proc.agent.ID)) } else { @@ -1382,8 +1667,12 @@ func (api *API) Close() error { err := api.watcher.Close() // Wait for loops to finish. - <-api.watcherDone - <-api.updaterDone + if api.watcherDone != nil { + <-api.watcherDone + } + if api.updaterDone != nil { + <-api.updaterDone + } // Wait for all async tasks to complete. api.asyncWg.Wait() diff --git a/agent/agentcontainers/api_internal_test.go b/agent/agentcontainers/api_internal_test.go new file mode 100644 index 0000000000000..2e049640d74b8 --- /dev/null +++ b/agent/agentcontainers/api_internal_test.go @@ -0,0 +1,358 @@ +package agentcontainers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/provisioner" +) + +func TestSafeAgentName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + folderName string + expected string + fallback bool + }{ + // Basic valid names + { + folderName: "simple", + expected: "simple", + }, + { + folderName: "with-hyphens", + expected: "with-hyphens", + }, + { + folderName: "123numbers", + expected: "123numbers", + }, + { + folderName: "mixed123", + expected: "mixed123", + }, + + // Names that need transformation + { + folderName: "With_Underscores", + expected: "with-underscores", + }, + { + folderName: "With Spaces", + expected: "with-spaces", + }, + { + folderName: "UPPERCASE", + expected: "uppercase", + }, + { + folderName: "Mixed_Case-Name", + expected: "mixed-case-name", + }, + + // Names with special characters that get replaced + { + folderName: "special@#$chars", + expected: "special-chars", + }, + { + folderName: "dots.and.more", + expected: "dots-and-more", + }, + { + folderName: "multiple___underscores", + expected: "multiple-underscores", + }, + { + folderName: "multiple---hyphens", + expected: "multiple-hyphens", + }, + + // Edge cases with leading/trailing special chars + { + folderName: "-leading-hyphen", + expected: "leading-hyphen", + }, + { + folderName: "trailing-hyphen-", + expected: "trailing-hyphen", + }, + { + folderName: "_leading_underscore", + expected: "leading-underscore", + }, + { + folderName: "trailing_underscore_", + expected: "trailing-underscore", + }, + { + folderName: "---multiple-leading", + expected: "multiple-leading", + }, + { + folderName: "trailing-multiple---", + expected: "trailing-multiple", + }, + + // Complex transformation cases + { + folderName: "___very---complex@@@name___", + expected: "very-complex-name", + }, + { + folderName: "my.project-folder_v2", + expected: "my-project-folder-v2", + }, + + // Empty and fallback cases - now correctly uses friendlyName fallback + { + folderName: "", + expected: "friendly-fallback", + fallback: true, + }, + { + folderName: "---", + expected: "friendly-fallback", + fallback: true, + }, + { + folderName: "___", + expected: "friendly-fallback", + fallback: true, + }, + { + folderName: "@#$", + expected: "friendly-fallback", + fallback: true, + }, + + // Additional edge cases + { + folderName: "a", + expected: "a", + }, + { + folderName: "1", + expected: "1", + }, + { + folderName: "a1b2c3", + expected: "a1b2c3", + }, + { + folderName: "CamelCase", + expected: "camelcase", + }, + { + folderName: "snake_case_name", + expected: "snake-case-name", + }, + { + folderName: "kebab-case-name", + expected: "kebab-case-name", + }, + { + folderName: "mix3d_C4s3-N4m3", + expected: "mix3d-c4s3-n4m3", + }, + { + folderName: "123-456-789", + expected: "123-456-789", + }, + { + folderName: "abc123def456", + expected: "abc123def456", + }, + { + folderName: " spaces everywhere ", + expected: "spaces-everywhere", + }, + { + folderName: "unicode-cafΓ©-naΓ―ve", + expected: "unicode-caf-na-ve", + }, + { + folderName: "path/with/slashes", + expected: "path-with-slashes", + }, + { + folderName: "file.tar.gz", + expected: "file-tar-gz", + }, + { + folderName: "version-1.2.3-alpha", + expected: "version-1-2-3-alpha", + }, + + // Truncation test for names exceeding 64 characters + { + folderName: "this-is-a-very-long-folder-name-that-exceeds-sixty-four-characters-limit-and-should-be-truncated", + expected: "this-is-a-very-long-folder-name-that-exceeds-sixty-four-characte", + }, + } + + for _, tt := range tests { + t.Run(tt.folderName, func(t *testing.T) { + t.Parallel() + name, usingWorkspaceFolder := safeAgentName(tt.folderName, "friendly-fallback") + + assert.Equal(t, tt.expected, name) + assert.True(t, provisioner.AgentNameRegex.Match([]byte(name))) + assert.Equal(t, tt.fallback, !usingWorkspaceFolder) + }) + } +} + +func TestExpandedAgentName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + workspaceFolder string + friendlyName string + depth int + expected string + fallback bool + }{ + { + name: "simple path depth 1", + workspaceFolder: "/home/coder/project", + friendlyName: "friendly-fallback", + depth: 0, + expected: "project", + }, + { + name: "simple path depth 2", + workspaceFolder: "/home/coder/project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-project", + }, + { + name: "simple path depth 3", + workspaceFolder: "/home/coder/project", + friendlyName: "friendly-fallback", + depth: 2, + expected: "home-coder-project", + }, + { + name: "simple path depth exceeds available", + workspaceFolder: "/home/coder/project", + friendlyName: "friendly-fallback", + depth: 9, + expected: "home-coder-project", + }, + // Cases with special characters that need sanitization + { + name: "path with spaces and special chars", + workspaceFolder: "/home/coder/My Project_v2", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-my-project-v2", + }, + { + name: "path with dots and underscores", + workspaceFolder: "/home/user.name/project_folder.git", + friendlyName: "friendly-fallback", + depth: 1, + expected: "user-name-project-folder-git", + }, + // Edge cases + { + name: "empty path", + workspaceFolder: "", + friendlyName: "friendly-fallback", + depth: 0, + expected: "friendly-fallback", + fallback: true, + }, + { + name: "root path", + workspaceFolder: "/", + friendlyName: "friendly-fallback", + depth: 0, + expected: "friendly-fallback", + fallback: true, + }, + { + name: "single component", + workspaceFolder: "project", + friendlyName: "friendly-fallback", + depth: 0, + expected: "project", + }, + { + name: "single component with depth 2", + workspaceFolder: "project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "project", + }, + // Collision simulation cases + { + name: "foo/project depth 1", + workspaceFolder: "/home/coder/foo/project", + friendlyName: "friendly-fallback", + depth: 0, + expected: "project", + }, + { + name: "foo/project depth 2", + workspaceFolder: "/home/coder/foo/project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "foo-project", + }, + { + name: "bar/project depth 1", + workspaceFolder: "/home/coder/bar/project", + friendlyName: "friendly-fallback", + depth: 0, + expected: "project", + }, + { + name: "bar/project depth 2", + workspaceFolder: "/home/coder/bar/project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "bar-project", + }, + // Path with trailing slashes + { + name: "path with trailing slash", + workspaceFolder: "/home/coder/project/", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-project", + }, + { + name: "path with multiple trailing slashes", + workspaceFolder: "/home/coder/project///", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-project", + }, + // Path with leading slashes + { + name: "path with multiple leading slashes", + workspaceFolder: "///home/coder/project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-project", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + name, usingWorkspaceFolder := expandedAgentName(tt.workspaceFolder, tt.friendlyName, tt.depth) + + assert.Equal(t, tt.expected, name) + assert.True(t, provisioner.AgentNameRegex.Match([]byte(name))) + assert.Equal(t, tt.fallback, !usingWorkspaceFolder) + }) + } +} diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index d0141ea590826..ab857ee59cb0b 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3,11 +3,14 @@ package agentcontainers_test import ( "context" "encoding/json" + "fmt" "math/rand" "net/http" "net/http/httptest" "os" + "os/exec" "runtime" + "slices" "strings" "sync" "testing" @@ -16,6 +19,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/lib/pq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -26,7 +30,9 @@ import ( "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentcontainers/acmock" "github.com/coder/coder/v2/agent/agentcontainers/watcher" + "github.com/coder/coder/v2/agent/usershell" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" ) @@ -261,6 +267,16 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S if agent.OperatingSystem == "" { return agentcontainers.SubAgent{}, xerrors.New("operating system must be set") } + + for _, a := range m.agents { + if a.Name == agent.Name { + return agentcontainers.SubAgent{}, &pq.Error{ + Code: "23505", + Message: fmt.Sprintf("workspace agent name %q already exists in this workspace build", agent.Name), + } + } + } + agent.ID = uuid.New() agent.AuthToken = uuid.New() if m.agents == nil { @@ -291,6 +307,38 @@ func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error { return nil } +// fakeExecer implements agentexec.Execer for testing and tracks execution details. +type fakeExecer struct { + commands [][]string + createdCommands []*exec.Cmd +} + +func (f *fakeExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd { + f.commands = append(f.commands, append([]string{cmd}, args...)) + // Create a command that returns empty JSON for docker commands. + c := exec.CommandContext(ctx, "echo", "[]") + f.createdCommands = append(f.createdCommands, c) + return c +} + +func (f *fakeExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd { + f.commands = append(f.commands, append([]string{cmd}, args...)) + return &pty.Cmd{ + Context: ctx, + Path: cmd, + Args: append([]string{cmd}, args...), + Env: []string{}, + Dir: "", + } +} + +func (f *fakeExecer) getLastCommand() *exec.Cmd { + if len(f.createdCommands) == 0 { + return nil + } + return f.createdCommands[len(f.createdCommands)-1] +} + func TestAPI(t *testing.T) { t.Parallel() @@ -389,6 +437,7 @@ func TestAPI(t *testing.T) { agentcontainers.WithContainerCLI(mLister), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) + api.Start() defer api.Close() r.Mount("/", api.Routes()) @@ -444,78 +493,77 @@ func TestAPI(t *testing.T) { t.Run("Recreate", func(t *testing.T) { t.Parallel() - validContainer := codersdk.WorkspaceAgentContainer{ - ID: "container-id", - FriendlyName: "container-name", + devcontainerID1 := uuid.New() + devcontainerID2 := uuid.New() + workspaceFolder1 := "/workspace/test1" + workspaceFolder2 := "/workspace/test2" + configPath1 := "/workspace/test1/.devcontainer/devcontainer.json" + configPath2 := "/workspace/test2/.devcontainer/devcontainer.json" + + // Create a container that represents an existing devcontainer + devContainer1 := codersdk.WorkspaceAgentContainer{ + ID: "container-1", + FriendlyName: "test-container-1", Running: true, Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/workspaces", - agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder1, + agentcontainers.DevcontainerConfigFileLabel: configPath1, }, } - missingFolderContainer := codersdk.WorkspaceAgentContainer{ - ID: "missing-folder-container", - FriendlyName: "missing-folder-container", - Labels: map[string]string{}, + devContainer2 := codersdk.WorkspaceAgentContainer{ + ID: "container-2", + FriendlyName: "test-container-2", + Running: true, + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder2, + agentcontainers.DevcontainerConfigFileLabel: configPath2, + }, } tests := []struct { - name string - containerID string - lister *fakeContainerCLI - devcontainerCLI *fakeDevcontainerCLI - wantStatus []int - wantBody []string + name string + devcontainerID string + setupDevcontainers []codersdk.WorkspaceAgentDevcontainer + lister *fakeContainerCLI + devcontainerCLI *fakeDevcontainerCLI + wantStatus []int + wantBody []string }{ { - name: "Missing container ID", - containerID: "", + name: "Missing devcontainer ID", + devcontainerID: "", lister: &fakeContainerCLI{}, devcontainerCLI: &fakeDevcontainerCLI{}, wantStatus: []int{http.StatusBadRequest}, - wantBody: []string{"Missing container ID or name"}, - }, - { - name: "List error", - containerID: "container-id", - lister: &fakeContainerCLI{ - listErr: xerrors.New("list error"), - }, - devcontainerCLI: &fakeDevcontainerCLI{}, - wantStatus: []int{http.StatusInternalServerError}, - wantBody: []string{"Could not list containers"}, + wantBody: []string{"Missing devcontainer ID"}, }, { - name: "Container not found", - containerID: "nonexistent-container", + name: "Devcontainer not found", + devcontainerID: uuid.NewString(), lister: &fakeContainerCLI{ - containers: codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{validContainer}, - }, + arch: "", // Unsupported architecture, don't inject subagent. }, devcontainerCLI: &fakeDevcontainerCLI{}, wantStatus: []int{http.StatusNotFound}, - wantBody: []string{"Container not found"}, + wantBody: []string{"Devcontainer not found"}, }, { - name: "Missing workspace folder label", - containerID: "missing-folder-container", - lister: &fakeContainerCLI{ - containers: codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{missingFolderContainer}, + name: "Devcontainer CLI error", + devcontainerID: devcontainerID1.String(), + setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: devcontainerID1, + Name: "test-devcontainer-1", + WorkspaceFolder: workspaceFolder1, + ConfigPath: configPath1, + Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, + Container: &devContainer1, }, }, - devcontainerCLI: &fakeDevcontainerCLI{}, - wantStatus: []int{http.StatusBadRequest}, - wantBody: []string{"Missing workspace folder label"}, - }, - { - name: "Devcontainer CLI error", - containerID: "container-id", lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{validContainer}, + Containers: []codersdk.WorkspaceAgentContainer{devContainer1}, }, arch: "", // Unsupported architecture, don't inject subagent. }, @@ -526,11 +574,21 @@ func TestAPI(t *testing.T) { wantBody: []string{"Devcontainer recreation initiated", "Devcontainer recreation already in progress"}, }, { - name: "OK", - containerID: "container-id", + name: "OK", + devcontainerID: devcontainerID2.String(), + setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: devcontainerID2, + Name: "test-devcontainer-2", + WorkspaceFolder: workspaceFolder2, + ConfigPath: configPath2, + Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, + Container: &devContainer2, + }, + }, lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{validContainer}, + Containers: []codersdk.WorkspaceAgentContainer{devContainer2}, }, arch: "", // Unsupported architecture, don't inject subagent. }, @@ -559,13 +617,17 @@ func TestAPI(t *testing.T) { // Setup router with the handler under test. r := chi.NewRouter() + api := agentcontainers.NewAPI( logger, agentcontainers.WithClock(mClock), agentcontainers.WithContainerCLI(tt.lister), agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI), agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithDevcontainers(tt.setupDevcontainers, nil), ) + + api.Start() defer api.Close() r.Mount("/", api.Routes()) @@ -576,7 +638,7 @@ func TestAPI(t *testing.T) { for i := range tt.wantStatus { // Simulate HTTP request to the recreate endpoint. - req := httptest.NewRequest(http.MethodPost, "/devcontainers/container/"+tt.containerID+"/recreate", nil). + req := httptest.NewRequest(http.MethodPost, "/devcontainers/"+tt.devcontainerID+"/recreate", nil). WithContext(ctx) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -699,6 +761,7 @@ func TestAPI(t *testing.T) { knownDevcontainers []codersdk.WorkspaceAgentDevcontainer wantStatus int wantCount int + wantTestContainer bool verify func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) }{ { @@ -897,8 +960,8 @@ func TestAPI(t *testing.T) { FriendlyName: "project1-container", Running: true, Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project", - agentcontainers.DevcontainerConfigFileLabel: "/workspace/project/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project1", + agentcontainers.DevcontainerConfigFileLabel: "/workspace/project1/.devcontainer/devcontainer.json", }, }, { @@ -906,8 +969,8 @@ func TestAPI(t *testing.T) { FriendlyName: "project2-container", Running: true, Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/home/user/project", - agentcontainers.DevcontainerConfigFileLabel: "/home/user/project/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/home/user/project2", + agentcontainers.DevcontainerConfigFileLabel: "/home/user/project2/.devcontainer/devcontainer.json", }, }, { @@ -915,8 +978,8 @@ func TestAPI(t *testing.T) { FriendlyName: "project3-container", Running: true, Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/var/lib/project", - agentcontainers.DevcontainerConfigFileLabel: "/var/lib/project/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/var/lib/project3", + agentcontainers.DevcontainerConfigFileLabel: "/var/lib/project3/.devcontainer/devcontainer.json", }, }, }, @@ -945,6 +1008,13 @@ func TestAPI(t *testing.T) { assert.Len(t, names, 4, "should have four unique devcontainer names") }, }, + { + name: "Include test containers", + lister: &fakeContainerCLI{}, + wantStatus: http.StatusOK, + wantTestContainer: true, + wantCount: 1, // Will be appended. + }, } for _, tt := range tests { @@ -957,14 +1027,33 @@ func TestAPI(t *testing.T) { mClock.Set(time.Now()).MustWait(testutil.Context(t, testutil.WaitShort)) tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + // This container should be ignored unless explicitly included. + tt.lister.containers.Containers = append(tt.lister.containers.Containers, codersdk.WorkspaceAgentContainer{ + ID: "test-container-1", + FriendlyName: "test-container-1", + Running: true, + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/workspace/test1", + agentcontainers.DevcontainerConfigFileLabel: "/workspace/test1/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerIsTestRunLabel: "true", + }, + }) + // Setup router with the handler under test. r := chi.NewRouter() apiOptions := []agentcontainers.Option{ agentcontainers.WithClock(mClock), agentcontainers.WithContainerCLI(tt.lister), + agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}), agentcontainers.WithWatcher(watcher.NewNoop()), } + if tt.wantTestContainer { + apiOptions = append(apiOptions, agentcontainers.WithContainerLabelIncludeFilter( + agentcontainers.DevcontainerIsTestRunLabel, "true", + )) + } + // Generate matching scripts for the known devcontainers // (required to extract log source ID). var scripts []codersdk.WorkspaceAgentScript @@ -979,6 +1068,7 @@ func TestAPI(t *testing.T) { } api := agentcontainers.NewAPI(logger, apiOptions...) + api.Start() defer api.Close() r.Mount("/", api.Routes()) @@ -990,6 +1080,11 @@ func TestAPI(t *testing.T) { tickerTrap.MustWait(ctx).MustRelease(ctx) tickerTrap.Close() + for _, dc := range tt.knownDevcontainers { + err := api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath) + require.NoError(t, err) + } + // Advance the clock to run the updater loop. _, aw := mClock.AdvanceNext() aw.MustWait(ctx) @@ -1063,6 +1158,7 @@ func TestAPI(t *testing.T) { []codersdk.WorkspaceAgentScript{{LogSourceID: uuid.New(), ID: dc.ID}}, ), ) + api.Start() defer api.Close() // Make sure the ticker function has been registered @@ -1158,6 +1254,7 @@ func TestAPI(t *testing.T) { agentcontainers.WithWatcher(fWatcher), agentcontainers.WithClock(mClock), ) + api.Start() defer api.Close() r := chi.NewRouter() @@ -1262,6 +1359,11 @@ func TestAPI(t *testing.T) { deleteErrC: make(chan error, 1), } fakeDCCLI = &fakeDevcontainerCLI{ + readConfig: agentcontainers.DevcontainerConfig{ + Workspace: agentcontainers.DevcontainerWorkspace{ + WorkspaceFolder: "/workspaces/coder", + }, + }, execErrC: make(chan func(cmd string, args ...string) error, 1), readConfigErrC: make(chan func(envs []string) error, 1), } @@ -1273,8 +1375,8 @@ func TestAPI(t *testing.T) { Running: true, CreatedAt: time.Now(), Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/workspaces", - agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/coder", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/coder/.devcontainer/devcontainer.json", }, } ) @@ -1290,6 +1392,7 @@ func TestAPI(t *testing.T) { mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), mCCLI.EXPECT().Copy(gomock.Any(), "test-container-id", coderBin, "/.coder-agent/coder").Return(nil), mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil), ) mClock.Set(time.Now()).MustWait(ctx) @@ -1303,8 +1406,9 @@ func TestAPI(t *testing.T) { agentcontainers.WithSubAgentClient(fakeSAC), agentcontainers.WithSubAgentURL("test-subagent-url"), agentcontainers.WithDevcontainerCLI(fakeDCCLI), - agentcontainers.WithManifestInfo("test-user", "test-workspace"), + agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent"), ) + api.Start() apiClose := func() { closeOnce.Do(func() { // Close before api.Close() defer to avoid deadlock after test. @@ -1320,16 +1424,13 @@ func TestAPI(t *testing.T) { // Allow initial agent creation and injection to succeed. testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil) - testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(cmd string, args ...string) error { - assert.Equal(t, "pwd", cmd) - assert.Empty(t, args) - return nil - }) // Exec pwd. testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error { - assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container") + assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder") assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace") assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user") + assert.Contains(t, envs, "CODER_WORKSPACE_PARENT_AGENT_NAME=test-parent-agent") assert.Contains(t, envs, "CODER_URL=test-subagent-url") + assert.Contains(t, envs, "CONTAINER_ID=test-container-id") return nil }) @@ -1349,8 +1450,8 @@ func TestAPI(t *testing.T) { // Verify agent was created. require.Len(t, fakeSAC.created, 1) - assert.Equal(t, "test-container", fakeSAC.created[0].Name) - assert.Equal(t, "/workspaces", fakeSAC.created[0].Directory) + assert.Equal(t, "coder", fakeSAC.created[0].Name) + assert.Equal(t, "/workspaces/coder", fakeSAC.created[0].Directory) assert.Len(t, fakeSAC.deleted, 0) t.Log("Agent injected successfully, now testing reinjection into the same container...") @@ -1380,6 +1481,7 @@ func TestAPI(t *testing.T) { mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), mCCLI.EXPECT().Copy(gomock.Any(), "test-container-id", coderBin, "/.coder-agent/coder").Return(nil), mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil), ) // Verify that the agent has started. @@ -1405,7 +1507,7 @@ func TestAPI(t *testing.T) { WaitStartLoop: for { // Agent reinjection will succeed and we will not re-create the - // agent, nor re-probe pwd. + // agent. mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{testContainer}, }, nil).Times(1) // 1 update. @@ -1440,6 +1542,7 @@ func TestAPI(t *testing.T) { mCCLI.EXPECT().ExecAs(gomock.Any(), "new-test-container-id", "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), mCCLI.EXPECT().Copy(gomock.Any(), "new-test-container-id", coderBin, "/.coder-agent/coder").Return(nil), mCCLI.EXPECT().ExecAs(gomock.Any(), "new-test-container-id", "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), "new-test-container-id", "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil), ) fakeDCCLI.readConfig.MergedConfiguration.Customizations.Coder = []agentcontainers.CoderCustomization{ @@ -1467,16 +1570,13 @@ func TestAPI(t *testing.T) { testutil.RequireSend(ctx, t, fakeSAC.deleteErrC, nil) // Expect the agent to be recreated. testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil) - testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(cmd string, args ...string) error { - assert.Equal(t, "pwd", cmd) - assert.Empty(t, args) - return nil - }) // Exec pwd. testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error { - assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container") + assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder") assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace") assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user") + assert.Contains(t, envs, "CODER_WORKSPACE_PARENT_AGENT_NAME=test-parent-agent") assert.Contains(t, envs, "CODER_URL=test-subagent-url") + assert.NotContains(t, envs, "CONTAINER_ID=test-container-id") return nil }) @@ -1535,6 +1635,7 @@ func TestAPI(t *testing.T) { agentcontainers.WithSubAgentClient(fakeSAC), agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}), ) + api.Start() defer api.Close() tickerTrap.MustWait(ctx).MustRelease(ctx) @@ -1814,7 +1915,6 @@ func TestAPI(t *testing.T) { }, }, }, - execErrC: make(chan func(cmd string, args ...string) error, 1), } testContainer = codersdk.WorkspaceAgentContainer{ @@ -1844,6 +1944,7 @@ func TestAPI(t *testing.T) { mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil), mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil), ) mClock.Set(time.Now()).MustWait(ctx) @@ -1857,19 +1958,14 @@ func TestAPI(t *testing.T) { agentcontainers.WithSubAgentURL("test-subagent-url"), agentcontainers.WithWatcher(watcher.NewNoop()), ) + api.Start() defer api.Close() // Close before api.Close() defer to avoid deadlock after test. defer close(fSAC.createErrC) - defer close(fDCCLI.execErrC) // Given: We allow agent creation and injection to succeed. testutil.RequireSend(ctx, t, fSAC.createErrC, nil) - testutil.RequireSend(ctx, t, fDCCLI.execErrC, func(cmd string, args ...string) error { - assert.Equal(t, "pwd", cmd) - assert.Empty(t, args) - return nil - }) // Wait until the ticker has been registered. tickerTrap.MustWait(ctx).MustRelease(ctx) @@ -1913,7 +2009,6 @@ func TestAPI(t *testing.T) { }, }, readConfigErrC: make(chan func(envs []string) error, 2), - execErrC: make(chan func(cmd string, args ...string) error, 1), } testContainer = codersdk.WorkspaceAgentContainer{ @@ -1923,8 +2018,8 @@ func TestAPI(t *testing.T) { Running: true, CreatedAt: time.Now(), Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/workspaces", - agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/coder", + agentcontainers.DevcontainerConfigFileLabel: "/workspaces/coder/.devcontainer/devcontainer.json", }, } ) @@ -1943,6 +2038,7 @@ func TestAPI(t *testing.T) { mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil), mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil), ) mClock.Set(time.Now()).MustWait(ctx) @@ -1956,29 +2052,24 @@ func TestAPI(t *testing.T) { agentcontainers.WithSubAgentURL("test-subagent-url"), agentcontainers.WithWatcher(watcher.NewNoop()), ) + api.Start() defer api.Close() // Close before api.Close() defer to avoid deadlock after test. defer close(fSAC.createErrC) - defer close(fDCCLI.execErrC) defer close(fDCCLI.readConfigErrC) // Given: We allow agent creation and injection to succeed. testutil.RequireSend(ctx, t, fSAC.createErrC, nil) - testutil.RequireSend(ctx, t, fDCCLI.execErrC, func(cmd string, args ...string) error { - assert.Equal(t, "pwd", cmd) - assert.Empty(t, args) - return nil - }) testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error { // We expect the wrong workspace agent name passed in first. - assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=test-container") + assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=coder") return nil }) testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error { // We then expect the agent name passed here to have been read from the config. assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=custom-name") - assert.NotContains(t, env, "CODER_WORKSPACE_AGENT_NAME=test-container") + assert.NotContains(t, env, "CODER_WORKSPACE_AGENT_NAME=coder") return nil }) @@ -1989,6 +2080,355 @@ func TestAPI(t *testing.T) { // Then: We expected it to succeed require.Len(t, fSAC.created, 1) }) + + t.Run("ReadConfigWithFeatureOptions", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)") + } + + var ( + ctx = testutil.Context(t, testutil.WaitMedium) + logger = testutil.Logger(t) + mClock = quartz.NewMock(t) + mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t)) + fSAC = &fakeSubAgentClient{ + logger: logger.Named("fakeSubAgentClient"), + createErrC: make(chan error, 1), + } + fDCCLI = &fakeDevcontainerCLI{ + readConfig: agentcontainers.DevcontainerConfig{ + MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{ + Features: agentcontainers.DevcontainerFeatures{ + "./code-server": map[string]any{ + "port": 9090, + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{ + "moby": "false", + }, + }, + }, + Workspace: agentcontainers.DevcontainerWorkspace{ + WorkspaceFolder: "/workspaces/coder", + }, + }, + readConfigErrC: make(chan func(envs []string) error, 2), + } + + testContainer = codersdk.WorkspaceAgentContainer{ + ID: "test-container-id", + FriendlyName: "test-container", + Image: "test-image", + Running: true, + CreatedAt: time.Now(), + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/coder", + agentcontainers.DevcontainerConfigFileLabel: "/workspaces/coder/.devcontainer/devcontainer.json", + }, + } + ) + + coderBin, err := os.Executable() + require.NoError(t, err) + + // Mock the `List` function to always return our test container. + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{testContainer}, + }, nil).AnyTimes() + + // Mock the steps used for injecting the coder agent. + gomock.InOrder( + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), + mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil), + ) + + mClock.Set(time.Now()).MustWait(ctx) + tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithSubAgentClient(fSAC), + agentcontainers.WithSubAgentURL("test-subagent-url"), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent"), + ) + api.Start() + defer api.Close() + + // Close before api.Close() defer to avoid deadlock after test. + defer close(fSAC.createErrC) + defer close(fDCCLI.readConfigErrC) + + // Allow agent creation and injection to succeed. + testutil.RequireSend(ctx, t, fSAC.createErrC, nil) + + testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(envs []string) error { + assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder") + assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace") + assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user") + assert.Contains(t, envs, "CODER_WORKSPACE_PARENT_AGENT_NAME=test-parent-agent") + assert.Contains(t, envs, "CODER_URL=test-subagent-url") + assert.Contains(t, envs, "CONTAINER_ID=test-container-id") + // First call should not have feature envs. + assert.NotContains(t, envs, "FEATURE_CODE_SERVER_OPTION_PORT=9090") + assert.NotContains(t, envs, "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false") + return nil + }) + + testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(envs []string) error { + assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder") + assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace") + assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user") + assert.Contains(t, envs, "CODER_WORKSPACE_PARENT_AGENT_NAME=test-parent-agent") + assert.Contains(t, envs, "CODER_URL=test-subagent-url") + assert.Contains(t, envs, "CONTAINER_ID=test-container-id") + // Second call should have feature envs from the first config read. + assert.Contains(t, envs, "FEATURE_CODE_SERVER_OPTION_PORT=9090") + assert.Contains(t, envs, "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false") + return nil + }) + + // Wait until the ticker has been registered. + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + // Verify agent was created successfully + require.Len(t, fSAC.created, 1) + }) + + t.Run("CommandEnv", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + + // Create fake execer to track execution details. + fakeExec := &fakeExecer{} + + // Custom CommandEnv that returns specific values. + testShell := "/bin/custom-shell" + testDir := t.TempDir() + testEnv := []string{"CUSTOM_VAR=test_value", "PATH=/custom/path"} + + commandEnv := func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) { + return testShell, testDir, testEnv, nil + } + + mClock := quartz.NewMock(t) // Stop time. + + // Create API with CommandEnv. + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithExecer(fakeExec), + agentcontainers.WithCommandEnv(commandEnv), + ) + api.Start() + defer api.Close() + + // Call RefreshContainers directly to trigger CommandEnv usage. + _ = api.RefreshContainers(ctx) // Ignore error since docker commands will fail. + + // Verify commands were executed through the custom shell and environment. + require.NotEmpty(t, fakeExec.commands, "commands should be executed") + + // Want: /bin/custom-shell -c '"docker" "ps" "--all" "--quiet" "--no-trunc"' + require.Equal(t, testShell, fakeExec.commands[0][0], "custom shell should be used") + if runtime.GOOS == "windows" { + require.Equal(t, "/c", fakeExec.commands[0][1], "shell should be called with /c on Windows") + } else { + require.Equal(t, "-c", fakeExec.commands[0][1], "shell should be called with -c") + } + require.Len(t, fakeExec.commands[0], 3, "command should have 3 arguments") + require.GreaterOrEqual(t, strings.Count(fakeExec.commands[0][2], " "), 2, "command/script should have multiple arguments") + require.True(t, strings.HasPrefix(fakeExec.commands[0][2], `"docker" "ps"`), "command should start with \"docker\" \"ps\"") + + // Verify the environment was set on the command. + lastCmd := fakeExec.getLastCommand() + require.NotNil(t, lastCmd, "command should be created") + require.Equal(t, testDir, lastCmd.Dir, "custom directory should be used") + require.Equal(t, testEnv, lastCmd.Env, "custom environment should be used") + }) + + t.Run("IgnoreCustomization", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)") + } + + ctx := testutil.Context(t, testutil.WaitShort) + + startTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + configPath := "/workspace/project/.devcontainer/devcontainer.json" + + container := codersdk.WorkspaceAgentContainer{ + ID: "container-id", + FriendlyName: "container-name", + Running: true, + CreatedAt: startTime.Add(-1 * time.Hour), + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project", + agentcontainers.DevcontainerConfigFileLabel: configPath, + }, + } + + fLister := &fakeContainerCLI{ + containers: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{container}, + }, + arch: runtime.GOARCH, + } + + // Start with ignore=true + fDCCLI := &fakeDevcontainerCLI{ + execErrC: make(chan func(string, ...string) error, 1), + readConfig: agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{Ignore: true}, + }, + }, + Workspace: agentcontainers.DevcontainerWorkspace{WorkspaceFolder: "/workspace/project"}, + }, + } + + fakeSAC := &fakeSubAgentClient{ + logger: slogtest.Make(t, nil).Named("fakeSubAgentClient"), + agents: make(map[uuid.UUID]agentcontainers.SubAgent), + createErrC: make(chan error, 1), + deleteErrC: make(chan error, 1), + } + + mClock := quartz.NewMock(t) + mClock.Set(startTime) + fWatcher := newFakeWatcher(t) + + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + api := agentcontainers.NewAPI( + logger, + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithContainerCLI(fLister), + agentcontainers.WithSubAgentClient(fakeSAC), + agentcontainers.WithWatcher(fWatcher), + agentcontainers.WithClock(mClock), + ) + api.Start() + defer func() { + close(fakeSAC.createErrC) + close(fakeSAC.deleteErrC) + api.Close() + }() + + err := api.RefreshContainers(ctx) + require.NoError(t, err, "RefreshContainers should not error") + + r := chi.NewRouter() + r.Mount("/", api.Routes()) + + t.Log("Phase 1: Test ignore=true filters out devcontainer") + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var response codersdk.WorkspaceAgentListContainersResponse + err = json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + assert.Empty(t, response.Devcontainers, "ignored devcontainer should not be in response when ignore=true") + assert.Len(t, response.Containers, 1, "regular container should still be listed") + + t.Log("Phase 2: Change to ignore=false") + fDCCLI.readConfig.Configuration.Customizations.Coder.Ignore = false + var ( + exitSubAgent = make(chan struct{}) + subAgentExited = make(chan struct{}) + exitSubAgentOnce sync.Once + ) + defer func() { + exitSubAgentOnce.Do(func() { + close(exitSubAgent) + }) + }() + execSubAgent := func(cmd string, args ...string) error { + if len(args) != 1 || args[0] != "agent" { + t.Log("execSubAgent called with unexpected arguments", cmd, args) + return nil + } + defer close(subAgentExited) + select { + case <-exitSubAgent: + case <-ctx.Done(): + return ctx.Err() + } + return nil + } + testutil.RequireSend(ctx, t, fDCCLI.execErrC, execSubAgent) + testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil) + + fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{ + Name: configPath, + Op: fsnotify.Write, + }) + + err = api.RefreshContainers(ctx) + require.NoError(t, err) + + t.Log("Phase 2: Cont, waiting for sub agent to exit") + exitSubAgentOnce.Do(func() { + close(exitSubAgent) + }) + select { + case <-subAgentExited: + case <-ctx.Done(): + t.Fatal("timeout waiting for sub agent to exit") + } + + req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + err = json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + assert.Len(t, response.Devcontainers, 1, "devcontainer should be in response when ignore=false") + assert.Len(t, response.Containers, 1, "regular container should still be listed") + assert.Equal(t, "/workspace/project", response.Devcontainers[0].WorkspaceFolder) + require.Len(t, fakeSAC.created, 1, "sub agent should be created when ignore=false") + createdAgentID := fakeSAC.created[0].ID + + t.Log("Phase 3: Change back to ignore=true and test sub agent deletion") + fDCCLI.readConfig.Configuration.Customizations.Coder.Ignore = true + testutil.RequireSend(ctx, t, fakeSAC.deleteErrC, nil) + + fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{ + Name: configPath, + Op: fsnotify.Write, + }) + + err = api.RefreshContainers(ctx) + require.NoError(t, err) + + req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + err = json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + assert.Empty(t, response.Devcontainers, "devcontainer should be filtered out when ignore=true again") + assert.Len(t, response.Containers, 1, "regular container should still be listed") + require.Len(t, fakeSAC.deleted, 1, "sub agent should be deleted when ignore=true") + assert.Equal(t, createdAgentID, fakeSAC.deleted[0], "the same sub agent that was created should be deleted") + }) } // mustFindDevcontainerByPath returns the devcontainer with the given workspace @@ -2006,6 +2446,128 @@ func mustFindDevcontainerByPath(t *testing.T, devcontainers []codersdk.Workspace return codersdk.WorkspaceAgentDevcontainer{} // Unreachable, but required for compilation } +// TestSubAgentCreationWithNameRetry tests the retry logic when unique constraint violations occur +func TestSubAgentCreationWithNameRetry(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows") + } + + tests := []struct { + name string + workspaceFolders []string + expectedNames []string + takenNames []string + }{ + { + name: "SingleCollision", + workspaceFolders: []string{ + "/home/coder/foo/project", + "/home/coder/bar/project", + }, + expectedNames: []string{ + "project", + "bar-project", + }, + }, + { + name: "MultipleCollisions", + workspaceFolders: []string{ + "/home/coder/foo/x/project", + "/home/coder/bar/x/project", + "/home/coder/baz/x/project", + }, + expectedNames: []string{ + "project", + "x-project", + "baz-x-project", + }, + }, + { + name: "NameAlreadyTaken", + takenNames: []string{"project", "x-project"}, + workspaceFolders: []string{ + "/home/coder/foo/x/project", + }, + expectedNames: []string{ + "foo-x-project", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitMedium) + logger = testutil.Logger(t) + mClock = quartz.NewMock(t) + fSAC = &fakeSubAgentClient{logger: logger, agents: make(map[uuid.UUID]agentcontainers.SubAgent)} + ccli = &fakeContainerCLI{arch: runtime.GOARCH} + ) + + for _, name := range tt.takenNames { + fSAC.agents[uuid.New()] = agentcontainers.SubAgent{Name: name} + } + + mClock.Set(time.Now()).MustWait(ctx) + tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(ccli), + agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}), + agentcontainers.WithSubAgentClient(fSAC), + agentcontainers.WithWatcher(watcher.NewNoop()), + ) + api.Start() + defer api.Close() + + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + for i, workspaceFolder := range tt.workspaceFolders { + ccli.containers.Containers = append(ccli.containers.Containers, newFakeContainer( + fmt.Sprintf("container%d", i+1), + fmt.Sprintf("/.devcontainer/devcontainer%d.json", i+1), + workspaceFolder, + )) + + err := api.RefreshContainers(ctx) + require.NoError(t, err) + } + + // Verify that both agents were created with expected names + require.Len(t, fSAC.created, len(tt.workspaceFolders)) + + actualNames := make([]string, len(fSAC.created)) + for i, agent := range fSAC.created { + actualNames[i] = agent.Name + } + + slices.Sort(tt.expectedNames) + slices.Sort(actualNames) + + assert.Equal(t, tt.expectedNames, actualNames) + }) + } +} + +func newFakeContainer(id, configPath, workspaceFolder string) codersdk.WorkspaceAgentContainer { + return codersdk.WorkspaceAgentContainer{ + ID: id, + FriendlyName: "test-friendly", + Image: "test-image:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder, + agentcontainers.DevcontainerConfigFileLabel: configPath, + }, + Running: true, + } +} + func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer { t.Helper() ct := codersdk.WorkspaceAgentContainer{ diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index 83463481c97f7..58ca3901e2f23 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -311,6 +311,10 @@ func (dcli *dockerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListCon // container IDs and returns the parsed output. // The stderr output is also returned for logging purposes. func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...string) (stdout, stderr []byte, err error) { + if ctx.Err() != nil { + // If the context is done, we don't want to run the command. + return []byte{}, []byte{}, ctx.Err() + } var stdoutBuf, stderrBuf bytes.Buffer cmd := execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...) cmd.Stdout = &stdoutBuf @@ -319,6 +323,12 @@ func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...strin stdout = bytes.TrimSpace(stdoutBuf.Bytes()) stderr = bytes.TrimSpace(stderrBuf.Bytes()) if err != nil { + if ctx.Err() != nil { + // If the context was canceled while running the command, + // return the context error instead of the command error, + // which is likely to be "signal: killed". + return stdout, stderr, ctx.Err() + } if bytes.Contains(stderr, []byte("No such object:")) { // This can happen if a container is deleted between the time we check for its existence and the time we inspect it. return stdout, stderr, nil diff --git a/agent/agentcontainers/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go index eeb6a5d0374d1..a60dec75cd845 100644 --- a/agent/agentcontainers/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -41,7 +41,6 @@ func TestWrapDockerExec(t *testing.T) { }, } for _, tt := range tests { - tt := tt // appease the linter even though this isn't needed anymore t.Run(tt.name, func(t *testing.T) { t.Parallel() actualCmd, actualArgs := wrapDockerExec("my-container", tt.containerUser, tt.cmdArgs[0], tt.cmdArgs[1:]...) @@ -54,7 +53,6 @@ func TestWrapDockerExec(t *testing.T) { func TestConvertDockerPort(t *testing.T) { t.Parallel() - //nolint:paralleltest // variable recapture no longer required for _, tc := range []struct { name string in string @@ -101,7 +99,6 @@ func TestConvertDockerPort(t *testing.T) { expectError: "invalid port", }, } { - //nolint: paralleltest // variable recapture no longer required t.Run(tc.name, func(t *testing.T) { t.Parallel() actualPort, actualNetwork, actualErr := convertDockerPort(tc.in) @@ -151,7 +148,6 @@ func TestConvertDockerVolume(t *testing.T) { expectError: "invalid volume", }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() }) diff --git a/agent/agentcontainers/devcontainer.go b/agent/agentcontainers/devcontainer.go index f13963d7b63d7..555e406e0b52c 100644 --- a/agent/agentcontainers/devcontainer.go +++ b/agent/agentcontainers/devcontainer.go @@ -2,10 +2,10 @@ package agentcontainers import ( "context" - "fmt" "os" "path/filepath" - "strings" + + "github.com/google/uuid" "cdr.dev/slog" "github.com/coder/coder/v2/codersdk" @@ -18,37 +18,25 @@ const ( // DevcontainerConfigFileLabel is the label that contains the path to // the devcontainer.json configuration file. DevcontainerConfigFileLabel = "devcontainer.config_file" + // DevcontainerIsTestRunLabel is set if the devcontainer is part of a test + // and should be excluded. + DevcontainerIsTestRunLabel = "devcontainer.is_test_run" // The default workspace folder inside the devcontainer. DevcontainerDefaultContainerWorkspaceFolder = "/workspaces" ) -const devcontainerUpScriptTemplate = ` -if ! which devcontainer > /dev/null 2>&1; then - echo "ERROR: Unable to start devcontainer, @devcontainers/cli is not installed or not found in \$PATH." 1>&2 - echo "Please install @devcontainers/cli by running \"npm install -g @devcontainers/cli\" or by using the \"devcontainers-cli\" Coder module." 1>&2 - exit 1 -fi -devcontainer up %s -` - -// ExtractAndInitializeDevcontainerScripts extracts devcontainer scripts from -// the given scripts and devcontainers. The devcontainer scripts are removed -// from the returned scripts so that they can be run separately. -// -// Dev Containers have an inherent dependency on start scripts, since they -// initialize the workspace (e.g. git clone, npm install, etc). This is -// important if e.g. a Coder module to install @devcontainer/cli is used. -func ExtractAndInitializeDevcontainerScripts( +func ExtractDevcontainerScripts( devcontainers []codersdk.WorkspaceAgentDevcontainer, scripts []codersdk.WorkspaceAgentScript, -) (filteredScripts []codersdk.WorkspaceAgentScript, devcontainerScripts []codersdk.WorkspaceAgentScript) { +) (filteredScripts []codersdk.WorkspaceAgentScript, devcontainerScripts map[uuid.UUID]codersdk.WorkspaceAgentScript) { + devcontainerScripts = make(map[uuid.UUID]codersdk.WorkspaceAgentScript) ScriptLoop: for _, script := range scripts { for _, dc := range devcontainers { // The devcontainer scripts match the devcontainer ID for // identification. if script.ID == dc.ID { - devcontainerScripts = append(devcontainerScripts, devcontainerStartupScript(dc, script)) + devcontainerScripts[dc.ID] = script continue ScriptLoop } } @@ -59,24 +47,6 @@ ScriptLoop: return filteredScripts, devcontainerScripts } -func devcontainerStartupScript(dc codersdk.WorkspaceAgentDevcontainer, script codersdk.WorkspaceAgentScript) codersdk.WorkspaceAgentScript { - args := []string{ - "--log-format json", - fmt.Sprintf("--workspace-folder %q", dc.WorkspaceFolder), - } - if dc.ConfigPath != "" { - args = append(args, fmt.Sprintf("--config %q", dc.ConfigPath)) - } - cmd := fmt.Sprintf(devcontainerUpScriptTemplate, strings.Join(args, " ")) - // Force the script to run in /bin/sh, since some shells (e.g. fish) - // don't support the script. - script.Script = fmt.Sprintf("/bin/sh -c '%s'", cmd) - // Disable RunOnStart, scripts have this set so that when devcontainers - // have not been enabled, a warning will be surfaced in the agent logs. - script.RunOnStart = false - return script -} - // ExpandAllDevcontainerPaths expands all devcontainer paths in the given // devcontainers. This is required by the devcontainer CLI, which requires // absolute paths for the workspace folder and config path. diff --git a/agent/agentcontainers/devcontainer_test.go b/agent/agentcontainers/devcontainer_test.go deleted file mode 100644 index b20c943175821..0000000000000 --- a/agent/agentcontainers/devcontainer_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package agentcontainers_test - -import ( - "path/filepath" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/v2/agent/agentcontainers" - "github.com/coder/coder/v2/codersdk" -) - -func TestExtractAndInitializeDevcontainerScripts(t *testing.T) { - t.Parallel() - - scriptIDs := []uuid.UUID{uuid.New(), uuid.New()} - devcontainerIDs := []uuid.UUID{uuid.New(), uuid.New()} - - type args struct { - expandPath func(string) (string, error) - devcontainers []codersdk.WorkspaceAgentDevcontainer - scripts []codersdk.WorkspaceAgentScript - } - tests := []struct { - name string - args args - wantFilteredScripts []codersdk.WorkspaceAgentScript - wantDevcontainerScripts []codersdk.WorkspaceAgentScript - - skipOnWindowsDueToPathSeparator bool - }{ - { - name: "no scripts", - args: args{ - expandPath: nil, - devcontainers: nil, - scripts: nil, - }, - wantFilteredScripts: nil, - wantDevcontainerScripts: nil, - }, - { - name: "no devcontainers", - args: args{ - expandPath: nil, - devcontainers: nil, - scripts: []codersdk.WorkspaceAgentScript{ - {ID: scriptIDs[0]}, - {ID: scriptIDs[1]}, - }, - }, - wantFilteredScripts: []codersdk.WorkspaceAgentScript{ - {ID: scriptIDs[0]}, - {ID: scriptIDs[1]}, - }, - wantDevcontainerScripts: nil, - }, - { - name: "no scripts match devcontainers", - args: args{ - expandPath: nil, - devcontainers: []codersdk.WorkspaceAgentDevcontainer{ - {ID: devcontainerIDs[0]}, - {ID: devcontainerIDs[1]}, - }, - scripts: []codersdk.WorkspaceAgentScript{ - {ID: scriptIDs[0]}, - {ID: scriptIDs[1]}, - }, - }, - wantFilteredScripts: []codersdk.WorkspaceAgentScript{ - {ID: scriptIDs[0]}, - {ID: scriptIDs[1]}, - }, - wantDevcontainerScripts: nil, - }, - { - name: "scripts match devcontainers and sets RunOnStart=false", - args: args{ - expandPath: nil, - devcontainers: []codersdk.WorkspaceAgentDevcontainer{ - {ID: devcontainerIDs[0], WorkspaceFolder: "workspace1"}, - {ID: devcontainerIDs[1], WorkspaceFolder: "workspace2"}, - }, - scripts: []codersdk.WorkspaceAgentScript{ - {ID: scriptIDs[0], RunOnStart: true}, - {ID: scriptIDs[1], RunOnStart: true}, - {ID: devcontainerIDs[0], RunOnStart: true}, - {ID: devcontainerIDs[1], RunOnStart: true}, - }, - }, - wantFilteredScripts: []codersdk.WorkspaceAgentScript{ - {ID: scriptIDs[0], RunOnStart: true}, - {ID: scriptIDs[1], RunOnStart: true}, - }, - wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{ - { - ID: devcontainerIDs[0], - Script: "devcontainer up --log-format json --workspace-folder \"workspace1\"", - RunOnStart: false, - }, - { - ID: devcontainerIDs[1], - Script: "devcontainer up --log-format json --workspace-folder \"workspace2\"", - RunOnStart: false, - }, - }, - }, - { - name: "scripts match devcontainers with config path", - args: args{ - expandPath: nil, - devcontainers: []codersdk.WorkspaceAgentDevcontainer{ - { - ID: devcontainerIDs[0], - WorkspaceFolder: "workspace1", - ConfigPath: "config1", - }, - { - ID: devcontainerIDs[1], - WorkspaceFolder: "workspace2", - ConfigPath: "config2", - }, - }, - scripts: []codersdk.WorkspaceAgentScript{ - {ID: devcontainerIDs[0]}, - {ID: devcontainerIDs[1]}, - }, - }, - wantFilteredScripts: []codersdk.WorkspaceAgentScript{}, - wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{ - { - ID: devcontainerIDs[0], - Script: "devcontainer up --log-format json --workspace-folder \"workspace1\" --config \"workspace1/config1\"", - RunOnStart: false, - }, - { - ID: devcontainerIDs[1], - Script: "devcontainer up --log-format json --workspace-folder \"workspace2\" --config \"workspace2/config2\"", - RunOnStart: false, - }, - }, - skipOnWindowsDueToPathSeparator: true, - }, - { - name: "scripts match devcontainers with expand path", - args: args{ - expandPath: func(s string) (string, error) { - return "/home/" + s, nil - }, - devcontainers: []codersdk.WorkspaceAgentDevcontainer{ - { - ID: devcontainerIDs[0], - WorkspaceFolder: "workspace1", - ConfigPath: "config1", - }, - { - ID: devcontainerIDs[1], - WorkspaceFolder: "workspace2", - ConfigPath: "config2", - }, - }, - scripts: []codersdk.WorkspaceAgentScript{ - {ID: devcontainerIDs[0], RunOnStart: true}, - {ID: devcontainerIDs[1], RunOnStart: true}, - }, - }, - wantFilteredScripts: []codersdk.WorkspaceAgentScript{}, - wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{ - { - ID: devcontainerIDs[0], - Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace1\" --config \"/home/workspace1/config1\"", - RunOnStart: false, - }, - { - ID: devcontainerIDs[1], - Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace2\" --config \"/home/workspace2/config2\"", - RunOnStart: false, - }, - }, - skipOnWindowsDueToPathSeparator: true, - }, - { - name: "expand config path when ~", - args: args{ - expandPath: func(s string) (string, error) { - s = strings.Replace(s, "~/", "", 1) - if filepath.IsAbs(s) { - return s, nil - } - return "/home/" + s, nil - }, - devcontainers: []codersdk.WorkspaceAgentDevcontainer{ - { - ID: devcontainerIDs[0], - WorkspaceFolder: "workspace1", - ConfigPath: "~/config1", - }, - { - ID: devcontainerIDs[1], - WorkspaceFolder: "workspace2", - ConfigPath: "/config2", - }, - }, - scripts: []codersdk.WorkspaceAgentScript{ - {ID: devcontainerIDs[0], RunOnStart: true}, - {ID: devcontainerIDs[1], RunOnStart: true}, - }, - }, - wantFilteredScripts: []codersdk.WorkspaceAgentScript{}, - wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{ - { - ID: devcontainerIDs[0], - Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace1\" --config \"/home/config1\"", - RunOnStart: false, - }, - { - ID: devcontainerIDs[1], - Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace2\" --config \"/config2\"", - RunOnStart: false, - }, - }, - skipOnWindowsDueToPathSeparator: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if tt.skipOnWindowsDueToPathSeparator && filepath.Separator == '\\' { - t.Skip("Skipping test on Windows due to path separator difference.") - } - - logger := slogtest.Make(t, nil) - if tt.args.expandPath == nil { - tt.args.expandPath = func(s string) (string, error) { - return s, nil - } - } - gotFilteredScripts, gotDevcontainerScripts := agentcontainers.ExtractAndInitializeDevcontainerScripts( - agentcontainers.ExpandAllDevcontainerPaths(logger, tt.args.expandPath, tt.args.devcontainers), - tt.args.scripts, - ) - - if diff := cmp.Diff(tt.wantFilteredScripts, gotFilteredScripts, cmpopts.EquateEmpty()); diff != "" { - t.Errorf("ExtractAndInitializeDevcontainerScripts() gotFilteredScripts mismatch (-want +got):\n%s", diff) - } - - // Preprocess the devcontainer scripts to remove scripting part. - for i := range gotDevcontainerScripts { - gotDevcontainerScripts[i].Script = textGrep("devcontainer up", gotDevcontainerScripts[i].Script) - require.NotEmpty(t, gotDevcontainerScripts[i].Script, "devcontainer up script not found") - } - if diff := cmp.Diff(tt.wantDevcontainerScripts, gotDevcontainerScripts); diff != "" { - t.Errorf("ExtractAndInitializeDevcontainerScripts() gotDevcontainerScripts mismatch (-want +got):\n%s", diff) - } - }) - } -} - -// textGrep returns matching lines from multiline string. -func textGrep(want, got string) (filtered string) { - var lines []string - for _, line := range strings.Split(got, "\n") { - if strings.Contains(line, want) { - lines = append(lines, line) - } - } - return strings.Join(lines, "\n") -} diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index e302ff07d6dd9..55e4708d46134 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -6,8 +6,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" - "os" + "slices" + "strings" "golang.org/x/xerrors" @@ -22,16 +24,60 @@ import ( type DevcontainerConfig struct { MergedConfiguration DevcontainerMergedConfiguration `json:"mergedConfiguration"` Configuration DevcontainerConfiguration `json:"configuration"` + Workspace DevcontainerWorkspace `json:"workspace"` } type DevcontainerMergedConfiguration struct { Customizations DevcontainerMergedCustomizations `json:"customizations,omitempty"` + Features DevcontainerFeatures `json:"features,omitempty"` } type DevcontainerMergedCustomizations struct { Coder []CoderCustomization `json:"coder,omitempty"` } +type DevcontainerFeatures map[string]any + +// OptionsAsEnvs converts the DevcontainerFeatures into a list of +// environment variables that can be used to set feature options. +// The format is FEATURE__OPTION_=. +// For example, if the feature is: +// +// "ghcr.io/coder/devcontainer-features/code-server:1": { +// "port": 9090, +// } +// +// It will produce: +// +// FEATURE_CODE_SERVER_OPTION_PORT=9090 +// +// Note that the feature name is derived from the last part of the key, +// so "ghcr.io/coder/devcontainer-features/code-server:1" becomes +// "CODE_SERVER". The version part (e.g. ":1") is removed, and dashes in +// the feature and option names are replaced with underscores. +func (f DevcontainerFeatures) OptionsAsEnvs() []string { + var env []string + for k, v := range f { + vv, ok := v.(map[string]any) + if !ok { + continue + } + // Take the last part of the key as the feature name/path. + k = k[strings.LastIndex(k, "/")+1:] + // Remove ":" and anything following it. + if idx := strings.Index(k, ":"); idx != -1 { + k = k[:idx] + } + k = strings.ReplaceAll(k, "-", "_") + for k2, v2 := range vv { + k2 = strings.ReplaceAll(k2, "-", "_") + env = append(env, fmt.Sprintf("FEATURE_%s_OPTION_%s=%s", strings.ToUpper(k), strings.ToUpper(k2), fmt.Sprintf("%v", v2))) + } + } + slices.Sort(env) + return env +} + type DevcontainerConfiguration struct { Customizations DevcontainerCustomizations `json:"customizations,omitempty"` } @@ -44,6 +90,11 @@ type CoderCustomization struct { DisplayApps map[codersdk.DisplayApp]bool `json:"displayApps,omitempty"` Apps []SubAgentApp `json:"apps,omitempty"` Name string `json:"name,omitempty"` + Ignore bool `json:"ignore,omitempty"` +} + +type DevcontainerWorkspace struct { + WorkspaceFolder string `json:"workspaceFolder"` } // DevcontainerCLI is an interface for the devcontainer CLI. @@ -135,7 +186,7 @@ func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOpt } func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig { - conf := devcontainerCLIUpConfig{} + conf := devcontainerCLIUpConfig{stdout: io.Discard, stderr: io.Discard} for _, opt := range opts { if opt != nil { opt(&conf) @@ -145,7 +196,7 @@ func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainer } func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devcontainerCLIExecConfig { - conf := devcontainerCLIExecConfig{} + conf := devcontainerCLIExecConfig{stdout: io.Discard, stderr: io.Discard} for _, opt := range opts { if opt != nil { opt(&conf) @@ -155,7 +206,7 @@ func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devconta } func applyDevcontainerCLIReadConfigOptions(opts []DevcontainerCLIReadConfigOptions) devcontainerCLIReadConfigConfig { - conf := devcontainerCLIReadConfigConfig{} + conf := devcontainerCLIReadConfigConfig{stdout: io.Discard, stderr: io.Discard} for _, opt := range opts { if opt != nil { opt(&conf) @@ -195,17 +246,20 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st // Capture stdout for parsing and stream logs for both default and provided writers. var stdoutBuf bytes.Buffer - stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}} - if conf.stdout != nil { - stdoutWriters = append(stdoutWriters, conf.stdout) - } - cmd.Stdout = io.MultiWriter(stdoutWriters...) + cmd.Stdout = io.MultiWriter( + &stdoutBuf, + &devcontainerCLILogWriter{ + ctx: ctx, + logger: logger.With(slog.F("stdout", true)), + writer: conf.stdout, + }, + ) // Stream stderr logs and provided writer if any. - stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}} - if conf.stderr != nil { - stderrWriters = append(stderrWriters, conf.stderr) + cmd.Stderr = &devcontainerCLILogWriter{ + ctx: ctx, + logger: logger.With(slog.F("stderr", true)), + writer: conf.stderr, } - cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { _, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) @@ -244,16 +298,16 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath args = append(args, cmdArgs...) c := d.execer.CommandContext(ctx, "devcontainer", args...) - stdoutWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}} - if conf.stdout != nil { - stdoutWriters = append(stdoutWriters, conf.stdout) - } - c.Stdout = io.MultiWriter(stdoutWriters...) - stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}} - if conf.stderr != nil { - stderrWriters = append(stderrWriters, conf.stderr) - } - c.Stderr = io.MultiWriter(stderrWriters...) + c.Stdout = io.MultiWriter(conf.stdout, &devcontainerCLILogWriter{ + ctx: ctx, + logger: logger.With(slog.F("stdout", true)), + writer: io.Discard, + }) + c.Stderr = io.MultiWriter(conf.stderr, &devcontainerCLILogWriter{ + ctx: ctx, + logger: logger.With(slog.F("stderr", true)), + writer: io.Discard, + }) if err := c.Run(); err != nil { return xerrors.Errorf("devcontainer exec failed: %w", err) @@ -275,20 +329,22 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi } c := d.execer.CommandContext(ctx, "devcontainer", args...) - c.Env = append(c.Env, "PATH="+os.Getenv("PATH")) c.Env = append(c.Env, env...) var stdoutBuf bytes.Buffer - stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}} - if conf.stdout != nil { - stdoutWriters = append(stdoutWriters, conf.stdout) - } - c.Stdout = io.MultiWriter(stdoutWriters...) - stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}} - if conf.stderr != nil { - stderrWriters = append(stderrWriters, conf.stderr) + c.Stdout = io.MultiWriter( + &stdoutBuf, + &devcontainerCLILogWriter{ + ctx: ctx, + logger: logger.With(slog.F("stdout", true)), + writer: conf.stdout, + }, + ) + c.Stderr = &devcontainerCLILogWriter{ + ctx: ctx, + logger: logger.With(slog.F("stderr", true)), + writer: conf.stderr, } - c.Stderr = io.MultiWriter(stderrWriters...) if err := c.Run(); err != nil { return DevcontainerConfig{}, xerrors.Errorf("devcontainer read-configuration failed: %w", err) @@ -381,6 +437,7 @@ type devcontainerCLIJSONLogLine struct { type devcontainerCLILogWriter struct { ctx context.Context logger slog.Logger + writer io.Writer } func (l *devcontainerCLILogWriter) Write(p []byte) (n int, err error) { @@ -401,8 +458,20 @@ func (l *devcontainerCLILogWriter) Write(p []byte) (n int, err error) { } if logLine.Level >= 3 { l.logger.Info(l.ctx, "@devcontainer/cli", slog.F("line", string(line))) + _, _ = l.writer.Write([]byte(strings.TrimSpace(logLine.Text) + "\n")) continue } + // If we've successfully parsed the final log line, it will successfully parse + // but will not fill out any of the fields for `logLine`. In this scenario we + // assume it is the final log line, unmarshal it as that, and check if the + // outcome is a non-empty string. + if logLine.Level == 0 { + var lastLine devcontainerCLIResult + if err := json.Unmarshal(line, &lastLine); err == nil && lastLine.Outcome != "" { + _, _ = l.writer.Write(line) + _, _ = l.writer.Write([]byte{'\n'}) + } + } l.logger.Debug(l.ctx, "@devcontainer/cli", slog.F("line", string(line))) } if err := s.Err(); err != nil { diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index 821e6e8f95e76..e3f0445751eb7 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -3,6 +3,7 @@ package agentcontainers_test import ( "bytes" "context" + "encoding/json" "errors" "flag" "fmt" @@ -10,9 +11,11 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" "github.com/stretchr/testify/assert" @@ -341,6 +344,10 @@ func TestDevcontainerCLI_WithOutput(t *testing.T) { t.Run("Up", func(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Windows uses CRLF line endings, golden file is LF") + } + // Buffers to capture stdout and stderr. outBuf := &bytes.Buffer{} errBuf := &bytes.Buffer{} @@ -363,7 +370,7 @@ func TestDevcontainerCLI_WithOutput(t *testing.T) { require.NotEmpty(t, containerID, "expected non-empty container ID") // Read expected log content. - expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.log")) + expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.golden")) require.NoError(t, err, "reading expected log file") // Verify stdout buffer contains the CLI logs and stderr is empty. @@ -586,7 +593,7 @@ func setupDevcontainerWorkspace(t *testing.T, workspaceFolder string) string { "containerEnv": { "TEST_CONTAINER": "true" }, - "runArgs": ["--label", "com.coder.test=devcontainercli"] + "runArgs": ["--label=com.coder.test=devcontainercli", "--label=` + agentcontainers.DevcontainerIsTestRunLabel + `=true"] }` err = os.WriteFile(configPath, []byte(content), 0o600) require.NoError(t, err, "create devcontainer.json file") @@ -637,3 +644,107 @@ func removeDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) { assert.NoError(t, err, "remove container failed") } } + +func TestDevcontainerFeatures_OptionsAsEnvs(t *testing.T) { + t.Parallel() + + realConfigJSON := `{ + "mergedConfiguration": { + "features": { + "./code-server": { + "port": 9090 + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": "false" + } + } + } + }` + var realConfig agentcontainers.DevcontainerConfig + err := json.Unmarshal([]byte(realConfigJSON), &realConfig) + require.NoError(t, err, "unmarshal JSON payload") + + tests := []struct { + name string + features agentcontainers.DevcontainerFeatures + want []string + }{ + { + name: "code-server feature", + features: agentcontainers.DevcontainerFeatures{ + "./code-server": map[string]any{ + "port": 9090, + }, + }, + want: []string{ + "FEATURE_CODE_SERVER_OPTION_PORT=9090", + }, + }, + { + name: "docker-in-docker feature", + features: agentcontainers.DevcontainerFeatures{ + "ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{ + "moby": "false", + }, + }, + want: []string{ + "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false", + }, + }, + { + name: "multiple features with multiple options", + features: agentcontainers.DevcontainerFeatures{ + "./code-server": map[string]any{ + "port": 9090, + "password": "secret", + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{ + "moby": "false", + "docker-dash-compose-version": "v2", + }, + }, + want: []string{ + "FEATURE_CODE_SERVER_OPTION_PASSWORD=secret", + "FEATURE_CODE_SERVER_OPTION_PORT=9090", + "FEATURE_DOCKER_IN_DOCKER_OPTION_DOCKER_DASH_COMPOSE_VERSION=v2", + "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false", + }, + }, + { + name: "feature with non-map value (should be ignored)", + features: agentcontainers.DevcontainerFeatures{ + "./code-server": map[string]any{ + "port": 9090, + }, + "./invalid-feature": "not-a-map", + }, + want: []string{ + "FEATURE_CODE_SERVER_OPTION_PORT=9090", + }, + }, + { + name: "real config example", + features: realConfig.MergedConfiguration.Features, + want: []string{ + "FEATURE_CODE_SERVER_OPTION_PORT=9090", + "FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false", + }, + }, + { + name: "empty features", + features: agentcontainers.DevcontainerFeatures{}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.features.OptionsAsEnvs() + if diff := cmp.Diff(tt.want, got); diff != "" { + require.Failf(t, "OptionsAsEnvs() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/agent/agentcontainers/execer.go b/agent/agentcontainers/execer.go new file mode 100644 index 0000000000000..323401f34ca81 --- /dev/null +++ b/agent/agentcontainers/execer.go @@ -0,0 +1,80 @@ +package agentcontainers + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" + + "cdr.dev/slog" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/usershell" + "github.com/coder/coder/v2/pty" +) + +// CommandEnv is a function that returns the shell, working directory, +// and environment variables to use when executing a command. It takes +// an EnvInfoer and a pre-existing environment slice as arguments. +// This signature matches agentssh.Server.CommandEnv. +type CommandEnv func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) + +// commandEnvExecer is an agentexec.Execer that uses a CommandEnv to +// determine the shell, working directory, and environment variables +// for commands. It wraps another agentexec.Execer to provide the +// necessary context. +type commandEnvExecer struct { + logger slog.Logger + commandEnv CommandEnv + execer agentexec.Execer +} + +func newCommandEnvExecer( + logger slog.Logger, + commandEnv CommandEnv, + execer agentexec.Execer, +) *commandEnvExecer { + return &commandEnvExecer{ + logger: logger, + commandEnv: commandEnv, + execer: execer, + } +} + +// Ensure commandEnvExecer implements agentexec.Execer. +var _ agentexec.Execer = (*commandEnvExecer)(nil) + +func (e *commandEnvExecer) prepare(ctx context.Context, inName string, inArgs ...string) (name string, args []string, dir string, env []string) { + shell, dir, env, err := e.commandEnv(nil, nil) + if err != nil { + e.logger.Error(ctx, "get command environment failed", slog.Error(err)) + return inName, inArgs, "", nil + } + + caller := "-c" + if runtime.GOOS == "windows" { + caller = "/c" + } + name = shell + for _, arg := range append([]string{inName}, inArgs...) { + args = append(args, fmt.Sprintf("%q", arg)) + } + args = []string{caller, strings.Join(args, " ")} + return name, args, dir, env +} + +func (e *commandEnvExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd { + name, args, dir, env := e.prepare(ctx, cmd, args...) + c := e.execer.CommandContext(ctx, name, args...) + c.Dir = dir + c.Env = env + return c +} + +func (e *commandEnvExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd { + name, args, dir, env := e.prepare(ctx, cmd, args...) + c := e.execer.PTYCommandContext(ctx, name, args...) + c.Dir = dir + c.Env = env + return c +} diff --git a/agent/agentcontainers/subagent.go b/agent/agentcontainers/subagent.go index 42df7080a890a..7d7603feef21d 100644 --- a/agent/agentcontainers/subagent.go +++ b/agent/agentcontainers/subagent.go @@ -188,7 +188,7 @@ func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) { return agents, nil } -func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (SubAgent, error) { +func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAgent, err error) { a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory)) displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps)) @@ -233,19 +233,27 @@ func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (SubAgen if err != nil { return SubAgent{}, err } + defer func() { + if err != nil { + // Best effort. + _, _ = a.api.DeleteSubAgent(ctx, &agentproto.DeleteSubAgentRequest{ + Id: resp.GetAgent().GetId(), + }) + } + }() - agent.Name = resp.Agent.Name - agent.ID, err = uuid.FromBytes(resp.Agent.Id) + agent.Name = resp.GetAgent().GetName() + agent.ID, err = uuid.FromBytes(resp.GetAgent().GetId()) if err != nil { - return agent, err + return SubAgent{}, err } - agent.AuthToken, err = uuid.FromBytes(resp.Agent.AuthToken) + agent.AuthToken, err = uuid.FromBytes(resp.GetAgent().GetAuthToken()) if err != nil { - return agent, err + return SubAgent{}, err } - for _, appError := range resp.AppCreationErrors { - app := apps[appError.Index] + for _, appError := range resp.GetAppCreationErrors() { + app := apps[appError.GetIndex()] a.logger.Warn(ctx, "unable to create app", slog.F("agent_name", agent.Name), diff --git a/agent/agentcontainers/testdata/devcontainercli/parse/up.golden b/agent/agentcontainers/testdata/devcontainercli/parse/up.golden new file mode 100644 index 0000000000000..022869052cf4b --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/parse/up.golden @@ -0,0 +1,64 @@ +@devcontainers/cli 0.75.0. Node.js v23.9.0. darwin 24.4.0 arm64. +Resolving Feature dependencies for 'ghcr.io/devcontainers/features/docker-in-docker:2'... +Soft-dependency 'ghcr.io/devcontainers/features/common-utils' is not required. Removing from installation order... +Files to omit: '' +Run: docker buildx build --load --build-context dev_containers_feature_content_source=/var/folders/1y/cm8mblxd7_x9cljwl_jvfprh0000gn/T/devcontainercli/container-features/0.75.0-1744102171193 --build-arg _DEV_CONTAINERS_BASE_IMAGE=mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye --build-arg _DEV_CONTAINERS_IMAGE_USER=root --build-arg _DEV_CONTAINERS_FEATURE_CONTENT_SOURCE=dev_container_feature_content_temp --target dev_containers_target_stage -f /var/folders/1y/cm8mblxd7_x9cljwl_jvfprh0000gn/T/devcontainercli/container-features/0.75.0-1744102171193/Dockerfile.extended -t vsc-devcontainers-template-starter-81d8f17e32abef6d434cbb5a37fe05e5c8a6f8ccede47a61197f002dcbf60566-features /var/folders/1y/cm8mblxd7_x9cljwl_jvfprh0000gn/T/devcontainercli/empty-folder +#0 building with "orbstack" instance using docker driver + +#1 [internal] load build definition from Dockerfile.extended +#1 transferring dockerfile: 3.09kB done +#1 DONE 0.0s + +#2 resolve image config for docker-image://docker.io/docker/dockerfile:1.4 +#2 DONE 1.3s +#3 docker-image://docker.io/docker/dockerfile:1.4@sha256:9ba7531bd80fb0a858632727cf7a112fbfd19b17e94c4e84ced81e24ef1a0dbc +#3 CACHED + +#4 [internal] load .dockerignore +#4 transferring context: 2B done +#4 DONE 0.0s + +#5 [internal] load metadata for mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye +#5 DONE 0.0s + +#6 [context dev_containers_feature_content_source] load .dockerignore +#6 transferring dev_containers_feature_content_source: 2B done +#6 DONE 0.0s + +#7 [dev_containers_feature_content_normalize 1/3] FROM mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye +#7 DONE 0.0s + +#8 [context dev_containers_feature_content_source] load from client +#8 transferring dev_containers_feature_content_source: 82.11kB 0.0s done +#8 DONE 0.0s + +#9 [dev_containers_feature_content_normalize 2/3] COPY --from=dev_containers_feature_content_source devcontainer-features.builtin.env /tmp/build-features/ +#9 CACHED + +#10 [dev_containers_target_stage 2/5] RUN mkdir -p /tmp/dev-container-features +#10 CACHED + +#11 [dev_containers_target_stage 3/5] COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features +#11 CACHED + +#12 [dev_containers_target_stage 4/5] RUN echo "_CONTAINER_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'root' || grep -E '^root|^[^:]*:[^:]*:root:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && echo "_REMOTE_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'node' || grep -E '^node|^[^:]*:[^:]*:node:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env +#12 CACHED + +#13 [dev_containers_feature_content_normalize 3/3] RUN chmod -R 0755 /tmp/build-features/ +#13 CACHED + +#14 [dev_containers_target_stage 5/5] RUN --mount=type=bind,from=dev_containers_feature_content_source,source=docker-in-docker_0,target=/tmp/build-features-src/docker-in-docker_0 cp -ar /tmp/build-features-src/docker-in-docker_0 /tmp/dev-container-features && chmod -R 0755 /tmp/dev-container-features/docker-in-docker_0 && cd /tmp/dev-container-features/docker-in-docker_0 && chmod +x ./devcontainer-features-install.sh && ./devcontainer-features-install.sh && rm -rf /tmp/dev-container-features/docker-in-docker_0 +#14 CACHED + +#15 exporting to image +#15 exporting layers done +#15 writing image sha256:275dc193c905d448ef3945e3fc86220cc315fe0cb41013988d6ff9f8d6ef2357 done +#15 naming to docker.io/library/vsc-devcontainers-template-starter-81d8f17e32abef6d434cbb5a37fe05e5c8a6f8ccede47a61197f002dcbf60566-features done +#15 DONE 0.0s +Run: docker buildx build --load --build-context dev_containers_feature_content_source=/var/folders/1y/cm8mblxd7_x9cljwl_jvfprh0000gn/T/devcontainercli/container-features/0.75.0-1744102171193 --build-arg _DEV_CONTAINERS_BASE_IMAGE=mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye --build-arg _DEV_CONTAINERS_IMAGE_USER=root --build-arg _DEV_CONTAINERS_FEATURE_CONTENT_SOURCE=dev_container_feature_content_temp --target dev_containers_target_stage -f /var/folders/1y/cm8mblxd7_x9cljwl_jvfprh0000gn/T/devcontainercli/container-features/0.75.0-1744102171193/Dockerfile.extended -t vsc-devcontainers-template-starter-81d8f17e32abef6d434cbb5a37fe05e5c8a6f8ccede47a61197f002dcbf60566-features /var/folders/1y/cm8mblxd7_x9cljwl_jvfprh0000gn/T/devcontainercli/empty-folder +Run: docker run --sig-proxy=false -a STDOUT -a STDERR --mount type=bind,source=/code/devcontainers-template-starter,target=/workspaces/devcontainers-template-starter,consistency=cached --mount type=volume,src=dind-var-lib-docker-0pctifo8bbg3pd06g3j5s9ae8j7lp5qfcd67m25kuahurel7v7jm,dst=/var/lib/docker -l devcontainer.local_folder=/code/devcontainers-template-starter -l devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json --privileged --entrypoint /bin/sh vsc-devcontainers-template-starter-81d8f17e32abef6d434cbb5a37fe05e5c8a6f8ccede47a61197f002dcbf60566-features -c echo Container started +Container started +Not setting dockerd DNS manually. +Running the postCreateCommand from devcontainer.json... +added 1 package in 784ms +{"outcome":"success","containerId":"bc72db8d0c4c4e941bd9ffc341aee64a18d3397fd45b87cd93d4746150967ba8","remoteUser":"node","remoteWorkspaceFolder":"/workspaces/devcontainers-template-starter"} diff --git a/agent/agentscripts/agentscripts.go b/agent/agentscripts/agentscripts.go index 79606a80233b9..bde3305b15415 100644 --- a/agent/agentscripts/agentscripts.go +++ b/agent/agentscripts/agentscripts.go @@ -79,21 +79,6 @@ func New(opts Options) *Runner { type ScriptCompletedFunc func(context.Context, *proto.WorkspaceAgentScriptCompletedRequest) (*proto.WorkspaceAgentScriptCompletedResponse, error) -type runnerScript struct { - runOnPostStart bool - codersdk.WorkspaceAgentScript -} - -func toRunnerScript(scripts ...codersdk.WorkspaceAgentScript) []runnerScript { - var rs []runnerScript - for _, s := range scripts { - rs = append(rs, runnerScript{ - WorkspaceAgentScript: s, - }) - } - return rs -} - type Runner struct { Options @@ -103,7 +88,7 @@ type Runner struct { closed chan struct{} closeMutex sync.Mutex cron *cron.Cron - scripts []runnerScript + scripts []codersdk.WorkspaceAgentScript dataDir string scriptCompleted ScriptCompletedFunc @@ -138,19 +123,6 @@ func (r *Runner) RegisterMetrics(reg prometheus.Registerer) { // InitOption describes an option for the runner initialization. type InitOption func(*Runner) -// WithPostStartScripts adds scripts that should be run after the workspace -// start scripts but before the workspace is marked as started. -func WithPostStartScripts(scripts ...codersdk.WorkspaceAgentScript) InitOption { - return func(r *Runner) { - for _, s := range scripts { - r.scripts = append(r.scripts, runnerScript{ - runOnPostStart: true, - WorkspaceAgentScript: s, - }) - } - } -} - // Init initializes the runner with the provided scripts. // It also schedules any scripts that have a schedule. // This function must be called before Execute. @@ -161,7 +133,7 @@ func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted S return xerrors.New("init: already initialized") } r.initialized = true - r.scripts = toRunnerScript(scripts...) + r.scripts = scripts r.scriptCompleted = scriptCompleted for _, opt := range opts { opt(r) @@ -177,9 +149,8 @@ func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted S if script.Cron == "" { continue } - script := script _, err := r.cron.AddFunc(script.Cron, func() { - err := r.trackRun(r.cronCtx, script.WorkspaceAgentScript, ExecuteCronScripts) + err := r.trackRun(r.cronCtx, script, ExecuteCronScripts) if err != nil { r.Logger.Warn(context.Background(), "run agent script on schedule", slog.Error(err)) } @@ -223,7 +194,6 @@ type ExecuteOption int const ( ExecuteAllScripts ExecuteOption = iota ExecuteStartScripts - ExecutePostStartScripts ExecuteStopScripts ExecuteCronScripts ) @@ -246,7 +216,6 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error { for _, script := range r.scripts { runScript := (option == ExecuteStartScripts && script.RunOnStart) || (option == ExecuteStopScripts && script.RunOnStop) || - (option == ExecutePostStartScripts && script.runOnPostStart) || (option == ExecuteCronScripts && script.Cron != "") || option == ExecuteAllScripts @@ -254,9 +223,8 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error { continue } - script := script eg.Go(func() error { - err := r.trackRun(ctx, script.WorkspaceAgentScript, option) + err := r.trackRun(ctx, script, option) if err != nil { return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err) } diff --git a/agent/agentscripts/agentscripts_test.go b/agent/agentscripts/agentscripts_test.go index f50a0cc065138..c032ea1f83a1a 100644 --- a/agent/agentscripts/agentscripts_test.go +++ b/agent/agentscripts/agentscripts_test.go @@ -4,7 +4,6 @@ import ( "context" "path/filepath" "runtime" - "slices" "sync" "testing" "time" @@ -177,11 +176,6 @@ func TestExecuteOptions(t *testing.T) { Script: "echo stop", RunOnStop: true, } - postStartScript := codersdk.WorkspaceAgentScript{ - ID: uuid.New(), - LogSourceID: uuid.New(), - Script: "echo poststart", - } regularScript := codersdk.WorkspaceAgentScript{ ID: uuid.New(), LogSourceID: uuid.New(), @@ -193,10 +187,9 @@ func TestExecuteOptions(t *testing.T) { stopScript, regularScript, } - allScripts := append(slices.Clone(scripts), postStartScript) scriptByID := func(t *testing.T, id uuid.UUID) codersdk.WorkspaceAgentScript { - for _, script := range allScripts { + for _, script := range scripts { if script.ID == id { return script } @@ -206,10 +199,9 @@ func TestExecuteOptions(t *testing.T) { } wantOutput := map[uuid.UUID]string{ - startScript.ID: "start", - stopScript.ID: "stop", - postStartScript.ID: "poststart", - regularScript.ID: "regular", + startScript.ID: "start", + stopScript.ID: "stop", + regularScript.ID: "regular", } testCases := []struct { @@ -220,18 +212,13 @@ func TestExecuteOptions(t *testing.T) { { name: "ExecuteAllScripts", option: agentscripts.ExecuteAllScripts, - wantRun: []uuid.UUID{startScript.ID, stopScript.ID, regularScript.ID, postStartScript.ID}, + wantRun: []uuid.UUID{startScript.ID, stopScript.ID, regularScript.ID}, }, { name: "ExecuteStartScripts", option: agentscripts.ExecuteStartScripts, wantRun: []uuid.UUID{startScript.ID}, }, - { - name: "ExecutePostStartScripts", - option: agentscripts.ExecutePostStartScripts, - wantRun: []uuid.UUID{postStartScript.ID}, - }, { name: "ExecuteStopScripts", option: agentscripts.ExecuteStopScripts, @@ -260,7 +247,6 @@ func TestExecuteOptions(t *testing.T) { err := runner.Init( scripts, aAPI.ScriptCompleted, - agentscripts.WithPostStartScripts(postStartScript), ) require.NoError(t, err) @@ -274,7 +260,7 @@ func TestExecuteOptions(t *testing.T) { "script %s should have run when using filter %s", scriptByID(t, id).Script, tc.name) } - for _, script := range allScripts { + for _, script := range scripts { if _, ok := gotRun[script.ID]; ok { continue } diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index a2fec79debcf1..6e3760c643cb3 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -113,9 +113,14 @@ type Config struct { BlockFileTransfer bool // ReportConnection. ReportConnection reportConnectionFunc - // Experimental: allow connecting to running containers if - // CODER_AGENT_DEVCONTAINERS_ENABLE=true. - ExperimentalDevContainersEnabled bool + // Experimental: allow connecting to running containers via Docker exec. + // Note that this is different from the devcontainers feature, which uses + // subagents. + ExperimentalContainers bool + // X11Net allows overriding the networking implementation used for X11 + // forwarding listeners. When nil, a default implementation backed by the + // standard library networking package is used. + X11Net X11Network } type Server struct { @@ -129,9 +134,10 @@ type Server struct { // a lock on mu but protected by closing. wg sync.WaitGroup - Execer agentexec.Execer - logger slog.Logger - srv *ssh.Server + Execer agentexec.Execer + logger slog.Logger + srv *ssh.Server + x11Forwarder *x11Forwarder config *Config @@ -187,6 +193,20 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom config: config, metrics: metrics, + x11Forwarder: &x11Forwarder{ + logger: logger, + x11HandlerErrors: metrics.x11HandlerErrors, + fs: fs, + displayOffset: *config.X11DisplayOffset, + sessions: make(map[*x11Session]struct{}), + connections: make(map[net.Conn]struct{}), + network: func() X11Network { + if config.X11Net != nil { + return config.X11Net + } + return osNet{} + }(), + }, } srv := &ssh.Server{ @@ -435,7 +455,7 @@ func (s *Server) sessionHandler(session ssh.Session) { switch ss := session.Subsystem(); ss { case "": case "sftp": - if s.config.ExperimentalDevContainersEnabled && container != "" { + if s.config.ExperimentalContainers && container != "" { closeCause("sftp not yet supported with containers") _ = session.Exit(1) return @@ -454,7 +474,7 @@ func (s *Server) sessionHandler(session ssh.Session) { x11, hasX11 := session.X11() if hasX11 { - display, handled := s.x11Handler(ctx, x11) + display, handled := s.x11Forwarder.x11Handler(ctx, session) if !handled { logger.Error(ctx, "x11 handler failed") closeCause("x11 handler failed") @@ -549,7 +569,7 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str var ei usershell.EnvInfoer var err error - if s.config.ExperimentalDevContainersEnabled && container != "" { + if s.config.ExperimentalContainers && container != "" { ei, err = agentcontainers.EnvInfo(ctx, s.Execer, container, containerUser) if err != nil { s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "container_env_info").Add(1) @@ -816,6 +836,49 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) error { return xerrors.Errorf("sftp server closed with error: %w", err) } +func (s *Server) CommandEnv(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) { + if ei == nil { + ei = &usershell.SystemEnvInfo{} + } + + currentUser, err := ei.User() + if err != nil { + return "", "", nil, xerrors.Errorf("get current user: %w", err) + } + username := currentUser.Username + + shell, err = ei.Shell(username) + if err != nil { + return "", "", nil, xerrors.Errorf("get user shell: %w", err) + } + + dir = s.config.WorkingDirectory() + + // If the metadata directory doesn't exist, we run the command + // in the users home directory. + _, err = os.Stat(dir) + if dir == "" || err != nil { + // Default to user home if a directory is not set. + homedir, err := ei.HomeDir() + if err != nil { + return "", "", nil, xerrors.Errorf("get home dir: %w", err) + } + dir = homedir + } + env = append(ei.Environ(), addEnv...) + // Set login variables (see `man login`). + env = append(env, fmt.Sprintf("USER=%s", username)) + env = append(env, fmt.Sprintf("LOGNAME=%s", username)) + env = append(env, fmt.Sprintf("SHELL=%s", shell)) + + env, err = s.config.UpdateEnv(env) + if err != nil { + return "", "", nil, xerrors.Errorf("apply env: %w", err) + } + + return shell, dir, env, nil +} + // CreateCommand processes raw command input with OpenSSH-like behavior. // If the script provided is empty, it will default to the users shell. // This injects environment variables specified by the user at launch too. @@ -827,15 +890,10 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string, if ei == nil { ei = &usershell.SystemEnvInfo{} } - currentUser, err := ei.User() - if err != nil { - return nil, xerrors.Errorf("get current user: %w", err) - } - username := currentUser.Username - shell, err := ei.Shell(username) + shell, dir, env, err := s.CommandEnv(ei, env) if err != nil { - return nil, xerrors.Errorf("get user shell: %w", err) + return nil, xerrors.Errorf("prepare command env: %w", err) } // OpenSSH executes all commands with the users current shell. @@ -893,24 +951,8 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string, ) } cmd := s.Execer.PTYCommandContext(ctx, modifiedName, modifiedArgs...) - cmd.Dir = s.config.WorkingDirectory() - - // If the metadata directory doesn't exist, we run the command - // in the users home directory. - _, err = os.Stat(cmd.Dir) - if cmd.Dir == "" || err != nil { - // Default to user home if a directory is not set. - homedir, err := ei.HomeDir() - if err != nil { - return nil, xerrors.Errorf("get home dir: %w", err) - } - cmd.Dir = homedir - } - cmd.Env = append(ei.Environ(), env...) - // Set login variables (see `man login`). - cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username)) - cmd.Env = append(cmd.Env, fmt.Sprintf("LOGNAME=%s", username)) - cmd.Env = append(cmd.Env, fmt.Sprintf("SHELL=%s", shell)) + cmd.Dir = dir + cmd.Env = env // Set SSH connection environment variables (these are also set by OpenSSH // and thus expected to be present by SSH clients). Since the agent does @@ -921,11 +963,6 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string, cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CLIENT=%s %s %s", srcAddr, srcPort, dstPort)) cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", srcAddr, srcPort, dstAddr, dstPort)) - cmd.Env, err = s.config.UpdateEnv(cmd.Env) - if err != nil { - return nil, xerrors.Errorf("apply env: %w", err) - } - return cmd, nil } @@ -1096,6 +1133,9 @@ func (s *Server) Close() error { s.mu.Unlock() + s.logger.Debug(ctx, "closing X11 forwarding") + _ = s.x11Forwarder.Close() + s.logger.Debug(ctx, "waiting for all goroutines to exit") s.wg.Wait() // Wait for all goroutines to exit. diff --git a/agent/agentssh/x11.go b/agent/agentssh/x11.go index 439f2c3021791..b02de0dcf003a 100644 --- a/agent/agentssh/x11.go +++ b/agent/agentssh/x11.go @@ -7,15 +7,16 @@ import ( "errors" "fmt" "io" - "math" "net" "os" "path/filepath" "strconv" + "sync" "time" "github.com/gliderlabs/ssh" "github.com/gofrs/flock" + "github.com/prometheus/client_golang/prometheus" "github.com/spf13/afero" gossh "golang.org/x/crypto/ssh" "golang.org/x/xerrors" @@ -29,8 +30,51 @@ const ( X11StartPort = 6000 // X11DefaultDisplayOffset is the default offset for X11 forwarding. X11DefaultDisplayOffset = 10 + X11MaxDisplays = 200 + // X11MaxPort is the highest port we will ever use for X11 forwarding. This limits the total number of TCP sockets + // we will create. It seems more useful to have a maximum port number than a direct limit on sockets with no max + // port because we'd like to be able to tell users the exact range of ports the Agent might use. + X11MaxPort = X11StartPort + X11MaxDisplays ) +// X11Network abstracts the creation of network listeners for X11 forwarding. +// It is intended mainly for testing; production code uses the default +// implementation backed by the operating system networking stack. +type X11Network interface { + Listen(network, address string) (net.Listener, error) +} + +// osNet is the default X11Network implementation that uses the standard +// library network stack. +type osNet struct{} + +func (osNet) Listen(network, address string) (net.Listener, error) { + return net.Listen(network, address) +} + +type x11Forwarder struct { + logger slog.Logger + x11HandlerErrors *prometheus.CounterVec + fs afero.Fs + displayOffset int + + // network creates X11 listener sockets. Defaults to osNet{}. + network X11Network + + mu sync.Mutex + sessions map[*x11Session]struct{} + connections map[net.Conn]struct{} + closing bool + wg sync.WaitGroup +} + +type x11Session struct { + session ssh.Session + display int + listener net.Listener + usedAt time.Time +} + // x11Callback is called when the client requests X11 forwarding. func (*Server) x11Callback(_ ssh.Context, _ ssh.X11) bool { // Always allow. @@ -39,115 +83,243 @@ func (*Server) x11Callback(_ ssh.Context, _ ssh.X11) bool { // x11Handler is called when a session has requested X11 forwarding. // It listens for X11 connections and forwards them to the client. -func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) (displayNumber int, handled bool) { - serverConn, valid := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn) +func (x *x11Forwarder) x11Handler(sshCtx ssh.Context, sshSession ssh.Session) (displayNumber int, handled bool) { + x11, hasX11 := sshSession.X11() + if !hasX11 { + return -1, false + } + serverConn, valid := sshCtx.Value(ssh.ContextKeyConn).(*gossh.ServerConn) if !valid { - s.logger.Warn(ctx, "failed to get server connection") + x.logger.Warn(sshCtx, "failed to get server connection") return -1, false } + ctx := slog.With(sshCtx, slog.F("session_id", fmt.Sprintf("%x", serverConn.SessionID()))) hostname, err := os.Hostname() if err != nil { - s.logger.Warn(ctx, "failed to get hostname", slog.Error(err)) - s.metrics.x11HandlerErrors.WithLabelValues("hostname").Add(1) + x.logger.Warn(ctx, "failed to get hostname", slog.Error(err)) + x.x11HandlerErrors.WithLabelValues("hostname").Add(1) return -1, false } - ln, display, err := createX11Listener(ctx, *s.config.X11DisplayOffset) + x11session, err := x.createX11Session(ctx, sshSession) if err != nil { - s.logger.Warn(ctx, "failed to create X11 listener", slog.Error(err)) - s.metrics.x11HandlerErrors.WithLabelValues("listen").Add(1) + x.logger.Warn(ctx, "failed to create X11 listener", slog.Error(err)) + x.x11HandlerErrors.WithLabelValues("listen").Add(1) return -1, false } - s.trackListener(ln, true) defer func() { if !handled { - s.trackListener(ln, false) - _ = ln.Close() + x.closeAndRemoveSession(x11session) } }() - err = addXauthEntry(ctx, s.fs, hostname, strconv.Itoa(display), x11.AuthProtocol, x11.AuthCookie) + err = addXauthEntry(ctx, x.fs, hostname, strconv.Itoa(x11session.display), x11.AuthProtocol, x11.AuthCookie) if err != nil { - s.logger.Warn(ctx, "failed to add Xauthority entry", slog.Error(err)) - s.metrics.x11HandlerErrors.WithLabelValues("xauthority").Add(1) + x.logger.Warn(ctx, "failed to add Xauthority entry", slog.Error(err)) + x.x11HandlerErrors.WithLabelValues("xauthority").Add(1) return -1, false } + // clean up the X11 session if the SSH session completes. go func() { - // Don't leave the listener open after the session is gone. <-ctx.Done() - _ = ln.Close() + x.closeAndRemoveSession(x11session) }() - go func() { - defer ln.Close() - defer s.trackListener(ln, false) - - for { - conn, err := ln.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) { - return - } - s.logger.Warn(ctx, "failed to accept X11 connection", slog.Error(err)) + go x.listenForConnections(ctx, x11session, serverConn, x11) + x.logger.Debug(ctx, "X11 forwarding started", slog.F("display", x11session.display)) + + return x11session.display, true +} + +func (x *x11Forwarder) trackGoroutine() (closing bool, done func()) { + x.mu.Lock() + defer x.mu.Unlock() + if !x.closing { + x.wg.Add(1) + return false, func() { x.wg.Done() } + } + return true, func() {} +} + +func (x *x11Forwarder) listenForConnections( + ctx context.Context, session *x11Session, serverConn *gossh.ServerConn, x11 ssh.X11, +) { + defer x.closeAndRemoveSession(session) + if closing, done := x.trackGoroutine(); closing { + return + } else { // nolint: revive + defer done() + } + + for { + conn, err := session.listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { return } - if x11.SingleConnection { - s.logger.Debug(ctx, "single connection requested, closing X11 listener") - _ = ln.Close() - } + x.logger.Warn(ctx, "failed to accept X11 connection", slog.Error(err)) + return + } - tcpConn, ok := conn.(*net.TCPConn) - if !ok { - s.logger.Warn(ctx, fmt.Sprintf("failed to cast connection to TCPConn. got: %T", conn)) - _ = conn.Close() - continue - } - tcpAddr, ok := tcpConn.LocalAddr().(*net.TCPAddr) - if !ok { - s.logger.Warn(ctx, fmt.Sprintf("failed to cast local address to TCPAddr. got: %T", tcpConn.LocalAddr())) - _ = conn.Close() - continue - } + // Update session usage time since a new X11 connection was forwarded. + x.mu.Lock() + session.usedAt = time.Now() + x.mu.Unlock() + if x11.SingleConnection { + x.logger.Debug(ctx, "single connection requested, closing X11 listener") + x.closeAndRemoveSession(session) + } - channel, reqs, err := serverConn.OpenChannel("x11", gossh.Marshal(struct { - OriginatorAddress string - OriginatorPort uint32 - }{ - OriginatorAddress: tcpAddr.IP.String(), + var originAddr string + var originPort uint32 + + if tcpConn, ok := conn.(*net.TCPConn); ok { + if tcpAddr, ok := tcpConn.LocalAddr().(*net.TCPAddr); ok { + originAddr = tcpAddr.IP.String() // #nosec G115 - Safe conversion as TCP port numbers are within uint32 range (0-65535) - OriginatorPort: uint32(tcpAddr.Port), - })) - if err != nil { - s.logger.Warn(ctx, "failed to open X11 channel", slog.Error(err)) - _ = conn.Close() - continue + originPort = uint32(tcpAddr.Port) } - go gossh.DiscardRequests(reqs) + } + // Fallback values for in-memory or non-TCP connections. + if originAddr == "" { + originAddr = "127.0.0.1" + } - if !s.trackConn(ln, conn, true) { - s.logger.Warn(ctx, "failed to track X11 connection") - _ = conn.Close() - continue - } - go func() { - defer s.trackConn(ln, conn, false) - Bicopy(ctx, conn, channel) - }() + channel, reqs, err := serverConn.OpenChannel("x11", gossh.Marshal(struct { + OriginatorAddress string + OriginatorPort uint32 + }{ + OriginatorAddress: originAddr, + OriginatorPort: originPort, + })) + if err != nil { + x.logger.Warn(ctx, "failed to open X11 channel", slog.Error(err)) + _ = conn.Close() + continue } - }() + go gossh.DiscardRequests(reqs) + + if !x.trackConn(conn, true) { + x.logger.Warn(ctx, "failed to track X11 connection") + _ = conn.Close() + continue + } + go func() { + defer x.trackConn(conn, false) + Bicopy(ctx, conn, channel) + }() + } +} + +// closeAndRemoveSession closes and removes the session. +func (x *x11Forwarder) closeAndRemoveSession(x11session *x11Session) { + _ = x11session.listener.Close() + x.mu.Lock() + delete(x.sessions, x11session) + x.mu.Unlock() +} + +// createX11Session creates an X11 forwarding session. +func (x *x11Forwarder) createX11Session(ctx context.Context, sshSession ssh.Session) (*x11Session, error) { + var ( + ln net.Listener + display int + err error + ) + // retry listener creation after evictions. Limit to 10 retries to prevent pathological cases looping forever. + const maxRetries = 10 + for try := range maxRetries { + ln, display, err = x.createX11Listener(ctx) + if err == nil { + break + } + if try == maxRetries-1 { + return nil, xerrors.New("max retries exceeded while creating X11 session") + } + x.logger.Warn(ctx, "failed to create X11 listener; will evict an X11 forwarding session", + slog.F("num_current_sessions", x.numSessions()), + slog.Error(err)) + x.evictLeastRecentlyUsedSession() + } + x.mu.Lock() + defer x.mu.Unlock() + if x.closing { + closeErr := ln.Close() + if closeErr != nil { + x.logger.Error(ctx, "error closing X11 listener", slog.Error(closeErr)) + } + return nil, xerrors.New("server is closing") + } + x11Sess := &x11Session{ + session: sshSession, + display: display, + listener: ln, + usedAt: time.Now(), + } + x.sessions[x11Sess] = struct{}{} + return x11Sess, nil +} + +func (x *x11Forwarder) numSessions() int { + x.mu.Lock() + defer x.mu.Unlock() + return len(x.sessions) +} + +func (x *x11Forwarder) popLeastRecentlyUsedSession() *x11Session { + x.mu.Lock() + defer x.mu.Unlock() + var lru *x11Session + for s := range x.sessions { + if lru == nil { + lru = s + continue + } + if s.usedAt.Before(lru.usedAt) { + lru = s + continue + } + } + if lru == nil { + x.logger.Debug(context.Background(), "tried to pop from empty set of X11 sessions") + return nil + } + delete(x.sessions, lru) + return lru +} - return display, true +func (x *x11Forwarder) evictLeastRecentlyUsedSession() { + lru := x.popLeastRecentlyUsedSession() + if lru == nil { + return + } + err := lru.listener.Close() + if err != nil { + x.logger.Error(context.Background(), "failed to close evicted X11 session listener", slog.Error(err)) + } + // when we evict, we also want to force the SSH session to be closed as well. This is because we intend to reuse + // the X11 TCP listener port for a new X11 forwarding session. If we left the SSH session up, then graphical apps + // started in that session could potentially connect to an unintended X11 Server (i.e. the display on a different + // computer than the one that started the SSH session). Most likely, this session is a zombie anyway if we've + // reached the maximum number of X11 forwarding sessions. + err = lru.session.Close() + if err != nil { + x.logger.Error(context.Background(), "failed to close evicted X11 SSH session", slog.Error(err)) + } } // createX11Listener creates a listener for X11 forwarding, it will use // the next available port starting from X11StartPort and displayOffset. -func createX11Listener(ctx context.Context, displayOffset int) (ln net.Listener, display int, err error) { - var lc net.ListenConfig +func (x *x11Forwarder) createX11Listener(ctx context.Context) (ln net.Listener, display int, err error) { // Look for an open port to listen on. - for port := X11StartPort + displayOffset; port < math.MaxUint16; port++ { - ln, err = lc.Listen(ctx, "tcp", fmt.Sprintf("localhost:%d", port)) + for port := X11StartPort + x.displayOffset; port <= X11MaxPort; port++ { + if ctx.Err() != nil { + return nil, -1, ctx.Err() + } + + ln, err = x.network.Listen("tcp", fmt.Sprintf("localhost:%d", port)) if err == nil { display = port - X11StartPort return ln, display, nil @@ -156,6 +328,49 @@ func createX11Listener(ctx context.Context, displayOffset int) (ln net.Listener, return nil, -1, xerrors.Errorf("failed to find open port for X11 listener: %w", err) } +// trackConn registers the connection with the x11Forwarder. If the server is +// closed, the connection is not registered and should be closed. +// +//nolint:revive +func (x *x11Forwarder) trackConn(c net.Conn, add bool) (ok bool) { + x.mu.Lock() + defer x.mu.Unlock() + if add { + if x.closing { + // Server or listener closed. + return false + } + x.wg.Add(1) + x.connections[c] = struct{}{} + return true + } + x.wg.Done() + delete(x.connections, c) + return true +} + +func (x *x11Forwarder) Close() error { + x.mu.Lock() + x.closing = true + + for s := range x.sessions { + sErr := s.listener.Close() + if sErr != nil { + x.logger.Debug(context.Background(), "failed to close X11 listener", slog.Error(sErr)) + } + } + for c := range x.connections { + cErr := c.Close() + if cErr != nil { + x.logger.Debug(context.Background(), "failed to close X11 connection", slog.Error(cErr)) + } + } + + x.mu.Unlock() + x.wg.Wait() + return nil +} + // addXauthEntry adds an Xauthority entry to the Xauthority file. // The Xauthority file is located at ~/.Xauthority. func addXauthEntry(ctx context.Context, fs afero.Fs, host string, display string, authProtocol string, authCookie string) error { diff --git a/agent/agentssh/x11_internal_test.go b/agent/agentssh/x11_internal_test.go index fdc3c04668663..f49242eb9f730 100644 --- a/agent/agentssh/x11_internal_test.go +++ b/agent/agentssh/x11_internal_test.go @@ -228,7 +228,6 @@ func Test_addXauthEntry(t *testing.T) { require.NoError(t, err) for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/agent/agentssh/x11_test.go b/agent/agentssh/x11_test.go index 2ccbbfe69ca5c..83af8a2f83838 100644 --- a/agent/agentssh/x11_test.go +++ b/agent/agentssh/x11_test.go @@ -3,9 +3,9 @@ package agentssh_test import ( "bufio" "bytes" - "context" "encoding/hex" "fmt" + "io" "net" "os" "path/filepath" @@ -32,10 +32,19 @@ func TestServer_X11(t *testing.T) { t.Skip("X11 forwarding is only supported on Linux") } - ctx := context.Background() + ctx := testutil.Context(t, testutil.WaitShort) logger := testutil.Logger(t) - fs := afero.NewOsFs() - s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, agentexec.DefaultExecer, &agentssh.Config{}) + fs := afero.NewMemMapFs() + + // Use in-process networking for X11 forwarding. + inproc := testutil.NewInProcNet() + + // Create server config with custom X11 listener. + cfg := &agentssh.Config{ + X11Net: inproc, + } + + s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, agentexec.DefaultExecer, cfg) require.NoError(t, err) defer s.Close() err = s.UpdateHostSigner(42) @@ -93,17 +102,15 @@ func TestServer_X11(t *testing.T) { x11Chans := c.HandleChannelOpen("x11") payload := "hello world" - require.Eventually(t, func() bool { - conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", agentssh.X11StartPort+displayNumber)) - if err == nil { - _, err = conn.Write([]byte(payload)) - assert.NoError(t, err) - _ = conn.Close() - } - return err == nil - }, testutil.WaitShort, testutil.IntervalFast) + go func() { + conn, err := inproc.Dial(ctx, testutil.NewAddr("tcp", fmt.Sprintf("localhost:%d", agentssh.X11StartPort+displayNumber))) + assert.NoError(t, err) + _, err = conn.Write([]byte(payload)) + assert.NoError(t, err) + _ = conn.Close() + }() - x11 := <-x11Chans + x11 := testutil.RequireReceive(ctx, t, x11Chans) ch, reqs, err := x11.Accept() require.NoError(t, err) go gossh.DiscardRequests(reqs) @@ -121,3 +128,209 @@ func TestServer_X11(t *testing.T) { _, err = fs.Stat(filepath.Join(home, ".Xauthority")) require.NoError(t, err) } + +func TestServer_X11_EvictionLRU(t *testing.T) { + t.Parallel() + if runtime.GOOS != "linux" { + t.Skip("X11 forwarding is only supported on Linux") + } + + ctx := testutil.Context(t, testutil.WaitLong) + logger := testutil.Logger(t) + fs := afero.NewMemMapFs() + + // Use in-process networking for X11 forwarding. + inproc := testutil.NewInProcNet() + + cfg := &agentssh.Config{ + X11Net: inproc, + } + + s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, agentexec.DefaultExecer, cfg) + require.NoError(t, err) + defer s.Close() + err = s.UpdateHostSigner(42) + require.NoError(t, err) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + done := testutil.Go(t, func() { + err := s.Serve(ln) + assert.Error(t, err) + }) + + c := sshClient(t, ln.Addr().String()) + + // block off one port to test x11Forwarder evicts at highest port, not number of listeners. + externalListener, err := inproc.Listen("tcp", + fmt.Sprintf("localhost:%d", agentssh.X11StartPort+agentssh.X11DefaultDisplayOffset+1)) + require.NoError(t, err) + defer externalListener.Close() + + // Calculate how many simultaneous X11 sessions we can create given the + // configured port range. + + startPort := agentssh.X11StartPort + agentssh.X11DefaultDisplayOffset + maxSessions := agentssh.X11MaxPort - startPort + 1 - 1 // -1 for the blocked port + require.Greater(t, maxSessions, 0, "expected a positive maxSessions value") + + // shellSession holds references to the session and its standard streams so + // that the test can keep them open (and optionally interact with them) for + // the lifetime of the test. If we don't start the Shell with pipes in place, + // the session will be torn down asynchronously during the test. + type shellSession struct { + sess *gossh.Session + stdin io.WriteCloser + stdout io.Reader + stderr io.Reader + // scanner is used to read the output of the session, line by line. + scanner *bufio.Scanner + } + + sessions := make([]shellSession, 0, maxSessions) + for i := 0; i < maxSessions; i++ { + sess, err := c.NewSession() + require.NoError(t, err) + + _, err = sess.SendRequest("x11-req", true, gossh.Marshal(ssh.X11{ + AuthProtocol: "MIT-MAGIC-COOKIE-1", + AuthCookie: hex.EncodeToString([]byte(fmt.Sprintf("cookie%d", i))), + ScreenNumber: uint32(0), + })) + require.NoError(t, err) + + stdin, err := sess.StdinPipe() + require.NoError(t, err) + stdout, err := sess.StdoutPipe() + require.NoError(t, err) + stderr, err := sess.StderrPipe() + require.NoError(t, err) + require.NoError(t, sess.Shell()) + + // The SSH server lazily starts the session. We need to write a command + // and read back to ensure the X11 forwarding is started. + scanner := bufio.NewScanner(stdout) + msg := fmt.Sprintf("ready-%d", i) + _, err = stdin.Write([]byte("echo " + msg + "\n")) + require.NoError(t, err) + // Read until we get the message (first token may be empty due to shell prompt) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.Contains(line, msg) { + break + } + } + require.NoError(t, scanner.Err()) + + sessions = append(sessions, shellSession{ + sess: sess, + stdin: stdin, + stdout: stdout, + stderr: stderr, + scanner: scanner, + }) + } + + // Connect X11 forwarding to the first session. This is used to test that + // connecting counts as a use of the display. + x11Chans := c.HandleChannelOpen("x11") + payload := "hello world" + go func() { + conn, err := inproc.Dial(ctx, testutil.NewAddr("tcp", fmt.Sprintf("localhost:%d", agentssh.X11StartPort+agentssh.X11DefaultDisplayOffset))) + assert.NoError(t, err) + _, err = conn.Write([]byte(payload)) + assert.NoError(t, err) + _ = conn.Close() + }() + + x11 := testutil.RequireReceive(ctx, t, x11Chans) + ch, reqs, err := x11.Accept() + require.NoError(t, err) + go gossh.DiscardRequests(reqs) + got := make([]byte, len(payload)) + _, err = ch.Read(got) + require.NoError(t, err) + assert.Equal(t, payload, string(got)) + _ = ch.Close() + + // Create one more session which should evict a session and reuse the display. + // The first session was used to connect X11 forwarding, so it should not be evicted. + // Therefore, the second session should be evicted and its display reused. + extraSess, err := c.NewSession() + require.NoError(t, err) + + _, err = extraSess.SendRequest("x11-req", true, gossh.Marshal(ssh.X11{ + AuthProtocol: "MIT-MAGIC-COOKIE-1", + AuthCookie: hex.EncodeToString([]byte("extra")), + ScreenNumber: uint32(0), + })) + require.NoError(t, err) + + // Ask the remote side for the DISPLAY value so we can extract the display + // number that was assigned to this session. + out, err := extraSess.Output("echo DISPLAY=$DISPLAY") + require.NoError(t, err) + + // Example output line: "DISPLAY=localhost:10.0". + var newDisplayNumber int + { + sc := bufio.NewScanner(bytes.NewReader(out)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if strings.HasPrefix(line, "DISPLAY=") { + parts := strings.SplitN(line, ":", 2) + require.Len(t, parts, 2) + displayPart := parts[1] + if strings.Contains(displayPart, ".") { + displayPart = strings.SplitN(displayPart, ".", 2)[0] + } + var convErr error + newDisplayNumber, convErr = strconv.Atoi(displayPart) + require.NoError(t, convErr) + break + } + } + require.NoError(t, sc.Err()) + } + + // The display number reused should correspond to the SECOND session (display offset 12) + expectedDisplay := agentssh.X11DefaultDisplayOffset + 2 // +1 was blocked port + assert.Equal(t, expectedDisplay, newDisplayNumber, "second session should have been evicted and its display reused") + + // First session should still be alive: send cmd and read output. + msgFirst := "still-alive" + _, err = sessions[0].stdin.Write([]byte("echo " + msgFirst + "\n")) + require.NoError(t, err) + for sessions[0].scanner.Scan() { + line := strings.TrimSpace(sessions[0].scanner.Text()) + if strings.Contains(line, msgFirst) { + break + } + } + require.NoError(t, sessions[0].scanner.Err()) + + // Second session should now be closed. + _, err = sessions[1].stdin.Write([]byte("echo dead\n")) + require.ErrorIs(t, err, io.EOF) + err = sessions[1].sess.Wait() + require.Error(t, err) + + // Cleanup. + for i, sh := range sessions { + if i == 1 { + // already closed + continue + } + err = sh.stdin.Close() + require.NoError(t, err) + err = sh.sess.Wait() + require.NoError(t, err) + } + err = extraSess.Close() + require.ErrorIs(t, err, io.EOF) + + err = s.Close() + require.NoError(t, err) + _ = testutil.TryReceive(ctx, t, done) +} diff --git a/agent/api.go b/agent/api.go index 464c5fc5dab30..0458df7c58e1f 100644 --- a/agent/api.go +++ b/agent/api.go @@ -7,15 +7,11 @@ import ( "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "github.com/coder/coder/v2/agent/agentcontainers" - "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" ) -func (a *agent) apiHandler(aAPI proto.DRPCAgentClient26) (http.Handler, func() error) { +func (a *agent) apiHandler() http.Handler { r := chi.NewRouter() r.Get("/", func(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{ @@ -40,34 +36,8 @@ func (a *agent) apiHandler(aAPI proto.DRPCAgentClient26) (http.Handler, func() e cacheDuration: cacheDuration, } - if a.experimentalDevcontainersEnabled { - containerAPIOpts := []agentcontainers.Option{ - agentcontainers.WithExecer(a.execer), - agentcontainers.WithScriptLogger(func(logSourceID uuid.UUID) agentcontainers.ScriptLogger { - return a.logSender.GetScriptLogger(logSourceID) - }), - agentcontainers.WithSubAgentClient(agentcontainers.NewSubAgentClientFromAPI(a.logger, aAPI)), - } - manifest := a.manifest.Load() - if manifest != nil { - containerAPIOpts = append(containerAPIOpts, - agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName), - ) - - if len(manifest.Devcontainers) > 0 { - containerAPIOpts = append( - containerAPIOpts, - agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts), - ) - } - } - - // Append after to allow the agent options to override the default options. - containerAPIOpts = append(containerAPIOpts, a.containerAPIOptions...) - - containerAPI := agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...) - r.Mount("/api/v0/containers", containerAPI.Routes()) - a.containerAPI.Store(containerAPI) + if a.containerAPI != nil { + r.Mount("/api/v0/containers", a.containerAPI.Routes()) } else { r.HandleFunc("/api/v0/containers", func(w http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), w, http.StatusForbidden, codersdk.Response{ @@ -88,12 +58,7 @@ func (a *agent) apiHandler(aAPI proto.DRPCAgentClient26) (http.Handler, func() e r.Get("/debug/manifest", a.HandleHTTPDebugManifest) r.Get("/debug/prometheus", promHandler.ServeHTTP) - return r, func() error { - if containerAPI := a.containerAPI.Load(); containerAPI != nil { - return containerAPI.Close() - } - return nil - } + return r } type listeningPortsHandler struct { diff --git a/agent/proto/compare_test.go b/agent/proto/compare_test.go index 3c5bdbf93a9e1..1e2645c59d5bc 100644 --- a/agent/proto/compare_test.go +++ b/agent/proto/compare_test.go @@ -67,7 +67,6 @@ func TestLabelsEqual(t *testing.T) { eq: false, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() require.Equal(t, tc.eq, proto.LabelsEqual(tc.a, tc.b)) diff --git a/agent/proto/resourcesmonitor/queue_test.go b/agent/proto/resourcesmonitor/queue_test.go index a3a8fbc0d0a3a..770cf9e732ac7 100644 --- a/agent/proto/resourcesmonitor/queue_test.go +++ b/agent/proto/resourcesmonitor/queue_test.go @@ -65,8 +65,6 @@ func TestResourceMonitorQueue(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() queue := resourcesmonitor.NewQueue(20) diff --git a/agent/proto/resourcesmonitor/resources_monitor_test.go b/agent/proto/resourcesmonitor/resources_monitor_test.go index ddf3522ecea30..da8ffef293903 100644 --- a/agent/proto/resourcesmonitor/resources_monitor_test.go +++ b/agent/proto/resourcesmonitor/resources_monitor_test.go @@ -195,7 +195,6 @@ func TestPushResourcesMonitoringWithConfig(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/agent/reconnectingpty/server.go b/agent/reconnectingpty/server.go index 04bbdc7efb7b2..19a2853c9d47f 100644 --- a/agent/reconnectingpty/server.go +++ b/agent/reconnectingpty/server.go @@ -31,8 +31,10 @@ type Server struct { connCount atomic.Int64 reconnectingPTYs sync.Map timeout time.Duration - - ExperimentalDevcontainersEnabled bool + // Experimental: allow connecting to running containers via Docker exec. + // Note that this is different from the devcontainers feature, which uses + // subagents. + ExperimentalContainers bool } // NewServer returns a new ReconnectingPTY server @@ -187,7 +189,7 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co }() var ei usershell.EnvInfoer - if s.ExperimentalDevcontainersEnabled && msg.Container != "" { + if s.ExperimentalContainers && msg.Container != "" { dei, err := agentcontainers.EnvInfo(ctx, s.commandCreator.Execer, msg.Container, msg.ContainerUser) if err != nil { return xerrors.Errorf("get container env info: %w", err) diff --git a/apiversion/apiversion_test.go b/apiversion/apiversion_test.go index 8a18a0bd5ca8e..dfe80bdb731a5 100644 --- a/apiversion/apiversion_test.go +++ b/apiversion/apiversion_test.go @@ -72,7 +72,6 @@ func TestAPIVersionValidate(t *testing.T) { expectedError: "no longer supported", }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/buildinfo/buildinfo_test.go b/buildinfo/buildinfo_test.go index b83c106148e9e..ac9f5cd4dee83 100644 --- a/buildinfo/buildinfo_test.go +++ b/buildinfo/buildinfo_test.go @@ -93,7 +93,6 @@ func TestBuildInfo(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() require.Equal(t, c.expectMatch, buildinfo.VersionsMatch(c.v1, c.v2), diff --git a/cli/agent.go b/cli/agent.go index 5d6037f9930ec..2285d44fc3584 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -55,8 +55,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { blockFileTransfer bool agentHeaderCommand string agentHeader []string - - experimentalDevcontainersEnabled bool + devcontainers bool ) cmd := &serpent.Command{ Use: "agent", @@ -321,7 +320,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { return xerrors.Errorf("create agent execer: %w", err) } - if experimentalDevcontainersEnabled { + if devcontainers { logger.Info(ctx, "agent devcontainer detection enabled") } else { logger.Info(ctx, "agent devcontainer detection not enabled") @@ -359,11 +358,11 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { SSHMaxTimeout: sshMaxTimeout, Subsystems: subsystems, - PrometheusRegistry: prometheusRegistry, - BlockFileTransfer: blockFileTransfer, - Execer: execer, - ExperimentalDevcontainersEnabled: experimentalDevcontainersEnabled, - ContainerAPIOptions: []agentcontainers.Option{ + PrometheusRegistry: prometheusRegistry, + BlockFileTransfer: blockFileTransfer, + Execer: execer, + Devcontainers: devcontainers, + DevcontainerAPIOptions: []agentcontainers.Option{ agentcontainers.WithSubAgentURL(r.agentURL.String()), }, }) @@ -506,10 +505,10 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { }, { Flag: "devcontainers-enable", - Default: "false", + Default: "true", Env: "CODER_AGENT_DEVCONTAINERS_ENABLE", Description: "Allow the agent to automatically detect running devcontainers.", - Value: serpent.BoolOf(&experimentalDevcontainersEnabled), + Value: serpent.BoolOf(&devcontainers), }, } diff --git a/cli/agent_internal_test.go b/cli/agent_internal_test.go index 910effb4191c1..02d65baaf623c 100644 --- a/cli/agent_internal_test.go +++ b/cli/agent_internal_test.go @@ -54,7 +54,6 @@ func Test_extractPort(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := extractPort(tt.urlString) diff --git a/cli/autoupdate_test.go b/cli/autoupdate_test.go index 51001d5109755..84647b0553d1c 100644 --- a/cli/autoupdate_test.go +++ b/cli/autoupdate_test.go @@ -62,7 +62,6 @@ func TestAutoUpdate(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/cli/clitest/golden.go b/cli/clitest/golden.go index d4401d6c5d5f9..fd44b523b9c9f 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -71,7 +71,6 @@ ExtractCommandPathsLoop: } for _, tt := range cases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/cli/cliui/agent_test.go b/cli/cliui/agent_test.go index 966d53578780a..7c3b71a204c3d 100644 --- a/cli/cliui/agent_test.go +++ b/cli/cliui/agent_test.go @@ -369,7 +369,6 @@ func TestAgent(t *testing.T) { wantErr: true, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -648,7 +647,6 @@ func TestPeerDiagnostics(t *testing.T) { }, } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() r, w := io.Pipe() @@ -852,7 +850,6 @@ func TestConnDiagnostics(t *testing.T) { }, } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() r, w := io.Pipe() diff --git a/cli/cliui/provisionerjob_test.go b/cli/cliui/provisionerjob_test.go index aa31c9b4a40cb..77310e9536321 100644 --- a/cli/cliui/provisionerjob_test.go +++ b/cli/cliui/provisionerjob_test.go @@ -124,8 +124,6 @@ func TestProvisionerJob(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/cli/cliui/resources_internal_test.go b/cli/cliui/resources_internal_test.go index 0c76e18eb1d1f..934322b5e9fb9 100644 --- a/cli/cliui/resources_internal_test.go +++ b/cli/cliui/resources_internal_test.go @@ -40,7 +40,6 @@ func TestRenderAgentVersion(t *testing.T) { }, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion) diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index 671002d713fcf..4e82707f3fec8 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -169,7 +169,6 @@ foo 10 [a, b, c] foo1 11 foo2 12 fo // Test with pointer values. inPtr := make([]*tableTest1, len(in)) for i, v := range in { - v := v inPtr[i] = &v } out, err = cliui.DisplayTable(inPtr, "", nil) diff --git a/cli/cliutil/levenshtein/levenshtein_test.go b/cli/cliutil/levenshtein/levenshtein_test.go index c635ad0564181..a210dd9253434 100644 --- a/cli/cliutil/levenshtein/levenshtein_test.go +++ b/cli/cliutil/levenshtein/levenshtein_test.go @@ -95,7 +95,6 @@ func Test_Levenshtein_Matches(t *testing.T) { Expected: []string{"kubernetes"}, }, } { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() actual := levenshtein.Matches(tt.Needle, tt.MaxDistance, tt.Haystack...) @@ -179,7 +178,6 @@ func Test_Levenshtein_Distance(t *testing.T) { Error: levenshtein.ErrMaxDist.Error(), }, } { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() actual, err := levenshtein.Distance(tt.A, tt.B, tt.MaxDist) diff --git a/cli/cliutil/license.go b/cli/cliutil/license.go new file mode 100644 index 0000000000000..f4012ba665845 --- /dev/null +++ b/cli/cliutil/license.go @@ -0,0 +1,87 @@ +package cliutil + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +// NewLicenseFormatter returns a new license formatter. +// The formatter will return a table and JSON output. +func NewLicenseFormatter() *cliui.OutputFormatter { + type tableLicense struct { + ID int32 `table:"id,default_sort"` + UUID uuid.UUID `table:"uuid" format:"uuid"` + UploadedAt time.Time `table:"uploaded at" format:"date-time"` + // Features is the formatted string for the license claims. + // Used for the table view. + Features string `table:"features"` + ExpiresAt time.Time `table:"expires at" format:"date-time"` + Trial bool `table:"trial"` + } + + return cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat([]tableLicense{}, []string{"ID", "UUID", "Expires At", "Uploaded At", "Features"}), + func(data any) (any, error) { + list, ok := data.([]codersdk.License) + if !ok { + return nil, xerrors.Errorf("invalid data type %T", data) + } + out := make([]tableLicense, 0, len(list)) + for _, lic := range list { + var formattedFeatures string + features, err := lic.FeaturesClaims() + if err != nil { + formattedFeatures = xerrors.Errorf("invalid license: %w", err).Error() + } else { + var strs []string + if lic.AllFeaturesClaim() { + // If all features are enabled, just include that + strs = append(strs, "all features") + } else { + for k, v := range features { + if v > 0 { + // Only include claims > 0 + strs = append(strs, fmt.Sprintf("%s=%v", k, v)) + } + } + } + formattedFeatures = strings.Join(strs, ", ") + } + // If this returns an error, a zero time is returned. + exp, _ := lic.ExpiresAt() + + out = append(out, tableLicense{ + ID: lic.ID, + UUID: lic.UUID, + UploadedAt: lic.UploadedAt, + Features: formattedFeatures, + ExpiresAt: exp, + Trial: lic.Trial(), + }) + } + return out, nil + }), + cliui.ChangeFormatterData(cliui.JSONFormat(), func(data any) (any, error) { + list, ok := data.([]codersdk.License) + if !ok { + return nil, xerrors.Errorf("invalid data type %T", data) + } + for i := range list { + humanExp, err := list[i].ExpiresAt() + if err == nil { + list[i].Claims[codersdk.LicenseExpiryClaim+"_human"] = humanExp.Format(time.RFC3339) + } + } + + return list, nil + }), + ) +} diff --git a/cli/cliutil/provisionerwarn_test.go b/cli/cliutil/provisionerwarn_test.go index a737223310d75..878f08f822330 100644 --- a/cli/cliutil/provisionerwarn_test.go +++ b/cli/cliutil/provisionerwarn_test.go @@ -59,7 +59,6 @@ func TestWarnMatchedProvisioners(t *testing.T) { }, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() var w strings.Builder diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go index acf534e7ae157..0ddfedf077fbb 100644 --- a/cli/configssh_internal_test.go +++ b/cli/configssh_internal_test.go @@ -118,7 +118,6 @@ func Test_sshConfigSplitOnCoderSection(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -157,7 +156,6 @@ func Test_sshConfigProxyCommandEscape(t *testing.T) { } // nolint:paralleltest // Fixes a flake for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows doesn't typically execute via /bin/sh or cmd.exe, so this test is not applicable.") @@ -207,7 +205,6 @@ func Test_sshConfigMatchExecEscape(t *testing.T) { } // nolint:paralleltest // Fixes a flake for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { cmd := "/bin/sh" arg := "-c" @@ -290,7 +287,6 @@ func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() found, err := sshConfigProxyCommandEscape(tt.path, tt.forceUnix) @@ -366,7 +362,6 @@ func Test_sshConfigOptions_addOption(t *testing.T) { } for _, tt := range testCases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 60c93b8e94f4b..1ffe93a7b838c 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -688,7 +688,6 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/delete_test.go b/cli/delete_test.go index 1d4dc8dfb40ad..a48ca98627f65 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -2,9 +2,19 @@ package cli_test import ( "context" + "database/sql" "fmt" "io" + "net/http" "testing" + "time" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/quartz" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -51,28 +61,35 @@ func TestDelete(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID) + template := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, workspace.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitShort) inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan") + clitest.SetupConfig(t, templateAdmin, root) - //nolint:gocritic // Deleting orphaned workspaces requires an admin. - clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) inv.Stderr = pty.Output() go func() { defer close(doneChan) - err := inv.Run() + err := inv.WithContext(ctx).Run() // When running with the race detector on, we sometimes get an EOF. if err != nil { assert.ErrorIs(t, err, io.EOF) } }() pty.ExpectMatch("has been deleted") - <-doneChan + testutil.TryReceive(ctx, t, doneChan) + + _, err := client.Workspace(ctx, workspace.ID) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusGone, cerr.StatusCode()) }) // Super orphaned, as the workspace doesn't even have a user. @@ -209,4 +226,225 @@ func TestDelete(t *testing.T) { cancel() <-doneChan }) + + t.Run("Prebuilt workspace delete permissions", func(t *testing.T) { + t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + + clock := quartz.NewMock(t) + ctx := testutil.Context(t, testutil.WaitSuperLong) + + // Setup + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + client, _ := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + Database: db, + Pubsub: pb, + IncludeProvisionerDaemon: true, + }) + owner := coderdtest.CreateFirstUser(t, client) + orgID := owner.OrganizationID + + // Given a template version with a preset and a template + version := coderdtest.CreateTemplateVersion(t, client, orgID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + preset := setupTestDBPreset(t, db, version.ID) + template := coderdtest.CreateTemplate(t, client, orgID, version.ID) + + cases := []struct { + name string + client *codersdk.Client + expectedPrebuiltDeleteErrMsg string + expectedWorkspaceDeleteErrMsg string + }{ + // Users with the OrgAdmin role should be able to delete both normal and prebuilt workspaces + { + name: "OrgAdmin", + client: func() *codersdk.Client { + client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.ScopedRoleOrgAdmin(orgID)) + return client + }(), + }, + // Users with the TemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces + { + name: "TemplateAdmin", + client: func() *codersdk.Client { + client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.RoleTemplateAdmin()) + return client + }(), + expectedWorkspaceDeleteErrMsg: "unexpected status code 403: You do not have permission to delete this workspace.", + }, + // Users with the OrgTemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces + { + name: "OrgTemplateAdmin", + client: func() *codersdk.Client { + client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID)) + return client + }(), + expectedWorkspaceDeleteErrMsg: "unexpected status code 403: You do not have permission to delete this workspace.", + }, + // Users with the Member role should not be able to delete prebuilt or normal workspaces + { + name: "Member", + client: func() *codersdk.Client { + client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.RoleMember()) + return client + }(), + expectedPrebuiltDeleteErrMsg: "unexpected status code 404: Resource not found or you do not have access to this resource", + expectedWorkspaceDeleteErrMsg: "unexpected status code 404: Resource not found or you do not have access to this resource", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create one prebuilt workspace (owned by system user) and one normal workspace (owned by a user) + // Each workspace is persisted in the DB along with associated workspace jobs and builds. + dbPrebuiltWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, database.PrebuildsSystemUserID, template.ID, version.ID, preset.ID) + userWorkspaceOwner, err := client.User(context.Background(), "testUser") + require.NoError(t, err) + dbUserWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, userWorkspaceOwner.ID, template.ID, version.ID, preset.ID) + + assertWorkspaceDelete := func( + runClient *codersdk.Client, + workspace database.Workspace, + workspaceOwner string, + expectedErr string, + ) { + t.Helper() + + // Attempt to delete the workspace as the test client + inv, root := clitest.New(t, "delete", workspaceOwner+"/"+workspace.Name, "-y") + clitest.SetupConfig(t, runClient, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + var runErr error + go func() { + defer close(doneChan) + runErr = inv.Run() + }() + + // Validate the result based on the expected error message + if expectedErr != "" { + <-doneChan + require.Error(t, runErr) + require.Contains(t, runErr.Error(), expectedErr) + } else { + pty.ExpectMatch("has been deleted") + <-doneChan + + // When running with the race detector on, we sometimes get an EOF. + if runErr != nil { + assert.ErrorIs(t, runErr, io.EOF) + } + + // Verify that the workspace is now marked as deleted + _, err := client.Workspace(context.Background(), workspace.ID) + require.ErrorContains(t, err, "was deleted") + } + } + + // Ensure at least one prebuilt workspace is reported as running in the database + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { + running, err := db.GetRunningPrebuiltWorkspaces(ctx) + if !assert.NoError(t, err) || !assert.GreaterOrEqual(t, len(running), 1) { + return false + } + return true + }, testutil.IntervalMedium, "running prebuilt workspaces timeout") + + runningWorkspaces, err := db.GetRunningPrebuiltWorkspaces(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(runningWorkspaces), 1) + + // Get the full prebuilt workspace object from the DB + prebuiltWorkspace, err := db.GetWorkspaceByID(ctx, dbPrebuiltWorkspace.ID) + require.NoError(t, err) + + // Assert the prebuilt workspace deletion + assertWorkspaceDelete(tc.client, prebuiltWorkspace, "prebuilds", tc.expectedPrebuiltDeleteErrMsg) + + // Get the full user workspace object from the DB + userWorkspace, err := db.GetWorkspaceByID(ctx, dbUserWorkspace.ID) + require.NoError(t, err) + + // Assert the user workspace deletion + assertWorkspaceDelete(tc.client, userWorkspace, userWorkspaceOwner.Username, tc.expectedWorkspaceDeleteErrMsg) + }) + } + }) +} + +func setupTestDBPreset( + t *testing.T, + db database.Store, + templateVersionID uuid.UUID, +) database.TemplateVersionPreset { + t.Helper() + + preset := dbgen.Preset(t, db, database.InsertPresetParams{ + TemplateVersionID: templateVersionID, + Name: "preset-test", + DesiredInstances: sql.NullInt32{ + Valid: true, + Int32: 1, + }, + }) + dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{ + TemplateVersionPresetID: preset.ID, + Names: []string{"test"}, + Values: []string{"test"}, + }) + + return preset +} + +func setupTestDBWorkspace( + t *testing.T, + clock quartz.Clock, + db database.Store, + ps pubsub.Pubsub, + orgID uuid.UUID, + ownerID uuid.UUID, + templateID uuid.UUID, + templateVersionID uuid.UUID, + presetID uuid.UUID, +) database.WorkspaceTable { + t.Helper() + + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: templateID, + OrganizationID: orgID, + OwnerID: ownerID, + Deleted: false, + CreatedAt: time.Now().Add(-time.Hour * 2), + }) + job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + InitiatorID: ownerID, + CreatedAt: time.Now().Add(-time.Hour * 2), + StartedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour * 2), Valid: true}, + CompletedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour), Valid: true}, + OrganizationID: orgID, + }) + workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + InitiatorID: ownerID, + TemplateVersionID: templateVersionID, + JobID: job.ID, + TemplateVersionPresetID: uuid.NullUUID{UUID: presetID, Valid: true}, + Transition: database.WorkspaceTransitionStart, + CreatedAt: clock.Now(), + }) + dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{ + { + WorkspaceBuildID: workspaceBuild.ID, + Name: "test", + Value: "test", + }, + }) + + return workspace } diff --git a/cli/exp_errors_test.go b/cli/exp_errors_test.go index 75272fc86d8d3..61e11dc770afc 100644 --- a/cli/exp_errors_test.go +++ b/cli/exp_errors_test.go @@ -49,7 +49,6 @@ ExtractCommandPathsLoop: } for _, tt := range cases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index d487af5691bca..0a1c9fcbeaf87 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -585,10 +585,10 @@ func (s *mcpServer) startWatcher(ctx context.Context, inv *serpent.Invocation) { case event := <-eventsCh: switch ev := event.(type) { case agentapi.EventStatusChange: - // If the screen is stable, assume complete. + // If the screen is stable, report idle. state := codersdk.WorkspaceAppStatusStateWorking if ev.Status == agentapi.StatusStable { - state = codersdk.WorkspaceAppStatusStateComplete + state = codersdk.WorkspaceAppStatusStateIdle } err := s.queue.Push(taskReport{ state: state, diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 08d6fbc4e2ce6..bcfafb0204874 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -900,7 +900,7 @@ func TestExpMcpReporter(t *testing.T) { { event: makeStatusEvent(agentapi.StatusStable), expected: &codersdk.WorkspaceAppStatus{ - State: codersdk.WorkspaceAppStatusStateComplete, + State: codersdk.WorkspaceAppStatusStateIdle, Message: "doing work", URI: "https://dev.coder.com", }, @@ -948,7 +948,7 @@ func TestExpMcpReporter(t *testing.T) { { event: makeStatusEvent(agentapi.StatusStable), expected: &codersdk.WorkspaceAppStatus{ - State: codersdk.WorkspaceAppStatusStateComplete, + State: codersdk.WorkspaceAppStatusStateIdle, Message: "oops", URI: "", }, diff --git a/cli/exp_rpty_test.go b/cli/exp_rpty_test.go index 923bf09bb0e15..213764bb40113 100644 --- a/cli/exp_rpty_test.go +++ b/cli/exp_rpty_test.go @@ -87,6 +87,8 @@ func TestExpRpty(t *testing.T) { t.Skip("Skipping test on non-Linux platform") } + wantLabel := "coder.devcontainers.TestExpRpty.Container" + client, workspace, agentToken := setupWorkspaceForAgent(t) ctx := testutil.Context(t, testutil.WaitLong) pool, err := dockertest.NewPool("") @@ -94,7 +96,10 @@ func TestExpRpty(t *testing.T) { ct, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "busybox", Tag: "latest", - Cmd: []string{"sleep", "infnity"}, + Cmd: []string{"sleep", "infinity"}, + Labels: map[string]string{ + wantLabel: "true", + }, }, func(config *docker.HostConfig) { config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"} @@ -111,9 +116,9 @@ func TestExpRpty(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, - agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, + agentcontainers.WithContainerLabelIncludeFilter(wantLabel, "true"), ) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() diff --git a/cli/gitauth/askpass_test.go b/cli/gitauth/askpass_test.go index d70e791c97afb..e9213daf37bda 100644 --- a/cli/gitauth/askpass_test.go +++ b/cli/gitauth/askpass_test.go @@ -60,7 +60,6 @@ func TestParse(t *testing.T) { wantHost: "http://wow.io", }, } { - tc := tc t.Run(tc.in, func(t *testing.T) { t.Parallel() user, host, err := gitauth.ParseAskpass(tc.in) diff --git a/cli/notifications_test.go b/cli/notifications_test.go index 5164657c6c1fb..0e8ece285b450 100644 --- a/cli/notifications_test.go +++ b/cli/notifications_test.go @@ -48,7 +48,6 @@ func TestNotifications(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/open_internal_test.go b/cli/open_internal_test.go index 7af4359a56bc2..5c3ec338aca42 100644 --- a/cli/open_internal_test.go +++ b/cli/open_internal_test.go @@ -47,7 +47,6 @@ func Test_resolveAgentAbsPath(t *testing.T) { {"fail with no working directory and rel path on windows", args{relOrAbsPath: "my\\path", agentOS: "windows"}, "", true}, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -156,7 +155,6 @@ func Test_buildAppLinkURL(t *testing.T) { expectedLink: "https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/", }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() baseURL, err := url.Parse(tt.baseURL) diff --git a/cli/open_test.go b/cli/open_test.go index f7180ab260fbd..b76b603d35b1e 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -113,7 +113,6 @@ func TestOpenVSCode(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -240,7 +239,6 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -336,8 +334,8 @@ func TestOpenVSCodeDevContainer(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mccli), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) @@ -414,8 +412,6 @@ func TestOpenVSCodeDevContainer(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -513,8 +509,8 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mccli), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) @@ -579,8 +575,6 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/organizationroles.go b/cli/organizationroles.go index 4d68ab02ae78d..3651baea88d2f 100644 --- a/cli/organizationroles.go +++ b/cli/organizationroles.go @@ -435,7 +435,6 @@ func applyOrgResourceActions(role *codersdk.Role, resource string, actions []str // Construct new site perms with only new perms for the resource keep := make([]codersdk.Permission, 0) for _, perm := range role.OrganizationPermissions { - perm := perm if string(perm.ResourceType) != resource { keep = append(keep, perm) } diff --git a/cli/organizationsettings.go b/cli/organizationsettings.go index 920ae41ebe1fc..391a4f72e27fd 100644 --- a/cli/organizationsettings.go +++ b/cli/organizationsettings.go @@ -116,7 +116,6 @@ func (r *RootCmd) setOrganizationSettings(orgContext *OrganizationContext, setti } for _, set := range settings { - set := set patch := set.Patch cmd.Children = append(cmd.Children, &serpent.Command{ Use: set.Name, @@ -192,7 +191,6 @@ func (r *RootCmd) printOrganizationSetting(orgContext *OrganizationContext, sett } for _, set := range settings { - set := set fetch := set.Fetch cmd.Children = append(cmd.Children, &serpent.Command{ Use: set.Name, diff --git a/cli/portforward_internal_test.go b/cli/portforward_internal_test.go index 0d1259713dac9..5698363f95e5e 100644 --- a/cli/portforward_internal_test.go +++ b/cli/portforward_internal_test.go @@ -103,7 +103,6 @@ func Test_parsePortForwards(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/portforward_test.go b/cli/portforward_test.go index 0be029748b3c8..9899bd28cccdf 100644 --- a/cli/portforward_test.go +++ b/cli/portforward_test.go @@ -13,7 +13,6 @@ import ( "github.com/pion/udp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/xerrors" "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agenttest" @@ -145,7 +144,6 @@ func TestPortForward(t *testing.T) { ) for _, c := range cases { - c := c t.Run(c.name+"_OnePort", func(t *testing.T) { t.Parallel() p1 := setupTestListener(t, c.setupRemote(t)) @@ -162,7 +160,7 @@ func TestPortForward(t *testing.T) { inv.Stdout = pty.Output() inv.Stderr = pty.Output() - iNet := newInProcNet() + iNet := testutil.NewInProcNet() inv.Net = iNet ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -178,10 +176,10 @@ func TestPortForward(t *testing.T) { // sync. dialCtx, dialCtxCancel := context.WithTimeout(ctx, testutil.WaitShort) defer dialCtxCancel() - c1, err := iNet.dial(dialCtx, addr{c.network, c.localAddress[0]}) + c1, err := iNet.Dial(dialCtx, testutil.NewAddr(c.network, c.localAddress[0])) require.NoError(t, err, "open connection 1 to 'local' listener") defer c1.Close() - c2, err := iNet.dial(dialCtx, addr{c.network, c.localAddress[0]}) + c2, err := iNet.Dial(dialCtx, testutil.NewAddr(c.network, c.localAddress[0])) require.NoError(t, err, "open connection 2 to 'local' listener") defer c2.Close() testDial(t, c2) @@ -219,7 +217,7 @@ func TestPortForward(t *testing.T) { inv.Stdout = pty.Output() inv.Stderr = pty.Output() - iNet := newInProcNet() + iNet := testutil.NewInProcNet() inv.Net = iNet ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -233,10 +231,10 @@ func TestPortForward(t *testing.T) { // then test them out of order. dialCtx, dialCtxCancel := context.WithTimeout(ctx, testutil.WaitShort) defer dialCtxCancel() - c1, err := iNet.dial(dialCtx, addr{c.network, c.localAddress[0]}) + c1, err := iNet.Dial(dialCtx, testutil.NewAddr(c.network, c.localAddress[0])) require.NoError(t, err, "open connection 1 to 'local' listener 1") defer c1.Close() - c2, err := iNet.dial(dialCtx, addr{c.network, c.localAddress[1]}) + c2, err := iNet.Dial(dialCtx, testutil.NewAddr(c.network, c.localAddress[1])) require.NoError(t, err, "open connection 2 to 'local' listener 2") defer c2.Close() testDial(t, c2) @@ -258,7 +256,7 @@ func TestPortForward(t *testing.T) { t.Run("All", func(t *testing.T) { t.Parallel() var ( - dials = []addr{} + dials = []testutil.Addr{} flags = []string{} ) @@ -266,10 +264,7 @@ func TestPortForward(t *testing.T) { for _, c := range cases { p := setupTestListener(t, c.setupRemote(t)) - dials = append(dials, addr{ - network: c.network, - addr: c.localAddress[0], - }) + dials = append(dials, testutil.NewAddr(c.network, c.localAddress[0])) flags = append(flags, fmt.Sprintf(c.flag[0], p)) } @@ -280,7 +275,7 @@ func TestPortForward(t *testing.T) { pty := ptytest.New(t).Attach(inv) inv.Stderr = pty.Output() - iNet := newInProcNet() + iNet := testutil.NewInProcNet() inv.Net = iNet ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -297,7 +292,7 @@ func TestPortForward(t *testing.T) { ) defer dialCtxCancel() for i, a := range dials { - c, err := iNet.dial(dialCtx, a) + c, err := iNet.Dial(dialCtx, a) require.NoErrorf(t, err, "open connection %v to 'local' listener %v", i+1, i+1) t.Cleanup(func() { _ = c.Close() @@ -341,7 +336,7 @@ func TestPortForward(t *testing.T) { inv.Stdout = pty.Output() inv.Stderr = pty.Output() - iNet := newInProcNet() + iNet := testutil.NewInProcNet() inv.Net = iNet // listen on port 5555 on IPv6 so it's busy when we try to port forward @@ -362,7 +357,7 @@ func TestPortForward(t *testing.T) { // Test IPv4 still works dialCtx, dialCtxCancel := context.WithTimeout(ctx, testutil.WaitShort) defer dialCtxCancel() - c1, err := iNet.dial(dialCtx, addr{"tcp", "127.0.0.1:5555"}) + c1, err := iNet.Dial(dialCtx, testutil.NewAddr("tcp", "127.0.0.1:5555")) require.NoError(t, err, "open connection 1 to 'local' listener") defer c1.Close() testDial(t, c1) @@ -474,95 +469,3 @@ func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { assert.NoError(t, err, "write payload") assert.Equal(t, len(payload), n, "payload length does not match") } - -type addr struct { - network string - addr string -} - -func (a addr) Network() string { - return a.network -} - -func (a addr) Address() string { - return a.addr -} - -func (a addr) String() string { - return a.network + "|" + a.addr -} - -type inProcNet struct { - sync.Mutex - - listeners map[addr]*inProcListener -} - -type inProcListener struct { - c chan net.Conn - n *inProcNet - a addr - o sync.Once -} - -func newInProcNet() *inProcNet { - return &inProcNet{listeners: make(map[addr]*inProcListener)} -} - -func (n *inProcNet) Listen(network, address string) (net.Listener, error) { - a := addr{network, address} - n.Lock() - defer n.Unlock() - if _, ok := n.listeners[a]; ok { - return nil, xerrors.New("busy") - } - l := newInProcListener(n, a) - n.listeners[a] = l - return l, nil -} - -func (n *inProcNet) dial(ctx context.Context, a addr) (net.Conn, error) { - n.Lock() - defer n.Unlock() - l, ok := n.listeners[a] - if !ok { - return nil, xerrors.Errorf("nothing listening on %s", a) - } - x, y := net.Pipe() - select { - case <-ctx.Done(): - return nil, ctx.Err() - case l.c <- x: - return y, nil - } -} - -func newInProcListener(n *inProcNet, a addr) *inProcListener { - return &inProcListener{ - c: make(chan net.Conn), - n: n, - a: a, - } -} - -func (l *inProcListener) Accept() (net.Conn, error) { - c, ok := <-l.c - if !ok { - return nil, net.ErrClosed - } - return c, nil -} - -func (l *inProcListener) Close() error { - l.o.Do(func() { - l.n.Lock() - defer l.n.Unlock() - delete(l.n.listeners, l.a) - close(l.c) - }) - return nil -} - -func (l *inProcListener) Addr() net.Addr { - return l.a -} diff --git a/cli/provisionerjobs_test.go b/cli/provisionerjobs_test.go index 1566147c5311d..b33fd8b984dc7 100644 --- a/cli/provisionerjobs_test.go +++ b/cli/provisionerjobs_test.go @@ -152,7 +152,6 @@ func TestProvisionerJobs(t *testing.T) { {"Member", memberClient, "TemplateVersionImport", prepareTemplateVersionImportJob, false}, {"Member", memberClient, "TemplateVersionImportDryRun", prepareTemplateVersionImportJobDryRun, false}, } { - tt := tt wantMsg := "OK" if !tt.wantCancelled { wantMsg = "FAIL" diff --git a/cli/root_internal_test.go b/cli/root_internal_test.go index f95ab04c1c9ec..9eb3fe7609582 100644 --- a/cli/root_internal_test.go +++ b/cli/root_internal_test.go @@ -76,7 +76,6 @@ func Test_formatExamples(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/schedule_internal_test.go b/cli/schedule_internal_test.go index cdbbb9ca6ce26..dea98f97d09fb 100644 --- a/cli/schedule_internal_test.go +++ b/cli/schedule_internal_test.go @@ -100,7 +100,6 @@ func TestParseCLISchedule(t *testing.T) { expectedError: errInvalidTimeFormat.Error(), }, } { - testCase := testCase //nolint:paralleltest // t.Setenv t.Run(testCase.name, func(t *testing.T) { t.Setenv("TZ", testCase.tzEnv) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 60fbf19f4db08..02997a9a4c40d 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -341,8 +341,6 @@ func TestScheduleOverride(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.command, func(t *testing.T) { // Given // Set timezone to Asia/Kolkata to surface any timezone-related bugs. diff --git a/cli/server.go b/cli/server.go index 0cc7b0edf2e36..5074bffc3a342 100644 --- a/cli/server.go +++ b/cli/server.go @@ -61,7 +61,6 @@ import ( "github.com/coder/serpent" "github.com/coder/wgtunnel/tunnelsdk" - "github.com/coder/coder/v2/coderd/ai" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/notifications/reports" "github.com/coder/coder/v2/coderd/runtimeconfig" @@ -611,22 +610,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ) } - aiProviders, err := ReadAIProvidersFromEnv(os.Environ()) - if err != nil { - return xerrors.Errorf("read ai providers from env: %w", err) - } - vals.AI.Value.Providers = append(vals.AI.Value.Providers, aiProviders...) - for _, provider := range aiProviders { - logger.Debug( - ctx, "loaded ai provider", - slog.F("type", provider.Type), - ) - } - languageModels, err := ai.ModelsFromConfig(ctx, vals.AI.Value.Providers) - if err != nil { - return xerrors.Errorf("create language models: %w", err) - } - realIPConfig, err := httpmw.ParseRealIPConfig(vals.ProxyTrustedHeaders, vals.ProxyTrustedOrigins) if err != nil { return xerrors.Errorf("parse real ip config: %w", err) @@ -657,7 +640,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. CacheDir: cacheDir, GoogleTokenValidator: googleTokenValidator, ExternalAuthConfigs: externalAuthConfigs, - LanguageModels: languageModels, RealIPConfig: realIPConfig, SSHKeygenAlgorithm: sshKeygenAlgorithm, TracerProvider: tracerProvider, @@ -1125,7 +1107,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value()) defer autobuildTicker.Stop() autobuildExecutor := autobuild.NewExecutor( - ctx, options.Database, options.Pubsub, options.PrometheusRegistry, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C, options.NotificationsEnqueuer, coderAPI.Experiments) + ctx, options.Database, options.Pubsub, coderAPI.FileCache, options.PrometheusRegistry, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C, options.NotificationsEnqueuer, coderAPI.Experiments) autobuildExecutor.Run() jobReaperTicker := time.NewTicker(vals.JobReaperDetectorInterval.Value()) @@ -1202,7 +1184,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. var wg sync.WaitGroup for i, provisionerDaemon := range provisionerDaemons { id := i + 1 - provisionerDaemon := provisionerDaemon wg.Add(1) go func() { defer wg.Done() @@ -1680,7 +1661,6 @@ func configureServerTLS(ctx context.Context, logger slog.Logger, tlsMinVersion, // Expensively check which certificate matches the client hello. for _, cert := range certs { - cert := cert if err := hi.SupportsCertificate(&cert); err == nil { return &cert, nil } @@ -2312,19 +2292,20 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d var err error var sqlDB *sql.DB + dbNeedsClosing := true // Try to connect for 30 seconds. ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() defer func() { - if err == nil { + if !dbNeedsClosing { return } if sqlDB != nil { _ = sqlDB.Close() sqlDB = nil + logger.Debug(ctx, "closed db before returning from ConnectToPostgres") } - logger.Error(ctx, "connect to postgres failed", slog.Error(err)) }() var tries int @@ -2361,12 +2342,7 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d } defer version.Close() if !version.Next() { - // it's critical we assign to the err variable, otherwise the defer statement - // that runs db.Close() will not execute it - if err = version.Err(); err != nil { - return nil, xerrors.Errorf("no rows returned for version select: %w", err) - } - return nil, xerrors.Errorf("no rows returned for version select") + return nil, xerrors.Errorf("no rows returned for version select: %w", version.Err()) } var versionNum int err = version.Scan(&versionNum) @@ -2408,6 +2384,7 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d // of connection churn. sqlDB.SetMaxIdleConns(3) + dbNeedsClosing = false return sqlDB, nil } @@ -2645,77 +2622,6 @@ func redirectHTTPToHTTPSDeprecation(ctx context.Context, logger slog.Logger, inv } } -func ReadAIProvidersFromEnv(environ []string) ([]codersdk.AIProviderConfig, error) { - // The index numbers must be in-order. - sort.Strings(environ) - - var providers []codersdk.AIProviderConfig - for _, v := range serpent.ParseEnviron(environ, "CODER_AI_PROVIDER_") { - tokens := strings.SplitN(v.Name, "_", 2) - if len(tokens) != 2 { - return nil, xerrors.Errorf("invalid env var: %s", v.Name) - } - - providerNum, err := strconv.Atoi(tokens[0]) - if err != nil { - return nil, xerrors.Errorf("parse number: %s", v.Name) - } - - var provider codersdk.AIProviderConfig - switch { - case len(providers) < providerNum: - return nil, xerrors.Errorf( - "provider num %v skipped: %s", - len(providers), - v.Name, - ) - case len(providers) == providerNum: - // At the next next provider. - providers = append(providers, provider) - case len(providers) == providerNum+1: - // At the current provider. - provider = providers[providerNum] - } - - key := tokens[1] - switch key { - case "TYPE": - provider.Type = v.Value - case "API_KEY": - provider.APIKey = v.Value - case "BASE_URL": - provider.BaseURL = v.Value - case "MODELS": - provider.Models = strings.Split(v.Value, ",") - } - providers[providerNum] = provider - } - for _, envVar := range environ { - tokens := strings.SplitN(envVar, "=", 2) - if len(tokens) != 2 { - continue - } - switch tokens[0] { - case "OPENAI_API_KEY": - providers = append(providers, codersdk.AIProviderConfig{ - Type: "openai", - APIKey: tokens[1], - }) - case "ANTHROPIC_API_KEY": - providers = append(providers, codersdk.AIProviderConfig{ - Type: "anthropic", - APIKey: tokens[1], - }) - case "GOOGLE_API_KEY": - providers = append(providers, codersdk.AIProviderConfig{ - Type: "google", - APIKey: tokens[1], - }) - } - } - return providers, nil -} - // ReadExternalAuthProvidersFromEnv is provided for compatibility purposes with // the viper CLI. func ReadExternalAuthProvidersFromEnv(environ []string) ([]codersdk.ExternalAuthConfig, error) { diff --git a/cli/server_internal_test.go b/cli/server_internal_test.go index b5417ceb04b8e..263445ccabd6f 100644 --- a/cli/server_internal_test.go +++ b/cli/server_internal_test.go @@ -62,7 +62,6 @@ func Test_configureCipherSuites(t *testing.T) { cipherByName := func(cipher string) *tls.CipherSuite { for _, c := range append(tls.CipherSuites(), tls.InsecureCipherSuites()...) { if cipher == c.Name { - c := c return c } } @@ -173,7 +172,6 @@ func Test_configureCipherSuites(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := context.Background() @@ -245,7 +243,6 @@ func TestRedirectHTTPToHTTPSDeprecation(t *testing.T) { } for _, tc := range testcases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -310,7 +307,6 @@ func TestIsDERPPath(t *testing.T) { }, } for _, tc := range testcases { - tc := tc t.Run(tc.path, func(t *testing.T) { t.Parallel() require.Equal(t, tc.expected, isDERPPath(tc.path)) @@ -363,7 +359,6 @@ func TestEscapePostgresURLUserInfo(t *testing.T) { }, } for _, tc := range testcases { - tc := tc t.Run(tc.input, func(t *testing.T) { t.Parallel() o, err := escapePostgresURLUserInfo(tc.input) diff --git a/cli/server_test.go b/cli/server_test.go index eb3eac8fa5cd4..2d0bbdd24e83b 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -471,7 +471,6 @@ func TestServer(t *testing.T) { expectGithubDefaultProviderConfigured: true, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { runGitHubProviderTest(t, tc) }) @@ -629,7 +628,6 @@ func TestServer(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) @@ -883,8 +881,6 @@ func TestServer(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/cli/ssh_test.go b/cli/ssh_test.go index bee075283c083..582f8a3fdf691 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -1517,7 +1517,6 @@ func TestSSH(t *testing.T) { pty.ExpectMatchContext(ctx, "ping pong") for i, sock := range sockets { - i := i // Start the listener on the "local machine". l, err := net.Listen("unix", sock.local) require.NoError(t, err) @@ -1641,7 +1640,6 @@ func TestSSH(t *testing.T) { } for _, tc := range tcs { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -2031,8 +2029,8 @@ func TestSSH_Container(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) }) @@ -2071,8 +2069,8 @@ func TestSSH_Container(t *testing.T) { Warnings: nil, }, nil).AnyTimes() _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mLister), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) diff --git a/cli/start_test.go b/cli/start_test.go index 29fa4cdb46e5f..ec5f0b4735b39 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -343,7 +343,6 @@ func TestStartAutoUpdate(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -359,7 +358,7 @@ func TestStartAutoUpdate(t *testing.T) { coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) if c.Cmd == "start" { - coderdtest.MustTransitionWorkspace(t, member, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, member, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) } version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(stringRichParameters), func(ctvr *codersdk.CreateTemplateVersionRequest) { ctvr.TemplateID = template.ID diff --git a/cli/stop.go b/cli/stop.go index 218c42061db10..ef4813ff0a1a0 100644 --- a/cli/stop.go +++ b/cli/stop.go @@ -37,32 +37,11 @@ func (r *RootCmd) stop() *serpent.Command { if err != nil { return err } - if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobPending { - // cliutil.WarnMatchedProvisioners also checks if the job is pending - // but we still want to avoid users spamming multiple builds that will - // not be picked up. - cliui.Warn(inv.Stderr, "The workspace is already stopping!") - cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) - if _, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Enqueue another stop?", - IsConfirm: true, - Default: cliui.ConfirmNo, - }); err != nil { - return err - } - } - wbr := codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionStop, - } - if bflags.provisionerLogDebug { - wbr.LogLevel = codersdk.ProvisionerLogLevelDebug - } - build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr) + build, err := stopWorkspace(inv, client, workspace, bflags) if err != nil { return err } - cliutil.WarnMatchedProvisioners(inv.Stderr, build.MatchedProvisioners, build.Job) err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID) if err != nil { @@ -71,8 +50,8 @@ func (r *RootCmd) stop() *serpent.Command { _, _ = fmt.Fprintf( inv.Stdout, - "\nThe %s workspace has been stopped at %s!\n", cliui.Keyword(workspace.Name), - + "\nThe %s workspace has been stopped at %s!\n", + cliui.Keyword(workspace.Name), cliui.Timestamp(time.Now()), ) return nil @@ -82,3 +61,27 @@ func (r *RootCmd) stop() *serpent.Command { return cmd } + +func stopWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, bflags buildFlags) (codersdk.WorkspaceBuild, error) { + if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobPending { + // cliutil.WarnMatchedProvisioners also checks if the job is pending + // but we still want to avoid users spamming multiple builds that will + // not be picked up. + cliui.Warn(inv.Stderr, "The workspace is already stopping!") + cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) + if _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Enqueue another stop?", + IsConfirm: true, + Default: cliui.ConfirmNo, + }); err != nil { + return codersdk.WorkspaceBuild{}, err + } + } + wbr := codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStop, + } + if bflags.provisionerLogDebug { + wbr.LogLevel = codersdk.ProvisionerLogLevelDebug + } + return client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr) +} diff --git a/cli/support.go b/cli/support.go index fa7c58261bd41..70fadc3994580 100644 --- a/cli/support.go +++ b/cli/support.go @@ -3,6 +3,7 @@ package cli import ( "archive/zip" "bytes" + "context" "encoding/base64" "encoding/json" "fmt" @@ -13,6 +14,8 @@ import ( "text/tabwriter" "time" + "github.com/coder/coder/v2/cli/cliutil" + "github.com/google/uuid" "golang.org/x/xerrors" @@ -48,6 +51,7 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information - Agent details (with environment variable sanitized) - Agent network diagnostics - Agent logs + - License status ` + cliui.Bold("Note: ") + cliui.Wrap("While we try to sanitize sensitive data from support bundles, we cannot guarantee that they do not contain information that you or your organization may consider sensitive.\n") + cliui.Bold("Please confirm that you will:\n") + @@ -302,6 +306,11 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { return xerrors.Errorf("decode template zip from base64") } + licenseStatus, err := humanizeLicenses(src.Deployment.Licenses) + if err != nil { + return xerrors.Errorf("format license status: %w", err) + } + // The below we just write as we have them: for k, v := range map[string]string{ "agent/logs.txt": string(src.Agent.Logs), @@ -315,6 +324,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { "network/tailnet_debug.html": src.Network.TailnetDebug, "workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs), "workspace/template_file.zip": string(templateVersionBytes), + "license-status.txt": licenseStatus, } { f, err := dest.Create(k) if err != nil { @@ -359,3 +369,13 @@ func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string { _ = tw.Flush() return buf.String() } + +func humanizeLicenses(licenses []codersdk.License) (string, error) { + formatter := cliutil.NewLicenseFormatter() + + if len(licenses) == 0 { + return "No licenses found", nil + } + + return formatter.Format(context.Background(), licenses) +} diff --git a/cli/support_test.go b/cli/support_test.go index e1ad7fca7b0a4..46be69caa3bfd 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -386,6 +386,9 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge case "cli_logs.txt": bs := readBytesFromZip(t, f) require.NotEmpty(t, bs, "CLI logs should not be empty") + case "license-status.txt": + bs := readBytesFromZip(t, f) + require.NotEmpty(t, bs, "license status should not be empty") default: require.Failf(t, "unexpected file in bundle", f.Name) } diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index d5fe730a14559..b551a4abcdb1d 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -299,7 +299,6 @@ func TestTemplateEdit(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -416,7 +415,6 @@ func TestTemplateEdit(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/cli/templatepull_test.go b/cli/templatepull_test.go index 99f23d12923cd..5d999de15ed02 100644 --- a/cli/templatepull_test.go +++ b/cli/templatepull_test.go @@ -262,8 +262,6 @@ func TestTemplatePull_ToDir(t *testing.T) { // nolint: paralleltest // These tests change the current working dir, and is therefore unsuitable for parallelisation. for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() diff --git a/cli/templatepush_test.go b/cli/templatepush_test.go index e1a7e612f4ed6..f7a31d5e0c25f 100644 --- a/cli/templatepush_test.go +++ b/cli/templatepush_test.go @@ -485,7 +485,6 @@ func TestTemplatePush(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 1b2dbcf25056b..09dd4c3bce3a5 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -57,7 +57,8 @@ SUBCOMMANDS: tokens Manage personal access tokens unfavorite Remove a workspace from your favorites update Will update and start a given workspace if it is out of - date + date. If the workspace is already running, it will be + stopped first. users Manage users version Show coder version whoami Fetch authenticated user info for Coder deployment diff --git a/cli/testdata/coder_agent_--help.golden b/cli/testdata/coder_agent_--help.golden index 6548a2fadbe49..3dcbb343149d3 100644 --- a/cli/testdata/coder_agent_--help.golden +++ b/cli/testdata/coder_agent_--help.golden @@ -33,7 +33,7 @@ OPTIONS: --debug-address string, $CODER_AGENT_DEBUG_ADDRESS (default: 127.0.0.1:2113) The bind address to serve a debug HTTP server. - --devcontainers-enable bool, $CODER_AGENT_DEVCONTAINERS_ENABLE (default: false) + --devcontainers-enable bool, $CODER_AGENT_DEVCONTAINERS_ENABLE (default: true) Allow the agent to automatically detect running devcontainers. --log-dir string, $CODER_AGENT_LOG_DIR (default: /tmp) diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 5304f2ce262ee..e97894c4afb21 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -68,7 +68,8 @@ "available": 0, "most_recently_seen": null }, - "template_version_preset_id": null + "template_version_preset_id": null, + "has_ai_task": false }, "latest_app_status": null, "outdated": false, diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 19857cf8ebe76..4b1fa1ca4e6c9 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -677,6 +677,12 @@ workspaces stopping during the day due to template scheduling. must be *. Only one hour and minute can be specified (ranges or comma separated values are not supported). +WORKSPACE PREBUILDS OPTIONS: +Configure how workspace prebuilds behave. + + --workspace-prebuilds-reconciliation-interval duration, $CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL (default: 15s) + How often to reconcile workspace prebuilds state. + ⚠️ DANGEROUS OPTIONS: --dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING Allow workspace apps that are not served from subdomains to be shared. diff --git a/cli/testdata/coder_update_--help.golden b/cli/testdata/coder_update_--help.golden index 501447add29a7..b7bd7c48ed1e0 100644 --- a/cli/testdata/coder_update_--help.golden +++ b/cli/testdata/coder_update_--help.golden @@ -3,7 +3,8 @@ coder v0.0.0-devel USAGE: coder update [flags] - Will update and start a given workspace if it is out of date + Will update and start a given workspace if it is out of date. If the workspace + is already running, it will be stopped first. Use --always-prompt to change the parameter values of the workspace. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 8befccf3e320d..0e4cfa71a2fc6 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -526,9 +526,6 @@ client: # Support links to display in the top right drop down menu. # (default: , type: struct[[]codersdk.LinkConfig]) supportLinks: [] -# Configure AI providers. -# (default: , type: struct[codersdk.AIConfig]) -ai: {} # External Authentication providers. # (default: , type: struct[[]codersdk.ExternalAuthConfig]) externalAuthProviders: [] diff --git a/cli/update.go b/cli/update.go index cf73992ea7ba4..21f090508d193 100644 --- a/cli/update.go +++ b/cli/update.go @@ -5,6 +5,7 @@ import ( "golang.org/x/xerrors" + "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -18,7 +19,7 @@ func (r *RootCmd) update() *serpent.Command { cmd := &serpent.Command{ Annotations: workspaceCommand, Use: "update ", - Short: "Will update and start a given workspace if it is out of date", + Short: "Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first.", Long: "Use --always-prompt to change the parameter values of the workspace.", Middleware: serpent.Chain( serpent.RequireNArgs(1), @@ -34,6 +35,20 @@ func (r *RootCmd) update() *serpent.Command { return nil } + // #17840: If the workspace is already running, we will stop it before + // updating. Simply performing a new start transition may not work if the + // template specifies ignore_changes. + if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart { + build, err := stopWorkspace(inv, client, workspace, bflags) + if err != nil { + return xerrors.Errorf("stop workspace: %w", err) + } + // Wait for the stop to complete. + if err := cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID); err != nil { + return xerrors.Errorf("wait for stop: %w", err) + } + } + build, err := startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceUpdate) if err != nil { return xerrors.Errorf("start workspace: %w", err) diff --git a/cli/update_test.go b/cli/update_test.go index 367a8196aa499..7a7480353c01d 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -34,28 +34,87 @@ func TestUpdate(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() + // Given: a workspace exists on the latest template version. client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) - member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) - inv, root := clitest.New(t, "create", - "my-workspace", - "--template", template.Name, - "-y", - ) + ws := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.Name = "my-workspace" + }) + require.False(t, ws.Outdated, "newly created workspace with active template version must not be outdated") + + // Given: the template version is updated + version2 := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: echo.PlanComplete, + }, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) + + ctx := testutil.Context(t, testutil.WaitShort) + err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: version2.ID, + }) + require.NoError(t, err, "failed to update active template version") + + // Then: the workspace is marked as 'outdated' + ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err, "member failed to get workspace they themselves own") + require.True(t, ws.Outdated, "workspace must be outdated after template version update") + + // When: the workspace is updated + inv, root := clitest.New(t, "update", ws.Name) clitest.SetupConfig(t, member, root) - err := inv.Run() - require.NoError(t, err) + err = inv.Run() + require.NoError(t, err, "update command failed") + + // Then: the workspace is no longer 'outdated' + ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err, "member failed to get workspace they themselves own after update") + require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String(), "workspace must have latest template version after update") + require.False(t, ws.Outdated, "workspace must not be outdated after update") + + // Then: the workspace must have been started with the new template version + require.Equal(t, int32(3), ws.LatestBuild.BuildNumber, "workspace must have 3 builds after update") + require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "latest build must be a start transition") + + // Then: the previous workspace build must be a stop transition with the old + // template version. + // This is important to ensure that the workspace resources are recreated + // correctly. Simply running a start transition with the new template + // version may not recreate resources that were changed in the new + // template version. This can happen, for example, if a user specifies + // ignore_changes in the template. + prevBuild, err := member.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx, codersdk.Me, ws.Name, "2") + require.NoError(t, err, "failed to get previous workspace build") + require.Equal(t, codersdk.WorkspaceTransitionStop, prevBuild.Transition, "previous build must be a stop transition") + require.Equal(t, version1.ID.String(), prevBuild.TemplateVersionID.String(), "previous build must have the old template version") + }) - ws, err := client.WorkspaceByOwnerAndName(context.Background(), memberUser.Username, "my-workspace", codersdk.WorkspaceOptions{}) - require.NoError(t, err) - require.Equal(t, version1.ID.String(), ws.LatestBuild.TemplateVersionID.String()) + t.Run("Stopped", func(t *testing.T) { + t.Parallel() + + // Given: a workspace exists on the latest template version. + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) + ws := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.Name = "my-workspace" + }) + require.False(t, ws.Outdated, "newly created workspace with active template version must not be outdated") + + // Given: the template version is updated version2 := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, ProvisionApply: echo.ApplyComplete, @@ -63,20 +122,37 @@ func TestUpdate(t *testing.T) { }, template.ID) _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) - err = client.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{ + ctx := testutil.Context(t, testutil.WaitShort) + err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ ID: version2.ID, }) - require.NoError(t, err) + require.NoError(t, err, "failed to update active template version") + + // Given: the workspace is in a stopped state. + coderdtest.MustTransitionWorkspace(t, member, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) - inv, root = clitest.New(t, "update", ws.Name) + // Then: the workspace is marked as 'outdated' + ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err, "member failed to get workspace they themselves own") + require.True(t, ws.Outdated, "workspace must be outdated after template version update") + + // When: the workspace is updated + inv, root := clitest.New(t, "update", ws.Name) clitest.SetupConfig(t, member, root) err = inv.Run() - require.NoError(t, err) - - ws, err = member.WorkspaceByOwnerAndName(context.Background(), memberUser.Username, "my-workspace", codersdk.WorkspaceOptions{}) - require.NoError(t, err) - require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String()) + require.NoError(t, err, "update command failed") + + // Then: the workspace is no longer 'outdated' + ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err, "member failed to get workspace they themselves own after update") + require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String(), "workspace must have latest template version after update") + require.False(t, ws.Outdated, "workspace must not be outdated after update") + + // Then: the workspace must have been started with the new template version + require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "latest build must be a start transition") + // Then: we expect 3 builds, as we manually stopped the workspace. + require.Equal(t, int32(3), ws.LatestBuild.BuildNumber, "workspace must have 3 builds after update") }) } diff --git a/cli/util_internal_test.go b/cli/util_internal_test.go index 5656bf2c81930..6c42033f7c0bf 100644 --- a/cli/util_internal_test.go +++ b/cli/util_internal_test.go @@ -30,7 +30,6 @@ func TestDurationDisplay(t *testing.T) { {"24h1m1s", "1d"}, {"25h", "1d1h"}, } { - testCase := testCase t.Run(testCase.Duration, func(t *testing.T) { t.Parallel() d, err := time.ParseDuration(testCase.Duration) @@ -71,7 +70,6 @@ func TestExtendedParseDuration(t *testing.T) { {"200y200y200y200y200y", 0, false}, {"9223372036854775807s", 0, false}, } { - testCase := testCase t.Run(testCase.Duration, func(t *testing.T) { t.Parallel() actual, err := extendedParseDuration(testCase.Duration) diff --git a/cli/version_test.go b/cli/version_test.go index 5802fff6f10f0..14214e995f752 100644 --- a/cli/version_test.go +++ b/cli/version_test.go @@ -50,7 +50,6 @@ Full build of Coder, supports the server subcommand. Expected: expectedText, }, } { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) diff --git a/cli/vpndaemon_windows_test.go b/cli/vpndaemon_windows_test.go index 98c63277d4fac..b03f74ee796e5 100644 --- a/cli/vpndaemon_windows_test.go +++ b/cli/vpndaemon_windows_test.go @@ -52,7 +52,6 @@ func TestVPNDaemonRun(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/coderd/agentapi/apps.go b/coderd/agentapi/apps.go index 956e154e89d0d..89c1a873d6310 100644 --- a/coderd/agentapi/apps.go +++ b/coderd/agentapi/apps.go @@ -92,7 +92,7 @@ func (a *AppsAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.Bat Health: app.Health, }) if err != nil { - return nil, xerrors.Errorf("update workspace app health for app %q (%q): %w", err, app.ID, app.Slug) + return nil, xerrors.Errorf("update workspace app health for app %q (%q): %w", app.ID, app.Slug, err) } } diff --git a/coderd/agentapi/manifest_internal_test.go b/coderd/agentapi/manifest_internal_test.go index 33e0cb7613099..7853041349126 100644 --- a/coderd/agentapi/manifest_internal_test.go +++ b/coderd/agentapi/manifest_internal_test.go @@ -80,7 +80,6 @@ func Test_vscodeProxyURI(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/agentapi/resources_monitoring_test.go b/coderd/agentapi/resources_monitoring_test.go index 087ccfd24e459..c491d3789355b 100644 --- a/coderd/agentapi/resources_monitoring_test.go +++ b/coderd/agentapi/resources_monitoring_test.go @@ -280,8 +280,6 @@ func TestMemoryResourceMonitor(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -713,8 +711,6 @@ func TestVolumeResourceMonitor(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/agentapi/subagent.go b/coderd/agentapi/subagent.go index c00bfecc5ff17..1753f5b7d4093 100644 --- a/coderd/agentapi/subagent.go +++ b/coderd/agentapi/subagent.go @@ -2,7 +2,9 @@ package agentapi import ( "context" + "crypto/sha256" "database/sql" + "encoding/base32" "errors" "fmt" "strings" @@ -12,12 +14,13 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/quartz" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner" - "github.com/coder/quartz" ) type SubAgentAPI struct { @@ -164,11 +167,20 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create } } - _, err := a.Database.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ - ID: uuid.New(), + // NOTE(DanielleMaywood): + // Slugs must be unique PER workspace/template. As of 2025-06-25, + // there is no database-layer enforcement of this constraint. + // We can get around this by creating a slug that *should* be + // unique (at least highly probable). + slugHash := sha256.Sum256([]byte(subAgent.Name + "/" + app.Slug)) + slugHashEnc := base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(slugHash[:]) + computedSlug := strings.ToLower(slugHashEnc[:8]) + "-" + app.Slug + + _, err := a.Database.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{ + ID: uuid.New(), // NOTE: we may need to maintain the app's ID here for stability, but for now we'll leave this as-is. CreatedAt: createdAt, AgentID: subAgent.ID, - Slug: app.Slug, + Slug: computedSlug, DisplayName: app.GetDisplayName(), Icon: app.GetIcon(), Command: sql.NullString{ diff --git a/coderd/agentapi/subagent_test.go b/coderd/agentapi/subagent_test.go index 3fa2bed1ead85..0a95a70e5216d 100644 --- a/coderd/agentapi/subagent_test.go +++ b/coderd/agentapi/subagent_test.go @@ -216,7 +216,7 @@ func TestSubAgentAPI(t *testing.T) { }, expectApps: []database.WorkspaceApp{ { - Slug: "code-server", + Slug: "fdqf0lpd-code-server", DisplayName: "VS Code", Icon: "/icon/code.svg", Command: sql.NullString{}, @@ -234,7 +234,7 @@ func TestSubAgentAPI(t *testing.T) { DisplayGroup: sql.NullString{}, }, { - Slug: "vim", + Slug: "547knu0f-vim", DisplayName: "Vim", Icon: "/icon/vim.svg", Command: sql.NullString{Valid: true, String: "vim"}, @@ -377,7 +377,7 @@ func TestSubAgentAPI(t *testing.T) { }, expectApps: []database.WorkspaceApp{ { - Slug: "valid-app", + Slug: "511ctirn-valid-app", DisplayName: "Valid App", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, @@ -410,19 +410,19 @@ func TestSubAgentAPI(t *testing.T) { }, expectApps: []database.WorkspaceApp{ { - Slug: "authenticated-app", + Slug: "atpt261l-authenticated-app", SharingLevel: database.AppSharingLevelAuthenticated, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, { - Slug: "owner-app", + Slug: "eh5gp1he-owner-app", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, { - Slug: "public-app", + Slug: "oopjevf1-public-app", SharingLevel: database.AppSharingLevelPublic, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, @@ -443,13 +443,13 @@ func TestSubAgentAPI(t *testing.T) { }, expectApps: []database.WorkspaceApp{ { - Slug: "tab-app", + Slug: "ci9500rm-tab-app", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInTab, }, { - Slug: "window-app", + Slug: "p17s76re-window-app", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, @@ -479,7 +479,7 @@ func TestSubAgentAPI(t *testing.T) { }, expectApps: []database.WorkspaceApp{ { - Slug: "full-app", + Slug: "0ccdbg39-full-app", Command: sql.NullString{Valid: true, String: "echo hello"}, DisplayName: "Full Featured App", External: true, @@ -507,7 +507,7 @@ func TestSubAgentAPI(t *testing.T) { }, expectApps: []database.WorkspaceApp{ { - Slug: "no-health-app", + Slug: "nphrhbh6-no-health-app", Health: database.WorkspaceAppHealthDisabled, SharingLevel: database.AppSharingLevelOwner, OpenIn: database.WorkspaceAppOpenInSlimWindow, @@ -531,7 +531,7 @@ func TestSubAgentAPI(t *testing.T) { }, expectApps: []database.WorkspaceApp{ { - Slug: "duplicate-app", + Slug: "uiklfckv-duplicate-app", DisplayName: "First App", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, @@ -568,14 +568,14 @@ func TestSubAgentAPI(t *testing.T) { }, expectApps: []database.WorkspaceApp{ { - Slug: "duplicate-app", + Slug: "uiklfckv-duplicate-app", DisplayName: "First Duplicate", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, { - Slug: "valid-app", + Slug: "511ctirn-valid-app", DisplayName: "Valid App", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, @@ -754,7 +754,7 @@ func TestSubAgentAPI(t *testing.T) { apps, err := db.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. require.NoError(t, err) require.Len(t, apps, 1) - require.Equal(t, "duplicate-slug", apps[0].Slug) + require.Equal(t, "k5jd7a99-duplicate-slug", apps[0].Slug) require.Equal(t, "First Duplicate", apps[0].DisplayName) }) }) @@ -1128,7 +1128,7 @@ func TestSubAgentAPI(t *testing.T) { apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. require.NoError(t, err) require.Len(t, apps, 1) - require.Equal(t, "custom-app", apps[0].Slug) + require.Equal(t, "v4qhkq17-custom-app", apps[0].Slug) require.Equal(t, "Custom App", apps[0].DisplayName) }) diff --git a/coderd/agentmetrics/labels_test.go b/coderd/agentmetrics/labels_test.go index b383ca0b25c0d..07f1998fed420 100644 --- a/coderd/agentmetrics/labels_test.go +++ b/coderd/agentmetrics/labels_test.go @@ -43,8 +43,6 @@ func TestValidateAggregationLabels(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/ai/ai.go b/coderd/ai/ai.go deleted file mode 100644 index 97c825ae44c06..0000000000000 --- a/coderd/ai/ai.go +++ /dev/null @@ -1,167 +0,0 @@ -package ai - -import ( - "context" - - "github.com/anthropics/anthropic-sdk-go" - anthropicoption "github.com/anthropics/anthropic-sdk-go/option" - "github.com/kylecarbs/aisdk-go" - "github.com/openai/openai-go" - openaioption "github.com/openai/openai-go/option" - "golang.org/x/xerrors" - "google.golang.org/genai" - - "github.com/coder/coder/v2/codersdk" -) - -type LanguageModel struct { - codersdk.LanguageModel - StreamFunc StreamFunc -} - -type StreamOptions struct { - SystemPrompt string - Model string - Messages []aisdk.Message - Thinking bool - Tools []aisdk.Tool -} - -type StreamFunc func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) - -// LanguageModels is a map of language model ID to language model. -type LanguageModels map[string]LanguageModel - -func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) (LanguageModels, error) { - models := make(LanguageModels) - - for _, config := range configs { - var streamFunc StreamFunc - - switch config.Type { - case "openai": - opts := []openaioption.RequestOption{ - openaioption.WithAPIKey(config.APIKey), - } - if config.BaseURL != "" { - opts = append(opts, openaioption.WithBaseURL(config.BaseURL)) - } - client := openai.NewClient(opts...) - streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { - openaiMessages, err := aisdk.MessagesToOpenAI(options.Messages) - if err != nil { - return nil, err - } - tools := aisdk.ToolsToOpenAI(options.Tools) - if options.SystemPrompt != "" { - openaiMessages = append([]openai.ChatCompletionMessageParamUnion{ - openai.SystemMessage(options.SystemPrompt), - }, openaiMessages...) - } - - return aisdk.OpenAIToDataStream(client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ - Messages: openaiMessages, - Model: options.Model, - Tools: tools, - MaxTokens: openai.Int(8192), - })), nil - } - if config.Models == nil { - models, err := client.Models.List(ctx) - if err != nil { - return nil, err - } - config.Models = make([]string, len(models.Data)) - for i, model := range models.Data { - config.Models[i] = model.ID - } - } - case "anthropic": - client := anthropic.NewClient(anthropicoption.WithAPIKey(config.APIKey)) - streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { - anthropicMessages, systemMessage, err := aisdk.MessagesToAnthropic(options.Messages) - if err != nil { - return nil, err - } - if options.SystemPrompt != "" { - systemMessage = []anthropic.TextBlockParam{ - *anthropic.NewTextBlock(options.SystemPrompt).OfRequestTextBlock, - } - } - return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ - Messages: anthropicMessages, - Model: options.Model, - System: systemMessage, - Tools: aisdk.ToolsToAnthropic(options.Tools), - MaxTokens: 8192, - })), nil - } - if config.Models == nil { - models, err := client.Models.List(ctx, anthropic.ModelListParams{}) - if err != nil { - return nil, err - } - config.Models = make([]string, len(models.Data)) - for i, model := range models.Data { - config.Models[i] = model.ID - } - } - case "google": - client, err := genai.NewClient(ctx, &genai.ClientConfig{ - APIKey: config.APIKey, - Backend: genai.BackendGeminiAPI, - }) - if err != nil { - return nil, err - } - streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { - googleMessages, err := aisdk.MessagesToGoogle(options.Messages) - if err != nil { - return nil, err - } - tools, err := aisdk.ToolsToGoogle(options.Tools) - if err != nil { - return nil, err - } - var systemInstruction *genai.Content - if options.SystemPrompt != "" { - systemInstruction = &genai.Content{ - Parts: []*genai.Part{ - genai.NewPartFromText(options.SystemPrompt), - }, - Role: "model", - } - } - return aisdk.GoogleToDataStream(client.Models.GenerateContentStream(ctx, options.Model, googleMessages, &genai.GenerateContentConfig{ - SystemInstruction: systemInstruction, - Tools: tools, - })), nil - } - if config.Models == nil { - models, err := client.Models.List(ctx, &genai.ListModelsConfig{}) - if err != nil { - return nil, err - } - config.Models = make([]string, len(models.Items)) - for i, model := range models.Items { - config.Models[i] = model.Name - } - } - default: - return nil, xerrors.Errorf("unsupported model type: %s", config.Type) - } - - for _, model := range config.Models { - models[model] = LanguageModel{ - LanguageModel: codersdk.LanguageModel{ - ID: model, - DisplayName: model, - Provider: config.Type, - }, - StreamFunc: streamFunc, - } - } - } - - return models, nil -} diff --git a/coderd/aitasks.go b/coderd/aitasks.go new file mode 100644 index 0000000000000..a982ccc39b26b --- /dev/null +++ b/coderd/aitasks.go @@ -0,0 +1,63 @@ +package coderd + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +// This endpoint is experimental and not guaranteed to be stable, so we're not +// generating public-facing documentation for it. +func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + buildIDsParam := r.URL.Query().Get("build_ids") + if buildIDsParam == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "build_ids query parameter is required", + }) + return + } + + // Parse build IDs + buildIDStrings := strings.Split(buildIDsParam, ",") + buildIDs := make([]uuid.UUID, 0, len(buildIDStrings)) + for _, idStr := range buildIDStrings { + id, err := uuid.Parse(strings.TrimSpace(idStr)) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid build ID format: %s", idStr), + Detail: err.Error(), + }) + return + } + buildIDs = append(buildIDs, id) + } + + parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace build parameters.", + Detail: err.Error(), + }) + return + } + + promptsByBuildID := make(map[string]string, len(parameters)) + for _, param := range parameters { + if param.Name != codersdk.AITaskPromptParameterName { + continue + } + buildID := param.WorkspaceBuildID.String() + promptsByBuildID[buildID] = param.Value + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.AITasksPromptsResponse{ + Prompts: promptsByBuildID, + }) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go new file mode 100644 index 0000000000000..53f0174d6f03d --- /dev/null +++ b/coderd/aitasks_test.go @@ -0,0 +1,141 @@ +package coderd_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestAITasksPrompts(t *testing.T) { + t.Parallel() + + t.Run("EmptyBuildIDs", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + experimentalClient := codersdk.NewExperimentalClient(client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Test with empty build IDs + prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{}) + require.NoError(t, err) + require.Empty(t, prompts.Prompts) + }) + + t.Run("MultipleBuilds", func(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("This test checks RBAC, which is not supported in the in-memory database") + } + + adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + first := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a template with parameters + version := coderdtest.CreateTemplateVersion(t, adminClient, first.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: "param1", + Type: "string", + DefaultValue: "default1", + }, + { + Name: codersdk.AITaskPromptParameterName, + Type: "string", + DefaultValue: "default2", + }, + }, + }, + }, + }}, + ProvisionApply: echo.ApplyComplete, + }) + template := coderdtest.CreateTemplate(t, adminClient, first.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID) + + // Create two workspaces with different parameters + workspace1 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1a"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2a"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace1.LatestBuild.ID) + + workspace2 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1b"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2b"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace2.LatestBuild.ID) + + workspace3 := coderdtest.CreateWorkspace(t, adminClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1c"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2c"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace3.LatestBuild.ID) + allBuildIDs := []uuid.UUID{workspace1.LatestBuild.ID, workspace2.LatestBuild.ID, workspace3.LatestBuild.ID} + + experimentalMemberClient := codersdk.NewExperimentalClient(memberClient) + // Test parameters endpoint as member + prompts, err := experimentalMemberClient.AITaskPrompts(ctx, allBuildIDs) + require.NoError(t, err) + // we expect 2 prompts because the member client does not have access to workspace3 + // since it was created by the admin client + require.Len(t, prompts.Prompts, 2) + + // Check workspace1 parameters + build1Prompt := prompts.Prompts[workspace1.LatestBuild.ID.String()] + require.Equal(t, "value2a", build1Prompt) + + // Check workspace2 parameters + build2Prompt := prompts.Prompts[workspace2.LatestBuild.ID.String()] + require.Equal(t, "value2b", build2Prompt) + + experimentalAdminClient := codersdk.NewExperimentalClient(adminClient) + // Test parameters endpoint as admin + // we expect 3 prompts because the admin client has access to all workspaces + prompts, err = experimentalAdminClient.AITaskPrompts(ctx, allBuildIDs) + require.NoError(t, err) + require.Len(t, prompts.Prompts, 3) + + // Check workspace3 parameters + build3Prompt := prompts.Prompts[workspace3.LatestBuild.ID.String()] + require.Equal(t, "value2c", build3Prompt) + }) + + t.Run("NonExistentBuildIDs", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Test with non-existent build IDs + nonExistentID := uuid.New() + experimentalClient := codersdk.NewExperimentalClient(client) + prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{nonExistentID}) + require.NoError(t, err) + require.Empty(t, prompts.Prompts) + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 062c70c2bed5c..522ba671a9a63 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -343,173 +343,6 @@ const docTemplate = `{ } } }, - "/chats": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chat" - ], - "summary": "List chats", - "operationId": "list-chats", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Chat" - } - } - } - } - }, - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chat" - ], - "summary": "Create a chat", - "operationId": "create-a-chat", - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/codersdk.Chat" - } - } - } - } - }, - "/chats/{chat}": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chat" - ], - "summary": "Get a chat", - "operationId": "get-a-chat", - "parameters": [ - { - "type": "string", - "description": "Chat ID", - "name": "chat", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.Chat" - } - } - } - } - }, - "/chats/{chat}/messages": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chat" - ], - "summary": "Get chat messages", - "operationId": "get-chat-messages", - "parameters": [ - { - "type": "string", - "description": "Chat ID", - "name": "chat", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Message" - } - } - } - } - }, - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Chat" - ], - "summary": "Create a chat message", - "operationId": "create-a-chat-message", - "parameters": [ - { - "type": "string", - "description": "Chat ID", - "name": "chat", - "in": "path", - "required": true - }, - { - "description": "Request body", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.CreateChatMessageRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": {} - } - } - } - } - }, "/csp/reports": { "post": { "security": [ @@ -826,31 +659,6 @@ const docTemplate = `{ } } }, - "/deployment/llms": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "General" - ], - "summary": "Get language models", - "operationId": "get-language-models", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.LanguageModelConfig" - } - } - } - } - }, "/deployment/ssh": { "get": { "security": [ @@ -8645,7 +8453,7 @@ const docTemplate = `{ } } }, - "/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate": { + "/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}/recreate": { "post": { "security": [ { @@ -8671,8 +8479,8 @@ const docTemplate = `{ }, { "type": "string", - "description": "Container ID or name", - "name": "container", + "description": "Devcontainer ID", + "name": "devcontainer", "in": "path", "required": true } @@ -10617,190 +10425,6 @@ const docTemplate = `{ "ReinitializeReasonPrebuildClaimed" ] }, - "aisdk.Attachment": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - }, - "name": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "aisdk.Message": { - "type": "object", - "properties": { - "annotations": { - "type": "array", - "items": {} - }, - "content": { - "type": "string" - }, - "createdAt": { - "type": "array", - "items": { - "type": "integer" - } - }, - "experimental_attachments": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Attachment" - } - }, - "id": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Part" - } - }, - "role": { - "type": "string" - } - } - }, - "aisdk.Part": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "integer" - } - }, - "details": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.ReasoningDetail" - } - }, - "mimeType": { - "description": "Type: \"file\"", - "type": "string" - }, - "reasoning": { - "description": "Type: \"reasoning\"", - "type": "string" - }, - "source": { - "description": "Type: \"source\"", - "allOf": [ - { - "$ref": "#/definitions/aisdk.SourceInfo" - } - ] - }, - "text": { - "description": "Type: \"text\"", - "type": "string" - }, - "toolInvocation": { - "description": "Type: \"tool-invocation\"", - "allOf": [ - { - "$ref": "#/definitions/aisdk.ToolInvocation" - } - ] - }, - "type": { - "$ref": "#/definitions/aisdk.PartType" - } - } - }, - "aisdk.PartType": { - "type": "string", - "enum": [ - "text", - "reasoning", - "tool-invocation", - "source", - "file", - "step-start" - ], - "x-enum-varnames": [ - "PartTypeText", - "PartTypeReasoning", - "PartTypeToolInvocation", - "PartTypeSource", - "PartTypeFile", - "PartTypeStepStart" - ] - }, - "aisdk.ReasoningDetail": { - "type": "object", - "properties": { - "data": { - "type": "string" - }, - "signature": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "aisdk.SourceInfo": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - }, - "data": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": {} - }, - "uri": { - "type": "string" - } - } - }, - "aisdk.ToolInvocation": { - "type": "object", - "properties": { - "args": {}, - "result": {}, - "state": { - "$ref": "#/definitions/aisdk.ToolInvocationState" - }, - "step": { - "type": "integer" - }, - "toolCallId": { - "type": "string" - }, - "toolName": { - "type": "string" - } - } - }, - "aisdk.ToolInvocationState": { - "type": "string", - "enum": [ - "call", - "partial-call", - "result" - ], - "x-enum-varnames": [ - "ToolInvocationStateCall", - "ToolInvocationStatePartialCall", - "ToolInvocationStateResult" - ] - }, "coderd.SCIMUser": { "type": "object", "properties": { @@ -10892,37 +10516,6 @@ const docTemplate = `{ } } }, - "codersdk.AIConfig": { - "type": "object", - "properties": { - "providers": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.AIProviderConfig" - } - } - } - }, - "codersdk.AIProviderConfig": { - "type": "object", - "properties": { - "base_url": { - "description": "BaseURL is the base URL to use for the API provider.", - "type": "string" - }, - "models": { - "description": "Models is the list of models to use for the API provider.", - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "description": "Type is the type of the API provider.", - "type": "string" - } - } - }, "codersdk.APIKey": { "type": "object", "required": [ @@ -11508,62 +11101,6 @@ const docTemplate = `{ } } }, - "codersdk.Chat": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "format": "date-time" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "title": { - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "codersdk.ChatMessage": { - "type": "object", - "properties": { - "annotations": { - "type": "array", - "items": {} - }, - "content": { - "type": "string" - }, - "createdAt": { - "type": "array", - "items": { - "type": "integer" - } - }, - "experimental_attachments": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Attachment" - } - }, - "id": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Part" - } - }, - "role": { - "type": "string" - } - } - }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -11597,20 +11134,6 @@ const docTemplate = `{ } } }, - "codersdk.CreateChatMessageRequest": { - "type": "object", - "properties": { - "message": { - "$ref": "#/definitions/codersdk.ChatMessage" - }, - "model": { - "type": "string" - }, - "thinking": { - "type": "boolean" - } - } - }, "codersdk.CreateFirstUserRequest": { "type": "object", "required": [ @@ -11898,73 +11421,7 @@ const docTemplate = `{ } }, "codersdk.CreateTestAuditLogRequest": { - "type": "object", - "properties": { - "action": { - "enum": [ - "create", - "write", - "delete", - "start", - "stop" - ], - "allOf": [ - { - "$ref": "#/definitions/codersdk.AuditAction" - } - ] - }, - "additional_fields": { - "type": "array", - "items": { - "type": "integer" - } - }, - "build_reason": { - "enum": [ - "autostart", - "autostop", - "initiator" - ], - "allOf": [ - { - "$ref": "#/definitions/codersdk.BuildReason" - } - ] - }, - "organization_id": { - "type": "string", - "format": "uuid" - }, - "request_id": { - "type": "string", - "format": "uuid" - }, - "resource_id": { - "type": "string", - "format": "uuid" - }, - "resource_type": { - "enum": [ - "template", - "template_version", - "user", - "workspace", - "workspace_build", - "git_ssh_key", - "auditable_group" - ], - "allOf": [ - { - "$ref": "#/definitions/codersdk.ResourceType" - } - ] - }, - "time": { - "type": "string", - "format": "date-time" - } - } + "type": "object" }, "codersdk.CreateTokenRequest": { "type": "object", @@ -12043,10 +11500,6 @@ const docTemplate = `{ "dry_run": { "type": "boolean" }, - "enable_dynamic_parameters": { - "description": "EnableDynamicParameters skips some of the static parameter checking.\nIt will default to whatever the template has marked as the default experience.\nRequires the \"dynamic-experiment\" to be used.", - "type": "boolean" - }, "log_level": { "description": "Log level changes the default logging verbosity of a provider (\"info\" if empty).", "enum": [ @@ -12128,9 +11581,6 @@ const docTemplate = `{ "autostart_schedule": { "type": "string" }, - "enable_dynamic_parameters": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -12417,9 +11867,6 @@ const docTemplate = `{ "agent_stat_refresh_interval": { "type": "integer" }, - "ai": { - "$ref": "#/definitions/serpent.Struct-codersdk_AIConfig" - }, "allow_workspace_renames": { "type": "boolean" }, @@ -12747,19 +12194,13 @@ const docTemplate = `{ "auto-fill-parameters", "notifications", "workspace-usage", - "web-push", - "workspace-prebuilds", - "agentic-chat", - "ai-tasks" + "web-push" ], "x-enum-comments": { - "ExperimentAITasks": "Enables the new AI tasks feature.", - "ExperimentAgenticChat": "Enables the new agentic AI chat feature.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentWebPush": "Enables web push notifications through the browser.", - "ExperimentWorkspacePrebuilds": "Enables the new workspace prebuilds feature.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ @@ -12767,10 +12208,7 @@ const docTemplate = `{ "ExperimentAutoFillParameters", "ExperimentNotifications", "ExperimentWorkspaceUsage", - "ExperimentWebPush", - "ExperimentWorkspacePrebuilds", - "ExperimentAgenticChat", - "ExperimentAITasks" + "ExperimentWebPush" ] }, "codersdk.ExternalAuth": { @@ -13298,33 +12736,6 @@ const docTemplate = `{ "RequiredTemplateVariables" ] }, - "codersdk.LanguageModel": { - "type": "object", - "properties": { - "display_name": { - "type": "string" - }, - "id": { - "description": "ID is used by the provider to identify the LLM.", - "type": "string" - }, - "provider": { - "description": "Provider is the provider of the LLM. e.g. openai, anthropic, etc.", - "type": "string" - } - } - }, - "codersdk.LanguageModelConfig": { - "type": "object", - "properties": { - "models": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.LanguageModel" - } - } - } - }, "codersdk.License": { "type": "object", "properties": { @@ -14526,6 +13937,9 @@ const docTemplate = `{ "codersdk.Preset": { "type": "object", "properties": { + "default": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -15240,7 +14654,6 @@ const docTemplate = `{ "assign_org_role", "assign_role", "audit_log", - "chat", "crypto_key", "debug_info", "deployment_config", @@ -15259,6 +14672,7 @@ const docTemplate = `{ "oauth2_app_secret", "organization", "organization_member", + "prebuilt_workspace", "provisioner_daemon", "provisioner_jobs", "replicas", @@ -15279,7 +14693,6 @@ const docTemplate = `{ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", - "ResourceChat", "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", @@ -15298,6 +14711,7 @@ const docTemplate = `{ "ResourceOauth2AppSecret", "ResourceOrganization", "ResourceOrganizationMember", + "ResourcePrebuiltWorkspace", "ResourceProvisionerDaemon", "ResourceProvisionerJobs", "ResourceReplicas", @@ -18079,11 +17493,13 @@ const docTemplate = `{ "type": "string", "enum": [ "working", + "idle", "complete", "failure" ], "x-enum-varnames": [ "WorkspaceAppStatusStateWorking", + "WorkspaceAppStatusStateIdle", "WorkspaceAppStatusStateComplete", "WorkspaceAppStatusStateFailure" ] @@ -18091,6 +17507,10 @@ const docTemplate = `{ "codersdk.WorkspaceBuild": { "type": "object", "properties": { + "ai_task_sidebar_app_id": { + "type": "string", + "format": "uuid" + }, "build_number": { "type": "integer" }, @@ -18105,6 +17525,9 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "has_ai_task": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -19338,14 +18761,6 @@ const docTemplate = `{ } } }, - "serpent.Struct-codersdk_AIConfig": { - "type": "object", - "properties": { - "value": { - "$ref": "#/definitions/codersdk.AIConfig" - } - } - }, "serpent.URL": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7199c122e9e87..abcae550a4ec5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -291,151 +291,6 @@ } } }, - "/chats": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Chat"], - "summary": "List chats", - "operationId": "list-chats", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Chat" - } - } - } - } - }, - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Chat"], - "summary": "Create a chat", - "operationId": "create-a-chat", - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/codersdk.Chat" - } - } - } - } - }, - "/chats/{chat}": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Chat"], - "summary": "Get a chat", - "operationId": "get-a-chat", - "parameters": [ - { - "type": "string", - "description": "Chat ID", - "name": "chat", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.Chat" - } - } - } - } - }, - "/chats/{chat}/messages": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Chat"], - "summary": "Get chat messages", - "operationId": "get-chat-messages", - "parameters": [ - { - "type": "string", - "description": "Chat ID", - "name": "chat", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Message" - } - } - } - } - }, - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Chat"], - "summary": "Create a chat message", - "operationId": "create-a-chat-message", - "parameters": [ - { - "type": "string", - "description": "Chat ID", - "name": "chat", - "in": "path", - "required": true - }, - { - "description": "Request body", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.CreateChatMessageRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": {} - } - } - } - } - }, "/csp/reports": { "post": { "security": [ @@ -708,27 +563,6 @@ } } }, - "/deployment/llms": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["General"], - "summary": "Get language models", - "operationId": "get-language-models", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.LanguageModelConfig" - } - } - } - } - }, "/deployment/ssh": { "get": { "security": [ @@ -7638,7 +7472,7 @@ } } }, - "/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate": { + "/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}/recreate": { "post": { "security": [ { @@ -7660,8 +7494,8 @@ }, { "type": "string", - "description": "Container ID or name", - "name": "container", + "description": "Devcontainer ID", + "name": "devcontainer", "in": "path", "required": true } @@ -9410,186 +9244,6 @@ "enum": ["prebuild_claimed"], "x-enum-varnames": ["ReinitializeReasonPrebuildClaimed"] }, - "aisdk.Attachment": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - }, - "name": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "aisdk.Message": { - "type": "object", - "properties": { - "annotations": { - "type": "array", - "items": {} - }, - "content": { - "type": "string" - }, - "createdAt": { - "type": "array", - "items": { - "type": "integer" - } - }, - "experimental_attachments": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Attachment" - } - }, - "id": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Part" - } - }, - "role": { - "type": "string" - } - } - }, - "aisdk.Part": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "integer" - } - }, - "details": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.ReasoningDetail" - } - }, - "mimeType": { - "description": "Type: \"file\"", - "type": "string" - }, - "reasoning": { - "description": "Type: \"reasoning\"", - "type": "string" - }, - "source": { - "description": "Type: \"source\"", - "allOf": [ - { - "$ref": "#/definitions/aisdk.SourceInfo" - } - ] - }, - "text": { - "description": "Type: \"text\"", - "type": "string" - }, - "toolInvocation": { - "description": "Type: \"tool-invocation\"", - "allOf": [ - { - "$ref": "#/definitions/aisdk.ToolInvocation" - } - ] - }, - "type": { - "$ref": "#/definitions/aisdk.PartType" - } - } - }, - "aisdk.PartType": { - "type": "string", - "enum": [ - "text", - "reasoning", - "tool-invocation", - "source", - "file", - "step-start" - ], - "x-enum-varnames": [ - "PartTypeText", - "PartTypeReasoning", - "PartTypeToolInvocation", - "PartTypeSource", - "PartTypeFile", - "PartTypeStepStart" - ] - }, - "aisdk.ReasoningDetail": { - "type": "object", - "properties": { - "data": { - "type": "string" - }, - "signature": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "aisdk.SourceInfo": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - }, - "data": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": {} - }, - "uri": { - "type": "string" - } - } - }, - "aisdk.ToolInvocation": { - "type": "object", - "properties": { - "args": {}, - "result": {}, - "state": { - "$ref": "#/definitions/aisdk.ToolInvocationState" - }, - "step": { - "type": "integer" - }, - "toolCallId": { - "type": "string" - }, - "toolName": { - "type": "string" - } - } - }, - "aisdk.ToolInvocationState": { - "type": "string", - "enum": ["call", "partial-call", "result"], - "x-enum-varnames": [ - "ToolInvocationStateCall", - "ToolInvocationStatePartialCall", - "ToolInvocationStateResult" - ] - }, "coderd.SCIMUser": { "type": "object", "properties": { @@ -9681,37 +9335,6 @@ } } }, - "codersdk.AIConfig": { - "type": "object", - "properties": { - "providers": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.AIProviderConfig" - } - } - } - }, - "codersdk.AIProviderConfig": { - "type": "object", - "properties": { - "base_url": { - "description": "BaseURL is the base URL to use for the API provider.", - "type": "string" - }, - "models": { - "description": "Models is the list of models to use for the API provider.", - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "description": "Type is the type of the API provider.", - "type": "string" - } - } - }, "codersdk.APIKey": { "type": "object", "required": [ @@ -10258,62 +9881,6 @@ } } }, - "codersdk.Chat": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "format": "date-time" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "title": { - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "codersdk.ChatMessage": { - "type": "object", - "properties": { - "annotations": { - "type": "array", - "items": {} - }, - "content": { - "type": "string" - }, - "createdAt": { - "type": "array", - "items": { - "type": "integer" - } - }, - "experimental_attachments": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Attachment" - } - }, - "id": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/definitions/aisdk.Part" - } - }, - "role": { - "type": "string" - } - } - }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -10344,20 +9911,6 @@ } } }, - "codersdk.CreateChatMessageRequest": { - "type": "object", - "properties": { - "message": { - "$ref": "#/definitions/codersdk.ChatMessage" - }, - "model": { - "type": "string" - }, - "thinking": { - "type": "boolean" - } - } - }, "codersdk.CreateFirstUserRequest": { "type": "object", "required": ["email", "password", "username"], @@ -10626,63 +10179,7 @@ } }, "codersdk.CreateTestAuditLogRequest": { - "type": "object", - "properties": { - "action": { - "enum": ["create", "write", "delete", "start", "stop"], - "allOf": [ - { - "$ref": "#/definitions/codersdk.AuditAction" - } - ] - }, - "additional_fields": { - "type": "array", - "items": { - "type": "integer" - } - }, - "build_reason": { - "enum": ["autostart", "autostop", "initiator"], - "allOf": [ - { - "$ref": "#/definitions/codersdk.BuildReason" - } - ] - }, - "organization_id": { - "type": "string", - "format": "uuid" - }, - "request_id": { - "type": "string", - "format": "uuid" - }, - "resource_id": { - "type": "string", - "format": "uuid" - }, - "resource_type": { - "enum": [ - "template", - "template_version", - "user", - "workspace", - "workspace_build", - "git_ssh_key", - "auditable_group" - ], - "allOf": [ - { - "$ref": "#/definitions/codersdk.ResourceType" - } - ] - }, - "time": { - "type": "string", - "format": "date-time" - } - } + "type": "object" }, "codersdk.CreateTokenRequest": { "type": "object", @@ -10753,10 +10250,6 @@ "dry_run": { "type": "boolean" }, - "enable_dynamic_parameters": { - "description": "EnableDynamicParameters skips some of the static parameter checking.\nIt will default to whatever the template has marked as the default experience.\nRequires the \"dynamic-experiment\" to be used.", - "type": "boolean" - }, "log_level": { "description": "Log level changes the default logging verbosity of a provider (\"info\" if empty).", "enum": ["debug"], @@ -10828,9 +10321,6 @@ "autostart_schedule": { "type": "string" }, - "enable_dynamic_parameters": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -11117,9 +10607,6 @@ "agent_stat_refresh_interval": { "type": "integer" }, - "ai": { - "$ref": "#/definitions/serpent.Struct-codersdk_AIConfig" - }, "allow_workspace_renames": { "type": "boolean" }, @@ -11440,19 +10927,13 @@ "auto-fill-parameters", "notifications", "workspace-usage", - "web-push", - "workspace-prebuilds", - "agentic-chat", - "ai-tasks" + "web-push" ], "x-enum-comments": { - "ExperimentAITasks": "Enables the new AI tasks feature.", - "ExperimentAgenticChat": "Enables the new agentic AI chat feature.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentWebPush": "Enables web push notifications through the browser.", - "ExperimentWorkspacePrebuilds": "Enables the new workspace prebuilds feature.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ @@ -11460,10 +10941,7 @@ "ExperimentAutoFillParameters", "ExperimentNotifications", "ExperimentWorkspaceUsage", - "ExperimentWebPush", - "ExperimentWorkspacePrebuilds", - "ExperimentAgenticChat", - "ExperimentAITasks" + "ExperimentWebPush" ] }, "codersdk.ExternalAuth": { @@ -11975,33 +11453,6 @@ "enum": ["REQUIRED_TEMPLATE_VARIABLES"], "x-enum-varnames": ["RequiredTemplateVariables"] }, - "codersdk.LanguageModel": { - "type": "object", - "properties": { - "display_name": { - "type": "string" - }, - "id": { - "description": "ID is used by the provider to identify the LLM.", - "type": "string" - }, - "provider": { - "description": "Provider is the provider of the LLM. e.g. openai, anthropic, etc.", - "type": "string" - } - } - }, - "codersdk.LanguageModelConfig": { - "type": "object", - "properties": { - "models": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.LanguageModel" - } - } - } - }, "codersdk.License": { "type": "object", "properties": { @@ -13152,6 +12603,9 @@ "codersdk.Preset": { "type": "object", "properties": { + "default": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -13832,7 +13286,6 @@ "assign_org_role", "assign_role", "audit_log", - "chat", "crypto_key", "debug_info", "deployment_config", @@ -13851,6 +13304,7 @@ "oauth2_app_secret", "organization", "organization_member", + "prebuilt_workspace", "provisioner_daemon", "provisioner_jobs", "replicas", @@ -13871,7 +13325,6 @@ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", - "ResourceChat", "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", @@ -13890,6 +13343,7 @@ "ResourceOauth2AppSecret", "ResourceOrganization", "ResourceOrganizationMember", + "ResourcePrebuiltWorkspace", "ResourceProvisionerDaemon", "ResourceProvisionerJobs", "ResourceReplicas", @@ -16520,9 +15974,10 @@ }, "codersdk.WorkspaceAppStatusState": { "type": "string", - "enum": ["working", "complete", "failure"], + "enum": ["working", "idle", "complete", "failure"], "x-enum-varnames": [ "WorkspaceAppStatusStateWorking", + "WorkspaceAppStatusStateIdle", "WorkspaceAppStatusStateComplete", "WorkspaceAppStatusStateFailure" ] @@ -16530,6 +15985,10 @@ "codersdk.WorkspaceBuild": { "type": "object", "properties": { + "ai_task_sidebar_app_id": { + "type": "string", + "format": "uuid" + }, "build_number": { "type": "integer" }, @@ -16544,6 +16003,9 @@ "type": "string", "format": "date-time" }, + "has_ai_task": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -17717,14 +17179,6 @@ } } }, - "serpent.Struct-codersdk_AIConfig": { - "type": "object", - "properties": { - "value": { - "$ref": "#/definitions/codersdk.AIConfig" - } - } - }, "serpent.URL": { "type": "object", "properties": { diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index ef4d260ddf0a6..198ef11511b3e 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -107,7 +107,6 @@ func TestGenerate(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/audit_test.go b/coderd/audit_test.go index 18bcd78b38807..e6fa985038155 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -454,7 +454,6 @@ func TestAuditLogsFilter(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase // Test filtering t.Run(testCase.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/authorize_test.go b/coderd/authorize_test.go index 3af6cfd7d620e..b8084211de60c 100644 --- a/coderd/authorize_test.go +++ b/coderd/authorize_test.go @@ -125,8 +125,6 @@ func TestCheckPermissions(t *testing.T) { } for _, c := range testCases { - c := c - t.Run("CheckAuthorization/"+c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index b0cba60111335..1846b1ea18284 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -5,6 +5,8 @@ import ( "database/sql" "fmt" "net/http" + "slices" + "strings" "sync" "sync/atomic" "time" @@ -17,6 +19,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -35,6 +38,7 @@ type Executor struct { ctx context.Context db database.Store ps pubsub.Pubsub + fileCache *files.Cache templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] accessControlStore *atomic.Pointer[dbauthz.AccessControlStore] auditor *atomic.Pointer[audit.Auditor] @@ -61,13 +65,14 @@ type Stats struct { } // New returns a new wsactions executor. -func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, reg prometheus.Registerer, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer, exp codersdk.Experiments) *Executor { +func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, fc *files.Cache, reg prometheus.Registerer, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer, exp codersdk.Experiments) *Executor { factory := promauto.With(reg) le := &Executor{ //nolint:gocritic // Autostart has a limited set of permissions. ctx: dbauthz.AsAutostart(ctx), db: db, ps: ps, + fileCache: fc, templateScheduleStore: tss, tick: tick, log: log.Named("autobuild"), @@ -152,6 +157,22 @@ func (e *Executor) runOnce(t time.Time) Stats { return stats } + // Sort the workspaces by build template version ID so that we can group + // identical template versions together. This is a slight (and imperfect) + // optimization. + // + // `wsbuilder` needs to load the terraform files for a given template version + // into memory. If 2 workspaces are using the same template version, they will + // share the same files in the FileCache. This only happens if the builds happen + // in parallel. + // TODO: Actually make sure the cache has the files in the cache for the full + // set of identical template versions. Then unload the files when the builds + // are done. Right now, this relies on luck for the 10 goroutine workers to + // overlap and keep the file reference in the cache alive. + slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForTransitionRow) int { + return strings.Compare(a.BuildTemplateVersionID.UUID.String(), b.BuildTemplateVersionID.UUID.String()) + }) + // We only use errgroup here for convenience of API, not for early // cancellation. This means we only return nil errors in th eg.Go. eg := errgroup.Group{} @@ -276,7 +297,7 @@ func (e *Executor) runOnce(t time.Time) Stats { } } - nextBuild, job, _, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + nextBuild, job, _, err = builder.Build(e.ctx, tx, e.fileCache, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) if err != nil { return xerrors.Errorf("build workspace with transition %q: %w", nextTransition, err) } diff --git a/coderd/autobuild/lifecycle_executor_internal_test.go b/coderd/autobuild/lifecycle_executor_internal_test.go index bfe3bb53592b3..2d556d58a2d5e 100644 --- a/coderd/autobuild/lifecycle_executor_internal_test.go +++ b/coderd/autobuild/lifecycle_executor_internal_test.go @@ -153,7 +153,6 @@ func Test_isEligibleForAutostart(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 453de63031a47..3bca6856534fa 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -47,7 +47,7 @@ func TestExecutorAutostartOK(t *testing.T) { }) ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks after the scheduled time go func() { @@ -105,7 +105,7 @@ func TestMultipleLifecycleExecutors(t *testing.T) { ) // Have the workspace stopped so we can perform an autostart - workspace = coderdtest.MustTransitionWorkspace(t, clientA, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, clientA, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Get both clients to perform a lifecycle execution tick next := sched.Next(workspace.LatestBuild.CreatedAt) @@ -177,7 +177,6 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { }, } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() var ( @@ -204,7 +203,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { ) // Given: workspace is stopped workspace = coderdtest.MustTransitionWorkspace( - t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) orgs, err := client.OrganizationsByUser(ctx, workspace.OwnerID.String()) require.NoError(t, err) @@ -345,7 +344,7 @@ func TestExecutorAutostartNotEnabled(t *testing.T) { require.Empty(t, workspace.AutostartSchedule) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks way into the future go func() { @@ -385,7 +384,7 @@ func TestExecutorAutostartUserSuspended(t *testing.T) { workspace = coderdtest.MustWorkspace(t, userClient, workspace.ID) // Given: workspace is stopped, and the user is suspended. - workspace = coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) ctx := testutil.Context(t, testutil.WaitShort) @@ -508,7 +507,7 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) { ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks past the TTL go func() { @@ -579,7 +578,7 @@ func TestExecutorWorkspaceDeleted(t *testing.T) { ) // Given: workspace is deleted - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionDelete) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete) // When: the autobuild executor ticks go func() { @@ -768,7 +767,7 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { }) ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks past the scheduled time go func() { @@ -833,7 +832,7 @@ func TestExecutorAutostartWithParameters(t *testing.T) { }) ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks after the scheduled time go func() { @@ -883,7 +882,7 @@ func TestExecutorAutostartTemplateDisabled(t *testing.T) { }) ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks before the next scheduled time go func() { @@ -1002,7 +1001,7 @@ func TestExecutorRequireActiveVersion(t *testing.T) { cwr.AutostartSchedule = ptr.Ref(sched.String()) }) _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, ownerClient, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, memberClient, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { + ws = coderdtest.MustTransitionWorkspace(t, memberClient, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { req.TemplateVersionID = inactiveVersion.ID }) require.Equal(t, inactiveVersion.ID, ws.LatestBuild.TemplateVersionID) @@ -1160,7 +1159,7 @@ func TestNotifications(t *testing.T) { coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) // Stop workspace - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) // Wait for workspace to become dormant diff --git a/coderd/autobuild/notify/notifier_test.go b/coderd/autobuild/notify/notifier_test.go index aa060406d30a1..4561fd2a45336 100644 --- a/coderd/autobuild/notify/notifier_test.go +++ b/coderd/autobuild/notify/notifier_test.go @@ -83,7 +83,6 @@ func TestNotifier(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/coderd/azureidentity/azureidentity_test.go b/coderd/azureidentity/azureidentity_test.go index bd55ae2538d3a..bd94f836beb3b 100644 --- a/coderd/azureidentity/azureidentity_test.go +++ b/coderd/azureidentity/azureidentity_test.go @@ -47,7 +47,6 @@ func TestValidate(t *testing.T) { vmID: "960a4b4a-dab2-44ef-9b73-7753043b4f16", date: mustTime(time.RFC3339, "2024-04-22T17:32:44Z"), }} { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() vm, err := azureidentity.Validate(context.Background(), tc.payload, azureidentity.Options{ diff --git a/coderd/chat.go b/coderd/chat.go deleted file mode 100644 index b10211075cfe6..0000000000000 --- a/coderd/chat.go +++ /dev/null @@ -1,366 +0,0 @@ -package coderd - -import ( - "encoding/json" - "io" - "net/http" - "time" - - "github.com/kylecarbs/aisdk-go" - - "github.com/coder/coder/v2/coderd/ai" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/httpapi" - "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/util/strings" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/toolsdk" -) - -// postChats creates a new chat. -// -// @Summary Create a chat -// @ID create-a-chat -// @Security CoderSessionToken -// @Produce json -// @Tags Chat -// @Success 201 {object} codersdk.Chat -// @Router /chats [post] -func (api *API) postChats(w http.ResponseWriter, r *http.Request) { - apiKey := httpmw.APIKey(r) - ctx := r.Context() - - chat, err := api.Database.InsertChat(ctx, database.InsertChatParams{ - OwnerID: apiKey.UserID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Title: "New Chat", - }) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to create chat", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, w, http.StatusCreated, db2sdk.Chat(chat)) -} - -// listChats lists all chats for a user. -// -// @Summary List chats -// @ID list-chats -// @Security CoderSessionToken -// @Produce json -// @Tags Chat -// @Success 200 {array} codersdk.Chat -// @Router /chats [get] -func (api *API) listChats(w http.ResponseWriter, r *http.Request) { - apiKey := httpmw.APIKey(r) - ctx := r.Context() - - chats, err := api.Database.GetChatsByOwnerID(ctx, apiKey.UserID) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to list chats", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, w, http.StatusOK, db2sdk.Chats(chats)) -} - -// chat returns a chat by ID. -// -// @Summary Get a chat -// @ID get-a-chat -// @Security CoderSessionToken -// @Produce json -// @Tags Chat -// @Param chat path string true "Chat ID" -// @Success 200 {object} codersdk.Chat -// @Router /chats/{chat} [get] -func (*API) chat(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - chat := httpmw.ChatParam(r) - httpapi.Write(ctx, w, http.StatusOK, db2sdk.Chat(chat)) -} - -// chatMessages returns the messages of a chat. -// -// @Summary Get chat messages -// @ID get-chat-messages -// @Security CoderSessionToken -// @Produce json -// @Tags Chat -// @Param chat path string true "Chat ID" -// @Success 200 {array} aisdk.Message -// @Router /chats/{chat}/messages [get] -func (api *API) chatMessages(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - chat := httpmw.ChatParam(r) - rawMessages, err := api.Database.GetChatMessagesByChatID(ctx, chat.ID) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get chat messages", - Detail: err.Error(), - }) - return - } - messages := make([]aisdk.Message, len(rawMessages)) - for i, message := range rawMessages { - var msg aisdk.Message - err = json.Unmarshal(message.Content, &msg) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to unmarshal chat message", - Detail: err.Error(), - }) - return - } - messages[i] = msg - } - - httpapi.Write(ctx, w, http.StatusOK, messages) -} - -// postChatMessages creates a new chat message and streams the response. -// -// @Summary Create a chat message -// @ID create-a-chat-message -// @Security CoderSessionToken -// @Accept json -// @Produce json -// @Tags Chat -// @Param chat path string true "Chat ID" -// @Param request body codersdk.CreateChatMessageRequest true "Request body" -// @Success 200 {array} aisdk.DataStreamPart -// @Router /chats/{chat}/messages [post] -func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - chat := httpmw.ChatParam(r) - var req codersdk.CreateChatMessageRequest - err := json.NewDecoder(r.Body).Decode(&req) - if err != nil { - httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to decode chat message", - Detail: err.Error(), - }) - return - } - - dbMessages, err := api.Database.GetChatMessagesByChatID(ctx, chat.ID) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get chat messages", - Detail: err.Error(), - }) - return - } - - messages := make([]codersdk.ChatMessage, 0) - for _, dbMsg := range dbMessages { - var msg codersdk.ChatMessage - err = json.Unmarshal(dbMsg.Content, &msg) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to unmarshal chat message", - Detail: err.Error(), - }) - return - } - messages = append(messages, msg) - } - messages = append(messages, req.Message) - - client := codersdk.New(api.AccessURL) - client.SetSessionToken(httpmw.APITokenFromRequest(r)) - - tools := make([]aisdk.Tool, 0) - handlers := map[string]toolsdk.GenericHandlerFunc{} - for _, tool := range toolsdk.All { - if tool.Name == "coder_report_task" { - continue // This tool requires an agent to run. - } - tools = append(tools, tool.Tool) - handlers[tool.Tool.Name] = tool.Handler - } - - provider, ok := api.LanguageModels[req.Model] - if !ok { - httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ - Message: "Model not found", - }) - return - } - - // If it's the user's first message, generate a title for the chat. - if len(messages) == 1 { - var acc aisdk.DataStreamAccumulator - stream, err := provider.StreamFunc(ctx, ai.StreamOptions{ - Model: req.Model, - SystemPrompt: `- You will generate a short title based on the user's message. -- It should be maximum of 40 characters. -- Do not use quotes, colons, special characters, or emojis.`, - Messages: messages, - Tools: []aisdk.Tool{}, // This initial stream doesn't use tools. - }) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to create stream", - Detail: err.Error(), - }) - return - } - stream = stream.WithAccumulator(&acc) - err = stream.Pipe(io.Discard) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to pipe stream", - Detail: err.Error(), - }) - return - } - var newTitle string - accMessages := acc.Messages() - // If for some reason the stream didn't return any messages, use the - // original message as the title. - if len(accMessages) == 0 { - newTitle = strings.Truncate(messages[0].Content, 40) - } else { - newTitle = strings.Truncate(accMessages[0].Content, 40) - } - err = api.Database.UpdateChatByID(ctx, database.UpdateChatByIDParams{ - ID: chat.ID, - Title: newTitle, - UpdatedAt: dbtime.Now(), - }) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to update chat title", - Detail: err.Error(), - }) - return - } - } - - // Write headers for the data stream! - aisdk.WriteDataStreamHeaders(w) - - // Insert the user-requested message into the database! - raw, err := json.Marshal([]aisdk.Message{req.Message}) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to marshal chat message", - Detail: err.Error(), - }) - return - } - _, err = api.Database.InsertChatMessages(ctx, database.InsertChatMessagesParams{ - ChatID: chat.ID, - CreatedAt: dbtime.Now(), - Model: req.Model, - Provider: provider.Provider, - Content: raw, - }) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to insert chat messages", - Detail: err.Error(), - }) - return - } - - deps, err := toolsdk.NewDeps(client) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to create tool dependencies", - Detail: err.Error(), - }) - return - } - - for { - var acc aisdk.DataStreamAccumulator - stream, err := provider.StreamFunc(ctx, ai.StreamOptions{ - Model: req.Model, - Messages: messages, - Tools: tools, - SystemPrompt: `You are a chat assistant for Coder - an open-source platform for creating and managing cloud development environments on any infrastructure. You are expected to be precise, concise, and helpful. - -You are running as an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Do NOT guess or make up an answer.`, - }) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to create stream", - Detail: err.Error(), - }) - return - } - stream = stream.WithToolCalling(func(toolCall aisdk.ToolCall) aisdk.ToolCallResult { - tool, ok := handlers[toolCall.Name] - if !ok { - return nil - } - toolArgs, err := json.Marshal(toolCall.Args) - if err != nil { - return nil - } - result, err := tool(ctx, deps, toolArgs) - if err != nil { - return map[string]any{ - "error": err.Error(), - } - } - return result - }).WithAccumulator(&acc) - - err = stream.Pipe(w) - if err != nil { - // The client disppeared! - api.Logger.Error(ctx, "stream pipe error", "error", err) - return - } - - // acc.Messages() may sometimes return nil. Serializing this - // will cause a pq error: "cannot extract elements from a scalar". - newMessages := append([]aisdk.Message{}, acc.Messages()...) - if len(newMessages) > 0 { - raw, err := json.Marshal(newMessages) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to marshal chat message", - Detail: err.Error(), - }) - return - } - messages = append(messages, newMessages...) - - // Insert these messages into the database! - _, err = api.Database.InsertChatMessages(ctx, database.InsertChatMessagesParams{ - ChatID: chat.ID, - CreatedAt: dbtime.Now(), - Model: req.Model, - Provider: provider.Provider, - Content: raw, - }) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to insert chat messages", - Detail: err.Error(), - }) - return - } - } - - if acc.FinishReason() == aisdk.FinishReasonToolCalls { - continue - } - - break - } -} diff --git a/coderd/chat_test.go b/coderd/chat_test.go deleted file mode 100644 index 71e7b99ab3720..0000000000000 --- a/coderd/chat_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package coderd_test - -import ( - "net/http" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/testutil" -) - -func TestChat(t *testing.T) { - t.Parallel() - - t.Run("ExperimentAgenticChatDisabled", func(t *testing.T) { - t.Parallel() - - client, _ := coderdtest.NewWithDatabase(t, nil) - owner := coderdtest.CreateFirstUser(t, client) - memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - // Hit the endpoint to get the chat. It should return a 404. - ctx := testutil.Context(t, testutil.WaitShort) - _, err := memberClient.ListChats(ctx) - require.Error(t, err, "list chats should fail") - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr, "request should fail with an SDK error") - require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) - }) - - t.Run("ChatCRUD", func(t *testing.T) { - t.Parallel() - - dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentAgenticChat)} - dv.AI.Value = codersdk.AIConfig{ - Providers: []codersdk.AIProviderConfig{ - { - Type: "fake", - APIKey: "", - BaseURL: "http://localhost", - Models: []string{"fake-model"}, - }, - }, - } - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ - DeploymentValues: dv, - }) - owner := coderdtest.CreateFirstUser(t, client) - memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - // Seed the database with some data. - dbChat := dbgen.Chat(t, db, database.Chat{ - OwnerID: memberUser.ID, - CreatedAt: dbtime.Now().Add(-time.Hour), - UpdatedAt: dbtime.Now().Add(-time.Hour), - Title: "This is a test chat", - }) - _ = dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: dbChat.ID, - CreatedAt: dbtime.Now().Add(-time.Hour), - Content: []byte(`[{"content": "Hello world"}]`), - Model: "fake model", - Provider: "fake", - }) - - ctx := testutil.Context(t, testutil.WaitShort) - - // Listing chats should return the chat we just inserted. - chats, err := memberClient.ListChats(ctx) - require.NoError(t, err, "list chats should succeed") - require.Len(t, chats, 1, "response should have one chat") - require.Equal(t, dbChat.ID, chats[0].ID, "unexpected chat ID") - require.Equal(t, dbChat.Title, chats[0].Title, "unexpected chat title") - require.Equal(t, dbChat.CreatedAt.UTC(), chats[0].CreatedAt.UTC(), "unexpected chat created at") - require.Equal(t, dbChat.UpdatedAt.UTC(), chats[0].UpdatedAt.UTC(), "unexpected chat updated at") - - // Fetching a single chat by ID should return the same chat. - chat, err := memberClient.Chat(ctx, dbChat.ID) - require.NoError(t, err, "get chat should succeed") - require.Equal(t, chats[0], chat, "get chat should return the same chat") - - // Listing chat messages should return the message we just inserted. - messages, err := memberClient.ChatMessages(ctx, dbChat.ID) - require.NoError(t, err, "list chat messages should succeed") - require.Len(t, messages, 1, "response should have one message") - require.Equal(t, "Hello world", messages[0].Content, "response should have the correct message content") - - // Creating a new chat will fail because the model does not exist. - // TODO: Test the message streaming functionality with a mock model. - // Inserting a chat message will fail due to the model not existing. - _, err = memberClient.CreateChatMessage(ctx, dbChat.ID, codersdk.CreateChatMessageRequest{ - Model: "echo", - Message: codersdk.ChatMessage{ - Role: "user", - Content: "Hello world", - }, - Thinking: false, - }) - require.Error(t, err, "create chat message should fail") - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr, "create chat should fail with an SDK error") - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode(), "create chat should fail with a 400 when model does not exist") - - // Creating a new chat message with malformed content should fail. - res, err := memberClient.Request(ctx, http.MethodPost, "/api/v2/chats/"+dbChat.ID.String()+"/messages", strings.NewReader(`{malformed json}`)) - require.NoError(t, err) - defer res.Body.Close() - apiErr := codersdk.ReadBodyAsError(res) - require.Contains(t, apiErr.Error(), "Failed to decode chat message") - - _, err = memberClient.CreateChat(ctx) - require.NoError(t, err, "create chat should succeed") - chats, err = memberClient.ListChats(ctx) - require.NoError(t, err, "list chats should succeed") - require.Len(t, chats, 2, "response should have two chats") - }) -} diff --git a/coderd/coderd.go b/coderd/coderd.go index 929c9f44a7a8b..b6a4bcbfa801b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -45,7 +45,6 @@ import ( "github.com/coder/coder/v2/codersdk/drpcsdk" - "github.com/coder/coder/v2/coderd/ai" "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/files" @@ -160,7 +159,6 @@ type Options struct { Authorizer rbac.Authorizer AzureCertificates x509.VerifyOptions GoogleTokenValidator *idtoken.Validator - LanguageModels ai.LanguageModels GithubOAuth2Config *GithubOAuth2Config OIDCConfig *OIDCConfig PrometheusRegistry *prometheus.Registry @@ -574,7 +572,7 @@ func New(options *Options) *API { TemplateScheduleStore: options.TemplateScheduleStore, UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, - FileCache: files.NewFromStore(options.Database, options.PrometheusRegistry, options.Authorizer), + FileCache: files.New(options.PrometheusRegistry, options.Authorizer), Experiments: experiments, WebpushDispatcher: options.WebPushDispatcher, healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{}, @@ -939,6 +937,14 @@ func New(options *Options) *API { }) }) + // Experimental routes are not guaranteed to be stable and may change at any time. + r.Route("/api/experimental", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Route("/aitasks", func(r chi.Router) { + r.Get("/prompts", api.aiTasksPrompts) + }) + }) + r.Route("/api/v2", func(r chi.Router) { api.APIHandler = r @@ -968,11 +974,10 @@ func New(options *Options) *API { r.Get("/config", api.deploymentValues) r.Get("/stats", api.deploymentStats) r.Get("/ssh", api.sshConfig) - r.Get("/llms", api.deploymentLLMs) }) r.Route("/experiments", func(r chi.Router) { r.Use(apiKeyMiddleware) - r.Get("/available", handleExperimentsSafe) + r.Get("/available", handleExperimentsAvailable) r.Get("/", api.handleExperimentsGet) }) r.Get("/updatecheck", api.updateCheck) @@ -1011,21 +1016,6 @@ func New(options *Options) *API { r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) - // Chats are an experimental feature - r.Route("/chats", func(r chi.Router) { - r.Use( - apiKeyMiddleware, - httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentAgenticChat), - ) - r.Get("/", api.listChats) - r.Post("/", api.postChats) - r.Route("/{chat}", func(r chi.Router) { - r.Use(httpmw.ExtractChatParam(options.Database)) - r.Get("/", api.chat) - r.Get("/messages", api.chatMessages) - r.Post("/messages", api.postChatMessages) - }) - }) r.Route("/external-auth", func(r chi.Router) { r.Use( apiKeyMiddleware, @@ -1324,7 +1314,7 @@ func New(options *Options) *API { r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/containers", api.workspaceAgentListContainers) - r.Post("/containers/devcontainers/container/{container}/recreate", api.workspaceAgentRecreateDevcontainer) + r.Post("/containers/devcontainers/{devcontainer}/recreate", api.workspaceAgentRecreateDevcontainer) r.Get("/coordinate", api.workspaceAgentClientCoordinate) // PTY is part of workspaceAppServer. @@ -1536,7 +1526,6 @@ func New(options *Options) *API { // Add CSP headers to all static assets and pages. CSP headers only affect // browsers, so these don't make sense on api routes. cspMW := httpmw.CSPHeaders( - api.Experiments, options.Telemetry.Enabled(), func() []*proxyhealth.ProxyHost { if api.DeploymentValues.Dangerous.AllowAllCors { // In this mode, allow all external requests. @@ -1895,7 +1884,9 @@ func ReadExperiments(log slog.Logger, raw []string) codersdk.Experiments { exps = append(exps, codersdk.ExperimentsSafe...) default: ex := codersdk.Experiment(strings.ToLower(v)) - if !slice.Contains(codersdk.ExperimentsSafe, ex) { + if !slice.Contains(codersdk.ExperimentsKnown, ex) { + log.Warn(context.Background(), "ignoring unknown experiment", slog.F("experiment", ex)) + } else if !slice.Contains(codersdk.ExperimentsSafe, ex) { log.Warn(context.Background(), "πŸ‰ HERE BE DRAGONS: opting into hidden experiment", slog.F("experiment", ex)) } exps = append(exps, ex) diff --git a/coderd/coderd_internal_test.go b/coderd/coderd_internal_test.go index 34f5738bf90a0..b03985e1e157d 100644 --- a/coderd/coderd_internal_test.go +++ b/coderd/coderd_internal_test.go @@ -32,8 +32,6 @@ func TestStripSlashesMW(t *testing.T) { }) for _, tt := range tests { - tt := tt - t.Run("chi/"+tt.name, func(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", tt.inputPath, nil) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a8f444c8f632e..55e62561af60a 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -52,6 +52,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" "github.com/coder/coder/v2/coderd" @@ -359,6 +360,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can ctx, options.Database, options.Pubsub, + files.New(prometheus.NewRegistry(), options.Authorizer), prometheus.NewRegistry(), &templateScheduleStore, &auditor, @@ -1245,16 +1247,16 @@ func CreateWorkspace(t testing.TB, client *codersdk.Client, templateID uuid.UUID } // TransitionWorkspace is a convenience method for transitioning a workspace from one state to another. -func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition, muts ...func(req *codersdk.CreateWorkspaceBuildRequest)) codersdk.Workspace { +func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to codersdk.WorkspaceTransition, muts ...func(req *codersdk.CreateWorkspaceBuildRequest)) codersdk.Workspace { t.Helper() ctx := context.Background() workspace, err := client.Workspace(ctx, workspaceID) require.NoError(t, err, "unexpected error fetching workspace") - require.Equal(t, workspace.LatestBuild.Transition, codersdk.WorkspaceTransition(from), "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition) + require.Equal(t, workspace.LatestBuild.Transition, from, "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition) req := codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - Transition: codersdk.WorkspaceTransition(to), + Transition: to, } for _, mut := range muts { @@ -1267,7 +1269,7 @@ func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID _ = AwaitWorkspaceBuildJobCompleted(t, client, build.ID) updated := MustWorkspace(t, client, workspace.ID) - require.Equal(t, codersdk.WorkspaceTransition(to), updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition) + require.Equal(t, to, updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition) return updated } diff --git a/coderd/coderdtest/dynamicparameters.go b/coderd/coderdtest/dynamicparameters.go new file mode 100644 index 0000000000000..cb295eeaae965 --- /dev/null +++ b/coderd/coderdtest/dynamicparameters.go @@ -0,0 +1,146 @@ +package coderdtest + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" +) + +type DynamicParameterTemplateParams struct { + MainTF string + Plan json.RawMessage + ModulesArchive []byte + + // StaticParams is used if the provisioner daemon version does not support dynamic parameters. + StaticParams []*proto.RichParameter + + // TemplateID is used to update an existing template instead of creating a new one. + TemplateID uuid.UUID +} + +func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UUID, args DynamicParameterTemplateParams) (codersdk.Template, codersdk.TemplateVersion) { + t.Helper() + + files := echo.WithExtraFiles(map[string][]byte{ + "main.tf": []byte(args.MainTF), + }) + files.ProvisionPlan = []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Plan: args.Plan, + ModuleFiles: args.ModulesArchive, + Parameters: args.StaticParams, + }, + }, + }} + + version := CreateTemplateVersion(t, client, org, files, func(request *codersdk.CreateTemplateVersionRequest) { + if args.TemplateID != uuid.Nil { + request.TemplateID = args.TemplateID + } + }) + AwaitTemplateVersionJobCompleted(t, client, version.ID) + + tplID := args.TemplateID + if args.TemplateID == uuid.Nil { + tpl := CreateTemplate(t, client, org, version.ID) + tplID = tpl.ID + } + + var err error + tpl, err := client.UpdateTemplateMeta(t.Context(), tplID, codersdk.UpdateTemplateMeta{ + UseClassicParameterFlow: ptr.Ref(false), + }) + require.NoError(t, err) + + err = client.UpdateActiveTemplateVersion(t.Context(), tpl.ID, codersdk.UpdateActiveTemplateVersion{ + ID: version.ID, + }) + require.NoError(t, err) + + return tpl, version +} + +type ParameterAsserter struct { + Name string + Params []codersdk.PreviewParameter + t *testing.T +} + +func AssertParameter(t *testing.T, name string, params []codersdk.PreviewParameter) *ParameterAsserter { + return &ParameterAsserter{ + Name: name, + Params: params, + t: t, + } +} + +func (a *ParameterAsserter) find(name string) *codersdk.PreviewParameter { + a.t.Helper() + for _, p := range a.Params { + if p.Name == name { + return &p + } + } + + assert.Fail(a.t, "parameter not found", "expected parameter %q to exist", a.Name) + return nil +} + +func (a *ParameterAsserter) NotExists() *ParameterAsserter { + a.t.Helper() + + names := slice.Convert(a.Params, func(p codersdk.PreviewParameter) string { + return p.Name + }) + + assert.NotContains(a.t, names, a.Name) + return a +} + +func (a *ParameterAsserter) Exists() *ParameterAsserter { + a.t.Helper() + + names := slice.Convert(a.Params, func(p codersdk.PreviewParameter) string { + return p.Name + }) + + assert.Contains(a.t, names, a.Name) + return a +} + +func (a *ParameterAsserter) Value(expected string) *ParameterAsserter { + a.t.Helper() + + p := a.find(a.Name) + if p == nil { + return a + } + + assert.Equal(a.t, expected, p.Value.Value) + return a +} + +func (a *ParameterAsserter) Options(expected ...string) *ParameterAsserter { + a.t.Helper() + + p := a.find(a.Name) + if p == nil { + return a + } + + optValues := slice.Convert(p.Options, func(p codersdk.PreviewParameterOption) string { + return p.Value.Value + }) + assert.ElementsMatch(a.t, expected, optValues, "parameter %q options", a.Name) + return a +} diff --git a/coderd/coderdtest/stream.go b/coderd/coderdtest/stream.go new file mode 100644 index 0000000000000..83bcce2ed29db --- /dev/null +++ b/coderd/coderdtest/stream.go @@ -0,0 +1,25 @@ +package coderdtest + +import "github.com/coder/coder/v2/codersdk/wsjson" + +// SynchronousStream returns a function that assumes the stream is synchronous. +// Meaning each request sent assumes exactly one response will be received. +// The function will block until the response is received or an error occurs. +// +// This should not be used in production code, as it does not handle edge cases. +// The second function `pop` can be used to retrieve the next response from the +// stream without sending a new request. This is useful for dynamic parameters +func SynchronousStream[R any, W any](stream *wsjson.Stream[R, W]) (do func(W) (R, error), pop func() R) { + rec := stream.Chan() + + return func(req W) (R, error) { + err := stream.Send(req) + if err != nil { + return *new(R), err + } + + return <-rec, nil + }, func() R { + return <-rec + } +} diff --git a/coderd/database/constants.go b/coderd/database/constants.go new file mode 100644 index 0000000000000..931e0d7e0983d --- /dev/null +++ b/coderd/database/constants.go @@ -0,0 +1,5 @@ +package database + +import "github.com/google/uuid" + +var PrebuildsSystemUserID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0") diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 4a7871f21d15d..c74e63bb86f59 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -16,16 +16,18 @@ import ( "golang.org/x/xerrors" "tailscale.com/tailcfg" + previewtypes "github.com/coder/preview/types" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/render" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/tailnet" - previewtypes "github.com/coder/preview/types" ) // List is a helper function to reduce boilerplate when converting slices of @@ -96,6 +98,49 @@ func TemplateVersionParameters(params []database.TemplateVersionParameter) ([]co return out, nil } +func TemplateVersionParameterFromPreview(param previewtypes.Parameter) (codersdk.TemplateVersionParameter, error) { + descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description) + if err != nil { + return codersdk.TemplateVersionParameter{}, err + } + + sdkParam := codersdk.TemplateVersionParameter{ + Name: param.Name, + DisplayName: param.DisplayName, + Description: param.Description, + DescriptionPlaintext: descriptionPlaintext, + Type: string(param.Type), + FormType: string(param.FormType), + Mutable: param.Mutable, + DefaultValue: param.DefaultValue.AsString(), + Icon: param.Icon, + Required: param.Required, + Ephemeral: param.Ephemeral, + Options: List(param.Options, TemplateVersionParameterOptionFromPreview), + // Validation set after + } + if len(param.Validations) > 0 { + validation := param.Validations[0] + sdkParam.ValidationError = validation.Error + if validation.Monotonic != nil { + sdkParam.ValidationMonotonic = codersdk.ValidationMonotonicOrder(*validation.Monotonic) + } + if validation.Regex != nil { + sdkParam.ValidationRegex = *validation.Regex + } + if validation.Min != nil { + //nolint:gosec // No other choice + sdkParam.ValidationMin = ptr.Ref(int32(*validation.Min)) + } + if validation.Max != nil { + //nolint:gosec // No other choice + sdkParam.ValidationMax = ptr.Ref(int32(*validation.Max)) + } + } + + return sdkParam, nil +} + func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk.TemplateVersionParameter, error) { options, err := templateVersionParameterOptions(param.Options) if err != nil { @@ -299,6 +344,15 @@ func templateVersionParameterOptions(rawOptions json.RawMessage) ([]codersdk.Tem return options, nil } +func TemplateVersionParameterOptionFromPreview(option *previewtypes.ParameterOption) codersdk.TemplateVersionParameterOption { + return codersdk.TemplateVersionParameterOption{ + Name: option.Name, + Description: option.Description, + Value: option.Value.AsString(), + Icon: option.Icon, + } +} + func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp { return codersdk.OAuth2ProviderApp{ ID: dbApp.ID, @@ -750,19 +804,6 @@ func AgentProtoConnectionActionToAuditAction(action database.AuditAction) (agent } } -func Chat(chat database.Chat) codersdk.Chat { - return codersdk.Chat{ - ID: chat.ID, - Title: chat.Title, - CreatedAt: chat.CreatedAt, - UpdatedAt: chat.UpdatedAt, - } -} - -func Chats(chats []database.Chat) []codersdk.Chat { - return List(chats, Chat) -} - func PreviewParameter(param previewtypes.Parameter) codersdk.PreviewParameter { return codersdk.PreviewParameter{ PreviewParameterData: codersdk.PreviewParameterData{ diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index bfee2f52cbbd9..8e879569e014a 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -119,8 +119,6 @@ func TestProvisionerJobStatus(t *testing.T) { org := dbgen.Organization(t, db, database.Organization{}) for i, tc := range cases { - tc := tc - i := i t.Run(tc.name, func(t *testing.T) { t.Parallel() // Populate standard fields diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index 815d6629f64f9..5e19f43ab5376 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -46,7 +46,6 @@ func TestInsertCustomRoles(t *testing.T) { merge := func(u ...interface{}) rbac.Roles { all := make([]rbac.Role, 0) for _, v := range u { - v := v switch t := v.(type) { case rbac.Role: all = append(all, t) @@ -201,8 +200,6 @@ func TestInsertCustomRoles(t *testing.T) { } for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() db := dbmem.New() diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 8d470aa13473b..d63e049abf8ee 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -21,7 +21,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi/httpapiconstraints" "github.com/coder/coder/v2/coderd/httpmw/loggermw" - "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/rolestore" @@ -150,6 +149,32 @@ func (q *querier) authorizeContext(ctx context.Context, action policy.Action, ob return nil } +// authorizePrebuiltWorkspace handles authorization for workspace resource types. +// prebuilt_workspaces are a subset of workspaces, currently limited to +// supporting delete operations. This function first attempts normal workspace +// authorization. If that fails, the action is delete or update and the workspace +// is a prebuild, a prebuilt-specific authorization is attempted. +// Note: Delete operations of workspaces requires both update and delete +// permissions. +func (q *querier) authorizePrebuiltWorkspace(ctx context.Context, action policy.Action, workspace database.Workspace) error { + // Try default workspace authorization first + var workspaceErr error + if workspaceErr = q.authorizeContext(ctx, action, workspace); workspaceErr == nil { + return nil + } + + // Special handling for prebuilt workspace deletion + if (action == policy.ActionUpdate || action == policy.ActionDelete) && workspace.IsPrebuild() { + var prebuiltErr error + if prebuiltErr = q.authorizeContext(ctx, action, workspace.AsPrebuild()); prebuiltErr == nil { + return nil + } + return xerrors.Errorf("authorize context failed for workspace (%v) and prebuilt (%w)", workspaceErr, prebuiltErr) + } + + return xerrors.Errorf("authorize context: %w", workspaceErr) +} + type authContextKey struct{} // ActorFromContext returns the authorization subject from the context. @@ -205,6 +230,8 @@ var ( Identifier: rbac.RoleIdentifier{Name: "autostart"}, DisplayName: "Autostart Daemon", Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceOrganizationMember.Type: {policy.ActionRead}, + rbac.ResourceFile.Type: {policy.ActionRead}, // Required to read terraform files rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead}, rbac.ResourceSystem.Type: {policy.WildcardSymbol}, rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate}, @@ -399,7 +426,7 @@ var ( subjectPrebuildsOrchestrator = rbac.Subject{ Type: rbac.SubjectTypePrebuildsOrchestrator, FriendlyName: "Prebuilds Orchestrator", - ID: prebuilds.SystemUserID.String(), + ID: database.PrebuildsSystemUserID.String(), Roles: rbac.Roles([]rbac.Role{ { Identifier: rbac.RoleIdentifier{Name: "prebuilds-orchestrator"}, @@ -412,8 +439,15 @@ var ( policy.ActionCreate, policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, }, + // PrebuiltWorkspaces are a subset of Workspaces. + // Explicitly setting PrebuiltWorkspace permissions for clarity. + // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. + rbac.ResourcePrebuiltWorkspace.Type: { + policy.ActionUpdate, policy.ActionDelete, + }, // Should be able to add the prebuilds system user as a member to any organization that needs prebuilds. rbac.ResourceOrganizationMember.Type: { + policy.ActionRead, policy.ActionCreate, }, // Needs to be able to assign roles to the system user in order to make it a member of an organization. @@ -427,6 +461,10 @@ var ( rbac.ResourceOrganization.Type: { policy.ActionRead, }, + // Required to read the terraform files of a template + rbac.ResourceFile.Type: { + policy.ActionRead, + }, }), }, }), @@ -1335,10 +1373,6 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } -func (q *querier) DeleteChat(ctx context.Context, id uuid.UUID) error { - return deleteQ(q.log, q.auth, q.db.GetChatByID, q.db.DeleteChat)(ctx, id) -} - func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil { return err @@ -1776,22 +1810,6 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } -func (q *querier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { - return fetch(q.log, q.auth, q.db.GetChatByID)(ctx, id) -} - -func (q *querier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { - c, err := q.GetChatByID(ctx, chatID) - if err != nil { - return nil, err - } - return q.db.GetChatMessagesByChatID(ctx, c.ID) -} - -func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { - return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByOwnerID)(ctx, ownerID) -} - func (q *querier) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return "", err @@ -3252,6 +3270,15 @@ func (q *querier) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuil return q.db.GetWorkspaceBuildParameters(ctx, workspaceBuildID) } +func (q *querier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + } + + return q.db.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prep) +} + func (q *querier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return nil, err @@ -3478,21 +3505,6 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg) } -func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { - return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()), q.db.InsertChat)(ctx, arg) -} - -func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { - c, err := q.db.GetChatByID(ctx, arg.ChatID) - if err != nil { - return nil, err - } - if err := q.authorizeContext(ctx, policy.ActionUpdate, c); err != nil { - return nil, err - } - return q.db.InsertChatMessages(ctx, arg) -} - func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -3909,23 +3921,6 @@ func (q *querier) InsertWorkspaceAgentStats(ctx context.Context, arg database.In return q.db.InsertWorkspaceAgentStats(ctx, arg) } -func (q *querier) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { - // NOTE(DanielleMaywood): - // It is possible for there to exist an agent without a workspace. - // This means that we want to allow execution to continue if - // there isn't a workspace found to allow this behavior to continue. - workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.AgentID) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return database.WorkspaceApp{}, err - } - - if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil { - return database.WorkspaceApp{}, err - } - - return q.db.InsertWorkspaceApp(ctx, arg) -} - func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { return err @@ -3953,8 +3948,9 @@ func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertW action = policy.ActionWorkspaceStop } - if err = q.authorizeContext(ctx, action, w); err != nil { - return xerrors.Errorf("authorize context: %w", err) + // Special handling for prebuilt workspace deletion + if err := q.authorizePrebuiltWorkspace(ctx, action, w); err != nil { + return err } // If we're starting a workspace we need to check the template. @@ -3993,8 +3989,8 @@ func (q *querier) InsertWorkspaceBuildParameters(ctx context.Context, arg databa return err } - err = q.authorizeContext(ctx, policy.ActionUpdate, workspace) - if err != nil { + // Special handling for prebuilt workspace deletion + if err := q.authorizePrebuiltWorkspace(ctx, policy.ActionUpdate, workspace); err != nil { return err } @@ -4170,13 +4166,6 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg) } -func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { - fetch := func(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) { - return q.db.GetChatByID(ctx, arg.ID) - } - return update(q.log, q.auth, fetch, q.db.UpdateChatByID)(ctx, arg) -} - func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -4505,6 +4494,28 @@ func (q *querier) UpdateTemplateScheduleByID(ctx context.Context, arg database.U return update(q.log, q.auth, fetch, q.db.UpdateTemplateScheduleByID)(ctx, arg) } +func (q *querier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { + // An actor is allowed to update the template version AI task flag if they are authorized to update the template. + tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) + if err != nil { + return err + } + var obj rbac.Objecter + if !tv.TemplateID.Valid { + obj = rbac.ResourceTemplate.InOrg(tv.OrganizationID) + } else { + tpl, err := q.db.GetTemplateByID(ctx, tv.TemplateID.UUID) + if err != nil { + return err + } + obj = tpl + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { + return err + } + return q.db.UpdateTemplateVersionAITaskByJobID(ctx, arg) +} + func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { // An actor is allowed to update the template version if they are authorized to update the template. tv, err := q.db.GetTemplateVersionByID(ctx, arg.ID) @@ -4861,6 +4872,24 @@ func (q *querier) UpdateWorkspaceAutostart(ctx context.Context, arg database.Upd return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceAutostart)(ctx, arg) } +func (q *querier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { + build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) + if err != nil { + return err + } + + workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return err + } + + err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) + if err != nil { + return err + } + return q.db.UpdateWorkspaceBuildAITaskByID(ctx, arg) +} + // UpdateWorkspaceBuildCostByID is used by the provisioning system to update the cost of a workspace build. func (q *querier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { @@ -5151,6 +5180,23 @@ func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg databas return q.db.UpsertWorkspaceAgentPortShare(ctx, arg) } +func (q *querier) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + // NOTE(DanielleMaywood): + // It is possible for there to exist an agent without a workspace. + // This means that we want to allow execution to continue if + // there isn't a workspace found to allow this behavior to continue. + workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.AgentID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return database.WorkspaceApp{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil { + return database.WorkspaceApp{}, err + } + + return q.db.UpsertWorkspaceApp(ctx, arg) +} + func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return false, err @@ -5196,6 +5242,10 @@ func (q *querier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, return q.GetWorkspacesAndAgentsByOwnerID(ctx, ownerID) } +func (q *querier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, _ rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + return q.GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs) +} + // GetAuthorizedUsers is not required for dbauthz since GetUsers is already // authenticated. func (q *querier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, _ rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index ba9d1ddf0d7d2..6d1c8c3df601c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -999,8 +999,7 @@ func (s *MethodTestSuite) TestOrganization() { PresetID: preset.ID, } check.Args(arg). - Asserts(rbac.ResourceTemplate, policy.ActionUpdate). - ErrorsWithInMemDB(dbmem.ErrUnimplemented) + Asserts(rbac.ResourceTemplate, policy.ActionUpdate) })) s.Run("DeleteOrganizationMember", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) @@ -1392,6 +1391,24 @@ func (s *MethodTestSuite) TestTemplate() { ID: t1.ID, }).Asserts(t1, policy.ActionUpdate) })) + s.Run("UpdateTemplateVersionAITaskByJobID", s.Subtest(func(db database.Store, check *expects) { + dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) + o := dbgen.Organization(s.T(), db, database.Organization{}) + u := dbgen.User(s.T(), db, database.User{}) + _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID}) + t := dbgen.Template(s.T(), db, database.Template{OrganizationID: o.ID, CreatedBy: u.ID}) + job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) + _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + OrganizationID: o.ID, + CreatedBy: u.ID, + JobID: job.ID, + TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, + }) + check.Args(database.UpdateTemplateVersionAITaskByJobIDParams{ + JobID: job.ID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + }).Asserts(t, policy.ActionUpdate) + })) s.Run("UpdateTemplateWorkspacesLastUsedAt", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) t1 := dbgen.Template(s.T(), db, database.Template{}) @@ -1995,6 +2012,14 @@ func (s *MethodTestSuite) TestWorkspace() { // No asserts here because SQLFilter. check.Args(ws.OwnerID, emptyPreparedAuthorized{}).Asserts() })) + s.Run("GetWorkspaceBuildParametersByBuildIDs", s.Subtest(func(db database.Store, check *expects) { + // no asserts here because SQLFilter + check.Args([]uuid.UUID{}).Asserts() + })) + s.Run("GetAuthorizedWorkspaceBuildParametersByBuildIDs", s.Subtest(func(db database.Store, check *expects) { + // no asserts here because SQLFilter + check.Args([]uuid.UUID{}, emptyPreparedAuthorized{}).Asserts() + })) s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) @@ -3051,6 +3076,40 @@ func (s *MethodTestSuite) TestWorkspace() { Deadline: b.Deadline, }).Asserts(w, policy.ActionUpdate) })) + s.Run("UpdateWorkspaceBuildAITaskByID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + TemplateID: tpl.ID, + OrganizationID: o.ID, + OwnerID: u.ID, + }) + j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ + JobID: j.ID, + WorkspaceID: w.ID, + TemplateVersionID: tv.ID, + }) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: b.JobID}) + agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) + check.Args(database.UpdateWorkspaceBuildAITaskByIDParams{ + HasAITask: sql.NullBool{Bool: true, Valid: true}, + SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + ID: b.ID, + }).Asserts(w, policy.ActionUpdate) + })) s.Run("SoftDeleteWorkspaceByID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) @@ -4115,7 +4174,7 @@ func (s *MethodTestSuite) TestSystemFunctions() { APIKeyScope: database.AgentKeyScopeEnumAll, }).Asserts(ws, policy.ActionCreateAgent) })) - s.Run("InsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) { _ = dbgen.User(s.T(), db, database.User{}) u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) @@ -4131,7 +4190,7 @@ func (s *MethodTestSuite) TestSystemFunctions() { _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID}) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID}) agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args(database.InsertWorkspaceAppParams{ + check.Args(database.UpsertWorkspaceAppParams{ ID: uuid.New(), AgentID: agent.ID, Health: database.WorkspaceAppHealthDisabled, @@ -4942,8 +5001,7 @@ func (s *MethodTestSuite) TestPrebuilds() { s.Run("GetActivePresetPrebuildSchedules", s.Subtest(func(db database.Store, check *expects) { check.Args(). Asserts(rbac.ResourceTemplate.All(), policy.ActionRead). - Returns([]database.TemplateVersionPresetPrebuildSchedule{}). - ErrorsWithInMemDB(dbmem.ErrUnimplemented) + Returns([]database.TemplateVersionPresetPrebuildSchedule{}) })) s.Run("GetPresetsByTemplateVersionID", s.Subtest(func(db database.Store, check *expects) { ctx := context.Background() @@ -5001,8 +5059,7 @@ func (s *MethodTestSuite) TestPrebuilds() { })) s.Run("GetPrebuildMetrics", s.Subtest(func(_ database.Store, check *expects) { check.Args(). - Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead). - ErrorsWithInMemDB(dbmem.ErrUnimplemented) + Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) })) s.Run("CountInProgressPrebuilds", s.Subtest(func(_ database.Store, check *expects) { check.Args(). @@ -5491,76 +5548,82 @@ func (s *MethodTestSuite) TestResourcesProvisionerdserver() { })) } -func (s *MethodTestSuite) TestChat() { - createChat := func(t *testing.T, db database.Store) (database.User, database.Chat, database.ChatMessage) { - t.Helper() - - usr := dbgen.User(t, db, database.User{}) - chat := dbgen.Chat(s.T(), db, database.Chat{ - OwnerID: usr.ID, +func (s *MethodTestSuite) TestAuthorizePrebuiltWorkspace() { + s.Run("PrebuildDelete/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, }) - msg := dbgen.ChatMessage(s.T(), db, database.ChatMessage{ - ChatID: chat.ID, + w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + TemplateID: tpl.ID, + OrganizationID: o.ID, + OwnerID: database.PrebuildsSystemUserID, }) - - return usr, chat, msg - } - - s.Run("DeleteChat", s.Subtest(func(db database.Store, check *expects) { - _, c, _ := createChat(s.T(), db) - check.Args(c.ID).Asserts(c, policy.ActionDelete) - })) - - s.Run("GetChatByID", s.Subtest(func(db database.Store, check *expects) { - _, c, _ := createChat(s.T(), db) - check.Args(c.ID).Asserts(c, policy.ActionRead).Returns(c) - })) - - s.Run("GetChatMessagesByChatID", s.Subtest(func(db database.Store, check *expects) { - _, c, m := createChat(s.T(), db) - check.Args(c.ID).Asserts(c, policy.ActionRead).Returns([]database.ChatMessage{m}) - })) - - s.Run("GetChatsByOwnerID", s.Subtest(func(db database.Store, check *expects) { - u1, u1c1, _ := createChat(s.T(), db) - u1c2 := dbgen.Chat(s.T(), db, database.Chat{ - OwnerID: u1.ID, - CreatedAt: u1c1.CreatedAt.Add(time.Hour), + pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + OrganizationID: o.ID, }) - _, _, _ = createChat(s.T(), db) // other user's chat - check.Args(u1.ID).Asserts(u1c2, policy.ActionRead, u1c1, policy.ActionRead).Returns([]database.Chat{u1c2, u1c1}) - })) - - s.Run("InsertChat", s.Subtest(func(db database.Store, check *expects) { - usr := dbgen.User(s.T(), db, database.User{}) - check.Args(database.InsertChatParams{ - OwnerID: usr.ID, - Title: "test chat", - CreatedAt: dbtime.Now(), - UpdatedAt: dbtime.Now(), - }).Asserts(rbac.ResourceChat.WithOwner(usr.ID.String()), policy.ActionCreate) - })) - - s.Run("InsertChatMessages", s.Subtest(func(db database.Store, check *expects) { - usr := dbgen.User(s.T(), db, database.User{}) - chat := dbgen.Chat(s.T(), db, database.Chat{ - OwnerID: usr.ID, + tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + OrganizationID: o.ID, + CreatedBy: u.ID, }) - check.Args(database.InsertChatMessagesParams{ - ChatID: chat.ID, - CreatedAt: dbtime.Now(), - Model: "test-model", - Provider: "test-provider", - Content: []byte(`[]`), - }).Asserts(chat, policy.ActionUpdate) - })) - - s.Run("UpdateChatByID", s.Subtest(func(db database.Store, check *expects) { - _, c, _ := createChat(s.T(), db) - check.Args(database.UpdateChatByIDParams{ - ID: c.ID, - Title: "new title", - UpdatedAt: dbtime.Now(), - }).Asserts(c, policy.ActionUpdate) + check.Args(database.InsertWorkspaceBuildParams{ + WorkspaceID: w.ID, + Transition: database.WorkspaceTransitionDelete, + Reason: database.BuildReasonInitiator, + TemplateVersionID: tv.ID, + JobID: pj.ID, + }). + // Simulate a fallback authorization flow: + // - First, the default workspace authorization fails (simulated by returning an error). + // - Then, authorization is retried using the prebuilt workspace object, which succeeds. + // The test asserts that both authorization attempts occur in the correct order. + WithSuccessAuthorizer(func(ctx context.Context, subject rbac.Subject, action policy.Action, obj rbac.Object) error { + if obj.Type == rbac.ResourceWorkspace.Type { + return xerrors.Errorf("not authorized for workspace type") + } + return nil + }).Asserts(w, policy.ActionDelete, w.AsPrebuild(), policy.ActionDelete) + })) + s.Run("PrebuildUpdate/InsertWorkspaceBuildParameters", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + tpl := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + TemplateID: tpl.ID, + OrganizationID: o.ID, + OwnerID: database.PrebuildsSystemUserID, + }) + pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + OrganizationID: o.ID, + }) + tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + wb := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ + JobID: pj.ID, + WorkspaceID: w.ID, + TemplateVersionID: tv.ID, + }) + check.Args(database.InsertWorkspaceBuildParametersParams{ + WorkspaceBuildID: wb.ID, + }). + // Simulate a fallback authorization flow: + // - First, the default workspace authorization fails (simulated by returning an error). + // - Then, authorization is retried using the prebuilt workspace object, which succeeds. + // The test asserts that both authorization attempts occur in the correct order. + WithSuccessAuthorizer(func(ctx context.Context, subject rbac.Subject, action policy.Action, obj rbac.Object) error { + if obj.Type == rbac.ResourceWorkspace.Type { + return xerrors.Errorf("not authorized for workspace type") + } + return nil + }).Asserts(w, policy.ActionUpdate, w.AsPrebuild(), policy.ActionUpdate) })) } diff --git a/coderd/database/dbauthz/groupsauth_test.go b/coderd/database/dbauthz/groupsauth_test.go index a9f26e303d644..79f936e103e09 100644 --- a/coderd/database/dbauthz/groupsauth_test.go +++ b/coderd/database/dbauthz/groupsauth_test.go @@ -135,7 +135,6 @@ func TestGroupsAuth(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 776667ba053cc..29ca421d6f11e 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -458,7 +458,6 @@ type AssertRBAC struct { func values(ins ...any) []reflect.Value { out := make([]reflect.Value, 0) for _, input := range ins { - input := input out = append(out, reflect.ValueOf(input)) } return out diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 335f0a7a1cb92..98e98122e74e5 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -416,6 +416,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { DesiredInstances: preset.DesiredInstances, InvalidateAfterSecs: preset.InvalidateAfterSecs, SchedulingTimezone: preset.SchedulingTimezone, + IsDefault: false, }) } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 5b03fd0eb1396..fb3adc9e6f057 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -143,30 +143,6 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database return key, fmt.Sprintf("%s-%s", key.ID, secret) } -func Chat(t testing.TB, db database.Store, seed database.Chat) database.Chat { - chat, err := db.InsertChat(genCtx, database.InsertChatParams{ - OwnerID: takeFirst(seed.OwnerID, uuid.New()), - CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), - UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), - Title: takeFirst(seed.Title, "Test Chat"), - }) - require.NoError(t, err, "insert chat") - return chat -} - -func ChatMessage(t testing.TB, db database.Store, seed database.ChatMessage) database.ChatMessage { - msg, err := db.InsertChatMessages(genCtx, database.InsertChatMessagesParams{ - CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), - ChatID: takeFirst(seed.ChatID, uuid.New()), - Model: takeFirst(seed.Model, "train"), - Provider: takeFirst(seed.Provider, "thomas"), - Content: takeFirstSlice(seed.Content, []byte(`[{"text": "Choo choo!"}]`)), - }) - require.NoError(t, err, "insert chat message") - require.Len(t, msg, 1, "insert one chat message did not return exactly one message") - return msg[0] -} - func WorkspaceAgentPortShare(t testing.TB, db database.Store, orig database.WorkspaceAgentPortShare) database.WorkspaceAgentPortShare { ps, err := db.UpsertWorkspaceAgentPortShare(genCtx, database.UpsertWorkspaceAgentPortShareParams{ WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), @@ -390,6 +366,9 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil t.Helper() buildID := takeFirst(orig.ID, uuid.New()) + jobID := takeFirst(orig.JobID, uuid.New()) + hasAITask := takeFirst(orig.HasAITask, sql.NullBool{}) + sidebarAppID := takeFirst(orig.AITaskSidebarAppID, uuid.NullUUID{}) var build database.WorkspaceBuild err := db.InTx(func(db database.Store) error { err := db.InsertWorkspaceBuild(genCtx, database.InsertWorkspaceBuildParams{ @@ -401,7 +380,7 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil BuildNumber: takeFirst(orig.BuildNumber, 1), Transition: takeFirst(orig.Transition, database.WorkspaceTransitionStart), InitiatorID: takeFirst(orig.InitiatorID, uuid.New()), - JobID: takeFirst(orig.JobID, uuid.New()), + JobID: jobID, ProvisionerState: takeFirstSlice(orig.ProvisionerState, []byte{}), Deadline: takeFirst(orig.Deadline, dbtime.Now().Add(time.Hour)), MaxDeadline: takeFirst(orig.MaxDeadline, time.Time{}), @@ -410,7 +389,6 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil UUID: uuid.UUID{}, Valid: false, }), - HasAITask: orig.HasAITask, }) if err != nil { return err @@ -424,6 +402,15 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil require.NoError(t, err) } + if hasAITask.Valid { + require.NoError(t, db.UpdateWorkspaceBuildAITaskByID(genCtx, database.UpdateWorkspaceBuildAITaskByIDParams{ + HasAITask: hasAITask, + SidebarAppID: sidebarAppID, + UpdatedAt: dbtime.Now(), + ID: buildID, + })) + } + build, err = db.GetWorkspaceBuildByID(genCtx, buildID) if err != nil { return err @@ -778,7 +765,7 @@ func ProvisionerKey(t testing.TB, db database.Store, orig database.ProvisionerKe } func WorkspaceApp(t testing.TB, db database.Store, orig database.WorkspaceApp) database.WorkspaceApp { - resource, err := db.InsertWorkspaceApp(genCtx, database.InsertWorkspaceAppParams{ + resource, err := db.UpsertWorkspaceApp(genCtx, database.UpsertWorkspaceAppParams{ ID: takeFirst(orig.ID, uuid.New()), CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), AgentID: takeFirst(orig.AgentID, uuid.New()), @@ -971,6 +958,8 @@ func ExternalAuthLink(t testing.TB, db database.Store, orig database.ExternalAut func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVersion) database.TemplateVersion { var version database.TemplateVersion + hasAITask := takeFirst(orig.HasAITask, sql.NullBool{}) + jobID := takeFirst(orig.JobID, uuid.New()) err := db.InTx(func(db database.Store) error { versionID := takeFirst(orig.ID, uuid.New()) err := db.InsertTemplateVersion(genCtx, database.InsertTemplateVersionParams{ @@ -982,15 +971,22 @@ func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVers Name: takeFirst(orig.Name, testutil.GetRandomName(t)), Message: orig.Message, Readme: takeFirst(orig.Readme, testutil.GetRandomName(t)), - JobID: takeFirst(orig.JobID, uuid.New()), + JobID: jobID, CreatedBy: takeFirst(orig.CreatedBy, uuid.New()), SourceExampleID: takeFirst(orig.SourceExampleID, sql.NullString{}), - HasAITask: orig.HasAITask, }) if err != nil { return err } + if hasAITask.Valid { + require.NoError(t, db.UpdateTemplateVersionAITaskByJobID(genCtx, database.UpdateTemplateVersionAITaskByJobIDParams{ + JobID: jobID, + HasAITask: hasAITask, + UpdatedAt: dbtime.Now(), + })) + } + version, err = db.GetTemplateVersionByID(genCtx, versionID) if err != nil { return err @@ -1303,6 +1299,7 @@ func Preset(t testing.TB, db database.Store, seed database.InsertPresetParams) d DesiredInstances: seed.DesiredInstances, InvalidateAfterSecs: seed.InvalidateAfterSecs, SchedulingTimezone: seed.SchedulingTimezone, + IsDefault: seed.IsDefault, }) require.NoError(t, err, "insert preset") return preset diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ee1c7471808d5..cd1067e61dbb5 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -23,11 +23,9 @@ import ( "golang.org/x/exp/maps" "golang.org/x/xerrors" - "github.com/coder/coder/v2/coderd/notifications/types" - "github.com/coder/coder/v2/coderd/prebuilds" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/regosql" "github.com/coder/coder/v2/coderd/util/slice" @@ -75,6 +73,7 @@ func New() database.Store { parameterSchemas: make([]database.ParameterSchema, 0), presets: make([]database.TemplateVersionPreset, 0), presetParameters: make([]database.TemplateVersionPresetParameter, 0), + presetPrebuildSchedules: make([]database.TemplateVersionPresetPrebuildSchedule, 0), provisionerDaemons: make([]database.ProvisionerDaemon, 0), provisionerJobs: make([]database.ProvisionerJob, 0), provisionerJobLogs: make([]database.ProvisionerJobLog, 0), @@ -159,7 +158,7 @@ func New() database.Store { q.mutex.Lock() // We can't insert this user using the interface, because it's a system user. q.data.users = append(q.data.users, database.User{ - ID: prebuilds.SystemUserID, + ID: database.PrebuildsSystemUserID, Email: "prebuilds@coder.com", Username: "prebuilds", CreatedAt: dbtime.Now(), @@ -216,8 +215,6 @@ type data struct { // New tables auditLogs []database.AuditLog - chats []database.Chat - chatMessages []database.ChatMessage cryptoKeys []database.CryptoKey dbcryptKeys []database.DBCryptKey files []database.File @@ -299,6 +296,7 @@ type data struct { telemetryItems []database.TelemetryItem presets []database.TemplateVersionPreset presetParameters []database.TemplateVersionPresetParameter + presetPrebuildSchedules []database.TemplateVersionPresetPrebuildSchedule } func tryPercentileCont(fs []float64, p float64) float64 { @@ -1811,7 +1809,6 @@ func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesPar found := make([]database.CustomRole, 0) for _, role := range q.data.customRoles { - role := role if len(arg.LookupRoles) > 0 { if !slices.ContainsFunc(arg.LookupRoles, func(pair database.NameOrganizationPair) bool { if pair.Name != role.Name { @@ -1910,19 +1907,6 @@ func (q *FakeQuerier) DeleteApplicationConnectAPIKeysByUserID(_ context.Context, return nil } -func (q *FakeQuerier) DeleteChat(ctx context.Context, id uuid.UUID) error { - q.mutex.Lock() - defer q.mutex.Unlock() - - for i, chat := range q.chats { - if chat.ID == id { - q.chats = append(q.chats[:i], q.chats[i+1:]...) - return nil - } - } - return sql.ErrNoRows -} - func (*FakeQuerier) DeleteCoordinator(context.Context, uuid.UUID) error { return ErrUnimplemented } @@ -2779,7 +2763,42 @@ func (q *FakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time } func (q *FakeQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) { - return nil, ErrUnimplemented + q.mutex.RLock() + defer q.mutex.RUnlock() + + var activeSchedules []database.TemplateVersionPresetPrebuildSchedule + + // Create a map of active template version IDs for quick lookup + activeTemplateVersions := make(map[uuid.UUID]bool) + for _, template := range q.templates { + if !template.Deleted && template.Deprecated == "" { + activeTemplateVersions[template.ActiveVersionID] = true + } + } + + // Create a map of presets for quick lookup + presetMap := make(map[uuid.UUID]database.TemplateVersionPreset) + for _, preset := range q.presets { + presetMap[preset.ID] = preset + } + + // Filter preset prebuild schedules to only include those for active template versions + for _, schedule := range q.presetPrebuildSchedules { + // Look up the preset using the map + preset, exists := presetMap[schedule.PresetID] + if !exists { + continue + } + + // Check if preset's template version is active + if !activeTemplateVersions[preset.TemplateVersionID] { + continue + } + + activeSchedules = append(activeSchedules, schedule) + } + + return activeSchedules, nil } // nolint:revive // It's not a control flag, it's a filter. @@ -2885,7 +2904,6 @@ func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U roles := make([]string, 0) for _, u := range q.users { if u.ID == userID { - u := u roles = append(roles, u.RBACRoles...) roles = append(roles, "member") user = &u @@ -2922,47 +2940,6 @@ func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U }, nil } -func (q *FakeQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - for _, chat := range q.chats { - if chat.ID == id { - return chat, nil - } - } - return database.Chat{}, sql.ErrNoRows -} - -func (q *FakeQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - messages := []database.ChatMessage{} - for _, chatMessage := range q.chatMessages { - if chatMessage.ChatID == chatID { - messages = append(messages, chatMessage) - } - } - return messages, nil -} - -func (q *FakeQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - chats := []database.Chat{} - for _, chat := range q.chats { - if chat.OwnerID == ownerID { - chats = append(chats, chat) - } - } - sort.Slice(chats, func(i, j int) bool { - return chats[i].CreatedAt.After(chats[j].CreatedAt) - }) - return chats, nil -} - func (q *FakeQuerier) GetCoordinatorResumeTokenSigningKey(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4293,7 +4270,7 @@ func (q *FakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U } func (*FakeQuerier) GetPrebuildMetrics(_ context.Context) ([]database.GetPrebuildMetricsRow, error) { - return nil, ErrUnimplemented + return make([]database.GetPrebuildMetricsRow, 0), nil } func (q *FakeQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) { @@ -4464,7 +4441,8 @@ func (q *FakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.Provi defer q.mutex.RUnlock() if len(q.provisionerDaemons) == 0 { - return nil, sql.ErrNoRows + // Returning err=nil here for consistency with real querier + return []database.ProvisionerDaemon{}, nil } // copy the data so that the caller can't manipulate any data inside dbmem // after returning @@ -7926,6 +7904,11 @@ func (q *FakeQuerier) GetWorkspaceBuildParameters(_ context.Context, workspaceBu return q.getWorkspaceBuildParametersNoLock(workspaceBuildID) } +func (q *FakeQuerier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + // No auth filter. + return q.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, nil) +} + func (q *FakeQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -8088,7 +8071,6 @@ func (q *FakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa var found *database.WorkspaceTable for _, workspace := range q.workspaces { - workspace := workspace if workspace.OwnerID != arg.OwnerID { continue } @@ -8146,7 +8128,6 @@ func (q *FakeQuerier) GetWorkspaceByWorkspaceAppID(_ context.Context, workspaceA defer q.mutex.RUnlock() for _, workspaceApp := range q.workspaceApps { - workspaceApp := workspaceApp if workspaceApp.ID == workspaceAppID { return q.getWorkspaceByAgentIDNoLock(context.Background(), workspaceApp.AgentID) } @@ -8593,66 +8574,6 @@ func (q *FakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit return alog, nil } -func (q *FakeQuerier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { - err := validateDatabaseType(arg) - if err != nil { - return database.Chat{}, err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - chat := database.Chat{ - ID: uuid.New(), - CreatedAt: arg.CreatedAt, - UpdatedAt: arg.UpdatedAt, - OwnerID: arg.OwnerID, - Title: arg.Title, - } - q.chats = append(q.chats, chat) - - return chat, nil -} - -func (q *FakeQuerier) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { - err := validateDatabaseType(arg) - if err != nil { - return nil, err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - id := int64(0) - if len(q.chatMessages) > 0 { - id = q.chatMessages[len(q.chatMessages)-1].ID - } - - messages := make([]database.ChatMessage, 0) - - rawMessages := make([]json.RawMessage, 0) - err = json.Unmarshal(arg.Content, &rawMessages) - if err != nil { - return nil, err - } - - for _, content := range rawMessages { - id++ - _ = content - messages = append(messages, database.ChatMessage{ - ID: id, - ChatID: arg.ChatID, - CreatedAt: arg.CreatedAt, - Model: arg.Model, - Provider: arg.Provider, - Content: content, - }) - } - - q.chatMessages = append(q.chatMessages, messages...) - return messages, nil -} - func (q *FakeQuerier) InsertCryptoKey(_ context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { err := validateDatabaseType(arg) if err != nil { @@ -9166,6 +9087,7 @@ func (q *FakeQuerier) InsertPreset(_ context.Context, arg database.InsertPresetP Valid: true, }, PrebuildStatus: database.PrebuildStatusHealthy, + IsDefault: arg.IsDefault, } q.presets = append(q.presets, preset) return preset, nil @@ -9201,7 +9123,17 @@ func (q *FakeQuerier) InsertPresetPrebuildSchedule(ctx context.Context, arg data return database.TemplateVersionPresetPrebuildSchedule{}, err } - return database.TemplateVersionPresetPrebuildSchedule{}, ErrUnimplemented + q.mutex.Lock() + defer q.mutex.Unlock() + + presetPrebuildSchedule := database.TemplateVersionPresetPrebuildSchedule{ + ID: uuid.New(), + PresetID: arg.PresetID, + CronExpression: arg.CronExpression, + DesiredInstances: arg.DesiredInstances, + } + q.presetPrebuildSchedules = append(q.presetPrebuildSchedules, presetPrebuildSchedule) + return presetPrebuildSchedule, nil } func (q *FakeQuerier) InsertProvisionerJob(_ context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) { @@ -9422,7 +9354,6 @@ func (q *FakeQuerier) InsertTemplateVersion(_ context.Context, arg database.Inse JobID: arg.JobID, CreatedBy: arg.CreatedBy, SourceExampleID: arg.SourceExampleID, - HasAITask: arg.HasAITask, } q.templateVersions = append(q.templateVersions, version) return nil @@ -9976,48 +9907,6 @@ func (q *FakeQuerier) InsertWorkspaceAgentStats(_ context.Context, arg database. return nil } -func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { - if err := validateDatabaseType(arg); err != nil { - return database.WorkspaceApp{}, err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - if arg.SharingLevel == "" { - arg.SharingLevel = database.AppSharingLevelOwner - } - - if arg.OpenIn == "" { - arg.OpenIn = database.WorkspaceAppOpenInSlimWindow - } - - // nolint:gosimple - workspaceApp := database.WorkspaceApp{ - ID: arg.ID, - AgentID: arg.AgentID, - CreatedAt: arg.CreatedAt, - Slug: arg.Slug, - DisplayName: arg.DisplayName, - Icon: arg.Icon, - Command: arg.Command, - Url: arg.Url, - External: arg.External, - Subdomain: arg.Subdomain, - SharingLevel: arg.SharingLevel, - HealthcheckUrl: arg.HealthcheckUrl, - HealthcheckInterval: arg.HealthcheckInterval, - HealthcheckThreshold: arg.HealthcheckThreshold, - Health: arg.Health, - Hidden: arg.Hidden, - DisplayOrder: arg.DisplayOrder, - OpenIn: arg.OpenIn, - DisplayGroup: arg.DisplayGroup, - } - q.workspaceApps = append(q.workspaceApps, workspaceApp) - return workspaceApp, nil -} - func (q *FakeQuerier) InsertWorkspaceAppStats(_ context.Context, arg database.InsertWorkspaceAppStatsParams) error { err := validateDatabaseType(arg) if err != nil { @@ -10102,7 +9991,6 @@ func (q *FakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.Inser MaxDeadline: arg.MaxDeadline, Reason: arg.Reason, TemplateVersionPresetID: arg.TemplateVersionPresetID, - HasAITask: arg.HasAITask, } q.workspaceBuilds = append(q.workspaceBuilds, workspaceBuild) return nil @@ -10379,7 +10267,6 @@ func (q *FakeQuerier) OrganizationMembers(_ context.Context, arg database.Organi continue } - organizationMember := organizationMember user, _ := q.getUserByIDNoLock(organizationMember.UserID) tmp = append(tmp, database.OrganizationMembersRow{ OrganizationMember: organizationMember, @@ -10635,27 +10522,6 @@ func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI return sql.ErrNoRows } -func (q *FakeQuerier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { - err := validateDatabaseType(arg) - if err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for i, chat := range q.chats { - if chat.ID == arg.ID { - q.chats[i].Title = arg.Title - q.chats[i].UpdatedAt = arg.UpdatedAt - q.chats[i] = chat - return nil - } - } - - return sql.ErrNoRows -} - func (q *FakeQuerier) UpdateCryptoKeyDeletesAt(_ context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { err := validateDatabaseType(arg) if err != nil { @@ -11308,6 +11174,26 @@ func (q *FakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database return sql.ErrNoRows } +func (q *FakeQuerier) UpdateTemplateVersionAITaskByJobID(_ context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, templateVersion := range q.templateVersions { + if templateVersion.JobID != arg.JobID { + continue + } + templateVersion.HasAITask = arg.HasAITask + templateVersion.UpdatedAt = arg.UpdatedAt + q.templateVersions[index] = templateVersion + return nil + } + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateTemplateVersionByID(_ context.Context, arg database.UpdateTemplateVersionByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err @@ -12003,6 +11889,35 @@ func (q *FakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.U return sql.ErrNoRows } +func (q *FakeQuerier) UpdateWorkspaceBuildAITaskByID(_ context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { + if arg.HasAITask.Bool && !arg.SidebarAppID.Valid { + return xerrors.Errorf("ai_task_sidebar_app_id is required when has_ai_task is true") + } + if !arg.HasAITask.Valid && arg.SidebarAppID.Valid { + return xerrors.Errorf("ai_task_sidebar_app_id is can only be set when has_ai_task is true") + } + + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspaceBuild := range q.workspaceBuilds { + if workspaceBuild.ID != arg.ID { + continue + } + workspaceBuild.HasAITask = arg.HasAITask + workspaceBuild.AITaskSidebarAppID = arg.SidebarAppID + workspaceBuild.UpdatedAt = dbtime.Now() + q.workspaceBuilds[index] = workspaceBuild + return nil + } + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateWorkspaceBuildCostByID(_ context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err @@ -13151,6 +13066,58 @@ func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg datab return psl, nil } +func (q *FakeQuerier) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.WorkspaceApp{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + if arg.SharingLevel == "" { + arg.SharingLevel = database.AppSharingLevelOwner + } + if arg.OpenIn == "" { + arg.OpenIn = database.WorkspaceAppOpenInSlimWindow + } + + buildApp := func(id uuid.UUID, createdAt time.Time) database.WorkspaceApp { + return database.WorkspaceApp{ + ID: id, + CreatedAt: createdAt, + AgentID: arg.AgentID, + Slug: arg.Slug, + DisplayName: arg.DisplayName, + Icon: arg.Icon, + Command: arg.Command, + Url: arg.Url, + External: arg.External, + Subdomain: arg.Subdomain, + SharingLevel: arg.SharingLevel, + HealthcheckUrl: arg.HealthcheckUrl, + HealthcheckInterval: arg.HealthcheckInterval, + HealthcheckThreshold: arg.HealthcheckThreshold, + Health: arg.Health, + Hidden: arg.Hidden, + DisplayOrder: arg.DisplayOrder, + OpenIn: arg.OpenIn, + DisplayGroup: arg.DisplayGroup, + } + } + + for i, app := range q.workspaceApps { + if app.ID == arg.ID { + q.workspaceApps[i] = buildApp(app.ID, app.CreatedAt) + return q.workspaceApps[i], nil + } + } + + workspaceApp := buildApp(arg.ID, arg.CreatedAt) + q.workspaceApps = append(q.workspaceApps, workspaceApp) + return workspaceApp, nil +} + func (q *FakeQuerier) UpsertWorkspaceAppAuditSession(_ context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) { err := validateDatabaseType(arg) if err != nil { @@ -13802,6 +13769,30 @@ func (q *FakeQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Cont return out, nil } +func (q *FakeQuerier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if prepared != nil { + // Call this to match the same function calls as the SQL implementation. + _, err := prepared.CompileToSQL(ctx, rbac.ConfigWithoutACL()) + if err != nil { + return nil, err + } + } + + filteredParameters := make([]database.WorkspaceBuildParameter, 0) + for _, buildID := range workspaceBuildIDs { + parameters, err := q.GetWorkspaceBuildParameters(ctx, buildID) + if err != nil { + return nil, err + } + filteredParameters = append(filteredParameters, parameters...) + } + + return filteredParameters, nil +} + func (q *FakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { if err := validateDatabaseType(arg); err != nil { return nil, err diff --git a/coderd/database/dbmem/dbmem_test.go b/coderd/database/dbmem/dbmem_test.go index 11d30e61a895d..c3df828b95c98 100644 --- a/coderd/database/dbmem/dbmem_test.go +++ b/coderd/database/dbmem/dbmem_test.go @@ -188,7 +188,6 @@ func TestProxyByHostname(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 0450776785d42..0d68d0c15e1be 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -249,13 +249,6 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return err } -func (m queryMetricsStore) DeleteChat(ctx context.Context, id uuid.UUID) error { - start := time.Now() - r0 := m.s.DeleteChat(ctx, id) - m.queryLatencies.WithLabelValues("DeleteChat").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteCoordinator(ctx, id) @@ -648,27 +641,6 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID return row, err } -func (m queryMetricsStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { - start := time.Now() - r0, r1 := m.s.GetChatByID(ctx, id) - m.queryLatencies.WithLabelValues("GetChatByID").Observe(time.Since(start).Seconds()) - return r0, r1 -} - -func (m queryMetricsStore) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { - start := time.Now() - r0, r1 := m.s.GetChatMessagesByChatID(ctx, chatID) - m.queryLatencies.WithLabelValues("GetChatMessagesByChatID").Observe(time.Since(start).Seconds()) - return r0, r1 -} - -func (m queryMetricsStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { - start := time.Now() - r0, r1 := m.s.GetChatsByOwnerID(ctx, ownerID) - m.queryLatencies.WithLabelValues("GetChatsByOwnerID").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetCoordinatorResumeTokenSigningKey(ctx) @@ -1873,6 +1845,13 @@ func (m queryMetricsStore) GetWorkspaceBuildParameters(ctx context.Context, work return params, err } +func (m queryMetricsStore) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIds) + m.queryLatencies.WithLabelValues("GetWorkspaceBuildParametersByBuildIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceBuildStatsByTemplates(ctx, since) @@ -2076,20 +2055,6 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse return log, err } -func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { - start := time.Now() - r0, r1 := m.s.InsertChat(ctx, arg) - m.queryLatencies.WithLabelValues("InsertChat").Observe(time.Since(start).Seconds()) - return r0, r1 -} - -func (m queryMetricsStore) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { - start := time.Now() - r0, r1 := m.s.InsertChatMessages(ctx, arg) - m.queryLatencies.WithLabelValues("InsertChatMessages").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.InsertCryptoKey(ctx, arg) @@ -2440,13 +2405,6 @@ func (m queryMetricsStore) InsertWorkspaceAgentStats(ctx context.Context, arg da return r0 } -func (m queryMetricsStore) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { - start := time.Now() - app, err := m.s.InsertWorkspaceApp(ctx, arg) - m.queryLatencies.WithLabelValues("InsertWorkspaceApp").Observe(time.Since(start).Seconds()) - return app, err -} - func (m queryMetricsStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { start := time.Now() r0 := m.s.InsertWorkspaceAppStats(ctx, arg) @@ -2622,13 +2580,6 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up return err } -func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { - start := time.Now() - r0 := m.s.UpdateChatByID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateChatByID").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.UpdateCryptoKeyDeletesAt(ctx, arg) @@ -2832,6 +2783,13 @@ func (m queryMetricsStore) UpdateTemplateScheduleByID(ctx context.Context, arg d return err } +func (m queryMetricsStore) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateVersionAITaskByJobID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateVersionAITaskByJobID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { start := time.Now() err := m.s.UpdateTemplateVersionByID(ctx, arg) @@ -3035,6 +2993,13 @@ func (m queryMetricsStore) UpdateWorkspaceAutostart(ctx context.Context, arg dat return err } +func (m queryMetricsStore) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspaceBuildAITaskByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildAITaskByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { start := time.Now() err := m.s.UpdateWorkspaceBuildCostByID(ctx, arg) @@ -3287,6 +3252,13 @@ func (m queryMetricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, ar return r0, r1 } +func (m queryMetricsStore) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + start := time.Now() + r0, r1 := m.s.UpsertWorkspaceApp(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertWorkspaceApp").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) { start := time.Now() r0, r1 := m.s.UpsertWorkspaceAppAuditSession(ctx, arg) @@ -3329,6 +3301,13 @@ func (m queryMetricsStore) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context return r0, r1 } +func (m queryMetricsStore) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + start := time.Now() + r0, r1 := m.s.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prepared) + m.queryLatencies.WithLabelValues("GetAuthorizedWorkspaceBuildParametersByBuildIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { start := time.Now() r0, r1 := m.s.GetAuthorizedUsers(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index dbd8f5ca0753c..03222782a5d68 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -376,20 +376,6 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } -// DeleteChat mocks base method. -func (m *MockStore) DeleteChat(ctx context.Context, id uuid.UUID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteChat", ctx, id) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteChat indicates an expected call of DeleteChat. -func (mr *MockStoreMockRecorder) DeleteChat(ctx, id any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChat", reflect.TypeOf((*MockStore)(nil).DeleteChat), ctx, id) -} - // DeleteCoordinator mocks base method. func (m *MockStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -1247,6 +1233,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedUsers(ctx, arg, prepared any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedUsers", reflect.TypeOf((*MockStore)(nil).GetAuthorizedUsers), ctx, arg, prepared) } +// GetAuthorizedWorkspaceBuildParametersByBuildIDs mocks base method. +func (m *MockStore) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuthorizedWorkspaceBuildParametersByBuildIDs", ctx, workspaceBuildIDs, prepared) + ret0, _ := ret[0].([]database.WorkspaceBuildParameter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAuthorizedWorkspaceBuildParametersByBuildIDs indicates an expected call of GetAuthorizedWorkspaceBuildParametersByBuildIDs. +func (mr *MockStoreMockRecorder) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspaceBuildParametersByBuildIDs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspaceBuildParametersByBuildIDs), ctx, workspaceBuildIDs, prepared) +} + // GetAuthorizedWorkspaces mocks base method. func (m *MockStore) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) { m.ctrl.T.Helper() @@ -1277,51 +1278,6 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared) } -// GetChatByID mocks base method. -func (m *MockStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatByID", ctx, id) - ret0, _ := ret[0].(database.Chat) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetChatByID indicates an expected call of GetChatByID. -func (mr *MockStoreMockRecorder) GetChatByID(ctx, id any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatByID", reflect.TypeOf((*MockStore)(nil).GetChatByID), ctx, id) -} - -// GetChatMessagesByChatID mocks base method. -func (m *MockStore) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatMessagesByChatID", ctx, chatID) - ret0, _ := ret[0].([]database.ChatMessage) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetChatMessagesByChatID indicates an expected call of GetChatMessagesByChatID. -func (mr *MockStoreMockRecorder) GetChatMessagesByChatID(ctx, chatID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessagesByChatID", reflect.TypeOf((*MockStore)(nil).GetChatMessagesByChatID), ctx, chatID) -} - -// GetChatsByOwnerID mocks base method. -func (m *MockStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatsByOwnerID", ctx, ownerID) - ret0, _ := ret[0].([]database.Chat) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetChatsByOwnerID indicates an expected call of GetChatsByOwnerID. -func (mr *MockStoreMockRecorder) GetChatsByOwnerID(ctx, ownerID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetChatsByOwnerID), ctx, ownerID) -} - // GetCoordinatorResumeTokenSigningKey mocks base method. func (m *MockStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -3932,6 +3888,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceBuildParameters(ctx, workspaceBuild return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildParameters", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildParameters), ctx, workspaceBuildID) } +// GetWorkspaceBuildParametersByBuildIDs mocks base method. +func (m *MockStore) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceBuildParametersByBuildIDs", ctx, workspaceBuildIds) + ret0, _ := ret[0].([]database.WorkspaceBuildParameter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceBuildParametersByBuildIDs indicates an expected call of GetWorkspaceBuildParametersByBuildIDs. +func (mr *MockStoreMockRecorder) GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildParametersByBuildIDs", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildParametersByBuildIDs), ctx, workspaceBuildIds) +} + // GetWorkspaceBuildStatsByTemplates mocks base method. func (m *MockStore) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { m.ctrl.T.Helper() @@ -4381,36 +4352,6 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg) } -// InsertChat mocks base method. -func (m *MockStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertChat", ctx, arg) - ret0, _ := ret[0].(database.Chat) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// InsertChat indicates an expected call of InsertChat. -func (mr *MockStoreMockRecorder) InsertChat(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChat", reflect.TypeOf((*MockStore)(nil).InsertChat), ctx, arg) -} - -// InsertChatMessages mocks base method. -func (m *MockStore) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertChatMessages", ctx, arg) - ret0, _ := ret[0].([]database.ChatMessage) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// InsertChatMessages indicates an expected call of InsertChatMessages. -func (mr *MockStoreMockRecorder) InsertChatMessages(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatMessages", reflect.TypeOf((*MockStore)(nil).InsertChatMessages), ctx, arg) -} - // InsertCryptoKey mocks base method. func (m *MockStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -5150,21 +5091,6 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAgentStats(ctx, arg any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAgentStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAgentStats), ctx, arg) } -// InsertWorkspaceApp mocks base method. -func (m *MockStore) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertWorkspaceApp", ctx, arg) - ret0, _ := ret[0].(database.WorkspaceApp) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// InsertWorkspaceApp indicates an expected call of InsertWorkspaceApp. -func (mr *MockStoreMockRecorder) InsertWorkspaceApp(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceApp), ctx, arg) -} - // InsertWorkspaceAppStats mocks base method. func (m *MockStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { m.ctrl.T.Helper() @@ -5560,20 +5486,6 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg) } -// UpdateChatByID mocks base method. -func (m *MockStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateChatByID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateChatByID indicates an expected call of UpdateChatByID. -func (mr *MockStoreMockRecorder) UpdateChatByID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatByID", reflect.TypeOf((*MockStore)(nil).UpdateChatByID), ctx, arg) -} - // UpdateCryptoKeyDeletesAt mocks base method. func (m *MockStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -5992,6 +5904,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateScheduleByID(ctx, arg any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateScheduleByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateScheduleByID), ctx, arg) } +// UpdateTemplateVersionAITaskByJobID mocks base method. +func (m *MockStore) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateVersionAITaskByJobID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateVersionAITaskByJobID indicates an expected call of UpdateTemplateVersionAITaskByJobID. +func (mr *MockStoreMockRecorder) UpdateTemplateVersionAITaskByJobID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionAITaskByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionAITaskByJobID), ctx, arg) +} + // UpdateTemplateVersionByID mocks base method. func (m *MockStore) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { m.ctrl.T.Helper() @@ -6410,6 +6336,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAutostart(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAutostart", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAutostart), ctx, arg) } +// UpdateWorkspaceBuildAITaskByID mocks base method. +func (m *MockStore) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceBuildAITaskByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspaceBuildAITaskByID indicates an expected call of UpdateWorkspaceBuildAITaskByID. +func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildAITaskByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildAITaskByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildAITaskByID), ctx, arg) +} + // UpdateWorkspaceBuildCostByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { m.ctrl.T.Helper() @@ -6924,6 +6864,21 @@ func (mr *MockStoreMockRecorder) UpsertWorkspaceAgentPortShare(ctx, arg any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAgentPortShare), ctx, arg) } +// UpsertWorkspaceApp mocks base method. +func (m *MockStore) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertWorkspaceApp", ctx, arg) + ret0, _ := ret[0].(database.WorkspaceApp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertWorkspaceApp indicates an expected call of UpsertWorkspaceApp. +func (mr *MockStoreMockRecorder) UpsertWorkspaceApp(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceApp), ctx, arg) +} + // UpsertWorkspaceAppAuditSession mocks base method. func (m *MockStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dbtestutil/postgres.go b/coderd/database/dbtestutil/postgres.go index e282da583a43b..c1cfa383577de 100644 --- a/coderd/database/dbtestutil/postgres.go +++ b/coderd/database/dbtestutil/postgres.go @@ -45,6 +45,13 @@ var ( connectionParamsInitOnce sync.Once defaultConnectionParams ConnectionParams errDefaultConnectionParamsInit error + retryableErrSubstrings = []string{ + "connection reset by peer", + } + noPostgresRunningErrSubstrings = []string{ + "connection refused", // nothing is listening on the port + "No connection could be made", // Windows variant of the above + } ) // initDefaultConnection initializes the default postgres connection parameters. @@ -59,28 +66,38 @@ func initDefaultConnection(t TBSubset) error { DBName: "postgres", } dsn := params.DSN() - db, dbErr := sql.Open("postgres", dsn) - if dbErr == nil { - dbErr = db.Ping() - if closeErr := db.Close(); closeErr != nil { - return xerrors.Errorf("close db: %w", closeErr) + + // Helper closure to try opening and pinging the default Postgres instance. + // Used within a single retry loop that handles both retryable and permanent errors. + attemptConn := func() error { + db, err := sql.Open("postgres", dsn) + if err == nil { + err = db.Ping() + if closeErr := db.Close(); closeErr != nil { + return xerrors.Errorf("close db: %w", closeErr) + } } + return err } - shouldOpenContainer := false - if dbErr != nil { - errSubstrings := []string{ - "connection refused", // this happens on Linux when there's nothing listening on the port - "No connection could be made", // like above but Windows + + var dbErr error + // Retry up to 3 seconds for temporary errors. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for r := retry.New(10*time.Millisecond, 500*time.Millisecond); r.Wait(ctx); { + dbErr = attemptConn() + if dbErr == nil { + break } errString := dbErr.Error() - for _, errSubstring := range errSubstrings { - if strings.Contains(errString, errSubstring) { - shouldOpenContainer = true - break - } + if !containsAnySubstring(errString, retryableErrSubstrings) { + break } + t.Logf("failed to connect to postgres, retrying: %s", errString) } - if dbErr != nil && shouldOpenContainer { + + // After the loop dbErr is the last connection error (if any). + if dbErr != nil && containsAnySubstring(dbErr.Error(), noPostgresRunningErrSubstrings) { // If there's no database running on the default port, we'll start a // postgres container. We won't be cleaning it up so it can be reused // by subsequent tests. It'll keep on running until the user terminates @@ -110,6 +127,7 @@ func initDefaultConnection(t TBSubset) error { if connErr == nil { break } + t.Logf("failed to connect to postgres after starting container, may retry: %s", connErr.Error()) } } else if dbErr != nil { return xerrors.Errorf("open postgres connection: %w", dbErr) @@ -523,3 +541,12 @@ func OpenContainerized(t TBSubset, opts DBContainerOptions) (string, func(), err return dbURL, containerCleanup, nil } + +func containsAnySubstring(s string, substrings []string) bool { + for _, substr := range substrings { + if strings.Contains(s, substr) { + return true + } + } + return false +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2a94ef0fe7b4e..480780c5fb556 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -324,7 +324,8 @@ CREATE TYPE workspace_app_open_in AS ENUM ( CREATE TYPE workspace_app_status_state AS ENUM ( 'working', 'complete', - 'failure' + 'failure', + 'idle' ); CREATE TYPE workspace_transition AS ENUM ( @@ -821,32 +822,6 @@ CREATE TABLE audit_logs ( resource_icon text NOT NULL ); -CREATE TABLE chat_messages ( - id bigint NOT NULL, - chat_id uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - model text NOT NULL, - provider text NOT NULL, - content jsonb NOT NULL -); - -CREATE SEQUENCE chat_messages_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE chat_messages_id_seq OWNED BY chat_messages.id; - -CREATE TABLE chats ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - owner_id uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - title text NOT NULL -); - CREATE TABLE crypto_keys ( feature crypto_key_feature NOT NULL, sequence integer NOT NULL, @@ -1512,7 +1487,8 @@ CREATE TABLE template_version_presets ( desired_instances integer, invalidate_after_secs integer DEFAULT 0, prebuild_status prebuild_status DEFAULT 'healthy'::prebuild_status NOT NULL, - scheduling_timezone text DEFAULT ''::text NOT NULL + scheduling_timezone text DEFAULT ''::text NOT NULL, + is_default boolean DEFAULT false NOT NULL ); CREATE TABLE template_version_terraform_values ( @@ -2097,7 +2073,8 @@ CREATE TABLE workspace_builds ( max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, template_version_preset_id uuid, has_ai_task boolean, - ai_tasks_sidebar_app_id uuid + ai_task_sidebar_app_id uuid, + CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))) ); CREATE VIEW workspace_build_with_user AS @@ -2117,7 +2094,7 @@ CREATE VIEW workspace_build_with_user AS workspace_builds.max_deadline, workspace_builds.template_version_preset_id, workspace_builds.has_ai_task, - workspace_builds.ai_tasks_sidebar_app_id, + workspace_builds.ai_task_sidebar_app_id, COALESCE(visible_users.avatar_url, ''::text) AS initiator_by_avatar_url, COALESCE(visible_users.username, ''::text) AS initiator_by_username, COALESCE(visible_users.name, ''::text) AS initiator_by_name @@ -2339,8 +2316,6 @@ CREATE VIEW workspaces_expanded AS COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.'; -ALTER TABLE ONLY chat_messages ALTER COLUMN id SET DEFAULT nextval('chat_messages_id_seq'::regclass); - ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass); ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass); @@ -2362,12 +2337,6 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); -ALTER TABLE ONLY chat_messages - ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); - -ALTER TABLE ONLY chats - ADD CONSTRAINT chats_pkey PRIMARY KEY (id); - ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); @@ -2689,6 +2658,8 @@ CREATE INDEX idx_tailnet_tunnels_dst_id ON tailnet_tunnels USING hash (dst_id); CREATE INDEX idx_tailnet_tunnels_src_id ON tailnet_tunnels USING hash (src_id); +CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true); + CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree (has_ai_task); CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); @@ -2862,12 +2833,6 @@ forward without requiring a migration to clean up historical data.'; ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE ONLY chat_messages - ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - -ALTER TABLE ONLY chats - ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; - ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); @@ -3091,7 +3056,7 @@ ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; ALTER TABLE ONLY workspace_builds - ADD CONSTRAINT workspace_builds_ai_tasks_sidebar_app_id_fkey FOREIGN KEY (ai_tasks_sidebar_app_id) REFERENCES workspace_apps(id); + ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id); ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index ea1ffaf4c8064..5be75d07288e6 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -7,8 +7,6 @@ type ForeignKeyConstraint string // ForeignKeyConstraint enums. const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); @@ -83,7 +81,7 @@ const ( ForeignKeyWorkspaceAppStatusesWorkspaceID ForeignKeyConstraint = "workspace_app_statuses_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); ForeignKeyWorkspaceAppsAgentID ForeignKeyConstraint = "workspace_apps_agent_id_fkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildParametersWorkspaceBuildID ForeignKeyConstraint = "workspace_build_parameters_workspace_build_id_fkey" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildsAiTasksSidebarAppID ForeignKeyConstraint = "workspace_builds_ai_tasks_sidebar_app_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_ai_tasks_sidebar_app_id_fkey FOREIGN KEY (ai_tasks_sidebar_app_id) REFERENCES workspace_apps(id); + ForeignKeyWorkspaceBuildsAiTaskSidebarAppID ForeignKeyConstraint = "workspace_builds_ai_task_sidebar_app_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id); ForeignKeyWorkspaceBuildsJobID ForeignKeyConstraint = "workspace_builds_job_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildsTemplateVersionID ForeignKeyConstraint = "workspace_builds_template_version_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildsTemplateVersionPresetID ForeignKeyConstraint = "workspace_builds_template_version_preset_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE SET NULL; diff --git a/coderd/database/migrations/000340_workspace_app_status_idle.down.sql b/coderd/database/migrations/000340_workspace_app_status_idle.down.sql new file mode 100644 index 0000000000000..a5d2095b1cd4a --- /dev/null +++ b/coderd/database/migrations/000340_workspace_app_status_idle.down.sql @@ -0,0 +1,15 @@ +-- It is not possible to delete a value from an enum, so we have to recreate it. +CREATE TYPE old_workspace_app_status_state AS ENUM ('working', 'complete', 'failure'); + +-- Convert the new "idle" state into "complete". This means we lose some +-- information when downgrading, but this is necessary to swap to the old enum. +UPDATE workspace_app_statuses SET state = 'complete' WHERE state = 'idle'; + +-- Swap to the old enum. +ALTER TABLE workspace_app_statuses +ALTER COLUMN state TYPE old_workspace_app_status_state +USING (state::text::old_workspace_app_status_state); + +-- Drop the new enum and rename the old one to the final name. +DROP TYPE workspace_app_status_state; +ALTER TYPE old_workspace_app_status_state RENAME TO workspace_app_status_state; diff --git a/coderd/database/migrations/000340_workspace_app_status_idle.up.sql b/coderd/database/migrations/000340_workspace_app_status_idle.up.sql new file mode 100644 index 0000000000000..1630e3580f45c --- /dev/null +++ b/coderd/database/migrations/000340_workspace_app_status_idle.up.sql @@ -0,0 +1 @@ +ALTER TYPE workspace_app_status_state ADD VALUE IF NOT EXISTS 'idle'; diff --git a/coderd/database/migrations/000341_template_version_preset_default.down.sql b/coderd/database/migrations/000341_template_version_preset_default.down.sql new file mode 100644 index 0000000000000..a48a6dc44bab8 --- /dev/null +++ b/coderd/database/migrations/000341_template_version_preset_default.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_template_version_presets_default; +ALTER TABLE template_version_presets DROP COLUMN IF EXISTS is_default; \ No newline at end of file diff --git a/coderd/database/migrations/000341_template_version_preset_default.up.sql b/coderd/database/migrations/000341_template_version_preset_default.up.sql new file mode 100644 index 0000000000000..9a58d0b7dd778 --- /dev/null +++ b/coderd/database/migrations/000341_template_version_preset_default.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE template_version_presets ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE; + +-- Add a unique constraint to ensure only one default preset per template version +CREATE UNIQUE INDEX idx_template_version_presets_default +ON template_version_presets (template_version_id) +WHERE is_default = TRUE; \ No newline at end of file diff --git a/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.down.sql b/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.down.sql new file mode 100644 index 0000000000000..613e17ed20933 --- /dev/null +++ b/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.down.sql @@ -0,0 +1,52 @@ +-- Drop the check constraint first +ALTER TABLE workspace_builds DROP CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required; + +-- Revert ai_task_sidebar_app_id back to ai_tasks_sidebar_app_id in workspace_builds table +ALTER TABLE workspace_builds DROP CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey; + +ALTER TABLE workspace_builds RENAME COLUMN ai_task_sidebar_app_id TO ai_tasks_sidebar_app_id; + +ALTER TABLE workspace_builds ADD CONSTRAINT workspace_builds_ai_tasks_sidebar_app_id_fkey FOREIGN KEY (ai_tasks_sidebar_app_id) REFERENCES workspace_apps(id); + +-- Revert the workspace_build_with_user view to use the original column name +DROP VIEW workspace_build_with_user; + +CREATE VIEW workspace_build_with_user AS +SELECT + workspace_builds.id, + workspace_builds.created_at, + workspace_builds.updated_at, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.build_number, + workspace_builds.transition, + workspace_builds.initiator_id, + workspace_builds.provisioner_state, + workspace_builds.job_id, + workspace_builds.deadline, + workspace_builds.reason, + workspace_builds.daily_cost, + workspace_builds.max_deadline, + workspace_builds.template_version_preset_id, + workspace_builds.has_ai_task, + workspace_builds.ai_tasks_sidebar_app_id, + COALESCE( + visible_users.avatar_url, + '' :: text + ) AS initiator_by_avatar_url, + COALESCE( + visible_users.username, + '' :: text + ) AS initiator_by_username, + COALESCE(visible_users.name, '' :: text) AS initiator_by_name +FROM + ( + workspace_builds + LEFT JOIN visible_users ON ( + ( + workspace_builds.initiator_id = visible_users.id + ) + ) + ); + +COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; diff --git a/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.up.sql b/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.up.sql new file mode 100644 index 0000000000000..3577b9396b0df --- /dev/null +++ b/coderd/database/migrations/000342_ai_task_sidebar_app_id_column_and_constraint.up.sql @@ -0,0 +1,66 @@ +-- Rename ai_tasks_sidebar_app_id to ai_task_sidebar_app_id in workspace_builds table +ALTER TABLE workspace_builds DROP CONSTRAINT workspace_builds_ai_tasks_sidebar_app_id_fkey; + +ALTER TABLE workspace_builds RENAME COLUMN ai_tasks_sidebar_app_id TO ai_task_sidebar_app_id; + +ALTER TABLE workspace_builds ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id); + +-- if has_ai_task is true, ai_task_sidebar_app_id MUST be set +-- ai_task_sidebar_app_id can ONLY be set if has_ai_task is true +-- +-- has_ai_task | ai_task_sidebar_app_id | Result +-- ------------|------------------------|--------------- +-- NULL | NULL | TRUE (passes) +-- NULL | NOT NULL | FALSE (fails) +-- FALSE | NULL | TRUE (passes) +-- FALSE | NOT NULL | FALSE (fails) +-- TRUE | NULL | FALSE (fails) +-- TRUE | NOT NULL | TRUE (passes) +ALTER TABLE workspace_builds + ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK ( + ((has_ai_task IS NULL OR has_ai_task = false) AND ai_task_sidebar_app_id IS NULL) + OR (has_ai_task = true AND ai_task_sidebar_app_id IS NOT NULL) + ); + +-- Update the workspace_build_with_user view to use the new column name +DROP VIEW workspace_build_with_user; + +CREATE VIEW workspace_build_with_user AS +SELECT + workspace_builds.id, + workspace_builds.created_at, + workspace_builds.updated_at, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.build_number, + workspace_builds.transition, + workspace_builds.initiator_id, + workspace_builds.provisioner_state, + workspace_builds.job_id, + workspace_builds.deadline, + workspace_builds.reason, + workspace_builds.daily_cost, + workspace_builds.max_deadline, + workspace_builds.template_version_preset_id, + workspace_builds.has_ai_task, + workspace_builds.ai_task_sidebar_app_id, + COALESCE( + visible_users.avatar_url, + '' :: text + ) AS initiator_by_avatar_url, + COALESCE( + visible_users.username, + '' :: text + ) AS initiator_by_username, + COALESCE(visible_users.name, '' :: text) AS initiator_by_name +FROM + ( + workspace_builds + LEFT JOIN visible_users ON ( + ( + workspace_builds.initiator_id = visible_users.id + ) + ) + ); + +COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; diff --git a/coderd/database/migrations/000343_delete_chats.down.sql b/coderd/database/migrations/000343_delete_chats.down.sql new file mode 100644 index 0000000000000..1fcd659ca64af --- /dev/null +++ b/coderd/database/migrations/000343_delete_chats.down.sql @@ -0,0 +1 @@ +-- noop diff --git a/coderd/database/migrations/000343_delete_chats.up.sql b/coderd/database/migrations/000343_delete_chats.up.sql new file mode 100644 index 0000000000000..53453647d583f --- /dev/null +++ b/coderd/database/migrations/000343_delete_chats.up.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS chat_messages; +DROP TABLE IF EXISTS chats; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index cd843bd97aa7a..f5d84e6532083 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -288,8 +288,6 @@ func TestMigrateUpWithFixtures(t *testing.T) { }) for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index b3f6deed9eff0..f4ddd906823a8 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -199,6 +199,13 @@ func (gm GroupMember) RBACObject() rbac.Object { return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String()) } +// PrebuiltWorkspaceResource defines the interface for types that can be identified as prebuilt workspaces +// and converted to their corresponding prebuilt workspace RBAC object. +type PrebuiltWorkspaceResource interface { + IsPrebuild() bool + AsPrebuild() rbac.Object +} + // WorkspaceTable converts a Workspace to it's reduced version. // A more generalized solution is to use json marshaling to // consistently keep these two structs in sync. @@ -229,6 +236,24 @@ func (w Workspace) RBACObject() rbac.Object { return w.WorkspaceTable().RBACObject() } +// IsPrebuild returns true if the workspace is a prebuild workspace. +// A workspace is considered a prebuild if its owner is the prebuild system user. +func (w Workspace) IsPrebuild() bool { + return w.OwnerID == PrebuildsSystemUserID +} + +// AsPrebuild returns the RBAC object corresponding to the workspace type. +// If the workspace is a prebuild, it returns a prebuilt_workspace RBAC object. +// Otherwise, it returns a normal workspace RBAC object. +func (w Workspace) AsPrebuild() rbac.Object { + if w.IsPrebuild() { + return rbac.ResourcePrebuiltWorkspace.WithID(w.ID). + InOrg(w.OrganizationID). + WithOwner(w.OwnerID.String()) + } + return w.RBACObject() +} + func (w WorkspaceTable) RBACObject() rbac.Object { if w.DormantAt.Valid { return w.DormantRBAC() @@ -246,6 +271,24 @@ func (w WorkspaceTable) DormantRBAC() rbac.Object { WithOwner(w.OwnerID.String()) } +// IsPrebuild returns true if the workspace is a prebuild workspace. +// A workspace is considered a prebuild if its owner is the prebuild system user. +func (w WorkspaceTable) IsPrebuild() bool { + return w.OwnerID == PrebuildsSystemUserID +} + +// AsPrebuild returns the RBAC object corresponding to the workspace type. +// If the workspace is a prebuild, it returns a prebuilt_workspace RBAC object. +// Otherwise, it returns a normal workspace RBAC object. +func (w WorkspaceTable) AsPrebuild() rbac.Object { + if w.IsPrebuild() { + return rbac.ResourcePrebuiltWorkspace.WithID(w.ID). + InOrg(w.OrganizationID). + WithOwner(w.OwnerID.String()) + } + return w.RBACObject() +} + func (m OrganizationMember) RBACObject() rbac.Object { return rbac.ResourceOrganizationMember. WithID(m.UserID). @@ -568,8 +611,3 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce( return m.DebouncedUntil, false } - -func (c Chat) RBACObject() rbac.Object { - return rbac.ResourceChat.WithID(c.ID). - WithOwner(c.OwnerID.String()) -} diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 931412204d780..eaf73af07f6d5 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -226,6 +226,7 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([ type workspaceQuerier interface { GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prepared rbac.PreparedAuthorized) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) + GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]WorkspaceBuildParameter, error) } // GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. @@ -372,6 +373,35 @@ func (q *sqlQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Conte return items, nil } +func (q *sqlQuerier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]WorkspaceBuildParameter, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWorkspaces()) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + + filtered, err := insertAuthorizedFilter(getWorkspaceBuildParametersByBuildIDs, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceBuildParametersByBuildIDs :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query, pq.Array(workspaceBuildIDs)) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []WorkspaceBuildParameter + for rows.Next() { + var i WorkspaceBuildParameter + if err := rows.Scan(&i.WorkspaceBuildID, &i.Name, &i.Value); err != nil { + return nil, err + } + items = append(items, i) + } + return items, nil +} + type userQuerier interface { GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, prepared rbac.PreparedAuthorized) ([]GetUsersRow, error) } diff --git a/coderd/database/models.go b/coderd/database/models.go index 6a571ffc1d0d4..634b5dcd4116d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2628,6 +2628,7 @@ const ( WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working" WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete" WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure" + WorkspaceAppStatusStateIdle WorkspaceAppStatusState = "idle" ) func (e *WorkspaceAppStatusState) Scan(src interface{}) error { @@ -2669,7 +2670,8 @@ func (e WorkspaceAppStatusState) Valid() bool { switch e { case WorkspaceAppStatusStateWorking, WorkspaceAppStatusStateComplete, - WorkspaceAppStatusStateFailure: + WorkspaceAppStatusStateFailure, + WorkspaceAppStatusStateIdle: return true } return false @@ -2680,6 +2682,7 @@ func AllWorkspaceAppStatusStateValues() []WorkspaceAppStatusState { WorkspaceAppStatusStateWorking, WorkspaceAppStatusStateComplete, WorkspaceAppStatusStateFailure, + WorkspaceAppStatusStateIdle, } } @@ -2778,23 +2781,6 @@ type AuditLog struct { ResourceIcon string `db:"resource_icon" json:"resource_icon"` } -type Chat struct { - ID uuid.UUID `db:"id" json:"id"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Title string `db:"title" json:"title"` -} - -type ChatMessage struct { - ID int64 `db:"id" json:"id"` - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - Model string `db:"model" json:"model"` - Provider string `db:"provider" json:"provider"` - Content json.RawMessage `db:"content" json:"content"` -} - type CryptoKey struct { Feature CryptoKeyFeature `db:"feature" json:"feature"` Sequence int32 `db:"sequence" json:"sequence"` @@ -3411,6 +3397,7 @@ type TemplateVersionPreset struct { InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"` SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"` + IsDefault bool `db:"is_default" json:"is_default"` } type TemplateVersionPresetParameter struct { @@ -3861,7 +3848,7 @@ type WorkspaceBuild struct { MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - AITasksSidebarAppID uuid.NullUUID `db:"ai_tasks_sidebar_app_id" json:"ai_tasks_sidebar_app_id"` + AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"` InitiatorByAvatarUrl string `db:"initiator_by_avatar_url" json:"initiator_by_avatar_url"` InitiatorByUsername string `db:"initiator_by_username" json:"initiator_by_username"` InitiatorByName string `db:"initiator_by_name" json:"initiator_by_name"` @@ -3892,7 +3879,7 @@ type WorkspaceBuildTable struct { MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - AITasksSidebarAppID uuid.NullUUID `db:"ai_tasks_sidebar_app_id" json:"ai_tasks_sidebar_app_id"` + AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"` } type WorkspaceLatestBuild struct { diff --git a/coderd/database/pubsub/pubsub_memory.go b/coderd/database/pubsub/pubsub_memory.go index c4766c3dfa3fb..59a5730ff9808 100644 --- a/coderd/database/pubsub/pubsub_memory.go +++ b/coderd/database/pubsub/pubsub_memory.go @@ -73,7 +73,6 @@ func (m *MemoryPubsub) Publish(event string, message []byte) error { var wg sync.WaitGroup for _, listener := range listeners { wg.Add(1) - listener := listener go func() { defer wg.Done() listener.send(context.Background(), message) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4cfd0d1c4da5f..b1c13d31ceb6d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -79,7 +79,6 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error - DeleteChat(ctx context.Context, id uuid.UUID) error DeleteCoordinator(ctx context.Context, id uuid.UUID) error DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error) DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error @@ -154,9 +153,6 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) - GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) - GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) - GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) @@ -428,6 +424,7 @@ type sqlcQuerier interface { GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceBuildParameter, error) + GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]WorkspaceBuildParameter, error) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]GetWorkspaceBuildStatsByTemplatesRow, error) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuild, error) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) @@ -471,8 +468,6 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) - InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) - InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error) InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error) InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error @@ -530,7 +525,6 @@ type sqlcQuerier interface { InsertWorkspaceAgentScriptTimings(ctx context.Context, arg InsertWorkspaceAgentScriptTimingsParams) (WorkspaceAgentScriptTiming, error) InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error - InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error @@ -567,7 +561,6 @@ type sqlcQuerier interface { UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error - UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) error UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error) UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) @@ -597,6 +590,7 @@ type sqlcQuerier interface { UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error + UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error @@ -626,6 +620,7 @@ type sqlcQuerier interface { UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error + UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error @@ -671,6 +666,7 @@ type sqlcQuerier interface { UpsertTemplateUsageStats(ctx context.Context) error UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) + UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error) // // The returned boolean, new_or_stale, can be used to deduce if a new session // was started. This means that a new row was inserted (no previous session) or diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 74ac5b0a20caf..5ae43138bd634 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -27,7 +27,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/migrations" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -1157,7 +1156,6 @@ func TestProxyByHostname(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -1395,7 +1393,6 @@ func TestGetUsers_IncludeSystem(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -1418,7 +1415,7 @@ func TestGetUsers_IncludeSystem(t *testing.T) { for _, u := range users { if u.IsSystem { foundSystemUser = true - require.Equal(t, prebuilds.SystemUserID, u.ID) + require.Equal(t, database.PrebuildsSystemUserID, u.ID) } else { foundRegularUser = true require.Equalf(t, other.ID.String(), u.ID.String(), "found unexpected regular user") @@ -1863,8 +1860,6 @@ func TestReadCustomRoles(t *testing.T) { } for _, tc := range testCases { - tc := tc - t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -2507,7 +2502,6 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) { } for _, tc := range testCases { - tc := tc // Capture loop variable to avoid data races t.Run(tc.name, func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -2948,7 +2942,6 @@ func TestGetUserStatusCounts(t *testing.T) { } for _, tz := range timezones { - tz := tz t.Run(tz, func(t *testing.T) { t.Parallel() @@ -2996,7 +2989,6 @@ func TestGetUserStatusCounts(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -3164,7 +3156,6 @@ func TestGetUserStatusCounts(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -3297,7 +3288,6 @@ func TestGetUserStatusCounts(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fe32851f0e002..733b42db7a461 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -766,207 +766,6 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam return i, err } -const deleteChat = `-- name: DeleteChat :exec -DELETE FROM chats WHERE id = $1 -` - -func (q *sqlQuerier) DeleteChat(ctx context.Context, id uuid.UUID) error { - _, err := q.db.ExecContext(ctx, deleteChat, id) - return err -} - -const getChatByID = `-- name: GetChatByID :one -SELECT id, owner_id, created_at, updated_at, title FROM chats -WHERE id = $1 -` - -func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) { - row := q.db.QueryRowContext(ctx, getChatByID, id) - var i Chat - err := row.Scan( - &i.ID, - &i.OwnerID, - &i.CreatedAt, - &i.UpdatedAt, - &i.Title, - ) - return i, err -} - -const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many -SELECT id, chat_id, created_at, model, provider, content FROM chat_messages -WHERE chat_id = $1 -ORDER BY created_at ASC -` - -func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) { - rows, err := q.db.QueryContext(ctx, getChatMessagesByChatID, chatID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ChatMessage - for rows.Next() { - var i ChatMessage - if err := rows.Scan( - &i.ID, - &i.ChatID, - &i.CreatedAt, - &i.Model, - &i.Provider, - &i.Content, - ); 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 getChatsByOwnerID = `-- name: GetChatsByOwnerID :many -SELECT id, owner_id, created_at, updated_at, title FROM chats -WHERE owner_id = $1 -ORDER BY created_at DESC -` - -func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) { - rows, err := q.db.QueryContext(ctx, getChatsByOwnerID, ownerID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Chat - for rows.Next() { - var i Chat - if err := rows.Scan( - &i.ID, - &i.OwnerID, - &i.CreatedAt, - &i.UpdatedAt, - &i.Title, - ); 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 insertChat = `-- name: InsertChat :one -INSERT INTO chats (owner_id, created_at, updated_at, title) -VALUES ($1, $2, $3, $4) -RETURNING id, owner_id, created_at, updated_at, title -` - -type InsertChatParams struct { - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Title string `db:"title" json:"title"` -} - -func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) { - row := q.db.QueryRowContext(ctx, insertChat, - arg.OwnerID, - arg.CreatedAt, - arg.UpdatedAt, - arg.Title, - ) - var i Chat - err := row.Scan( - &i.ID, - &i.OwnerID, - &i.CreatedAt, - &i.UpdatedAt, - &i.Title, - ) - return i, err -} - -const insertChatMessages = `-- name: InsertChatMessages :many -INSERT INTO chat_messages (chat_id, created_at, model, provider, content) -SELECT - $1 :: uuid AS chat_id, - $2 :: timestamptz AS created_at, - $3 :: VARCHAR(127) AS model, - $4 :: VARCHAR(127) AS provider, - jsonb_array_elements($5 :: jsonb) AS content -RETURNING chat_messages.id, chat_messages.chat_id, chat_messages.created_at, chat_messages.model, chat_messages.provider, chat_messages.content -` - -type InsertChatMessagesParams struct { - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - Model string `db:"model" json:"model"` - Provider string `db:"provider" json:"provider"` - Content json.RawMessage `db:"content" json:"content"` -} - -func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) { - rows, err := q.db.QueryContext(ctx, insertChatMessages, - arg.ChatID, - arg.CreatedAt, - arg.Model, - arg.Provider, - arg.Content, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ChatMessage - for rows.Next() { - var i ChatMessage - if err := rows.Scan( - &i.ID, - &i.ChatID, - &i.CreatedAt, - &i.Model, - &i.Provider, - &i.Content, - ); 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 updateChatByID = `-- name: UpdateChatByID :exec -UPDATE chats -SET title = $2, updated_at = $3 -WHERE id = $1 -` - -type UpdateChatByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - Title string `db:"title" json:"title"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` -} - -func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) error { - _, err := q.db.ExecContext(ctx, updateChatByID, arg.ID, arg.Title, arg.UpdatedAt) - return err -} - const deleteCryptoKey = `-- name: DeleteCryptoKey :one UPDATE crypto_keys SET secret = NULL, secret_key_id = NULL @@ -6629,7 +6428,7 @@ func (q *sqlQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]Te } const getPresetByID = `-- name: GetPresetByID :one -SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tvp.scheduling_timezone, tv.template_id, tv.organization_id FROM +SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tvp.scheduling_timezone, tvp.is_default, tv.template_id, tv.organization_id FROM template_version_presets tvp INNER JOIN template_versions tv ON tvp.template_version_id = tv.id WHERE tvp.id = $1 @@ -6644,6 +6443,7 @@ type GetPresetByIDRow struct { InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"` SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"` + IsDefault bool `db:"is_default" json:"is_default"` TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } @@ -6660,6 +6460,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get &i.InvalidateAfterSecs, &i.PrebuildStatus, &i.SchedulingTimezone, + &i.IsDefault, &i.TemplateID, &i.OrganizationID, ) @@ -6668,7 +6469,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get const getPresetByWorkspaceBuildID = `-- name: GetPresetByWorkspaceBuildID :one SELECT - template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status, template_version_presets.scheduling_timezone + template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status, template_version_presets.scheduling_timezone, template_version_presets.is_default FROM template_version_presets INNER JOIN workspace_builds ON workspace_builds.template_version_preset_id = template_version_presets.id @@ -6688,6 +6489,7 @@ func (q *sqlQuerier) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceB &i.InvalidateAfterSecs, &i.PrebuildStatus, &i.SchedulingTimezone, + &i.IsDefault, ) return i, err } @@ -6769,7 +6571,7 @@ func (q *sqlQuerier) GetPresetParametersByTemplateVersionID(ctx context.Context, const getPresetsByTemplateVersionID = `-- name: GetPresetsByTemplateVersionID :many SELECT - id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone + id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone, is_default FROM template_version_presets WHERE @@ -6794,6 +6596,7 @@ func (q *sqlQuerier) GetPresetsByTemplateVersionID(ctx context.Context, template &i.InvalidateAfterSecs, &i.PrebuildStatus, &i.SchedulingTimezone, + &i.IsDefault, ); err != nil { return nil, err } @@ -6816,7 +6619,8 @@ INSERT INTO template_version_presets ( created_at, desired_instances, invalidate_after_secs, - scheduling_timezone + scheduling_timezone, + is_default ) VALUES ( $1, @@ -6825,8 +6629,9 @@ VALUES ( $4, $5, $6, - $7 -) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone + $7, + $8 +) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone, is_default ` type InsertPresetParams struct { @@ -6837,6 +6642,7 @@ type InsertPresetParams struct { DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"` InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"` + IsDefault bool `db:"is_default" json:"is_default"` } func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (TemplateVersionPreset, error) { @@ -6848,6 +6654,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) ( arg.DesiredInstances, arg.InvalidateAfterSecs, arg.SchedulingTimezone, + arg.IsDefault, ) var i TemplateVersionPreset err := row.Scan( @@ -6859,6 +6666,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) ( &i.InvalidateAfterSecs, &i.PrebuildStatus, &i.SchedulingTimezone, + &i.IsDefault, ) return i, err } @@ -11917,11 +11725,10 @@ INSERT INTO readme, job_id, created_by, - source_example_id, - has_ai_task + source_example_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ` type InsertTemplateVersionParams struct { @@ -11936,7 +11743,6 @@ type InsertTemplateVersionParams struct { JobID uuid.UUID `db:"job_id" json:"job_id"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` } func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) error { @@ -11952,7 +11758,6 @@ func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTempla arg.JobID, arg.CreatedBy, arg.SourceExampleID, - arg.HasAITask, ) return err } @@ -11978,6 +11783,27 @@ func (q *sqlQuerier) UnarchiveTemplateVersion(ctx context.Context, arg Unarchive return err } +const updateTemplateVersionAITaskByJobID = `-- name: UpdateTemplateVersionAITaskByJobID :exec +UPDATE + template_versions +SET + has_ai_task = $2, + updated_at = $3 +WHERE + job_id = $1 +` + +type UpdateTemplateVersionAITaskByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionAITaskByJobID, arg.JobID, arg.HasAITask, arg.UpdatedAt) + return err +} + const updateTemplateVersionByID = `-- name: UpdateTemplateVersionByID :exec UPDATE template_versions @@ -14303,7 +14129,7 @@ const getWorkspaceAgentAndLatestBuildByAuthToken = `-- name: GetWorkspaceAgentAn SELECT workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted, - workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_tasks_sidebar_app_id, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name + workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_task_sidebar_app_id, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name FROM workspace_agents JOIN @@ -14413,7 +14239,7 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont &i.WorkspaceBuild.MaxDeadline, &i.WorkspaceBuild.TemplateVersionPresetID, &i.WorkspaceBuild.HasAITask, - &i.WorkspaceBuild.AITasksSidebarAppID, + &i.WorkspaceBuild.AITaskSidebarAppID, &i.WorkspaceBuild.InitiatorByAvatarUrl, &i.WorkspaceBuild.InitiatorByUsername, &i.WorkspaceBuild.InitiatorByName, @@ -16673,7 +16499,68 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt return items, nil } -const insertWorkspaceApp = `-- name: InsertWorkspaceApp :one +const insertWorkspaceAppStatus = `-- name: InsertWorkspaceAppStatus :one +INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, uri) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, created_at, agent_id, app_id, workspace_id, state, message, uri +` + +type InsertWorkspaceAppStatusParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + State WorkspaceAppStatusState `db:"state" json:"state"` + Message string `db:"message" json:"message"` + Uri sql.NullString `db:"uri" json:"uri"` +} + +func (q *sqlQuerier) InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceAppStatus, + arg.ID, + arg.CreatedAt, + arg.WorkspaceID, + arg.AgentID, + arg.AppID, + arg.State, + arg.Message, + arg.Uri, + ) + var i WorkspaceAppStatus + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.Message, + &i.Uri, + ) + return i, err +} + +const updateWorkspaceAppHealthByID = `-- name: UpdateWorkspaceAppHealthByID :exec +UPDATE + workspace_apps +SET + health = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceAppHealthByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + Health WorkspaceAppHealth `db:"health" json:"health"` +} + +func (q *sqlQuerier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAppHealthByID, arg.ID, arg.Health) + return err +} + +const upsertWorkspaceApp = `-- name: UpsertWorkspaceApp :one INSERT INTO workspace_apps ( id, @@ -16697,10 +16584,29 @@ INSERT INTO display_group ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in, display_group -` - -type InsertWorkspaceAppParams struct { + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) +ON CONFLICT (id) DO UPDATE SET + display_name = EXCLUDED.display_name, + icon = EXCLUDED.icon, + command = EXCLUDED.command, + url = EXCLUDED.url, + external = EXCLUDED.external, + subdomain = EXCLUDED.subdomain, + sharing_level = EXCLUDED.sharing_level, + healthcheck_url = EXCLUDED.healthcheck_url, + healthcheck_interval = EXCLUDED.healthcheck_interval, + healthcheck_threshold = EXCLUDED.healthcheck_threshold, + health = EXCLUDED.health, + display_order = EXCLUDED.display_order, + hidden = EXCLUDED.hidden, + open_in = EXCLUDED.open_in, + display_group = EXCLUDED.display_group, + agent_id = EXCLUDED.agent_id, + slug = EXCLUDED.slug +RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in, display_group +` + +type UpsertWorkspaceAppParams struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` AgentID uuid.UUID `db:"agent_id" json:"agent_id"` @@ -16722,8 +16628,8 @@ type InsertWorkspaceAppParams struct { DisplayGroup sql.NullString `db:"display_group" json:"display_group"` } -func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) { - row := q.db.QueryRowContext(ctx, insertWorkspaceApp, +func (q *sqlQuerier) UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error) { + row := q.db.QueryRowContext(ctx, upsertWorkspaceApp, arg.ID, arg.CreatedAt, arg.AgentID, @@ -16769,67 +16675,6 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace return i, err } -const insertWorkspaceAppStatus = `-- name: InsertWorkspaceAppStatus :one -INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, uri) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, created_at, agent_id, app_id, workspace_id, state, message, uri -` - -type InsertWorkspaceAppStatusParams struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - AppID uuid.UUID `db:"app_id" json:"app_id"` - State WorkspaceAppStatusState `db:"state" json:"state"` - Message string `db:"message" json:"message"` - Uri sql.NullString `db:"uri" json:"uri"` -} - -func (q *sqlQuerier) InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) { - row := q.db.QueryRowContext(ctx, insertWorkspaceAppStatus, - arg.ID, - arg.CreatedAt, - arg.WorkspaceID, - arg.AgentID, - arg.AppID, - arg.State, - arg.Message, - arg.Uri, - ) - var i WorkspaceAppStatus - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.AgentID, - &i.AppID, - &i.WorkspaceID, - &i.State, - &i.Message, - &i.Uri, - ) - return i, err -} - -const updateWorkspaceAppHealthByID = `-- name: UpdateWorkspaceAppHealthByID :exec -UPDATE - workspace_apps -SET - health = $2 -WHERE - id = $1 -` - -type UpdateWorkspaceAppHealthByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - Health WorkspaceAppHealth `db:"health" json:"health"` -} - -func (q *sqlQuerier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceAppHealthByID, arg.ID, arg.Health) - return err -} - const insertWorkspaceAppStats = `-- name: InsertWorkspaceAppStats :exec INSERT INTO workspace_app_stats ( @@ -16989,6 +16834,44 @@ func (q *sqlQuerier) GetWorkspaceBuildParameters(ctx context.Context, workspaceB return items, nil } +const getWorkspaceBuildParametersByBuildIDs = `-- name: GetWorkspaceBuildParametersByBuildIDs :many +SELECT + workspace_build_parameters.workspace_build_id, workspace_build_parameters.name, workspace_build_parameters.value +FROM + workspace_build_parameters +JOIN + workspace_builds ON workspace_builds.id = workspace_build_parameters.workspace_build_id +JOIN + workspaces ON workspaces.id = workspace_builds.workspace_id +WHERE + workspace_build_parameters.workspace_build_id = ANY($1 :: uuid[]) + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceBuildParametersByBuildIDs + -- @authorize_filter +` + +func (q *sqlQuerier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]WorkspaceBuildParameter, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceBuildParametersByBuildIDs, pq.Array(workspaceBuildIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceBuildParameter + for rows.Next() { + var i WorkspaceBuildParameter + if err := rows.Scan(&i.WorkspaceBuildID, &i.Name, &i.Value); 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 insertWorkspaceBuildParameters = `-- name: InsertWorkspaceBuildParameters :exec INSERT INTO workspace_build_parameters (workspace_build_id, name, value) @@ -17011,7 +16894,7 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins } const getActiveWorkspaceBuildsByTemplateID = `-- name: GetActiveWorkspaceBuildsByTemplateID :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_tasks_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -17067,7 +16950,7 @@ func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, t &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17167,7 +17050,7 @@ func (q *sqlQuerier) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, a const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17198,7 +17081,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17207,7 +17090,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w } const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_tasks_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -17247,7 +17130,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17266,7 +17149,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB } const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_tasks_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -17308,7 +17191,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17328,7 +17211,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17357,7 +17240,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17367,7 +17250,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17396,7 +17279,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17406,7 +17289,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17439,7 +17322,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17516,7 +17399,7 @@ func (q *sqlQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, sinc const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -17588,7 +17471,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17607,7 +17490,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge } const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many -SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_tasks_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 +SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { @@ -17636,7 +17519,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created &i.MaxDeadline, &i.TemplateVersionPresetID, &i.HasAITask, - &i.AITasksSidebarAppID, + &i.AITaskSidebarAppID, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -17670,11 +17553,10 @@ INSERT INTO deadline, max_deadline, reason, - template_version_preset_id, - has_ai_task + template_version_preset_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ` type InsertWorkspaceBuildParams struct { @@ -17692,7 +17574,6 @@ type InsertWorkspaceBuildParams struct { MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` Reason BuildReason `db:"reason" json:"reason"` TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` } func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error { @@ -17711,7 +17592,33 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa arg.MaxDeadline, arg.Reason, arg.TemplateVersionPresetID, + ) + return err +} + +const updateWorkspaceBuildAITaskByID = `-- name: UpdateWorkspaceBuildAITaskByID :exec +UPDATE + workspace_builds +SET + has_ai_task = $1, + ai_task_sidebar_app_id = $2, + updated_at = $3::timestamptz +WHERE id = $4::uuid +` + +type UpdateWorkspaceBuildAITaskByIDParams struct { + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + SidebarAppID uuid.NullUUID `db:"sidebar_app_id" json:"sidebar_app_id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildAITaskByID, arg.HasAITask, + arg.SidebarAppID, + arg.UpdatedAt, + arg.ID, ) return err } @@ -19335,7 +19242,8 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID u const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT workspaces.id, - workspaces.name + workspaces.name, + workspace_builds.template_version_id as build_template_version_id FROM workspaces LEFT JOIN @@ -19454,8 +19362,9 @@ WHERE ` type GetWorkspacesEligibleForTransitionRow struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + BuildTemplateVersionID uuid.NullUUID `db:"build_template_version_id" json:"build_template_version_id"` } func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) { @@ -19467,7 +19376,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now var items []GetWorkspacesEligibleForTransitionRow for rows.Next() { var i GetWorkspacesEligibleForTransitionRow - if err := rows.Scan(&i.ID, &i.Name); err != nil { + if err := rows.Scan(&i.ID, &i.Name, &i.BuildTemplateVersionID); err != nil { return nil, err } items = append(items, i) diff --git a/coderd/database/queries/chat.sql b/coderd/database/queries/chat.sql deleted file mode 100644 index 68f662d8a886b..0000000000000 --- a/coderd/database/queries/chat.sql +++ /dev/null @@ -1,36 +0,0 @@ --- name: InsertChat :one -INSERT INTO chats (owner_id, created_at, updated_at, title) -VALUES ($1, $2, $3, $4) -RETURNING *; - --- name: UpdateChatByID :exec -UPDATE chats -SET title = $2, updated_at = $3 -WHERE id = $1; - --- name: GetChatsByOwnerID :many -SELECT * FROM chats -WHERE owner_id = $1 -ORDER BY created_at DESC; - --- name: GetChatByID :one -SELECT * FROM chats -WHERE id = $1; - --- name: InsertChatMessages :many -INSERT INTO chat_messages (chat_id, created_at, model, provider, content) -SELECT - @chat_id :: uuid AS chat_id, - @created_at :: timestamptz AS created_at, - @model :: VARCHAR(127) AS model, - @provider :: VARCHAR(127) AS provider, - jsonb_array_elements(@content :: jsonb) AS content -RETURNING chat_messages.*; - --- name: GetChatMessagesByChatID :many -SELECT * FROM chat_messages -WHERE chat_id = $1 -ORDER BY created_at ASC; - --- name: DeleteChat :exec -DELETE FROM chats WHERE id = $1; diff --git a/coderd/database/queries/presets.sql b/coderd/database/queries/presets.sql index 13cba4c57e173..d275e4744c729 100644 --- a/coderd/database/queries/presets.sql +++ b/coderd/database/queries/presets.sql @@ -6,7 +6,8 @@ INSERT INTO template_version_presets ( created_at, desired_instances, invalidate_after_secs, - scheduling_timezone + scheduling_timezone, + is_default ) VALUES ( @id, @@ -15,7 +16,8 @@ VALUES ( @created_at, @desired_instances, @invalidate_after_secs, - @scheduling_timezone + @scheduling_timezone, + @is_default ) RETURNING *; -- name: InsertPresetParameters :many diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index ac88a8b493152..4a37413d2f439 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -88,11 +88,10 @@ INSERT INTO readme, job_id, created_by, - source_example_id, - has_ai_task + source_example_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12); + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); -- name: UpdateTemplateVersionByID :exec UPDATE @@ -123,6 +122,15 @@ SET WHERE job_id = $1; +-- name: UpdateTemplateVersionAITaskByJobID :exec +UPDATE + template_versions +SET + has_ai_task = $2, + updated_at = $3 +WHERE + job_id = $1; + -- name: GetPreviousTemplateVersion :one SELECT * diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index f3f8e4066970b..9a6fbf9251788 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -10,7 +10,7 @@ SELECT * FROM workspace_apps WHERE agent_id = $1 AND slug = $2; -- name: GetWorkspaceAppsCreatedAfter :many SELECT * FROM workspace_apps WHERE created_at > $1 ORDER BY slug ASC; --- name: InsertWorkspaceApp :one +-- name: UpsertWorkspaceApp :one INSERT INTO workspace_apps ( id, @@ -34,7 +34,26 @@ INSERT INTO display_group ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) +ON CONFLICT (id) DO UPDATE SET + display_name = EXCLUDED.display_name, + icon = EXCLUDED.icon, + command = EXCLUDED.command, + url = EXCLUDED.url, + external = EXCLUDED.external, + subdomain = EXCLUDED.subdomain, + sharing_level = EXCLUDED.sharing_level, + healthcheck_url = EXCLUDED.healthcheck_url, + healthcheck_interval = EXCLUDED.healthcheck_interval, + healthcheck_threshold = EXCLUDED.healthcheck_threshold, + health = EXCLUDED.health, + display_order = EXCLUDED.display_order, + hidden = EXCLUDED.hidden, + open_in = EXCLUDED.open_in, + display_group = EXCLUDED.display_group, + agent_id = EXCLUDED.agent_id, + slug = EXCLUDED.slug +RETURNING *; -- name: UpdateWorkspaceAppHealthByID :exec UPDATE diff --git a/coderd/database/queries/workspacebuildparameters.sql b/coderd/database/queries/workspacebuildparameters.sql index 5cda9c020641f..b639a553ef273 100644 --- a/coderd/database/queries/workspacebuildparameters.sql +++ b/coderd/database/queries/workspacebuildparameters.sql @@ -41,3 +41,18 @@ FROM ( ) q1 ORDER BY created_at DESC, name LIMIT 100; + +-- name: GetWorkspaceBuildParametersByBuildIDs :many +SELECT + workspace_build_parameters.* +FROM + workspace_build_parameters +JOIN + workspace_builds ON workspace_builds.id = workspace_build_parameters.workspace_build_id +JOIN + workspaces ON workspaces.id = workspace_builds.workspace_id +WHERE + workspace_build_parameters.workspace_build_id = ANY(@workspace_build_ids :: uuid[]) + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceBuildParametersByBuildIDs + -- @authorize_filter +; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index b380e5423c21c..be76b6642df1f 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -121,11 +121,10 @@ INSERT INTO deadline, max_deadline, reason, - template_version_preset_id, - has_ai_task + template_version_preset_id ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15); + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14); -- name: UpdateWorkspaceBuildCostByID :exec UPDATE @@ -152,6 +151,15 @@ SET updated_at = @updated_at::timestamptz WHERE id = @id::uuid; +-- name: UpdateWorkspaceBuildAITaskByID :exec +UPDATE + workspace_builds +SET + has_ai_task = @has_ai_task, + ai_task_sidebar_app_id = @sidebar_app_id, + updated_at = @updated_at::timestamptz +WHERE id = @id::uuid; + -- name: GetActiveWorkspaceBuildsByTemplateID :many SELECT wb.* FROM ( diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index f6ee14ae0ac7d..25e4d4f97a46b 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -642,7 +642,8 @@ FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspa -- name: GetWorkspacesEligibleForTransition :many SELECT workspaces.id, - workspaces.name + workspaces.name, + workspace_builds.template_version_id as build_template_version_id FROM workspaces LEFT JOIN diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 85bb286881f97..b96dabd1fc187 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -148,7 +148,7 @@ sql: crypto_key_feature_oidc_convert: CryptoKeyFeatureOIDCConvert stale_interval_ms: StaleIntervalMS has_ai_task: HasAITask - ai_tasks_sidebar_app_id: AITasksSidebarAppID + ai_task_sidebar_app_id: AITaskSidebarAppID latest_build_has_ai_task: LatestBuildHasAITask rules: - name: do-not-use-public-schema-in-queries diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9a109c2fcab70..8377c630a6d92 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -9,8 +9,6 @@ const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); - UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); - UniqueChatsPkey UniqueConstraint = "chats_pkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); @@ -106,6 +104,7 @@ const ( UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); + UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true); UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); diff --git a/coderd/deployment.go b/coderd/deployment.go index 60988aeb2ce5a..4c78563a80456 100644 --- a/coderd/deployment.go +++ b/coderd/deployment.go @@ -1,11 +1,8 @@ package coderd import ( - "context" "net/http" - "github.com/kylecarbs/aisdk-go" - "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -87,25 +84,3 @@ func buildInfoHandler(resp codersdk.BuildInfoResponse) http.HandlerFunc { func (api *API) sshConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, api.SSHConfig) } - -type LanguageModel struct { - codersdk.LanguageModel - Provider func(ctx context.Context, messages []aisdk.Message, thinking bool) (aisdk.DataStream, error) -} - -// @Summary Get language models -// @ID get-language-models -// @Security CoderSessionToken -// @Produce json -// @Tags General -// @Success 200 {object} codersdk.LanguageModelConfig -// @Router /deployment/llms [get] -func (api *API) deploymentLLMs(rw http.ResponseWriter, r *http.Request) { - models := make([]codersdk.LanguageModel, 0, len(api.LanguageModels)) - for _, model := range api.LanguageModels { - models = append(models, model.LanguageModel) - } - httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.LanguageModelConfig{ - Models: models, - }) -} diff --git a/coderd/devtunnel/tunnel_test.go b/coderd/devtunnel/tunnel_test.go index ca1c5b7752628..e8f526fed7db0 100644 --- a/coderd/devtunnel/tunnel_test.go +++ b/coderd/devtunnel/tunnel_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "runtime" "strconv" "strings" "testing" @@ -33,6 +34,10 @@ import ( func TestTunnel(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("these tests are flaky on windows and cause the tests to fail with '(unknown)' and no output, see https://github.com/coder/internal/issues/579") + } + cases := []struct { name string version tunnelsdk.TunnelVersion @@ -48,8 +53,6 @@ func TestTunnel(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -175,6 +178,7 @@ func newTunnelServer(t *testing.T) *tunnelServer { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if handler != nil { handler.ServeHTTP(w, r) + return } w.WriteHeader(http.StatusBadGateway) @@ -209,6 +213,7 @@ func newTunnelServer(t *testing.T) *tunnelServer { if err == nil { break } + td = nil t.Logf("failed to create tunnel server on port %d: %s", wireguardPort, err) } if td == nil { diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go new file mode 100644 index 0000000000000..05c0f1b6c68c9 --- /dev/null +++ b/coderd/dynamicparameters/render.go @@ -0,0 +1,344 @@ +package dynamicparameters + +import ( + "context" + "database/sql" + "io/fs" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/apiversion" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/files" + "github.com/coder/preview" + previewtypes "github.com/coder/preview/types" + + "github.com/hashicorp/hcl/v2" +) + +// Renderer is able to execute and evaluate terraform with the given inputs. +// It may use the database to fetch additional state, such as a user's groups, +// roles, etc. Therefore, it requires an authenticated `ctx`. +// +// 'Close()' **must** be called once the renderer is no longer needed. +// Forgetting to do so will result in a memory leak. +type Renderer interface { + Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) + Close() +} + +var ErrTemplateVersionNotReady = xerrors.New("template version job not finished") + +// loader is used to load the necessary coder objects for rendering a template +// version's parameters. The output is a Renderer, which is the object that uses +// the cached objects to render the template version's parameters. +type loader struct { + templateVersionID uuid.UUID + + // cache of objects + templateVersion *database.TemplateVersion + job *database.ProvisionerJob + terraformValues *database.TemplateVersionTerraformValue +} + +// Prepare is the entrypoint for this package. It loads the necessary objects & +// files from the database and returns a Renderer that can be used to render the +// template version's parameters. +func Prepare(ctx context.Context, db database.Store, cache files.FileAcquirer, versionID uuid.UUID, options ...func(r *loader)) (Renderer, error) { + l := &loader{ + templateVersionID: versionID, + } + + for _, opt := range options { + opt(l) + } + + return l.Renderer(ctx, db, cache) +} + +func WithTemplateVersion(tv database.TemplateVersion) func(r *loader) { + return func(r *loader) { + if tv.ID == r.templateVersionID { + r.templateVersion = &tv + } + } +} + +func WithProvisionerJob(job database.ProvisionerJob) func(r *loader) { + return func(r *loader) { + r.job = &job + } +} + +func WithTerraformValues(values database.TemplateVersionTerraformValue) func(r *loader) { + return func(r *loader) { + if values.TemplateVersionID == r.templateVersionID { + r.terraformValues = &values + } + } +} + +func (r *loader) loadData(ctx context.Context, db database.Store) error { + if r.templateVersion == nil { + tv, err := db.GetTemplateVersionByID(ctx, r.templateVersionID) + if err != nil { + return xerrors.Errorf("template version: %w", err) + } + r.templateVersion = &tv + } + + if r.job == nil { + job, err := db.GetProvisionerJobByID(ctx, r.templateVersion.JobID) + if err != nil { + return xerrors.Errorf("provisioner job: %w", err) + } + r.job = &job + } + + if !r.job.CompletedAt.Valid { + return ErrTemplateVersionNotReady + } + + if r.terraformValues == nil { + values, err := db.GetTemplateVersionTerraformValues(ctx, r.templateVersion.ID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("template version terraform values: %w", err) + } + + if xerrors.Is(err, sql.ErrNoRows) { + // If the row does not exist, return zero values. + // + // Older template versions (prior to dynamic parameters) will be missing + // this row, and we can assume the 'ProvisionerdVersion' "" (unknown). + values = database.TemplateVersionTerraformValue{ + TemplateVersionID: r.templateVersionID, + UpdatedAt: time.Time{}, + CachedPlan: nil, + CachedModuleFiles: uuid.NullUUID{}, + ProvisionerdVersion: "", + } + } + + r.terraformValues = &values + } + + return nil +} + +// Renderer returns a Renderer that can be used to render the template version's +// parameters. It automatically determines whether to use a static or dynamic +// renderer based on the template version's state. +// +// Static parameter rendering is required to support older template versions that +// do not have the database state to support dynamic parameters. A constant +// warning will be displayed for these template versions. +func (r *loader) Renderer(ctx context.Context, db database.Store, cache files.FileAcquirer) (Renderer, error) { + err := r.loadData(ctx, db) + if err != nil { + return nil, xerrors.Errorf("load data: %w", err) + } + + if !ProvisionerVersionSupportsDynamicParameters(r.terraformValues.ProvisionerdVersion) { + return r.staticRender(ctx, db) + } + + return r.dynamicRenderer(ctx, db, files.NewCacheCloser(cache)) +} + +// Renderer caches all the necessary files when rendering a template version's +// parameters. It must be closed after use to release the cached files. +func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *files.CacheCloser) (*dynamicRenderer, error) { + closeFiles := true // If the function returns with no error, this will toggle to false. + defer func() { + if closeFiles { + cache.Close() + } + }() + + // If they can read the template version, then they can read the file for + // parameter loading purposes. + //nolint:gocritic + fileCtx := dbauthz.AsFileReader(ctx) + + var templateFS fs.FS + var err error + + templateFS, err = cache.Acquire(fileCtx, db, r.job.FileID) + if err != nil { + return nil, xerrors.Errorf("acquire template file: %w", err) + } + + var moduleFilesFS *files.CloseFS + if r.terraformValues.CachedModuleFiles.Valid { + moduleFilesFS, err = cache.Acquire(fileCtx, db, r.terraformValues.CachedModuleFiles.UUID) + if err != nil { + return nil, xerrors.Errorf("acquire module files: %w", err) + } + templateFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}}) + } + + closeFiles = false // Caller will have to call close + return &dynamicRenderer{ + data: r, + templateFS: templateFS, + db: db, + ownerErrors: make(map[uuid.UUID]error), + close: cache.Close, + }, nil +} + +type dynamicRenderer struct { + db database.Store + data *loader + templateFS fs.FS + + ownerErrors map[uuid.UUID]error + currentOwner *previewtypes.WorkspaceOwner + + once sync.Once + close func() +} + +func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) { + // Always start with the cached error, if we have one. + ownerErr := r.ownerErrors[ownerID] + if ownerErr == nil { + ownerErr = r.getWorkspaceOwnerData(ctx, ownerID) + } + + if ownerErr != nil || r.currentOwner == nil { + r.ownerErrors[ownerID] = ownerErr + return nil, hcl.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Failed to fetch workspace owner", + Detail: "Please check your permissions or the user may not exist.", + Extra: previewtypes.DiagnosticExtra{ + Code: "owner_not_found", + }, + }, + } + } + + input := preview.Input{ + PlanJSON: r.data.terraformValues.CachedPlan, + ParameterValues: values, + Owner: *r.currentOwner, + // Do not emit parser logs to coderd output logs. + // TODO: Returning this logs in the output would benefit the caller. + // Unsure how large the logs can be, so for now we just discard them. + Logger: slog.New(slog.DiscardHandler), + } + + return preview.Preview(ctx, input, r.templateFS) +} + +func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error { + if r.currentOwner != nil && r.currentOwner.ID == ownerID.String() { + return nil // already fetched + } + + user, err := r.db.GetUserByID(ctx, ownerID) + if err != nil { + // If the user failed to read, we also try to read the user from their + // organization member. You only need to be able to read the organization member + // to get the owner data. + // + // Only the terraform files can therefore leak more information than the + // caller should have access to. All this info should be public assuming you can + // read the user though. + mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{ + OrganizationID: r.data.templateVersion.OrganizationID, + UserID: ownerID, + IncludeSystem: true, + })) + if err != nil { + return xerrors.Errorf("fetch user: %w", err) + } + + // Org member fetched, so use the provisioner context to fetch the user. + //nolint:gocritic // Has the correct permissions, and matches the provisioning flow. + user, err = r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID) + if err != nil { + return xerrors.Errorf("fetch user: %w", err) + } + } + + // nolint:gocritic // This is kind of the wrong query to use here, but it + // matches how the provisioner currently works. We should figure out + // something that needs less escalation but has the correct behavior. + row, err := r.db.GetAuthorizationUserRoles(dbauthz.AsProvisionerd(ctx), ownerID) + if err != nil { + return xerrors.Errorf("user roles: %w", err) + } + roles, err := row.RoleNames() + if err != nil { + return xerrors.Errorf("expand roles: %w", err) + } + ownerRoles := make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles)) + for _, it := range roles { + if it.OrganizationID != uuid.Nil && it.OrganizationID != r.data.templateVersion.OrganizationID { + continue + } + var orgID string + if it.OrganizationID != uuid.Nil { + orgID = it.OrganizationID.String() + } + ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{ + Name: it.Name, + OrgID: orgID, + }) + } + + // The correct public key has to be sent. This will not be leaked + // unless the template leaks it. + // nolint:gocritic + key, err := r.db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("ssh key: %w", err) + } + + // The groups need to be sent to preview. These groups are not exposed to the + // user, unless the template does it through the parameters. Regardless, we need + // the correct groups, and a user might not have read access. + // nolint:gocritic + groups, err := r.db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{ + OrganizationID: r.data.templateVersion.OrganizationID, + HasMemberID: ownerID, + }) + if err != nil { + return xerrors.Errorf("groups: %w", err) + } + groupNames := make([]string, 0, len(groups)) + for _, it := range groups { + groupNames = append(groupNames, it.Group.Name) + } + + r.currentOwner = &previewtypes.WorkspaceOwner{ + ID: user.ID.String(), + Name: user.Username, + FullName: user.Name, + Email: user.Email, + LoginType: string(user.LoginType), + RBACRoles: ownerRoles, + SSHPublicKey: key.PublicKey, + Groups: groupNames, + } + return nil +} + +func (r *dynamicRenderer) Close() { + r.once.Do(r.close) +} + +func ProvisionerVersionSupportsDynamicParameters(version string) bool { + major, minor, err := apiversion.Parse(version) + // If the api version is not valid or less than 1.6, we need to use the static parameters + useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6) + return !useStaticParams +} diff --git a/coderd/dynamicparameters/render_test.go b/coderd/dynamicparameters/render_test.go new file mode 100644 index 0000000000000..c71230c14e19b --- /dev/null +++ b/coderd/dynamicparameters/render_test.go @@ -0,0 +1,35 @@ +package dynamicparameters_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/dynamicparameters" +) + +func TestProvisionerVersionSupportsDynamicParameters(t *testing.T) { + t.Parallel() + + for v, dyn := range map[string]bool{ + "": false, + "na": false, + "0.0": false, + "0.10": false, + "1.4": false, + "1.5": false, + "1.6": true, + "1.7": true, + "1.8": true, + "2.0": true, + "2.17": true, + "4.0": true, + } { + t.Run(v, func(t *testing.T) { + t.Parallel() + + does := dynamicparameters.ProvisionerVersionSupportsDynamicParameters(v) + require.Equal(t, dyn, does) + }) + } +} diff --git a/coderd/dynamicparameters/rendermock/mock.go b/coderd/dynamicparameters/rendermock/mock.go new file mode 100644 index 0000000000000..ffb23780629f6 --- /dev/null +++ b/coderd/dynamicparameters/rendermock/mock.go @@ -0,0 +1,2 @@ +//go:generate mockgen -destination ./rendermock.go -package rendermock github.com/coder/coder/v2/coderd/dynamicparameters Renderer +package rendermock diff --git a/coderd/dynamicparameters/rendermock/rendermock.go b/coderd/dynamicparameters/rendermock/rendermock.go new file mode 100644 index 0000000000000..996b02a555b08 --- /dev/null +++ b/coderd/dynamicparameters/rendermock/rendermock.go @@ -0,0 +1,71 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coder/coder/v2/coderd/dynamicparameters (interfaces: Renderer) +// +// Generated by this command: +// +// mockgen -destination ./rendermock.go -package rendermock github.com/coder/coder/v2/coderd/dynamicparameters Renderer +// + +// Package rendermock is a generated GoMock package. +package rendermock + +import ( + context "context" + reflect "reflect" + + preview "github.com/coder/preview" + uuid "github.com/google/uuid" + hcl "github.com/hashicorp/hcl/v2" + gomock "go.uber.org/mock/gomock" +) + +// MockRenderer is a mock of Renderer interface. +type MockRenderer struct { + ctrl *gomock.Controller + recorder *MockRendererMockRecorder + isgomock struct{} +} + +// MockRendererMockRecorder is the mock recorder for MockRenderer. +type MockRendererMockRecorder struct { + mock *MockRenderer +} + +// NewMockRenderer creates a new mock instance. +func NewMockRenderer(ctrl *gomock.Controller) *MockRenderer { + mock := &MockRenderer{ctrl: ctrl} + mock.recorder = &MockRendererMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRenderer) EXPECT() *MockRendererMockRecorder { + return m.recorder +} + +// Close mocks base method. +func (m *MockRenderer) Close() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Close") +} + +// Close indicates an expected call of Close. +func (mr *MockRendererMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockRenderer)(nil).Close)) +} + +// Render mocks base method. +func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Render", ctx, ownerID, values) + ret0, _ := ret[0].(*preview.Output) + ret1, _ := ret[1].(hcl.Diagnostics) + return ret0, ret1 +} + +// Render indicates an expected call of Render. +func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), ctx, ownerID, values) +} diff --git a/coderd/dynamicparameters/resolver.go b/coderd/dynamicparameters/resolver.go new file mode 100644 index 0000000000000..3cb9c59f286d6 --- /dev/null +++ b/coderd/dynamicparameters/resolver.go @@ -0,0 +1,248 @@ +package dynamicparameters + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/hashicorp/hcl/v2" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" +) + +type parameterValueSource int + +const ( + sourceDefault parameterValueSource = iota + sourcePrevious + sourceBuild + sourcePreset +) + +type parameterValue struct { + Value string + Source parameterValueSource +} + +type ResolverError struct { + Diagnostics hcl.Diagnostics + Parameter map[string]hcl.Diagnostics +} + +// Error is a pretty bad format for these errors. Try to avoid using this. +func (e *ResolverError) Error() string { + var diags hcl.Diagnostics + diags = diags.Extend(e.Diagnostics) + for _, d := range e.Parameter { + diags = diags.Extend(d) + } + + return diags.Error() +} + +func (e *ResolverError) HasError() bool { + if e.Diagnostics.HasErrors() { + return true + } + + for _, diags := range e.Parameter { + if diags.HasErrors() { + return true + } + } + return false +} + +func (e *ResolverError) Extend(parameterName string, diag hcl.Diagnostics) { + if e.Parameter == nil { + e.Parameter = make(map[string]hcl.Diagnostics) + } + if _, ok := e.Parameter[parameterName]; !ok { + e.Parameter[parameterName] = hcl.Diagnostics{} + } + e.Parameter[parameterName] = e.Parameter[parameterName].Extend(diag) +} + +//nolint:revive // firstbuild is a control flag to turn on immutable validation +func ResolveParameters( + ctx context.Context, + ownerID uuid.UUID, + renderer Renderer, + firstBuild bool, + previousValues []database.WorkspaceBuildParameter, + buildValues []codersdk.WorkspaceBuildParameter, + presetValues []database.TemplateVersionPresetParameter, +) (map[string]string, error) { + previousValuesMap := slice.ToMapFunc(previousValues, func(p database.WorkspaceBuildParameter) (string, string) { + return p.Name, p.Value + }) + + // Start with previous + values := parameterValueMap(slice.ToMapFunc(previousValues, func(p database.WorkspaceBuildParameter) (string, parameterValue) { + return p.Name, parameterValue{Source: sourcePrevious, Value: p.Value} + })) + + // Add build values (overwrite previous values if they exist) + for _, buildValue := range buildValues { + values[buildValue.Name] = parameterValue{Source: sourceBuild, Value: buildValue.Value} + } + + // Add preset values (overwrite previous and build values if they exist) + for _, preset := range presetValues { + values[preset.Name] = parameterValue{Source: sourcePreset, Value: preset.Value} + } + + // originalValues is going to be used to detect if a user tried to change + // an immutable parameter after the first build. + originalValues := make(map[string]parameterValue, len(values)) + for name, value := range values { + // Store the original values for later use. + originalValues[name] = value + } + + // Render the parameters using the values that were supplied to the previous build. + // + // This is how the form should look to the user on their workspace settings page. + // This is the original form truth that our validations should initially be based on. + output, diags := renderer.Render(ctx, ownerID, values.ValuesMap()) + if diags.HasErrors() { + // Top level diagnostics should break the build. Previous values (and new) should + // always be valid. If there is a case where this is not true, then this has to + // be changed to allow the build to continue with a different set of values. + + return nil, &ResolverError{ + Diagnostics: diags, + Parameter: nil, + } + } + + // The user's input now needs to be validated against the parameters. + // Mutability & Ephemeral parameters depend on sequential workspace builds. + // + // To enforce these, the user's input values are trimmed based on the + // mutability and ephemeral parameters defined in the template version. + for _, parameter := range output.Parameters { + // Ephemeral parameters should not be taken from the previous build. + // They must always be explicitly set in every build. + // So remove their values if they are sourced from the previous build. + if parameter.Ephemeral { + v := values[parameter.Name] + if v.Source == sourcePrevious { + delete(values, parameter.Name) + } + } + + // Immutable parameters should also not be allowed to be changed from + // the previous build. Remove any values taken from the preset or + // new build params. This forces the value to be the same as it was before. + // + // We do this so the next form render uses the original immutable value. + if !firstBuild && !parameter.Mutable { + delete(values, parameter.Name) + prev, ok := previousValuesMap[parameter.Name] + if ok { + values[parameter.Name] = parameterValue{ + Value: prev, + Source: sourcePrevious, + } + } + } + } + + // This is the final set of values that will be used. Any errors at this stage + // are fatal. Additional validation for immutability has to be done manually. + output, diags = renderer.Render(ctx, ownerID, values.ValuesMap()) + if diags.HasErrors() { + return nil, &ResolverError{ + Diagnostics: diags, + Parameter: nil, + } + } + + // parameterNames is going to be used to remove any excess values that were left + // around without a parameter. + parameterNames := make(map[string]struct{}, len(output.Parameters)) + parameterError := &ResolverError{} + for _, parameter := range output.Parameters { + parameterNames[parameter.Name] = struct{}{} + + if !firstBuild && !parameter.Mutable { + originalValue, ok := originalValues[parameter.Name] + // Immutable parameters should not be changed after the first build. + // If the value matches the original value, that is fine. + // + // If the original value is not set, that means this is a new parameter. New + // immutable parameters are allowed. This is an opinionated choice to prevent + // workspaces failing to update or delete. Ideally we would block this, as + // immutable parameters should only be able to be set at creation time. + if ok && parameter.Value.AsString() != originalValue.Value { + var src *hcl.Range + if parameter.Source != nil { + src = ¶meter.Source.HCLBlock().TypeRange + } + + // An immutable parameter was changed, which is not allowed. + // Add a failed diagnostic to the output. + parameterError.Extend(parameter.Name, hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Immutable parameter changed", + Detail: fmt.Sprintf("Parameter %q is not mutable, so it can't be updated after creating a workspace.", parameter.Name), + Subject: src, + }, + }) + } + } + + // TODO: Fix the `hcl.Diagnostics(...)` type casting. It should not be needed. + if hcl.Diagnostics(parameter.Diagnostics).HasErrors() { + // All validation errors are raised here for each parameter. + parameterError.Extend(parameter.Name, hcl.Diagnostics(parameter.Diagnostics)) + } + + // If the parameter has a value, but it was not set explicitly by the user at any + // build, then save the default value. An example where this is important is if a + // template has a default value of 'region = us-west-2', but the user never sets + // it. If the default value changes to 'region = us-east-1', we want to preserve + // the original value of 'us-west-2' for the existing workspaces. + // + // parameter.Value will be populated from the default at this point. So grab it + // from there. + if _, ok := values[parameter.Name]; !ok && parameter.Value.IsKnown() && parameter.Value.Valid() { + values[parameter.Name] = parameterValue{ + Value: parameter.Value.AsString(), + Source: sourceDefault, + } + } + } + + // Delete any values that do not belong to a parameter. This is to not save + // parameter values that have no effect. These leaky parameter values can cause + // problems in the future, as it makes it challenging to remove values from the + // database + for k := range values { + if _, ok := parameterNames[k]; !ok { + delete(values, k) + } + } + + if parameterError.HasError() { + // If there are any errors, return them. + return nil, parameterError + } + + // Return the values to be saved for the build. + return values.ValuesMap(), nil +} + +type parameterValueMap map[string]parameterValue + +func (p parameterValueMap) ValuesMap() map[string]string { + values := make(map[string]string, len(p)) + for name, paramValue := range p { + values[name] = paramValue.Value + } + return values +} diff --git a/coderd/dynamicparameters/resolver_test.go b/coderd/dynamicparameters/resolver_test.go new file mode 100644 index 0000000000000..ec5218613ff03 --- /dev/null +++ b/coderd/dynamicparameters/resolver_test.go @@ -0,0 +1,59 @@ +package dynamicparameters_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/dynamicparameters" + "github.com/coder/coder/v2/coderd/dynamicparameters/rendermock" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/preview" + previewtypes "github.com/coder/preview/types" + "github.com/coder/terraform-provider-coder/v2/provider" +) + +func TestResolveParameters(t *testing.T) { + t.Parallel() + + t.Run("NewImmutable", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + render := rendermock.NewMockRenderer(ctrl) + + // A single immutable parameter with no previous value. + render.EXPECT(). + Render(gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes(). + Return(&preview.Output{ + Parameters: []previewtypes.Parameter{ + { + ParameterData: previewtypes.ParameterData{ + Name: "immutable", + Type: previewtypes.ParameterTypeString, + FormType: provider.ParameterFormTypeInput, + Mutable: false, + DefaultValue: previewtypes.StringLiteral("foo"), + Required: true, + }, + Value: previewtypes.StringLiteral("foo"), + Diagnostics: nil, + }, + }, + }, nil) + + ctx := testutil.Context(t, testutil.WaitShort) + values, err := dynamicparameters.ResolveParameters(ctx, uuid.New(), render, false, + []database.WorkspaceBuildParameter{}, // No previous values + []codersdk.WorkspaceBuildParameter{}, // No new build values + []database.TemplateVersionPresetParameter{}, // No preset values + ) + require.NoError(t, err) + require.Equal(t, map[string]string{"immutable": "foo"}, values) + }) +} diff --git a/coderd/dynamicparameters/static.go b/coderd/dynamicparameters/static.go new file mode 100644 index 0000000000000..fec5de2581aef --- /dev/null +++ b/coderd/dynamicparameters/static.go @@ -0,0 +1,147 @@ +package dynamicparameters + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/hashicorp/hcl/v2" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/util/ptr" + sdkproto "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/preview" + previewtypes "github.com/coder/preview/types" + "github.com/coder/terraform-provider-coder/v2/provider" +) + +type staticRender struct { + staticParams []previewtypes.Parameter +} + +func (r *loader) staticRender(ctx context.Context, db database.Store) (*staticRender, error) { + dbTemplateVersionParameters, err := db.GetTemplateVersionParameters(ctx, r.templateVersionID) + if err != nil { + return nil, xerrors.Errorf("template version parameters: %w", err) + } + + params := db2sdk.List(dbTemplateVersionParameters, TemplateVersionParameter) + + for i, param := range params { + // Update the diagnostics to validate the 'default' value. + // We do not have a user supplied value yet, so we use the default. + params[i].Diagnostics = append(params[i].Diagnostics, previewtypes.Diagnostics(param.Valid(param.Value))...) + } + return &staticRender{ + staticParams: params, + }, nil +} + +func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) { + params := r.staticParams + for i := range params { + param := ¶ms[i] + paramValue, ok := values[param.Name] + if ok { + param.Value = previewtypes.StringLiteral(paramValue) + } else { + param.Value = param.DefaultValue + } + param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value)) + } + + return &preview.Output{ + Parameters: params, + }, hcl.Diagnostics{ + { + // Only a warning because the form does still work. + Severity: hcl.DiagWarning, + Summary: "This template version is missing required metadata to support dynamic parameters.", + Detail: "To restore full functionality, please re-import the terraform as a new template version.", + }, + } +} + +func (*staticRender) Close() {} + +func TemplateVersionParameter(it database.TemplateVersionParameter) previewtypes.Parameter { + param := previewtypes.Parameter{ + ParameterData: previewtypes.ParameterData{ + Name: it.Name, + DisplayName: it.DisplayName, + Description: it.Description, + Type: previewtypes.ParameterType(it.Type), + FormType: provider.ParameterFormType(it.FormType), + Styling: previewtypes.ParameterStyling{}, + Mutable: it.Mutable, + DefaultValue: previewtypes.StringLiteral(it.DefaultValue), + Icon: it.Icon, + Options: make([]*previewtypes.ParameterOption, 0), + Validations: make([]*previewtypes.ParameterValidation, 0), + Required: it.Required, + Order: int64(it.DisplayOrder), + Ephemeral: it.Ephemeral, + Source: nil, + }, + // Always use the default, since we used to assume the empty string + Value: previewtypes.StringLiteral(it.DefaultValue), + Diagnostics: make(previewtypes.Diagnostics, 0), + } + + if it.ValidationError != "" || it.ValidationRegex != "" || it.ValidationMonotonic != "" { + var reg *string + if it.ValidationRegex != "" { + reg = ptr.Ref(it.ValidationRegex) + } + + var vMin *int64 + if it.ValidationMin.Valid { + vMin = ptr.Ref(int64(it.ValidationMin.Int32)) + } + + var vMax *int64 + if it.ValidationMax.Valid { + vMax = ptr.Ref(int64(it.ValidationMax.Int32)) + } + + var monotonic *string + if it.ValidationMonotonic != "" { + monotonic = ptr.Ref(it.ValidationMonotonic) + } + + param.Validations = append(param.Validations, &previewtypes.ParameterValidation{ + Error: it.ValidationError, + Regex: reg, + Min: vMin, + Max: vMax, + Monotonic: monotonic, + }) + } + + var protoOptions []*sdkproto.RichParameterOption + err := json.Unmarshal(it.Options, &protoOptions) + if err != nil { + param.Diagnostics = append(param.Diagnostics, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to parse json parameter options", + Detail: err.Error(), + }) + } + + for _, opt := range protoOptions { + param.Options = append(param.Options, &previewtypes.ParameterOption{ + Name: opt.Name, + Description: opt.Description, + Value: previewtypes.StringLiteral(opt.Value), + Icon: opt.Icon, + }) + } + + // Take the form type from the ValidateFormType function. This is a bit + // unfortunate we have to do this, but it will return the default form_type + // for a given set of conditions. + _, param.FormType, _ = provider.ValidateFormType(provider.OptionType(param.Type), len(param.Options), param.FormType) + return param +} diff --git a/coderd/experiments.go b/coderd/experiments.go index 6f03daa4e9d88..a0949e9411664 100644 --- a/coderd/experiments.go +++ b/coderd/experiments.go @@ -26,7 +26,7 @@ func (api *API) handleExperimentsGet(rw http.ResponseWriter, r *http.Request) { // @Tags General // @Success 200 {array} codersdk.Experiment // @Router /experiments/available [get] -func handleExperimentsSafe(rw http.ResponseWriter, r *http.Request) { +func handleExperimentsAvailable(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() httpapi.Write(ctx, rw, http.StatusOK, codersdk.AvailableExperiments{ Safe: codersdk.ExperimentsSafe, diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 600aacf62f7dd..9b8b87748e784 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -505,8 +505,6 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut ids := map[string]struct{}{} configs := []*Config{} for _, entry := range entries { - entry := entry - // Applies defaults to the config entry. // This allows users to very simply state that they type is "GitHub", // apply their client secret and ID, and have the UI appear nicely. diff --git a/coderd/externalauth/externalauth_internal_test.go b/coderd/externalauth/externalauth_internal_test.go index 26515ff78f215..f50593c019b4f 100644 --- a/coderd/externalauth/externalauth_internal_test.go +++ b/coderd/externalauth/externalauth_internal_test.go @@ -102,7 +102,6 @@ func TestGitlabDefaults(t *testing.T) { }, } for _, c := range tests { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() applyDefaultsToConfig(&c.input) @@ -177,7 +176,6 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() applyDefaultsToConfig(tt.config) diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index ec540fba2eac6..81cf5aa1f21e2 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -463,7 +463,6 @@ func TestConvertYAML(t *testing.T) { }}, Error: "device auth url must be provided", }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() output, err := externalauth.ConvertConfig(instrument, tc.Input, &url.URL{}) diff --git a/coderd/files/cache.go b/coderd/files/cache.go index 6e4dc9383b6f1..3698aac9286c8 100644 --- a/coderd/files/cache.go +++ b/coderd/files/cache.go @@ -19,35 +19,16 @@ import ( "github.com/coder/coder/v2/coderd/util/lazy" ) -// NewFromStore returns a file cache that will fetch files from the provided -// database. -func NewFromStore(store database.Store, registerer prometheus.Registerer, authz rbac.Authorizer) *Cache { - fetch := func(ctx context.Context, fileID uuid.UUID) (CacheEntryValue, error) { - // Make sure the read does not fail due to authorization issues. - // Authz is checked on the Acquire call, so this is safe. - //nolint:gocritic - file, err := store.GetFileByID(dbauthz.AsFileReader(ctx), fileID) - if err != nil { - return CacheEntryValue{}, xerrors.Errorf("failed to read file from database: %w", err) - } - - content := bytes.NewBuffer(file.Data) - return CacheEntryValue{ - Object: file.RBACObject(), - FS: archivefs.FromTarReader(content), - Size: int64(len(file.Data)), - }, nil - } - - return New(fetch, registerer, authz) +type FileAcquirer interface { + Acquire(ctx context.Context, db database.Store, fileID uuid.UUID) (*CloseFS, error) } -func New(fetch fetcher, registerer prometheus.Registerer, authz rbac.Authorizer) *Cache { +// New returns a file cache that will fetch files from a database +func New(registerer prometheus.Registerer, authz rbac.Authorizer) *Cache { return (&Cache{ - lock: sync.Mutex{}, - data: make(map[uuid.UUID]*cacheEntry), - fetcher: fetch, - authz: authz, + lock: sync.Mutex{}, + data: make(map[uuid.UUID]*cacheEntry), + authz: authz, }).registerMetrics(registerer) } @@ -90,12 +71,12 @@ func (c *Cache) registerMetrics(registerer prometheus.Registerer) *Cache { Help: "The count of file references currently open in the file cache. Multiple references can be held for the same file.", }) - c.totalOpenFileReferences = f.NewCounter(prometheus.CounterOpts{ + c.totalOpenFileReferences = f.NewCounterVec(prometheus.CounterOpts{ Namespace: "coderd", Subsystem: subsystem, Name: "open_file_refs_total", - Help: "The total number of file references ever opened in the file cache.", - }) + Help: "The total number of file references ever opened in the file cache. The 'hit' label indicates if the file was loaded from the cache.", + }, []string{"hit"}) return c } @@ -106,9 +87,8 @@ func (c *Cache) registerMetrics(registerer prometheus.Registerer) *Cache { // loaded into memory exactly once. We hold those files until there are no // longer any open connections, and then we remove the value from the map. type Cache struct { - lock sync.Mutex - data map[uuid.UUID]*cacheEntry - fetcher + lock sync.Mutex + data map[uuid.UUID]*cacheEntry authz rbac.Authorizer // metrics @@ -117,7 +97,7 @@ type Cache struct { type cacheMetrics struct { currentOpenFileReferences prometheus.Gauge - totalOpenFileReferences prometheus.Counter + totalOpenFileReferences *prometheus.CounterVec currentOpenFiles prometheus.Gauge totalOpenedFiles prometheus.Counter @@ -138,8 +118,6 @@ type cacheEntry struct { value *lazy.ValueWithError[CacheEntryValue] } -type fetcher func(context.Context, uuid.UUID) (CacheEntryValue, error) - var _ fs.FS = (*CloseFS)(nil) // CloseFS is a wrapper around fs.FS that implements io.Closer. The Close() @@ -159,12 +137,12 @@ func (f *CloseFS) Close() { f.close() } // // Safety: Every call to Acquire that does not return an error must have a // matching call to Release. -func (c *Cache) Acquire(ctx context.Context, fileID uuid.UUID) (*CloseFS, error) { +func (c *Cache) Acquire(ctx context.Context, db database.Store, fileID uuid.UUID) (*CloseFS, error) { // It's important that this `Load` call occurs outside `prepare`, after the // mutex has been released, or we would continue to hold the lock until the // entire file has been fetched, which may be slow, and would prevent other // files from being fetched in parallel. - it, err := c.prepare(ctx, fileID).Load() + it, err := c.prepare(ctx, db, fileID).Load() if err != nil { c.release(fileID) return nil, err @@ -191,14 +169,15 @@ func (c *Cache) Acquire(ctx context.Context, fileID uuid.UUID) (*CloseFS, error) }, nil } -func (c *Cache) prepare(ctx context.Context, fileID uuid.UUID) *lazy.ValueWithError[CacheEntryValue] { +func (c *Cache) prepare(ctx context.Context, db database.Store, fileID uuid.UUID) *lazy.ValueWithError[CacheEntryValue] { c.lock.Lock() defer c.lock.Unlock() + hitLabel := "true" entry, ok := c.data[fileID] if !ok { value := lazy.NewWithError(func() (CacheEntryValue, error) { - val, err := c.fetcher(ctx, fileID) + val, err := fetch(ctx, db, fileID) // Always add to the cache size the bytes of the file loaded. if err == nil { @@ -216,10 +195,11 @@ func (c *Cache) prepare(ctx context.Context, fileID uuid.UUID) *lazy.ValueWithEr c.data[fileID] = entry c.currentOpenFiles.Inc() c.totalOpenedFiles.Inc() + hitLabel = "false" } c.currentOpenFileReferences.Inc() - c.totalOpenFileReferences.Inc() + c.totalOpenFileReferences.WithLabelValues(hitLabel).Inc() entry.refCount++ return entry.value } @@ -265,3 +245,20 @@ func (c *Cache) Count() int { return len(c.data) } + +func fetch(ctx context.Context, store database.Store, fileID uuid.UUID) (CacheEntryValue, error) { + // Make sure the read does not fail due to authorization issues. + // Authz is checked on the Acquire call, so this is safe. + //nolint:gocritic + file, err := store.GetFileByID(dbauthz.AsFileReader(ctx), fileID) + if err != nil { + return CacheEntryValue{}, xerrors.Errorf("failed to read file from database: %w", err) + } + + content := bytes.NewBuffer(file.Data) + return CacheEntryValue{ + Object: file.RBACObject(), + FS: archivefs.FromTarReader(content), + Size: int64(len(file.Data)), + }, nil +} diff --git a/coderd/files/cache_test.go b/coderd/files/cache_test.go index 5efb4ba19be28..8a8acfbc0772f 100644 --- a/coderd/files/cache_test.go +++ b/coderd/files/cache_test.go @@ -8,8 +8,8 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" - "github.com/spf13/afero" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/sync/errgroup" "cdr.dev/slog/sloggers/slogtest" @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/rbac" @@ -58,7 +59,7 @@ func TestCacheRBAC(t *testing.T) { require.Equal(t, 0, cache.Count()) rec.Reset() - _, err := cache.Acquire(nobody, file.ID) + _, err := cache.Acquire(nobody, db, file.ID) require.Error(t, err) require.True(t, rbac.IsUnauthorizedError(err)) @@ -75,18 +76,18 @@ func TestCacheRBAC(t *testing.T) { require.Equal(t, 0, cache.Count()) // Read the file with a file reader to put it into the cache. - a, err := cache.Acquire(cacheReader, file.ID) + a, err := cache.Acquire(cacheReader, db, file.ID) require.NoError(t, err) require.Equal(t, 1, cache.Count()) // "nobody" should not be able to read the file. - _, err = cache.Acquire(nobody, file.ID) + _, err = cache.Acquire(nobody, db, file.ID) require.Error(t, err) require.True(t, rbac.IsUnauthorizedError(err)) require.Equal(t, 1, cache.Count()) // UserReader can - b, err := cache.Acquire(userReader, file.ID) + b, err := cache.Acquire(userReader, db, file.ID) require.NoError(t, err) require.Equal(t, 1, cache.Count()) @@ -110,16 +111,21 @@ func TestConcurrency(t *testing.T) { ctx := dbauthz.AsFileReader(t.Context()) const fileSize = 10 - emptyFS := afero.NewIOFS(afero.NewReadOnlyFs(afero.NewMemMapFs())) var fetches atomic.Int64 reg := prometheus.NewRegistry() - c := files.New(func(_ context.Context, _ uuid.UUID) (files.CacheEntryValue, error) { + + dbM := dbmock.NewMockStore(gomock.NewController(t)) + dbM.EXPECT().GetFileByID(gomock.Any(), gomock.Any()).DoAndReturn(func(mTx context.Context, fileID uuid.UUID) (database.File, error) { fetches.Add(1) - // Wait long enough before returning to make sure that all of the goroutines + // Wait long enough before returning to make sure that all the goroutines // will be waiting in line, ensuring that no one duplicated a fetch. time.Sleep(testutil.IntervalMedium) - return files.CacheEntryValue{FS: emptyFS, Size: fileSize}, nil - }, reg, &coderdtest.FakeAuthorizer{}) + return database.File{ + Data: make([]byte, fileSize), + }, nil + }).AnyTimes() + + c := files.New(reg, &coderdtest.FakeAuthorizer{}) batches := 1000 groups := make([]*errgroup.Group, 0, batches) @@ -137,7 +143,7 @@ func TestConcurrency(t *testing.T) { g.Go(func() error { // We don't bother to Release these references because the Cache will be // released at the end of the test anyway. - _, err := c.Acquire(ctx, id) + _, err := c.Acquire(ctx, dbM, id) return err }) } @@ -155,7 +161,9 @@ func TestConcurrency(t *testing.T) { require.Equal(t, batches, promhelp.GaugeValue(t, reg, cachePromMetricName("open_files_current"), nil)) require.Equal(t, batches, promhelp.CounterValue(t, reg, cachePromMetricName("open_files_total"), nil)) require.Equal(t, batches*batchSize, promhelp.GaugeValue(t, reg, cachePromMetricName("open_file_refs_current"), nil)) - require.Equal(t, batches*batchSize, promhelp.CounterValue(t, reg, cachePromMetricName("open_file_refs_total"), nil)) + hit, miss := promhelp.CounterValue(t, reg, cachePromMetricName("open_file_refs_total"), prometheus.Labels{"hit": "false"}), + promhelp.CounterValue(t, reg, cachePromMetricName("open_file_refs_total"), prometheus.Labels{"hit": "true"}) + require.Equal(t, batches*batchSize, hit+miss) } func TestRelease(t *testing.T) { @@ -164,14 +172,15 @@ func TestRelease(t *testing.T) { ctx := dbauthz.AsFileReader(t.Context()) const fileSize = 10 - emptyFS := afero.NewIOFS(afero.NewReadOnlyFs(afero.NewMemMapFs())) reg := prometheus.NewRegistry() - c := files.New(func(_ context.Context, _ uuid.UUID) (files.CacheEntryValue, error) { - return files.CacheEntryValue{ - FS: emptyFS, - Size: fileSize, + dbM := dbmock.NewMockStore(gomock.NewController(t)) + dbM.EXPECT().GetFileByID(gomock.Any(), gomock.Any()).DoAndReturn(func(mTx context.Context, fileID uuid.UUID) (database.File, error) { + return database.File{ + Data: make([]byte, fileSize), }, nil - }, reg, &coderdtest.FakeAuthorizer{}) + }).AnyTimes() + + c := files.New(reg, &coderdtest.FakeAuthorizer{}) batches := 100 ids := make([]uuid.UUID, 0, batches) @@ -184,9 +193,8 @@ func TestRelease(t *testing.T) { batchSize := 10 for openedIdx, id := range ids { for batchIdx := range batchSize { - it, err := c.Acquire(ctx, id) + it, err := c.Acquire(ctx, dbM, id) require.NoError(t, err) - require.Equal(t, emptyFS, it.FS) releases[id] = append(releases[id], it.Close) // Each time a new file is opened, the metrics should be updated as so: @@ -239,7 +247,6 @@ func TestRelease(t *testing.T) { // Total counts remain require.Equal(t, batches*fileSize, promhelp.CounterValue(t, reg, cachePromMetricName("open_files_size_bytes_total"), nil)) require.Equal(t, batches, promhelp.CounterValue(t, reg, cachePromMetricName("open_files_total"), nil)) - require.Equal(t, batches*batchSize, promhelp.CounterValue(t, reg, cachePromMetricName("open_file_refs_total"), nil)) } func cacheAuthzSetup(t *testing.T) (database.Store, *files.Cache, *coderdtest.RecordingAuthorizer) { @@ -257,7 +264,7 @@ func cacheAuthzSetup(t *testing.T) (database.Store, *files.Cache, *coderdtest.Re // Dbauthz wrap the db db = dbauthz.New(db, rec, logger, coderdtest.AccessControlStorePointer()) - c := files.NewFromStore(db, reg, rec) + c := files.New(reg, rec) return db, c, rec } diff --git a/coderd/files/closer.go b/coderd/files/closer.go new file mode 100644 index 0000000000000..560786c78f80e --- /dev/null +++ b/coderd/files/closer.go @@ -0,0 +1,59 @@ +package files + +import ( + "context" + "sync" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" +) + +// CacheCloser is a cache wrapper used to close all acquired files. +// This is a more simple interface to use if opening multiple files at once. +type CacheCloser struct { + cache FileAcquirer + + closers []func() + mu sync.Mutex +} + +func NewCacheCloser(cache FileAcquirer) *CacheCloser { + return &CacheCloser{ + cache: cache, + closers: make([]func(), 0), + } +} + +func (c *CacheCloser) Close() { + c.mu.Lock() + defer c.mu.Unlock() + + for _, doClose := range c.closers { + doClose() + } + + // Prevent further acquisitions + c.cache = nil + // Remove any references + c.closers = nil +} + +func (c *CacheCloser) Acquire(ctx context.Context, db database.Store, fileID uuid.UUID) (*CloseFS, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cache == nil { + return nil, xerrors.New("cache is closed, and cannot acquire new files") + } + + f, err := c.cache.Acquire(ctx, db, fileID) + if err != nil { + return nil, err + } + + c.closers = append(c.closers, f.close) + + return f, nil +} diff --git a/coderd/healthcheck/health/model_test.go b/coderd/healthcheck/health/model_test.go index 8b28dc5862517..2ff51652f3275 100644 --- a/coderd/healthcheck/health/model_test.go +++ b/coderd/healthcheck/health/model_test.go @@ -21,7 +21,6 @@ func Test_MessageURL(t *testing.T) { {"default", health.CodeAccessURLFetch, "", "https://coder.com/docs/admin/monitoring/health-check#eacs03"}, {"custom docs base", health.CodeAccessURLFetch, "https://example.com/docs", "https://example.com/docs/admin/monitoring/health-check#eacs03"}, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() uut := health.Message{Code: tt.code} diff --git a/coderd/healthcheck/healthcheck_test.go b/coderd/healthcheck/healthcheck_test.go index 9c744b42d1dca..2b49b3215e251 100644 --- a/coderd/healthcheck/healthcheck_test.go +++ b/coderd/healthcheck/healthcheck_test.go @@ -508,7 +508,6 @@ func TestHealthcheck(t *testing.T) { }, severity: health.SeverityError, }} { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/healthcheck/provisioner_test.go b/coderd/healthcheck/provisioner_test.go index 93871f4a709ad..e2f0c6119ed09 100644 --- a/coderd/healthcheck/provisioner_test.go +++ b/coderd/healthcheck/provisioner_test.go @@ -335,7 +335,6 @@ func TestProvisionerDaemonReport(t *testing.T) { expectedItems: []healthsdk.ProvisionerDaemonsReportItem{}, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/healthcheck/workspaceproxy_internal_test.go b/coderd/healthcheck/workspaceproxy_internal_test.go index 5a7875518df7e..be367ee2061c9 100644 --- a/coderd/healthcheck/workspaceproxy_internal_test.go +++ b/coderd/healthcheck/workspaceproxy_internal_test.go @@ -47,7 +47,6 @@ func Test_WorkspaceProxyReport_appendErrors(t *testing.T) { errs: []string{assert.AnError.Error(), "another error"}, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -85,7 +84,6 @@ func Test_calculateSeverity(t *testing.T) { {2, 0, 0, health.SeverityError}, {2, 0, 1, health.SeverityError}, } { - tt := tt name := fmt.Sprintf("%d total, %d healthy, %d warning -> %s", tt.total, tt.healthy, tt.warning, tt.expected) t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/coderd/healthcheck/workspaceproxy_test.go b/coderd/healthcheck/workspaceproxy_test.go index d5bd5c12210b8..e8fc7a339c408 100644 --- a/coderd/healthcheck/workspaceproxy_test.go +++ b/coderd/healthcheck/workspaceproxy_test.go @@ -172,7 +172,6 @@ func TestWorkspaceProxies(t *testing.T) { expectedSeverity: health.SeverityError, }, } { - tt := tt if tt.name != "Enabled/ProxyWarnings" { continue } diff --git a/coderd/httpapi/cookie_test.go b/coderd/httpapi/cookie_test.go index 4d44cd8f7d130..e653e3653ba14 100644 --- a/coderd/httpapi/cookie_test.go +++ b/coderd/httpapi/cookie_test.go @@ -26,7 +26,6 @@ func TestStripCoderCookies(t *testing.T) { "coder_session_token=ok; oauth_state=wow; oauth_redirect=/", "", }} { - tc := tc t.Run(tc.Input, func(t *testing.T) { t.Parallel() require.Equal(t, tc.Output, httpapi.StripCoderCookies(tc.Input)) diff --git a/coderd/httpapi/httperror/doc.go b/coderd/httpapi/httperror/doc.go new file mode 100644 index 0000000000000..01a0b3956e3e7 --- /dev/null +++ b/coderd/httpapi/httperror/doc.go @@ -0,0 +1,4 @@ +// Package httperror handles formatting and writing some sentinel errors returned +// within coder to the API. +// This package exists outside httpapi to avoid some cyclic dependencies +package httperror diff --git a/coderd/httpapi/httperror/wsbuild.go b/coderd/httpapi/httperror/wsbuild.go new file mode 100644 index 0000000000000..17436b55d5ae0 --- /dev/null +++ b/coderd/httpapi/httperror/wsbuild.go @@ -0,0 +1,91 @@ +package httperror + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + + "github.com/hashicorp/hcl/v2" + + "github.com/coder/coder/v2/coderd/dynamicparameters" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/wsbuilder" + "github.com/coder/coder/v2/codersdk" +) + +func WriteWorkspaceBuildError(ctx context.Context, rw http.ResponseWriter, err error) { + var buildErr wsbuilder.BuildError + if errors.As(err, &buildErr) { + if httpapi.IsUnauthorizedError(err) { + buildErr.Status = http.StatusForbidden + } + + httpapi.Write(ctx, rw, buildErr.Status, codersdk.Response{ + Message: buildErr.Message, + Detail: buildErr.Error(), + }) + return + } + + var parameterErr *dynamicparameters.ResolverError + if errors.As(err, ¶meterErr) { + resp := codersdk.Response{ + Message: "Unable to validate parameters", + Validations: nil, + } + + // Sort the parameter names so that the order is consistent. + sortedNames := make([]string, 0, len(parameterErr.Parameter)) + for name := range parameterErr.Parameter { + sortedNames = append(sortedNames, name) + } + sort.Strings(sortedNames) + + for _, name := range sortedNames { + diag := parameterErr.Parameter[name] + resp.Validations = append(resp.Validations, codersdk.ValidationError{ + Field: name, + Detail: DiagnosticsErrorString(diag), + }) + } + + if parameterErr.Diagnostics.HasErrors() { + resp.Detail = DiagnosticsErrorString(parameterErr.Diagnostics) + } + + httpapi.Write(ctx, rw, http.StatusBadRequest, resp) + return + } + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating workspace build.", + Detail: err.Error(), + }) +} + +func DiagnosticError(d *hcl.Diagnostic) string { + return fmt.Sprintf("%s; %s", d.Summary, d.Detail) +} + +func DiagnosticsErrorString(d hcl.Diagnostics) string { + count := len(d) + switch { + case count == 0: + return "no diagnostics" + case count == 1: + return DiagnosticError(d[0]) + default: + for _, d := range d { + // Render the first error diag. + // If there are warnings, do not priority them over errors. + if d.Severity == hcl.DiagError { + return fmt.Sprintf("%s, and %d other diagnostic(s)", DiagnosticError(d), count-1) + } + } + + // All warnings? ok... + return fmt.Sprintf("%s, and %d other diagnostic(s)", DiagnosticError(d[0]), count-1) + } +} diff --git a/coderd/httpapi/json_test.go b/coderd/httpapi/json_test.go index a0a93e884d44f..8cfe8068e7b2e 100644 --- a/coderd/httpapi/json_test.go +++ b/coderd/httpapi/json_test.go @@ -46,8 +46,6 @@ func TestDuration(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.expected, func(t *testing.T) { t.Parallel() @@ -109,8 +107,6 @@ func TestDuration(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.value, func(t *testing.T) { t.Parallel() @@ -153,8 +149,6 @@ func TestDuration(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.value, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 3ee9d92742252..4991dbeb9c46e 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -107,7 +107,6 @@ func TestExtractUserRoles(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/chat.go b/coderd/httpmw/chat.go deleted file mode 100644 index c92fa5038ab22..0000000000000 --- a/coderd/httpmw/chat.go +++ /dev/null @@ -1,59 +0,0 @@ -package httpmw - -import ( - "context" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/httpapi" - "github.com/coder/coder/v2/codersdk" -) - -type chatContextKey struct{} - -func ChatParam(r *http.Request) database.Chat { - chat, ok := r.Context().Value(chatContextKey{}).(database.Chat) - if !ok { - panic("developer error: chat param middleware not provided") - } - return chat -} - -func ExtractChatParam(db database.Store) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - arg := chi.URLParam(r, "chat") - if arg == "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "\"chat\" must be provided.", - }) - return - } - chatID, err := uuid.Parse(arg) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid chat ID.", - }) - return - } - chat, err := db.GetChatByID(ctx, chatID) - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get chat.", - Detail: err.Error(), - }) - return - } - ctx = context.WithValue(ctx, chatContextKey{}, chat) - next.ServeHTTP(rw, r.WithContext(ctx)) - }) - } -} diff --git a/coderd/httpmw/chat_test.go b/coderd/httpmw/chat_test.go deleted file mode 100644 index 3acc2db8b9877..0000000000000 --- a/coderd/httpmw/chat_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package httpmw_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/codersdk" -) - -func TestExtractChat(t *testing.T) { - t.Parallel() - - setupAuthentication := func(db database.Store) (*http.Request, database.User) { - r := httptest.NewRequest("GET", "/", nil) - - user := dbgen.User(t, db, database.User{ - ID: uuid.New(), - }) - _, token := dbgen.APIKey(t, db, database.APIKey{ - UserID: user.ID, - }) - r.Header.Set(codersdk.SessionTokenHeader, token) - r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.NewRouteContext())) - return r, user - } - - t.Run("None", func(t *testing.T) { - t.Parallel() - var ( - db, _ = dbtestutil.NewDB(t) - rw = httptest.NewRecorder() - r, _ = setupAuthentication(db) - rtr = chi.NewRouter() - ) - rtr.Use( - httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ - DB: db, - RedirectToLogin: false, - }), - httpmw.ExtractChatParam(db), - ) - rtr.Get("/", nil) - rtr.ServeHTTP(rw, r) - res := rw.Result() - defer res.Body.Close() - require.Equal(t, http.StatusBadRequest, res.StatusCode) - }) - - t.Run("InvalidUUID", func(t *testing.T) { - t.Parallel() - var ( - db, _ = dbtestutil.NewDB(t) - rw = httptest.NewRecorder() - r, _ = setupAuthentication(db) - rtr = chi.NewRouter() - ) - chi.RouteContext(r.Context()).URLParams.Add("chat", "not-a-uuid") - rtr.Use( - httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ - DB: db, - RedirectToLogin: false, - }), - httpmw.ExtractChatParam(db), - ) - rtr.Get("/", nil) - rtr.ServeHTTP(rw, r) - res := rw.Result() - defer res.Body.Close() - require.Equal(t, http.StatusBadRequest, res.StatusCode) // Changed from NotFound in org test to BadRequest as per chat.go - }) - - t.Run("NotFound", func(t *testing.T) { - t.Parallel() - var ( - db, _ = dbtestutil.NewDB(t) - rw = httptest.NewRecorder() - r, _ = setupAuthentication(db) - rtr = chi.NewRouter() - ) - chi.RouteContext(r.Context()).URLParams.Add("chat", uuid.NewString()) - rtr.Use( - httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ - DB: db, - RedirectToLogin: false, - }), - httpmw.ExtractChatParam(db), - ) - rtr.Get("/", nil) - rtr.ServeHTTP(rw, r) - res := rw.Result() - defer res.Body.Close() - require.Equal(t, http.StatusNotFound, res.StatusCode) - }) - - t.Run("Success", func(t *testing.T) { - t.Parallel() - var ( - db, _ = dbtestutil.NewDB(t) - rw = httptest.NewRecorder() - r, user = setupAuthentication(db) - rtr = chi.NewRouter() - ) - - // Create a test chat - testChat := dbgen.Chat(t, db, database.Chat{ - ID: uuid.New(), - OwnerID: user.ID, - CreatedAt: dbtime.Now(), - UpdatedAt: dbtime.Now(), - Title: "Test Chat", - }) - - rtr.Use( - httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ - DB: db, - RedirectToLogin: false, - }), - httpmw.ExtractChatParam(db), - ) - rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { - chat := httpmw.ChatParam(r) - require.NotZero(t, chat) - assert.Equal(t, testChat.ID, chat.ID) - assert.WithinDuration(t, testChat.CreatedAt, chat.CreatedAt, time.Second) - assert.WithinDuration(t, testChat.UpdatedAt, chat.UpdatedAt, time.Second) - assert.Equal(t, testChat.Title, chat.Title) - rw.WriteHeader(http.StatusOK) - }) - - // Try by ID - chi.RouteContext(r.Context()).URLParams.Add("chat", testChat.ID.String()) - rtr.ServeHTTP(rw, r) - res := rw.Result() - defer res.Body.Close() - require.Equal(t, http.StatusOK, res.StatusCode, "by id") - }) -} diff --git a/coderd/httpmw/cors_test.go b/coderd/httpmw/cors_test.go index 57111799ff292..4d48d535a23e4 100644 --- a/coderd/httpmw/cors_test.go +++ b/coderd/httpmw/cors_test.go @@ -91,7 +91,6 @@ func TestWorkspaceAppCors(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/csp.go b/coderd/httpmw/csp.go index 06897a45afd01..f39781ad51b03 100644 --- a/coderd/httpmw/csp.go +++ b/coderd/httpmw/csp.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/coder/coder/v2/coderd/proxyhealth" - "github.com/coder/coder/v2/codersdk" ) // cspDirectives is a map of all csp fetch directives to their values. @@ -59,7 +58,7 @@ const ( // Example: https://github.com/coder/coder/issues/15118 // //nolint:revive -func CSPHeaders(experiments codersdk.Experiments, telemetry bool, proxyHosts func() []*proxyhealth.ProxyHost, staticAdditions map[CSPFetchDirective][]string) func(next http.Handler) http.Handler { +func CSPHeaders(telemetry bool, proxyHosts func() []*proxyhealth.ProxyHost, staticAdditions map[CSPFetchDirective][]string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Content-Security-Policy disables loading certain content types and can prevent XSS injections. @@ -124,9 +123,7 @@ func CSPHeaders(experiments codersdk.Experiments, telemetry bool, proxyHosts fun if len(extraConnect) > 0 { for _, extraHost := range extraConnect { // Allow embedding the app host. - if experiments.Enabled(codersdk.ExperimentAITasks) { - cspSrcs.Append(CSPDirectiveFrameSrc, extraHost.AppHost) - } + cspSrcs.Append(CSPDirectiveFrameSrc, extraHost.AppHost) if extraHost.Host == "*" { // '*' means all cspSrcs.Append(CSPDirectiveConnectSrc, "*") diff --git a/coderd/httpmw/csp_test.go b/coderd/httpmw/csp_test.go index 5fd4b5bbd38aa..7bf8b879ef26f 100644 --- a/coderd/httpmw/csp_test.go +++ b/coderd/httpmw/csp_test.go @@ -10,7 +10,6 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/proxyhealth" - "github.com/coder/coder/v2/codersdk" ) func TestCSP(t *testing.T) { @@ -50,9 +49,7 @@ func TestCSP(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/", nil) rw := httptest.NewRecorder() - httpmw.CSPHeaders(codersdk.Experiments{ - codersdk.ExperimentAITasks, - }, false, func() []*proxyhealth.ProxyHost { + httpmw.CSPHeaders(false, func() []*proxyhealth.ProxyHost { return proxyHosts }, map[httpmw.CSPFetchDirective][]string{ httpmw.CSPDirectiveMediaSrc: expectedMedia, diff --git a/coderd/httpmw/csrf_test.go b/coderd/httpmw/csrf_test.go index 9e8094ad50d6d..62e8150fb099f 100644 --- a/coderd/httpmw/csrf_test.go +++ b/coderd/httpmw/csrf_test.go @@ -57,7 +57,6 @@ func TestCSRFExemptList(t *testing.T) { csrfmw := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).(*nosurf.CSRFHandler) for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/hsts_test.go b/coderd/httpmw/hsts_test.go index 3bc3463e69e65..0e36f8993c1dd 100644 --- a/coderd/httpmw/hsts_test.go +++ b/coderd/httpmw/hsts_test.go @@ -77,7 +77,6 @@ func TestHSTS(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/loggermw/logger_internal_test.go b/coderd/httpmw/loggermw/logger_internal_test.go index 53cc9f4eb9462..f372c665fda14 100644 --- a/coderd/httpmw/loggermw/logger_internal_test.go +++ b/coderd/httpmw/loggermw/logger_internal_test.go @@ -247,7 +247,6 @@ func TestRequestLogger_RouteParamsLogging(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index efedc3a764591..c12772a4de4e4 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -180,7 +180,7 @@ func ExtractOrganizationMember(ctx context.Context, auth func(r *http.Request, a organizationMembers, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{ OrganizationID: orgID, UserID: user.ID, - IncludeSystem: false, + IncludeSystem: true, }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) diff --git a/coderd/httpmw/patternmatcher/routepatterns_test.go b/coderd/httpmw/patternmatcher/routepatterns_test.go index 58d914d231e90..623c22afbab92 100644 --- a/coderd/httpmw/patternmatcher/routepatterns_test.go +++ b/coderd/httpmw/patternmatcher/routepatterns_test.go @@ -108,7 +108,6 @@ func Test_RoutePatterns(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index 3070070bd90d8..18b870ae379c2 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -200,7 +200,6 @@ func TestExtractAddress(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.Name, func(t *testing.T) { t.Parallel() @@ -235,9 +234,6 @@ func TestTrustedOrigins(t *testing.T) { // ipv6: trust an IPv4 network for _, trusted := range []string{"none", "ipv4", "ipv6"} { for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} { - trusted := trusted - header := header - proto := proto name := fmt.Sprintf("%s-%s-%s", trusted, proto, strings.ToLower(header)) t.Run(name, func(t *testing.T) { @@ -311,7 +307,6 @@ func TestCorruptedHeaders(t *testing.T) { t.Parallel() for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} { - header := header name := strings.ToLower(header) t.Run(name, func(t *testing.T) { @@ -364,9 +359,6 @@ func TestAddressFamilies(t *testing.T) { for _, clientFamily := range []string{"ipv4", "ipv6"} { for _, proxyFamily := range []string{"ipv4", "ipv6"} { for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} { - clientFamily := clientFamily - proxyFamily := proxyFamily - header := header name := fmt.Sprintf("%s-%s-%s", strings.ToLower(header), clientFamily, proxyFamily) t.Run(name, func(t *testing.T) { @@ -466,7 +458,6 @@ func TestFilterUntrusted(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.Name, func(t *testing.T) { t.Parallel() @@ -612,7 +603,6 @@ func TestApplicationProxy(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/recover_test.go b/coderd/httpmw/recover_test.go index b76c5b105baf5..d4d4227ff15ef 100644 --- a/coderd/httpmw/recover_test.go +++ b/coderd/httpmw/recover_test.go @@ -52,8 +52,6 @@ func TestRecover(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 33b0c753068f7..85e11cf3975fd 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -316,7 +316,6 @@ func TestWorkspaceAgentByNameParam(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() db, r := setupWorkspaceWithAgents(t, setupConfig{ diff --git a/coderd/idpsync/group.go b/coderd/idpsync/group.go index b5d8003165665..0b21c5b9ac84c 100644 --- a/coderd/idpsync/group.go +++ b/coderd/idpsync/group.go @@ -99,7 +99,6 @@ func (s AGPLIDPSync) SyncGroups(ctx context.Context, db database.Store, user dat // membership via the groups the user is in. userOrgs := make(map[uuid.UUID][]database.GetGroupsRow) for _, g := range userGroups { - g := g userOrgs[g.Group.OrganizationID] = append(userOrgs[g.Group.OrganizationID], g) } @@ -337,8 +336,6 @@ func (s GroupSyncSettings) ParseClaims(orgID uuid.UUID, mergedClaims jwt.MapClai groups := make([]ExpectedGroup, 0) for _, group := range parsedGroups { - group := group - // Legacy group mappings happen before the regex filter. mappedGroupName, ok := s.LegacyNameMapping[group] if ok { @@ -355,7 +352,6 @@ func (s GroupSyncSettings) ParseClaims(orgID uuid.UUID, mergedClaims jwt.MapClai mappedGroupIDs, ok := s.Mapping[group] if ok { for _, gid := range mappedGroupIDs { - gid := gid groups = append(groups, ExpectedGroup{OrganizationID: orgID, GroupID: &gid}) } continue diff --git a/coderd/idpsync/group_test.go b/coderd/idpsync/group_test.go index 7b0fb70ae8f68..478d6557de551 100644 --- a/coderd/idpsync/group_test.go +++ b/coderd/idpsync/group_test.go @@ -243,7 +243,6 @@ func TestGroupSyncTable(t *testing.T) { } for _, tc := range testCases { - tc := tc // The final test, "AllTogether", cannot run in parallel. // These tests are nearly instant using the memory db, so // this is still fast without being in parallel. @@ -341,8 +340,6 @@ func TestGroupSyncTable(t *testing.T) { }) for _, tc := range testCases { - tc := tc - orgID := uuid.New() SetupOrganization(t, s, db, user, orgID, tc) asserts = append(asserts, func(t *testing.T) { @@ -523,7 +520,6 @@ func TestApplyGroupDifference(t *testing.T) { } for _, tc := range testCase { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -713,7 +709,6 @@ func TestExpectedGroupEqual(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/idpsync/idpsync_test.go b/coderd/idpsync/idpsync_test.go index 0db5c66bed174..f3dc9c2f07986 100644 --- a/coderd/idpsync/idpsync_test.go +++ b/coderd/idpsync/idpsync_test.go @@ -159,7 +159,6 @@ func TestParseStringSliceClaim(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/idpsync/role_test.go b/coderd/idpsync/role_test.go index f07d97a2b0f31..6df091097b966 100644 --- a/coderd/idpsync/role_test.go +++ b/coderd/idpsync/role_test.go @@ -186,7 +186,6 @@ func TestRoleSyncTable(t *testing.T) { } for _, tc := range testCases { - tc := tc // The final test, "AllTogether", cannot run in parallel. // These tests are nearly instant using the memory db, so // this is still fast without being in parallel. @@ -248,8 +247,6 @@ func TestRoleSyncTable(t *testing.T) { var asserts []func(t *testing.T) for _, tc := range testCases { - tc := tc - orgID := uuid.New() SetupOrganization(t, s, db, user, orgID, tc) asserts = append(asserts, func(t *testing.T) { diff --git a/coderd/inboxnotifications_internal_test.go b/coderd/inboxnotifications_internal_test.go index e7d9a85d3e74f..c99d376bb77e9 100644 --- a/coderd/inboxnotifications_internal_test.go +++ b/coderd/inboxnotifications_internal_test.go @@ -29,8 +29,6 @@ func TestInboxNotifications_ensureNotificationIcon(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 82ae539518ae0..c43149d8c8211 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -57,7 +57,6 @@ func TestInboxNotification_Watch(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -393,7 +392,6 @@ func TestInboxNotifications_List(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go index 111bd268e8855..d3302e23cc85b 100644 --- a/coderd/insights_internal_test.go +++ b/coderd/insights_internal_test.go @@ -144,7 +144,6 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -253,8 +252,6 @@ func Test_parseInsightsInterval_week(t *testing.T) { }, } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -323,7 +320,6 @@ func TestLastReportIntervalHasAtLeastSixDays(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 693bb48811acc..ded030351a3b3 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -550,8 +550,6 @@ func TestTemplateInsights_Golden(t *testing.T) { // Prepare all the templates. for _, template := range templates { - template := template - var parameters []*proto.RichParameter for _, parameter := range template.parameters { var options []*proto.RichParameterOption @@ -582,10 +580,7 @@ func TestTemplateInsights_Golden(t *testing.T) { ) var resources []*proto.Resource for _, user := range users { - user := user for _, workspace := range user.workspaces { - workspace := workspace - if workspace.template != template { continue } @@ -1246,7 +1241,6 @@ func TestTemplateInsights_Golden(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -1261,7 +1255,6 @@ func TestTemplateInsights_Golden(t *testing.T) { _, _ = <-events, <-events for _, req := range tt.requests { - req := req t.Run(req.name, func(t *testing.T) { t.Parallel() @@ -1489,8 +1482,6 @@ func TestUserActivityInsights_Golden(t *testing.T) { // Prepare all the templates. for _, template := range templates { - template := template - // Prepare all workspace resources (agents and apps). var ( createWorkspaces []func(uuid.UUID) @@ -1498,10 +1489,7 @@ func TestUserActivityInsights_Golden(t *testing.T) { ) var resources []*proto.Resource for _, user := range users { - user := user for _, workspace := range user.workspaces { - workspace := workspace - if workspace.template != template { continue } @@ -2031,7 +2019,6 @@ func TestUserActivityInsights_Golden(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -2046,7 +2033,6 @@ func TestUserActivityInsights_Golden(t *testing.T) { _, _ = <-events, <-events for _, req := range tt.requests { - req := req t.Run(req.name, func(t *testing.T) { t.Parallel() @@ -2159,8 +2145,6 @@ func TestTemplateInsights_RBAC(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(fmt.Sprintf("with interval=%q", tt.interval), func(t *testing.T) { t.Parallel() @@ -2279,9 +2263,6 @@ func TestGenericInsights_RBAC(t *testing.T) { } for endpointName, endpoint := range endpoints { - endpointName := endpointName - endpoint := endpoint - t.Run(fmt.Sprintf("With%sEndpoint", endpointName), func(t *testing.T) { t.Parallel() @@ -2291,8 +2272,6 @@ func TestGenericInsights_RBAC(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run("AsOwner", func(t *testing.T) { t.Parallel() diff --git a/coderd/jobreaper/detector_test.go b/coderd/jobreaper/detector_test.go index 28457aeeca3a8..4078f92c03a36 100644 --- a/coderd/jobreaper/detector_test.go +++ b/coderd/jobreaper/detector_test.go @@ -844,8 +844,6 @@ func TestDetectorPushesLogs(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/jwtutils/jwt_test.go b/coderd/jwtutils/jwt_test.go index a2126092ff015..9a9ae8d3f44fb 100644 --- a/coderd/jwtutils/jwt_test.go +++ b/coderd/jwtutils/jwt_test.go @@ -149,12 +149,9 @@ func TestClaims(t *testing.T) { } for _, tt := range types { - tt := tt - t.Run(tt.Name, func(t *testing.T) { t.Parallel() for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 53852f41c904b..4582187a33651 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -202,7 +202,6 @@ func TestCache_BuildTime(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/notifications/dispatch/inbox_test.go b/coderd/notifications/dispatch/inbox_test.go index a06b698e9769a..744623ed2c99f 100644 --- a/coderd/notifications/dispatch/inbox_test.go +++ b/coderd/notifications/dispatch/inbox_test.go @@ -69,7 +69,6 @@ func TestInbox(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index b3e087a9e7d8f..ec9edee4c8514 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1283,8 +1283,6 @@ func TestNotificationTemplates_Golden(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -2006,8 +2004,6 @@ func TestNotificationTargetMatrix(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/notifications/render/gotmpl_test.go b/coderd/notifications/render/gotmpl_test.go index 25e52cc07f671..c49cab7b991fd 100644 --- a/coderd/notifications/render/gotmpl_test.go +++ b/coderd/notifications/render/gotmpl_test.go @@ -68,8 +68,6 @@ func TestGoTemplate(t *testing.T) { } for _, tc := range tests { - tc := tc // unnecessary as of go1.22 but the linter is outdated - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index f5311be173bac..e081d3e1483db 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -151,7 +151,6 @@ func TestOAuth2ProviderApps(t *testing.T) { require.NoError(t, err) for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -661,7 +660,6 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) { }, } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -804,7 +802,6 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { }, } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -996,7 +993,6 @@ func TestOAuth2ProviderRevoke(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/coderd/pagination_internal_test.go b/coderd/pagination_internal_test.go index adcfde6bbb641..18d98c2fab319 100644 --- a/coderd/pagination_internal_test.go +++ b/coderd/pagination_internal_test.go @@ -110,7 +110,6 @@ func TestPagination(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() rw := httptest.NewRecorder() diff --git a/coderd/parameters.go b/coderd/parameters.go index dacd8de812ab8..4b8b13486934f 100644 --- a/coderd/parameters.go +++ b/coderd/parameters.go @@ -2,31 +2,18 @@ package coderd import ( "context" - "database/sql" - "encoding/json" - "io/fs" "net/http" "time" "github.com/google/uuid" - "github.com/hashicorp/hcl/v2" - "golang.org/x/sync/errgroup" "golang.org/x/xerrors" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/coderd/database/dbauthz" - "github.com/coder/coder/v2/coderd/files" + "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" - sdkproto "github.com/coder/coder/v2/provisionersdk/proto" - "github.com/coder/preview" - previewtypes "github.com/coder/preview/types" - "github.com/coder/terraform-provider-coder/v2/provider" "github.com/coder/websocket" ) @@ -81,292 +68,54 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter })(rw, r) } +// The `listen` control flag determines whether to open a websocket connection to +// handle the request or not. This same function is used to 'evaluate' a template +// as a single invocation, or to 'listen' for a back and forth interaction with +// the user to update the form as they type. +// +//nolint:revive // listen is a control flag func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.DynamicParametersRequest) func(rw http.ResponseWriter, r *http.Request) { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() templateVersion := httpmw.TemplateVersionParam(r) - // Check that the job has completed successfully - job, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID) - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } + renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID, + dynamicparameters.WithTemplateVersion(templateVersion), + ) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching provisioner job.", - Detail: err.Error(), - }) - return - } - if !job.CompletedAt.Valid { - httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{ - Message: "Template version job has not finished", - }) - return - } + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + + if xerrors.Is(err, dynamicparameters.ErrTemplateVersionNotReady) { + httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{ + Message: "Template version job has not finished", + }) + return + } - tf, err := api.Database.GetTemplateVersionTerraformValues(ctx, templateVersion.ID) - if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to retrieve Terraform values for template version", + Message: "Internal error fetching template version data.", Detail: err.Error(), }) return } + defer renderer.Close() - if wsbuilder.ProvisionerVersionSupportsDynamicParameters(tf.ProvisionerdVersion) { - api.handleDynamicParameters(listen, rw, r, tf, templateVersion, initial) + if listen { + api.handleParameterWebsocket(rw, r, initial, renderer) } else { - api.handleStaticParameters(listen, rw, r, templateVersion.ID, initial) - } - } -} - -type previewFunction func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) - -// nolint:revive -func (api *API) handleDynamicParameters(listen bool, rw http.ResponseWriter, r *http.Request, tf database.TemplateVersionTerraformValue, templateVersion database.TemplateVersion, initial codersdk.DynamicParametersRequest) { - var ( - ctx = r.Context() - apikey = httpmw.APIKey(r) - ) - - // nolint:gocritic // We need to fetch the templates files for the Terraform - // evaluator, and the user likely does not have permission. - fileCtx := dbauthz.AsFileReader(ctx) - fileID, err := api.Database.GetFileIDByTemplateVersionID(fileCtx, templateVersion.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error finding template version Terraform.", - Detail: err.Error(), - }) - return - } - - // Add the file first. Calling `Release` if it fails is a no-op, so this is safe. - var templateFS fs.FS - closeableTemplateFS, err := api.FileCache.Acquire(fileCtx, fileID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "Internal error fetching template version Terraform.", - Detail: err.Error(), - }) - return - } - defer closeableTemplateFS.Close() - // templateFS does not implement the Close method. For it to be later merged with - // the module files, we need to convert it to an OverlayFS. - templateFS = closeableTemplateFS - - // Having the Terraform plan available for the evaluation engine is helpful - // for populating values from data blocks, but isn't strictly required. If - // we don't have a cached plan available, we just use an empty one instead. - plan := json.RawMessage("{}") - if len(tf.CachedPlan) > 0 { - plan = tf.CachedPlan - } - - if tf.CachedModuleFiles.Valid { - moduleFilesFS, err := api.FileCache.Acquire(fileCtx, tf.CachedModuleFiles.UUID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "Internal error fetching Terraform modules.", - Detail: err.Error(), - }) - return - } - defer moduleFilesFS.Close() - - templateFS = files.NewOverlayFS(closeableTemplateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}}) - } - - owner, err := getWorkspaceOwnerData(ctx, api.Database, apikey.UserID, templateVersion.OrganizationID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace owner.", - Detail: err.Error(), - }) - return - } - - input := preview.Input{ - PlanJSON: plan, - ParameterValues: map[string]string{}, - Owner: owner, - } - - // failedOwners keeps track of which owners failed to fetch from the database. - // This prevents db spam on repeated requests for the same failed owner. - failedOwners := make(map[uuid.UUID]error) - failedOwnerDiag := hcl.Diagnostics{ - { - Severity: hcl.DiagError, - Summary: "Failed to fetch workspace owner", - Detail: "Please check your permissions or the user may not exist.", - Extra: previewtypes.DiagnosticExtra{ - Code: "owner_not_found", - }, - }, - } - - dynamicRender := func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) { - if ownerID == uuid.Nil { - // Default to the authenticated user - // Nice for testing - ownerID = apikey.UserID - } - - if _, ok := failedOwners[ownerID]; ok { - // If it has failed once, assume it will fail always. - // Re-open the websocket to try again. - return nil, failedOwnerDiag - } - - // Update the input values with the new values. - input.ParameterValues = values - - // Update the owner if there is a change - if input.Owner.ID != ownerID.String() { - owner, err = getWorkspaceOwnerData(ctx, api.Database, ownerID, templateVersion.OrganizationID) - if err != nil { - failedOwners[ownerID] = err - return nil, failedOwnerDiag - } - input.Owner = owner - } - - return preview.Preview(ctx, input, templateFS) - } - if listen { - api.handleParameterWebsocket(rw, r, initial, dynamicRender) - } else { - api.handleParameterEvaluate(rw, r, initial, dynamicRender) - } -} - -// nolint:revive -func (api *API) handleStaticParameters(listen bool, rw http.ResponseWriter, r *http.Request, version uuid.UUID, initial codersdk.DynamicParametersRequest) { - ctx := r.Context() - dbTemplateVersionParameters, err := api.Database.GetTemplateVersionParameters(ctx, version) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to retrieve template version parameters", - Detail: err.Error(), - }) - return - } - - params := make([]previewtypes.Parameter, 0, len(dbTemplateVersionParameters)) - for _, it := range dbTemplateVersionParameters { - param := previewtypes.Parameter{ - ParameterData: previewtypes.ParameterData{ - Name: it.Name, - DisplayName: it.DisplayName, - Description: it.Description, - Type: previewtypes.ParameterType(it.Type), - FormType: "", // ooooof - Styling: previewtypes.ParameterStyling{}, - Mutable: it.Mutable, - DefaultValue: previewtypes.StringLiteral(it.DefaultValue), - Icon: it.Icon, - Options: make([]*previewtypes.ParameterOption, 0), - Validations: make([]*previewtypes.ParameterValidation, 0), - Required: it.Required, - Order: int64(it.DisplayOrder), - Ephemeral: it.Ephemeral, - Source: nil, - }, - // Always use the default, since we used to assume the empty string - Value: previewtypes.StringLiteral(it.DefaultValue), - Diagnostics: nil, - } - - if it.ValidationError != "" || it.ValidationRegex != "" || it.ValidationMonotonic != "" { - var reg *string - if it.ValidationRegex != "" { - reg = ptr.Ref(it.ValidationRegex) - } - - var vMin *int64 - if it.ValidationMin.Valid { - vMin = ptr.Ref(int64(it.ValidationMin.Int32)) - } - - var vMax *int64 - if it.ValidationMax.Valid { - vMin = ptr.Ref(int64(it.ValidationMax.Int32)) - } - - var monotonic *string - if it.ValidationMonotonic != "" { - monotonic = ptr.Ref(it.ValidationMonotonic) - } - - param.Validations = append(param.Validations, &previewtypes.ParameterValidation{ - Error: it.ValidationError, - Regex: reg, - Min: vMin, - Max: vMax, - Monotonic: monotonic, - }) - } - - var protoOptions []*sdkproto.RichParameterOption - _ = json.Unmarshal(it.Options, &protoOptions) // Not going to make this fatal - for _, opt := range protoOptions { - param.Options = append(param.Options, &previewtypes.ParameterOption{ - Name: opt.Name, - Description: opt.Description, - Value: previewtypes.StringLiteral(opt.Value), - Icon: opt.Icon, - }) + api.handleParameterEvaluate(rw, r, initial, renderer) } - - // Take the form type from the ValidateFormType function. This is a bit - // unfortunate we have to do this, but it will return the default form_type - // for a given set of conditions. - _, param.FormType, _ = provider.ValidateFormType(provider.OptionType(param.Type), len(param.Options), param.FormType) - - param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value)) - params = append(params, param) - } - - staticRender := func(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) { - for i := range params { - param := ¶ms[i] - paramValue, ok := values[param.Name] - if ok { - param.Value = previewtypes.StringLiteral(paramValue) - } else { - param.Value = param.DefaultValue - } - param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value)) - } - - return &preview.Output{ - Parameters: params, - }, hcl.Diagnostics{ - { - // Only a warning because the form does still work. - Severity: hcl.DiagWarning, - Summary: "This template version is missing required metadata to support dynamic parameters.", - Detail: "To restore full functionality, please re-import the terraform as a new template version.", - }, - } - } - if listen { - api.handleParameterWebsocket(rw, r, initial, staticRender) - } else { - api.handleParameterEvaluate(rw, r, initial, staticRender) } } -func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) { +func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) { ctx := r.Context() // Send an initial form state, computed without any user input. - result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs) + result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs) response := codersdk.DynamicParametersResponse{ ID: 0, Diagnostics: db2sdk.HCLDiagnostics(diagnostics), @@ -378,7 +127,7 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini httpapi.Write(ctx, rw, http.StatusOK, response) } -func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) { +func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) defer cancel() @@ -398,7 +147,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request ) // Send an initial form state, computed without any user input. - result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs) + result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs) response := codersdk.DynamicParametersResponse{ ID: -1, // Always start with -1. Diagnostics: db2sdk.HCLDiagnostics(diagnostics), @@ -415,6 +164,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request // As the user types into the form, reprocess the state using their input, // and respond with updates. updates := stream.Chan() + ownerID := initial.OwnerID for { select { case <-ctx.Done(): @@ -426,7 +176,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request return } - result, diagnostics := render(ctx, update.OwnerID, update.Inputs) + // Take a nil uuid to mean the previous owner ID. + // This just removes the need to constantly send who you are. + if update.OwnerID == uuid.Nil { + update.OwnerID = ownerID + } + + ownerID = update.OwnerID + + result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs) response := codersdk.DynamicParametersResponse{ ID: update.ID, Diagnostics: db2sdk.HCLDiagnostics(diagnostics), @@ -442,98 +200,3 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request } } } - -func getWorkspaceOwnerData( - ctx context.Context, - db database.Store, - ownerID uuid.UUID, - organizationID uuid.UUID, -) (previewtypes.WorkspaceOwner, error) { - var g errgroup.Group - - // TODO: @emyrk we should only need read access on the org member, not the - // site wide user object. Figure out a better way to handle this. - user, err := db.GetUserByID(ctx, ownerID) - if err != nil { - return previewtypes.WorkspaceOwner{}, xerrors.Errorf("fetch user: %w", err) - } - - var ownerRoles []previewtypes.WorkspaceOwnerRBACRole - g.Go(func() error { - // nolint:gocritic // This is kind of the wrong query to use here, but it - // matches how the provisioner currently works. We should figure out - // something that needs less escalation but has the correct behavior. - row, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), ownerID) - if err != nil { - return err - } - roles, err := row.RoleNames() - if err != nil { - return err - } - ownerRoles = make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles)) - for _, it := range roles { - if it.OrganizationID != uuid.Nil && it.OrganizationID != organizationID { - continue - } - var orgID string - if it.OrganizationID != uuid.Nil { - orgID = it.OrganizationID.String() - } - ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{ - Name: it.Name, - OrgID: orgID, - }) - } - return nil - }) - - var publicKey string - g.Go(func() error { - // The correct public key has to be sent. This will not be leaked - // unless the template leaks it. - // nolint:gocritic - key, err := db.GetGitSSHKey(dbauthz.AsSystemRestricted(ctx), ownerID) - if err != nil { - return err - } - publicKey = key.PublicKey - return nil - }) - - var groupNames []string - g.Go(func() error { - // The groups need to be sent to preview. These groups are not exposed to the - // user, unless the template does it through the parameters. Regardless, we need - // the correct groups, and a user might not have read access. - // nolint:gocritic - groups, err := db.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{ - OrganizationID: organizationID, - HasMemberID: ownerID, - }) - if err != nil { - return err - } - groupNames = make([]string, 0, len(groups)) - for _, it := range groups { - groupNames = append(groupNames, it.Group.Name) - } - return nil - }) - - err = g.Wait() - if err != nil { - return previewtypes.WorkspaceOwner{}, err - } - - return previewtypes.WorkspaceOwner{ - ID: user.ID.String(), - Name: user.Username, - FullName: user.Name, - Email: user.Email, - LoginType: string(user.LoginType), - RBACRoles: ownerRoles, - SSHPublicKey: publicKey, - Groups: groupNames, - }, nil -} diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 3c792c2ce9a7a..3a5cae7adbe5b 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -15,7 +15,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/provisioner/echo" @@ -100,10 +99,11 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) - require.Len(t, preview.Parameters, 1) - require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name) - require.True(t, preview.Parameters[0].Value.Valid) - require.Equal(t, "CL", preview.Parameters[0].Value.Value) + require.Len(t, preview.Parameters, 2) + coderdtest.AssertParameter(t, "jetbrains_ide", preview.Parameters). + Exists().Value("CL") + coderdtest.AssertParameter(t, "region", preview.Parameters). + Exists().Value("na") }) // OldProvisioners use the static parameters in the dynamic param flow @@ -203,11 +203,16 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, modulesArchive: modulesArchive, - expectWebsocketError: true, }) - // This is checked in setupDynamicParamsTest. Just doing this in the - // test to make it obvious what this test is doing. - require.Zero(t, setup.api.FileCache.Count()) + + stream := setup.stream + previews := stream.Chan() + + // Assert the failed owner + ctx := testutil.Context(t, testutil.WaitShort) + preview := testutil.RequireReceive(ctx, t, previews) + require.Len(t, preview.Diagnostics, 1) + require.Equal(t, preview.Diagnostics[0].Summary, "Failed to fetch workspace owner") }) t.Run("RebuildParameters", func(t *testing.T) { @@ -236,10 +241,11 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) - require.Len(t, preview.Parameters, 1) - require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name) - require.True(t, preview.Parameters[0].Value.Valid) - require.Equal(t, "CL", preview.Parameters[0].Value.Value) + require.Len(t, preview.Parameters, 2) + coderdtest.AssertParameter(t, "jetbrains_ide", preview.Parameters). + Exists().Value("CL") + coderdtest.AssertParameter(t, "region", preview.Parameters). + Exists().Value("na") _ = stream.Close(websocket.StatusGoingAway) wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) { @@ -248,31 +254,35 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { Name: preview.Parameters[0].Name, Value: "GO", }, + { + Name: preview.Parameters[1].Name, + Value: "eu", + }, } - request.EnableDynamicParameters = true }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID) require.NoError(t, err) - require.Len(t, params, 1) - require.Equal(t, "jetbrains_ide", params[0].Name) - require.Equal(t, "GO", params[0].Value) + require.ElementsMatch(t, []codersdk.WorkspaceBuildParameter{ + {Name: "jetbrains_ide", Value: "GO"}, {Name: "region", Value: "eu"}, + }, params) + + regionOptions := []string{"na", "af", "sa", "as"} // A helper function to assert params doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) { t.Helper() - fooVal := coderdtest.RandomUsername(t) + regionVal := regionOptions[0] + regionOptions = regionOptions[1:] // Choose the next region on the next build + bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: setup.template.ActiveVersionID, Transition: trans, RichParameterValues: []codersdk.WorkspaceBuildParameter{ - // No validation, so this should work as is. - // Overwrite the value on each transition - {Name: "foo", Value: fooVal}, + {Name: "region", Value: regionVal}, }, - EnableDynamicParameters: ptr.Ref(true), }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, bld.ID) @@ -281,7 +291,7 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { require.NoError(t, err) require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{ {Name: "jetbrains_ide", Value: "GO"}, - {Name: "foo", Value: fooVal}, + {Name: "region", Value: regionVal}, }) } @@ -363,28 +373,12 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn owner := coderdtest.CreateFirstUser(t, ownerClient) templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) - files := echo.WithExtraFiles(map[string][]byte{ - "main.tf": args.mainTF, + tpl, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, owner.OrganizationID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(args.mainTF), + Plan: args.plan, + ModulesArchive: args.modulesArchive, + StaticParams: args.static, }) - files.ProvisionPlan = []*proto.Response{{ - Type: &proto.Response_Plan{ - Plan: &proto.PlanComplete{ - Plan: args.plan, - ModuleFiles: args.modulesArchive, - Parameters: args.static, - }, - }, - }} - - version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files) - coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID) - tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID) - - var err error - tpl, err = templateAdmin.UpdateTemplateMeta(t.Context(), tpl.ID, codersdk.UpdateTemplateMeta{ - UseClassicParameterFlow: ptr.Ref(false), - }) - require.NoError(t, err) ctx := testutil.Context(t, testutil.WaitShort) stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID) diff --git a/coderd/prebuilds/id.go b/coderd/prebuilds/id.go deleted file mode 100644 index 7c2bbe79b7a6f..0000000000000 --- a/coderd/prebuilds/id.go +++ /dev/null @@ -1,5 +0,0 @@ -package prebuilds - -import "github.com/google/uuid" - -var SystemUserID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0") diff --git a/coderd/prebuilds/preset_snapshot.go b/coderd/prebuilds/preset_snapshot.go index beb2b7452def8..be9299c8f5bdf 100644 --- a/coderd/prebuilds/preset_snapshot.go +++ b/coderd/prebuilds/preset_snapshot.go @@ -267,14 +267,14 @@ func (p PresetSnapshot) CalculateState() *ReconciliationState { // - ActionTypeBackoff: Only BackoffUntil is set, indicating when to retry // - ActionTypeCreate: Only Create is set, indicating how many prebuilds to create // - ActionTypeDelete: Only DeleteIDs is set, containing IDs of prebuilds to delete -func (p PresetSnapshot) CalculateActions(clock quartz.Clock, backoffInterval time.Duration) ([]*ReconciliationActions, error) { +func (p PresetSnapshot) CalculateActions(backoffInterval time.Duration) ([]*ReconciliationActions, error) { // TODO: align workspace states with how we represent them on the FE and the CLI // right now there's some slight differences which can lead to additional prebuilds being created // TODO: add mechanism to prevent prebuilds being reconciled from being claimable by users; i.e. if a prebuild is // about to be deleted, it should not be deleted if it has been claimed - beware of TOCTOU races! - actions, needsBackoff := p.needsBackoffPeriod(clock, backoffInterval) + actions, needsBackoff := p.needsBackoffPeriod(p.clock, backoffInterval) if needsBackoff { return actions, nil } diff --git a/coderd/prebuilds/preset_snapshot_test.go b/coderd/prebuilds/preset_snapshot_test.go index eacd264fb519a..8a1a10451323a 100644 --- a/coderd/prebuilds/preset_snapshot_test.go +++ b/coderd/prebuilds/preset_snapshot_test.go @@ -86,12 +86,12 @@ func TestNoPrebuilds(t *testing.T) { preset(true, 0, current), } - snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t)) + snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, clock, testutil.Logger(t)) ps, err := snapshot.FilterByPreset(current.presetID) require.NoError(t, err) state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ /*all zero values*/ }, *state) @@ -108,12 +108,12 @@ func TestNetNew(t *testing.T) { preset(true, 1, current), } - snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t)) + snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, clock, testutil.Logger(t)) ps, err := snapshot.FilterByPreset(current.presetID) require.NoError(t, err) state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ @@ -156,7 +156,7 @@ func TestOutdatedPrebuilds(t *testing.T) { // THEN: we should identify that this prebuild is outdated and needs to be deleted. state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ Actual: 1, @@ -174,7 +174,7 @@ func TestOutdatedPrebuilds(t *testing.T) { // THEN: we should not be blocked from creating a new prebuild while the outdate one deletes. state = ps.CalculateState() - actions, err = ps.CalculateActions(clock, backoffInterval) + actions, err = ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{Desired: 1}, *state) validateActions(t, []*prebuilds.ReconciliationActions{ @@ -223,7 +223,7 @@ func TestDeleteOutdatedPrebuilds(t *testing.T) { // THEN: we should identify that this prebuild is outdated and needs to be deleted. // Despite the fact that deletion of another outdated prebuild is already in progress. state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ Actual: 1, @@ -420,7 +420,6 @@ func TestInProgressActions(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -467,7 +466,7 @@ func TestInProgressActions(t *testing.T) { // THEN: we should identify that this prebuild is in progress. state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) tc.checkFn(*state, actions) }) @@ -510,7 +509,7 @@ func TestExtraneous(t *testing.T) { // THEN: an extraneous prebuild is detected and marked for deletion. state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ Actual: 2, Desired: 1, Extraneous: 1, Eligible: 2, @@ -650,7 +649,6 @@ func TestExpiredPrebuilds(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -685,13 +683,13 @@ func TestExpiredPrebuilds(t *testing.T) { } // WHEN: calculating the current preset's state. - snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t)) + snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, nil, nil, nil, clock, testutil.Logger(t)) ps, err := snapshot.FilterByPreset(current.presetID) require.NoError(t, err) // THEN: we should identify that this prebuild is expired. state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) tc.checkFn(running, *state, actions) }) @@ -727,7 +725,7 @@ func TestDeprecated(t *testing.T) { // THEN: all running prebuilds should be deleted because the template is deprecated. state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ Actual: 1, @@ -774,13 +772,13 @@ func TestLatestBuildFailed(t *testing.T) { } // WHEN: calculating the current preset's state. - snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, backoffs, nil, quartz.NewMock(t), testutil.Logger(t)) + snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, backoffs, nil, clock, testutil.Logger(t)) psCurrent, err := snapshot.FilterByPreset(current.presetID) require.NoError(t, err) // THEN: reconciliation should backoff. state := psCurrent.CalculateState() - actions, err := psCurrent.CalculateActions(clock, backoffInterval) + actions, err := psCurrent.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ Actual: 0, Desired: 1, @@ -798,7 +796,7 @@ func TestLatestBuildFailed(t *testing.T) { // THEN: it should NOT be in backoff because all is OK. state = psOther.CalculateState() - actions, err = psOther.CalculateActions(clock, backoffInterval) + actions, err = psOther.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ Actual: 1, Desired: 1, Eligible: 1, @@ -812,7 +810,7 @@ func TestLatestBuildFailed(t *testing.T) { psCurrent, err = snapshot.FilterByPreset(current.presetID) require.NoError(t, err) state = psCurrent.CalculateState() - actions, err = psCurrent.CalculateActions(clock, backoffInterval) + actions, err = psCurrent.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ Actual: 0, Desired: 1, @@ -867,7 +865,7 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) { }, } - snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t)) + snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, inProgress, nil, nil, clock, testutil.Logger(t)) // Nothing has to be created for preset 1. { @@ -875,7 +873,7 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) { require.NoError(t, err) state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ @@ -891,7 +889,7 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) { require.NoError(t, err) state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ @@ -995,7 +993,7 @@ func TestPrebuildScheduling(t *testing.T) { require.NoError(t, err) state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ @@ -1016,7 +1014,7 @@ func TestPrebuildScheduling(t *testing.T) { require.NoError(t, err) state := ps.CalculateState() - actions, err := ps.CalculateActions(clock, backoffInterval) + actions, err := ps.CalculateActions(backoffInterval) require.NoError(t, err) validateState(t, prebuilds.ReconciliationState{ diff --git a/coderd/presets.go b/coderd/presets.go index 1b5f646438339..151a1e7d5a904 100644 --- a/coderd/presets.go +++ b/coderd/presets.go @@ -41,8 +41,9 @@ func (api *API) templateVersionPresets(rw http.ResponseWriter, r *http.Request) var res []codersdk.Preset for _, preset := range presets { sdkPreset := codersdk.Preset{ - ID: preset.ID, - Name: preset.Name, + ID: preset.ID, + Name: preset.Name, + Default: preset.IsDefault, } for _, presetParam := range presetParams { if presetParam.TemplateVersionPresetID != preset.ID { diff --git a/coderd/presets_test.go b/coderd/presets_test.go index dc47b10cfd36f..99472a013600d 100644 --- a/coderd/presets_test.go +++ b/coderd/presets_test.go @@ -1,8 +1,10 @@ package coderd_test import ( + "slices" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" @@ -78,7 +80,6 @@ func TestTemplateVersionPresets(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -138,3 +139,93 @@ func TestTemplateVersionPresets(t *testing.T) { }) } } + +func TestTemplateVersionPresetsDefault(t *testing.T) { + t.Parallel() + + type expectedPreset struct { + name string + isDefault bool + } + + cases := []struct { + name string + presets []database.InsertPresetParams + expected []expectedPreset + }{ + { + name: "no presets", + presets: nil, + expected: nil, + }, + { + name: "single default preset", + presets: []database.InsertPresetParams{ + {Name: "Default Preset", IsDefault: true}, + }, + expected: []expectedPreset{ + {name: "Default Preset", isDefault: true}, + }, + }, + { + name: "single non-default preset", + presets: []database.InsertPresetParams{ + {Name: "Regular Preset", IsDefault: false}, + }, + expected: []expectedPreset{ + {name: "Regular Preset", isDefault: false}, + }, + }, + { + name: "mixed presets", + presets: []database.InsertPresetParams{ + {Name: "Default Preset", IsDefault: true}, + {Name: "Regular Preset", IsDefault: false}, + }, + expected: []expectedPreset{ + {name: "Default Preset", isDefault: true}, + {name: "Regular Preset", isDefault: false}, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + // Create presets + for _, preset := range tc.presets { + preset.TemplateVersionID = version.ID + _ = dbgen.Preset(t, db, preset) + } + + // Get presets via API + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.UserID, rbac.ScopeAll) + require.NoError(t, err) + userCtx := dbauthz.As(ctx, userSubject) + + gotPresets, err := client.TemplateVersionPresets(userCtx, version.ID) + require.NoError(t, err) + + // Verify results + require.Len(t, gotPresets, len(tc.expected)) + + for _, expected := range tc.expected { + found := slices.ContainsFunc(gotPresets, func(preset codersdk.Preset) bool { + if preset.Name != expected.name { + return false + } + + return assert.Equal(t, expected.isDefault, preset.Default) + }) + require.True(t, found, "Expected preset %s not found", expected.name) + } + }) + } +} diff --git a/coderd/prometheusmetrics/aggregator_test.go b/coderd/prometheusmetrics/aggregator_test.go index 0930f186bd328..6cbe5514b1c2e 100644 --- a/coderd/prometheusmetrics/aggregator_test.go +++ b/coderd/prometheusmetrics/aggregator_test.go @@ -587,8 +587,6 @@ func TestLabelsAggregation(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/prometheusmetrics/prometheusmetrics_internal_test.go b/coderd/prometheusmetrics/prometheusmetrics_internal_test.go index 5eaf1d92ed67f..3a6ecec5c12ec 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_internal_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_internal_test.go @@ -29,8 +29,6 @@ func TestFilterAcceptableAgentLabels(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 34309042c5f55..1ce6b72347999 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -99,7 +99,6 @@ func TestActiveUsers(t *testing.T) { }, Count: 2, }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() registry := prometheus.NewRegistry() @@ -161,7 +160,6 @@ func TestUsers(t *testing.T) { }, Count: map[database.UserStatus]int{database.UserStatusActive: 3}, }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -250,7 +248,6 @@ func TestWorkspaceLatestBuildTotals(t *testing.T) { codersdk.ProvisionerJobRunning: 1, }, }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() registry := prometheus.NewRegistry() @@ -327,7 +324,6 @@ func TestWorkspaceLatestBuildStatuses(t *testing.T) { codersdk.ProvisionerJobRunning: 1, }, }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() registry := prometheus.NewRegistry() @@ -660,8 +656,6 @@ func TestExperimentsMetric(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() reg := prometheus.NewRegistry() diff --git a/coderd/promoauth/oauth2_test.go b/coderd/promoauth/oauth2_test.go index 9e31d90944f36..5aeec4f0fb949 100644 --- a/coderd/promoauth/oauth2_test.go +++ b/coderd/promoauth/oauth2_test.go @@ -155,7 +155,6 @@ func TestGithubRateLimits(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/provisionerdserver/acquirer_test.go b/coderd/provisionerdserver/acquirer_test.go index e90fb3df0198a..817bae45bbd60 100644 --- a/coderd/provisionerdserver/acquirer_test.go +++ b/coderd/provisionerdserver/acquirer_test.go @@ -466,7 +466,6 @@ func TestAcquirer_MatchTags(t *testing.T) { }, } for _, tt := range testCases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 78dcd4e993b9f..f545169c93b31 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -28,6 +28,7 @@ import ( protobuf "google.golang.org/protobuf/proto" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk/drpcsdk" @@ -1654,6 +1655,17 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro if err != nil { return xerrors.Errorf("update template version external auth providers: %w", err) } + err = db.UpdateTemplateVersionAITaskByJobID(ctx, database.UpdateTemplateVersionAITaskByJobIDParams{ + JobID: jobID, + HasAITask: sql.NullBool{ + Bool: jobType.TemplateImport.HasAiTasks, + Valid: true, + }, + UpdatedAt: now, + }) + if err != nil { + return xerrors.Errorf("update template version external auth providers: %w", err) + } // Process terraform values plan := jobType.TemplateImport.Plan @@ -1866,6 +1878,37 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro } } + var sidebarAppID uuid.NullUUID + hasAITask := len(jobType.WorkspaceBuild.AiTasks) == 1 + if hasAITask { + task := jobType.WorkspaceBuild.AiTasks[0] + if task.SidebarApp == nil { + return xerrors.Errorf("update ai task: sidebar app is nil") + } + + id, err := uuid.Parse(task.SidebarApp.Id) + if err != nil { + return xerrors.Errorf("parse sidebar app id: %w", err) + } + + sidebarAppID = uuid.NullUUID{UUID: id, Valid: true} + } + + // Regardless of whether there is an AI task or not, update the field to indicate one way or the other since it + // always defaults to nil. ONLY if has_ai_task=true MUST ai_task_sidebar_app_id be set. + err = db.UpdateWorkspaceBuildAITaskByID(ctx, database.UpdateWorkspaceBuildAITaskByIDParams{ + ID: workspaceBuild.ID, + HasAITask: sql.NullBool{ + Bool: hasAITask, + Valid: true, + }, + SidebarAppID: sidebarAppID, + UpdatedAt: now, + }) + if err != nil { + return xerrors.Errorf("update workspace build ai tasks flag: %w", err) + } + // Insert timings inside the transaction now // nolint:exhaustruct // The other fields are set further down. params := database.InsertProvisionerJobTimingsParams{ @@ -2229,6 +2272,7 @@ func InsertWorkspacePresetAndParameters(ctx context.Context, db database.Store, DesiredInstances: desiredInstances, InvalidateAfterSecs: ttl, SchedulingTimezone: schedulingTimezone, + IsDefault: protoPreset.GetDefault(), }) if err != nil { return xerrors.Errorf("insert preset: %w", err) @@ -2595,8 +2639,20 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. openIn = database.WorkspaceAppOpenInSlimWindow } - dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ - ID: uuid.New(), + var appID string + if app.Id == "" || app.Id == uuid.Nil.String() { + appID = uuid.NewString() + } else { + appID = app.Id + } + id, err := uuid.Parse(appID) + if err != nil { + return xerrors.Errorf("parse app uuid: %w", err) + } + + // If workspace apps are "persistent", the ID will not be regenerated across workspace builds, so we have to upsert. + dbApp, err := db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{ + ID: id, CreatedAt: dbtime.Now(), AgentID: dbAgent.ID, Slug: slug, @@ -2624,7 +2680,7 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. OpenIn: openIn, }) if err != nil { - return xerrors.Errorf("insert app: %w", err) + return xerrors.Errorf("upsert app: %w", err) } snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp)) } diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 695437068f50f..66684835650a8 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -150,7 +150,6 @@ func TestAcquireJob(t *testing.T) { }}, } for _, tc := range cases { - tc := tc t.Run(tc.name+"_InitiatorNotFound", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, nil) @@ -176,7 +175,6 @@ func TestAcquireJob(t *testing.T) { sdkproto.PrebuiltWorkspaceBuildStage_CREATE, sdkproto.PrebuiltWorkspaceBuildStage_CLAIM, } { - prebuiltWorkspaceBuildStage := prebuiltWorkspaceBuildStage t.Run(tc.name+"_WorkspaceBuildJob_Stage"+prebuiltWorkspaceBuildStage.String(), func(t *testing.T) { t.Parallel() // Set the max session token lifetime so we can assert we @@ -1709,8 +1707,6 @@ func TestCompleteJob(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -2134,8 +2130,6 @@ func TestCompleteJob(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -2441,6 +2435,233 @@ func TestCompleteJob(t *testing.T) { testutil.RequireReceive(ctx, t, done) require.Equal(t, replacements, orchestrator.replacements) }) + + t.Run("AITasks", func(t *testing.T) { + t.Parallel() + + // has_ai_task has a default value of nil, but once the template import completes it will have a value; + // it is set to "true" if the template has any coder_ai_task resources defined. + t.Run("TemplateImport", func(t *testing.T) { + type testcase struct { + name string + input *proto.CompletedJob_TemplateImport + expected bool + } + + for _, tc := range []testcase{ + { + name: "has_ai_task is false by default", + input: &proto.CompletedJob_TemplateImport{ + // HasAiTasks is not set. + Plan: []byte("{}"), + }, + expected: false, + }, + { + name: "has_ai_task gets set to true", + input: &proto.CompletedJob_TemplateImport{ + HasAiTasks: true, + Plan: []byte("{}"), + }, + expected: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv, db, _, pd := setup(t, false, &overrides{}) + + importJobID := uuid.New() + tvID := uuid.New() + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: pd.OrganizationID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + ID: tvID, + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: importJobID, + }) + _ = version + + ctx := testutil.Context(t, testutil.WaitShort) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: importJobID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + OrganizationID: pd.OrganizationID, + InitiatorID: uuid.New(), + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: tvID, + })), + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionImport, + Tags: pd.Tags, + }) + require.NoError(t, err) + + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, + }) + require.NoError(t, err) + + version, err = db.GetTemplateVersionByID(ctx, tvID) + require.NoError(t, err) + require.False(t, version.HasAITask.Valid) // Value should be nil (i.e. valid = false). + + completedJob := proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_TemplateImport_{ + TemplateImport: tc.input, + }, + } + _, err = srv.CompleteJob(ctx, &completedJob) + require.NoError(t, err) + + version, err = db.GetTemplateVersionByID(ctx, tvID) + require.NoError(t, err) + require.True(t, version.HasAITask.Valid) // We ALWAYS expect a value to be set, therefore not nil, i.e. valid = true. + require.Equal(t, tc.expected, version.HasAITask.Bool) + }) + } + }) + + // has_ai_task has a default value of nil, but once the workspace build completes it will have a value; + // it is set to "true" if the related template has any coder_ai_task resources defined, and its sidebar app ID + // will be set as well in that case. + t.Run("WorkspaceBuild", func(t *testing.T) { + type testcase struct { + name string + input *proto.CompletedJob_WorkspaceBuild + expected bool + } + + sidebarAppID := uuid.NewString() + for _, tc := range []testcase{ + { + name: "has_ai_task is false by default", + input: &proto.CompletedJob_WorkspaceBuild{ + // No AiTasks defined. + }, + expected: false, + }, + { + name: "has_ai_task is set to true", + input: &proto.CompletedJob_WorkspaceBuild{ + AiTasks: []*sdkproto.AITask{ + { + Id: uuid.NewString(), + SidebarApp: &sdkproto.AITaskSidebarApp{ + Id: sidebarAppID, + }, + }, + }, + }, + expected: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv, db, _, pd := setup(t, false, &overrides{}) + + importJobID := uuid.New() + tvID := uuid.New() + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: pd.OrganizationID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + ID: tvID, + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: importJobID, + }) + user := dbgen.User(t, db, database.User{}) + workspaceTable := dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: template.ID, + OwnerID: user.ID, + OrganizationID: pd.OrganizationID, + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspaceTable.ID, + TemplateVersionID: version.ID, + InitiatorID: user.ID, + Transition: database.WorkspaceTransitionStart, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: importJobID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + OrganizationID: pd.OrganizationID, + InitiatorID: uuid.New(), + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + LogLevel: "DEBUG", + })), + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Tags: pd.Tags, + }) + require.NoError(t, err) + + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, + }) + require.NoError(t, err) + + build, err = db.GetWorkspaceBuildByID(ctx, build.ID) + require.NoError(t, err) + require.False(t, build.HasAITask.Valid) // Value should be nil (i.e. valid = false). + + completedJob := proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: tc.input, + }, + } + _, err = srv.CompleteJob(ctx, &completedJob) + require.NoError(t, err) + + build, err = db.GetWorkspaceBuildByID(ctx, build.ID) + require.NoError(t, err) + require.True(t, build.HasAITask.Valid) // We ALWAYS expect a value to be set, therefore not nil, i.e. valid = true. + require.Equal(t, tc.expected, build.HasAITask.Bool) + + if tc.expected { + require.Equal(t, sidebarAppID, build.AITaskSidebarAppID.UUID.String()) + } + }) + } + }) + }) } type mockPrebuildsOrchestrator struct { @@ -2579,7 +2800,6 @@ func TestInsertWorkspacePresetsAndParameters(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/provisionerjobs_internal_test.go b/coderd/provisionerjobs_internal_test.go index f3bc2eb1dea99..bc94836028ce4 100644 --- a/coderd/provisionerjobs_internal_test.go +++ b/coderd/provisionerjobs_internal_test.go @@ -132,7 +132,6 @@ func TestConvertProvisionerJob_Unit(t *testing.T) { }, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() actual := convertProvisionerJob(database.GetProvisionerJobsByIDsWithQueuePositionRow{ diff --git a/coderd/rbac/README.md b/coderd/rbac/README.md index 07bfaf061ca94..78781d3660826 100644 --- a/coderd/rbac/README.md +++ b/coderd/rbac/README.md @@ -102,18 +102,106 @@ Example of a scope for a workspace agent token, using an `allow_list` containing } ``` +## OPA (Open Policy Agent) + +Open Policy Agent (OPA) is an open source tool used to define and enforce policies. +Policies are written in a high-level, declarative language called Rego. +Coder’s RBAC rules are defined in the [`policy.rego`](policy.rego) file under the `authz` package. + +When OPA evaluates policies, it binds input data to a global variable called `input`. +In the `rbac` package, this structured data is defined as JSON and contains the action, object and subject (see `regoInputValue` in [astvalue.go](astvalue.go)). +OPA evaluates whether the subject is allowed to perform the action on the object across three levels: `site`, `org`, and `user`. +This is determined by the final rule `allow`, which aggregates the results of multiple rules to decide if the user has the necessary permissions. +Similarly to the input, OPA produces structured output data, which includes the `allow` variable as part of the evaluation result. +Authorization succeeds only if `allow` explicitly evaluates to `true`. If no `allow` is returned, it is considered unauthorized. +To learn more about OPA and Rego, see https://www.openpolicyagent.org/docs. + +### Application and Database Integration + +- [`rbac/authz.go`](authz.go) – Application layer integration: provides the core authorization logic that integrates with Rego for policy evaluation. +- [`database/dbauthz/dbauthz.go`](../database/dbauthz/dbauthz.go) – Database layer integration: wraps the database layer with authorization checks to enforce access control. + +There are two types of evaluation in OPA: + +- **Full evaluation**: Produces a decision that can be enforced. +This is the default evaluation mode, where OPA evaluates the policy using `input` data that contains all known values and returns output data with the `allow` variable. +- **Partial evaluation**: Produces a new policy that can be evaluated later when the _unknowns_ become _known_. +This is an optimization in OPA where it evaluates as much of the policy as possible without resolving expressions that depend on _unknown_ values from the `input`. +To learn more about partial evaluation, see this [OPA blog post](https://blog.openpolicyagent.org/partial-evaluation-162750eaf422). + +Application of Full and Partial evaluation in `rbac` package: + +- **Full Evaluation** is handled by the `RegoAuthorizer.Authorize()` method in [`authz.go`](authz.go). +This method determines whether a subject (user) can perform a specific action on an object. +It performs a full evaluation of the Rego policy, which returns the `allow` variable to decide whether access is granted (`true`) or denied (`false` or undefined). +- **Partial Evaluation** is handled by the `RegoAuthorizer.Prepare()` method in [`authz.go`](authz.go). +This method compiles OPA’s partial evaluation queries into `SQL WHERE` clauses. +These clauses are then used to enforce authorization directly in database queries, rather than in application code. + +Authorization Patterns: + +- Fetch-then-authorize: an object is first retrieved from the database, and a single authorization check is performed using full evaluation via `Authorize()`. +- Authorize-while-fetching: Partial evaluation via `Prepare()` is used to inject SQL filters directly into queries, allowing efficient authorization of many objects of the same type. +`dbauthz` methods that enforce authorization directly in the SQL query are prefixed with `Authorized`, for example, `GetAuthorizedWorkspaces`. + ## Testing -You can test outside of golang by using the `opa` cli. +- OPA Playground: https://play.openpolicyagent.org/ +- OPA CLI (`opa eval`): useful for experimenting with different inputs and understanding how the policy behaves under various conditions. +`opa eval` returns the constraints that must be satisfied for a rule to evaluate to `true`. + - `opa eval` requires an `input.json` file containing the input data to run the policy against. + You can generate this file using the [gen_input.go](../../scripts/rbac-authz/gen_input.go) script. + Note: the script currently produces a fixed input. You may need to tweak it for your specific use case. -**Evaluation** +### Full Evaluation ```bash opa eval --format=pretty "data.authz.allow" -d policy.rego -i input.json ``` -**Partial Evaluation** +This command fully evaluates the policy in the `policy.rego` file using the input data from `input.json`, and returns the result of the `allow` variable: + +- `data.authz.allow` accesses the `allow` rule within the `authz` package. +- `data.authz` on its own would return the entire output object of the package. + +This command answers the question: β€œIs the user allowed?” + +### Partial Evaluation ```bash opa eval --partial --format=pretty 'data.authz.allow' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json ``` + +This command performs a partial evaluation of the policy, specifying a set of unknown input parameters. +The result is a set of partial queries that can be converted into `SQL WHERE` clauses and injected into SQL queries. + +This command answers the question: β€œWhat conditions must be met for the user to be allowed?” + +### Benchmarking + +Benchmark tests to evaluate the performance of full and partial evaluation can be found in `authz_test.go`. +You can run these tests with the `-bench` flag, for example: + +```bash +go test -bench=BenchmarkRBACFilter -run=^$ +``` + +To capture memory and CPU profiles, use the following flags: + +- `-memprofile memprofile.out` +- `-cpuprofile cpuprofile.out` + +The script [`benchmark_authz.sh`](../../scripts/rbac-authz/benchmark_authz.sh) runs the `authz` benchmark tests on the current Git branch or compares benchmark results between two branches using [`benchstat`](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat). +`benchstat` compares the performance of a baseline benchmark against a new benchmark result and highlights any statistically significant differences. + +- To run benchmark on the current branch: + + ```bash + benchmark_authz.sh --single + ``` + +- To compare benchmarks between 2 branches: + + ```bash + benchmark_authz.sh --compare main prebuild_policy + ``` diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index a7f77d57ab253..f57ed2585c068 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -760,7 +760,6 @@ func rbacTraceAttributes(actor Subject, action policy.Action, objectType string, uniqueRoleNames := actor.SafeRoleNames() roleStrings := make([]string, 0, len(uniqueRoleNames)) for _, roleName := range uniqueRoleNames { - roleName := roleName roleStrings = append(roleStrings, roleName.String()) } return trace.WithAttributes( diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 9c09837c7915d..838c7bce1c5e8 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -243,7 +243,6 @@ func TestFilter(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() actor := tc.Actor @@ -1135,7 +1134,6 @@ func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTes authorizer := NewAuthorizer(prometheus.NewRegistry()) for _, cases := range sets { for i, c := range cases { - c := c caseName := fmt.Sprintf("%s/%d", name, i) t.Run(caseName, func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index 163af320afbe9..cd2bbb808add9 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -148,7 +148,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U // BenchmarkRBACAuthorize benchmarks the rbac.Authorize method. // -// go test -run=^$ -bench BenchmarkRBACAuthorize -benchmem -memprofile memprofile.out -cpuprofile profile.out +// go test -run=^$ -bench '^BenchmarkRBACAuthorize$' -benchmem -memprofile memprofile.out -cpuprofile profile.out func BenchmarkRBACAuthorize(b *testing.B) { benchCases, user, orgs := benchmarkUserCases() users := append([]uuid.UUID{}, @@ -178,7 +178,7 @@ func BenchmarkRBACAuthorize(b *testing.B) { // BenchmarkRBACAuthorizeGroups benchmarks the rbac.Authorize method and leverages // groups for authorizing rather than the permissions/roles. // -// go test -bench BenchmarkRBACAuthorizeGroups -benchmem -memprofile memprofile.out -cpuprofile profile.out +// go test -bench '^BenchmarkRBACAuthorizeGroups$' -benchmem -memprofile memprofile.out -cpuprofile profile.out func BenchmarkRBACAuthorizeGroups(b *testing.B) { benchCases, user, orgs := benchmarkUserCases() users := append([]uuid.UUID{}, @@ -229,7 +229,7 @@ func BenchmarkRBACAuthorizeGroups(b *testing.B) { // BenchmarkRBACFilter benchmarks the rbac.Filter method. // -// go test -bench BenchmarkRBACFilter -benchmem -memprofile memprofile.out -cpuprofile profile.out +// go test -bench '^BenchmarkRBACFilter$' -benchmem -memprofile memprofile.out -cpuprofile profile.out func BenchmarkRBACFilter(b *testing.B) { benchCases, user, orgs := benchmarkUserCases() users := append([]uuid.UUID{}, diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index f19d90894dd55..d0d5dc4aab0fe 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -54,16 +54,6 @@ var ( Type: "audit_log", } - // ResourceChat - // Valid Actions - // - "ActionCreate" :: create a chat - // - "ActionDelete" :: delete a chat - // - "ActionRead" :: read a chat - // - "ActionUpdate" :: update a chat - ResourceChat = Object{ - Type: "chat", - } - // ResourceCryptoKey // Valid Actions // - "ActionCreate" :: create crypto keys @@ -222,6 +212,14 @@ var ( Type: "organization_member", } + // ResourcePrebuiltWorkspace + // Valid Actions + // - "ActionDelete" :: delete prebuilt workspace + // - "ActionUpdate" :: update prebuilt workspace settings + ResourcePrebuiltWorkspace = Object{ + Type: "prebuilt_workspace", + } + // ResourceProvisionerDaemon // Valid Actions // - "ActionCreate" :: create a provisioner daemon/key @@ -370,7 +368,6 @@ func AllResources() []Objecter { ResourceAssignOrgRole, ResourceAssignRole, ResourceAuditLog, - ResourceChat, ResourceCryptoKey, ResourceDebugInfo, ResourceDeploymentConfig, @@ -389,6 +386,7 @@ func AllResources() []Objecter { ResourceOauth2AppSecret, ResourceOrganization, ResourceOrganizationMember, + ResourcePrebuiltWorkspace, ResourceProvisionerDaemon, ResourceProvisionerJobs, ResourceReplicas, diff --git a/coderd/rbac/object_test.go b/coderd/rbac/object_test.go index ea6031f2ccae8..ff579b48c03af 100644 --- a/coderd/rbac/object_test.go +++ b/coderd/rbac/object_test.go @@ -165,7 +165,6 @@ func TestObjectEqual(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index ea381fa88d8e4..2ee47c35c8952 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -29,76 +29,93 @@ import rego.v1 # different code branches based on the org_owner. 'num's value does, but # that is the whole point of partial evaluation. -# bool_flip lets you assign a value to an inverted bool. +# bool_flip(b) returns the logical negation of a boolean value 'b'. # You cannot do 'x := !false', but you can do 'x := bool_flip(false)' -bool_flip(b) := flipped if { +bool_flip(b) := false if { b - flipped = false } -bool_flip(b) := flipped if { +bool_flip(b) := true if { not b - flipped = true } -# number is a quick way to get a set of {true, false} and convert it to -# -1: {false, true} or {false} -# 0: {} -# 1: {true} -number(set) := c if { - count(set) == 0 - c := 0 -} +# number(set) maps a set of boolean values to one of the following numbers: +# -1: deny (if 'false' value is in the set) => set is {true, false} or {false} +# 0: no decision (if the set is empty) => set is {} +# 1: allow (if only 'true' values are in the set) => set is {true} -number(set) := c if { +# Return -1 if the set contains any 'false' value (i.e., an explicit deny) +number(set) := -1 if { false in set - c := -1 } -number(set) := c if { +# Return 0 if the set is empty (no matching permissions) +number(set) := 0 if { + count(set) == 0 +} + +# Return 1 if the set is non-empty and contains no 'false' values (i.e., only allows) +number(set) := 1 if { not false in set set[_] - c := 1 } -# site, org, and user rules are all similar. Each rule should return a number -# from [-1, 1]. The number corresponds to "negative", "abstain", and "positive" -# for the given level. See the 'allow' rules for how these numbers are used. -default site := 0 +# Permission evaluation is structured into three levels: site, org, and user. +# For each level, two variables are computed: +# - : the decision based on the subject's full set of roles for that level +# - scope_: the decision based on the subject's scoped roles for that level +# +# Each of these variables is assigned one of three values: +# -1 => negative (deny) +# 0 => abstain (no matching permission) +# 1 => positive (allow) +# +# These values are computed by calling the corresponding _allow functions. +# The final decision is derived from combining these values (see 'allow' rule). + +# ------------------- +# Site Level Rules +# ------------------- +default site := 0 site := site_allow(input.subject.roles) default scope_site := 0 - scope_site := site_allow([input.subject.scope]) +# site_allow receives a list of roles and returns a single number: +# -1 if any matching permission denies access +# 1 if there's at least one allow and no denies +# 0 if there are no matching permissions site_allow(roles) := num if { - # allow is a set of boolean values without duplicates. - allow := {x | + # allow is a set of boolean values (sets don't contain duplicates) + allow := {is_allowed | # Iterate over all site permissions in all roles perm := roles[_].site[_] perm.action in [input.action, "*"] perm.resource_type in [input.object.type, "*"] - # x is either 'true' or 'false' if a matching permission exists. - x := bool_flip(perm.negate) + # is_allowed is either 'true' or 'false' if a matching permission exists. + is_allowed := bool_flip(perm.negate) } num := number(allow) } +# ------------------- +# Org Level Rules +# ------------------- + # org_members is the list of organizations the actor is apart of. org_members := {orgID | input.subject.roles[_].org[orgID] } -# org is the same as 'site' except we need to iterate over each organization +# 'org' is the same as 'site' except we need to iterate over each organization # that the actor is a member of. default org := 0 - org := org_allow(input.subject.roles) default scope_org := 0 - scope_org := org_allow([input.scope]) # org_allow_set is a helper function that iterates over all orgs that the actor @@ -114,11 +131,14 @@ scope_org := org_allow([input.scope]) org_allow_set(roles) := allow_set if { allow_set := {id: num | id := org_members[_] - set := {x | + set := {is_allowed | + # Iterate over all org permissions in all roles perm := roles[_].org[id][_] perm.action in [input.action, "*"] perm.resource_type in [input.object.type, "*"] - x := bool_flip(perm.negate) + + # is_allowed is either 'true' or 'false' if a matching permission exists. + is_allowed := bool_flip(perm.negate) } num := number(set) } @@ -191,24 +211,30 @@ org_ok if { not input.object.any_org } -# User is the same as the site, except it only applies if the user owns the object and +# ------------------- +# User Level Rules +# ------------------- + +# 'user' is the same as 'site', except it only applies if the user owns the object and # the user is apart of the org (if the object has an org). default user := 0 - user := user_allow(input.subject.roles) -default user_scope := 0 - +default scope_user := 0 scope_user := user_allow([input.scope]) user_allow(roles) := num if { input.object.owner != "" input.subject.id = input.object.owner - allow := {x | + + allow := {is_allowed | + # Iterate over all user permissions in all roles perm := roles[_].user[_] perm.action in [input.action, "*"] perm.resource_type in [input.object.type, "*"] - x := bool_flip(perm.negate) + + # is_allowed is either 'true' or 'false' if a matching permission exists. + is_allowed := bool_flip(perm.negate) } num := number(allow) } @@ -227,17 +253,9 @@ scope_allow_list if { input.object.id in input.subject.scope.allow_list } -# The allow block is quite simple. Any set with `-1` cascades down in levels. -# Authorization looks for any `allow` statement that is true. Multiple can be true! -# Note that the absence of `allow` means "unauthorized". -# An explicit `"allow": true` is required. -# -# Scope is also applied. The default scope is "wildcard:wildcard" allowing -# all actions. If the scope is not "1", then the action is not authorized. -# -# -# Allow query: -# data.authz.role_allow = true data.authz.scope_allow = true +# ------------------- +# Role-Specific Rules +# ------------------- role_allow if { site = 1 @@ -258,6 +276,10 @@ role_allow if { user = 1 } +# ------------------- +# Scope-Specific Rules +# ------------------- + scope_allow if { scope_allow_list scope_site = 1 @@ -280,6 +302,11 @@ scope_allow if { scope_user = 1 } +# ------------------- +# ACL-Specific Rules +# Access Control List +# ------------------- + # ACL for users acl_allow if { # Should you have to be a member of the org too? @@ -308,11 +335,24 @@ acl_allow if { [input.action, "*"][_] in perms } -############### +# ------------------- # Final Allow +# +# The 'allow' block is quite simple. Any set with `-1` cascades down in levels. +# Authorization looks for any `allow` statement that is true. Multiple can be true! +# Note that the absence of `allow` means "unauthorized". +# An explicit `"allow": true` is required. +# +# Scope is also applied. The default scope is "wildcard:wildcard" allowing +# all actions. If the scope is not "1", then the action is not authorized. +# +# Allow query: +# data.authz.role_allow = true +# data.authz.scope_allow = true +# ------------------- + # The role or the ACL must allow the action. Scopes can be used to limit, # so scope_allow must always be true. - allow if { role_allow scope_allow diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 160062283f857..a3ad614439c9a 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -102,6 +102,20 @@ var RBACPermissions = map[string]PermissionDefinition{ "workspace_dormant": { Actions: workspaceActions, }, + "prebuilt_workspace": { + // Prebuilt_workspace actions currently apply only to delete operations. + // To successfully delete a prebuilt workspace, a user must have the following permissions: + // * workspace.read: to read the current workspace state + // * update: to modify workspace metadata and related resources during deletion + // (e.g., updating the deleted field in the database) + // * delete: to perform the actual deletion of the workspace + // If the user lacks prebuilt_workspace update or delete permissions, + // the authorization will always fall back to the corresponding permissions on workspace. + Actions: map[Action]ActionDefinition{ + ActionUpdate: actDef("update prebuilt workspace settings"), + ActionDelete: actDef("delete prebuilt workspace"), + }, + }, "workspace_proxy": { Actions: map[Action]ActionDefinition{ ActionCreate: actDef("create a workspace proxy"), @@ -110,14 +124,6 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionRead: actDef("read and use a workspace proxy"), }, }, - "chat": { - Actions: map[Action]ActionDefinition{ - ActionCreate: actDef("create a chat"), - ActionRead: actDef("read a chat"), - ActionDelete: actDef("delete a chat"), - ActionUpdate: actDef("update a chat"), - }, - }, "license": { Actions: map[Action]ActionDefinition{ ActionCreate: actDef("create a license"), diff --git a/coderd/rbac/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go index 208cb920ad1f7..07e8e7245a53e 100644 --- a/coderd/rbac/regosql/compile_test.go +++ b/coderd/rbac/regosql/compile_test.go @@ -265,7 +265,6 @@ neq(input.object.owner, ""); } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() part := partialQueries(tc.Queries...) diff --git a/coderd/rbac/regosql/sqltypes/equality_test.go b/coderd/rbac/regosql/sqltypes/equality_test.go index 17a3d7f45eed1..37922064466de 100644 --- a/coderd/rbac/regosql/sqltypes/equality_test.go +++ b/coderd/rbac/regosql/sqltypes/equality_test.go @@ -114,7 +114,6 @@ func TestEquality(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/regosql/sqltypes/member_test.go b/coderd/rbac/regosql/sqltypes/member_test.go index 0fedcc176c49f..e933989d7b0df 100644 --- a/coderd/rbac/regosql/sqltypes/member_test.go +++ b/coderd/rbac/regosql/sqltypes/member_test.go @@ -92,7 +92,6 @@ func TestMembership(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 28ddc38462ce9..ebc7ff8f12070 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -270,11 +270,15 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: append( // Workspace dormancy and workspace are omitted. // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec - allPermsExcept(ResourceWorkspaceDormant, ResourceWorkspace), + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace), // This adds back in the Workspace permissions. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, + // PrebuiltWorkspaces are a subset of Workspaces. + // Explicitly setting PrebuiltWorkspace permissions for clarity. + // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. + ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete}, })...), Org: map[string][]Permission{}, User: []Permission{}, @@ -290,7 +294,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceWorkspaceProxy.Type: {policy.ActionRead}, }), Org: map[string][]Permission{}, - User: append(allPermsExcept(ResourceWorkspaceDormant, ResourceUser, ResourceOrganizationMember), + User: append(allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember), Permissions(map[string][]policy.Action{ // Reduced permission set on dormant workspaces. No build, ssh, or exec ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, @@ -301,8 +305,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceOrganizationMember.Type: {policy.ActionRead}, // Users can create provisioner daemons scoped to themselves. ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, - // Users can create, read, update, and delete their own agentic chat messages. - ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, })..., ), }.withCachedRegoValue() @@ -335,8 +337,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceAssignOrgRole.Type: {policy.ActionRead}, ResourceTemplate.Type: ResourceTemplate.AvailableActions(), // CRUD all files, even those they did not upload. - ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, - ResourceWorkspace.Type: {policy.ActionRead}, + ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, + ResourceWorkspace.Type: {policy.ActionRead}, + ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete}, // CRUD to provisioner daemons for now. ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, // Needs to read all organizations since @@ -413,9 +416,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) { }), Org: map[string][]Permission{ // Org admins should not have workspace exec perms. - organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourceAssignRole), Permissions(map[string][]policy.Action{ + organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole), Permissions(map[string][]policy.Action{ ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH), + // PrebuiltWorkspaces are a subset of Workspaces. + // Explicitly setting PrebuiltWorkspace permissions for clarity. + // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. + ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete}, })...), }, User: []Permission{}, @@ -493,9 +500,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: []Permission{}, Org: map[string][]Permission{ organizationID.String(): Permissions(map[string][]policy.Action{ - ResourceTemplate.Type: ResourceTemplate.AvailableActions(), - ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, - ResourceWorkspace.Type: {policy.ActionRead}, + ResourceTemplate.Type: ResourceTemplate.AvailableActions(), + ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, + ResourceWorkspace.Type: {policy.ActionRead}, + ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete}, // Assigning template perms requires this permission. ResourceOrganization.Type: {policy.ActionRead}, ResourceOrganizationMember.Type: {policy.ActionRead}, @@ -837,7 +845,6 @@ func Permissions(perms map[string][]policy.Action) []Permission { list := make([]Permission, 0, len(perms)) for k, actions := range perms { for _, act := range actions { - act := act list = append(list, Permission{ Negate: false, ResourceType: k, diff --git a/coderd/rbac/roles_internal_test.go b/coderd/rbac/roles_internal_test.go index 3f2d0d89fe455..f851280a0417e 100644 --- a/coderd/rbac/roles_internal_test.go +++ b/coderd/rbac/roles_internal_test.go @@ -229,7 +229,6 @@ func TestRoleByName(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Role.Identifier.String(), func(t *testing.T) { role, err := RoleByName(c.Role.Identifier) require.NoError(t, err, "role exists") diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 5738edfe8caa2..3e6f7d1e330d5 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/coder/coder/v2/coderd/database" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" @@ -35,7 +37,6 @@ func (a authSubject) Subjects() []authSubject { return []authSubject{a} } func TestBuiltInRoles(t *testing.T) { t.Parallel() for _, r := range rbac.SiteBuiltInRoles() { - r := r t.Run(r.Identifier.String(), func(t *testing.T) { t.Parallel() require.NoError(t, r.Valid(), "invalid role") @@ -43,7 +44,6 @@ func TestBuiltInRoles(t *testing.T) { } for _, r := range rbac.OrganizationRoles(uuid.New()) { - r := r t.Run(r.Identifier.String(), func(t *testing.T) { t.Parallel() require.NoError(t, r.Valid(), "invalid role") @@ -496,6 +496,15 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, userAdmin, templateAdmin, memberMe, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, + { + Name: "PrebuiltWorkspace", + Actions: []policy.Action{policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourcePrebuiltWorkspace.WithID(uuid.New()).InOrg(orgID).WithOwner(database.PrebuildsSystemUserID.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin}, + false: {setOtherOrg, userAdmin, memberMe, orgUserAdmin, orgAuditor, orgMemberMe}, + }, + }, // Some admin style resources { Name: "Licenses", @@ -840,37 +849,6 @@ func TestRolePermissions(t *testing.T) { }, }, }, - // Members may read their own chats. - { - Name: "CreateReadUpdateDeleteMyChats", - Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - Resource: rbac.ResourceChat.WithOwner(currentUser.String()), - AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {memberMe, orgMemberMe, owner}, - false: { - userAdmin, orgUserAdmin, templateAdmin, - orgAuditor, orgTemplateAdmin, - otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, - orgAdmin, otherOrgAdmin, - }, - }, - }, - // Only owners can create, read, update, and delete other users' chats. - { - Name: "CreateReadUpdateDeleteOtherUserChats", - Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - Resource: rbac.ResourceChat.WithOwner(uuid.NewString()), // some other user - AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner}, - false: { - memberMe, orgMemberMe, - userAdmin, orgUserAdmin, templateAdmin, - orgAuditor, orgTemplateAdmin, - otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, - orgAdmin, otherOrgAdmin, - }, - }, - }, } // We expect every permission to be tested above. @@ -885,7 +863,6 @@ func TestRolePermissions(t *testing.T) { passed := true // nolint:tparallel,paralleltest for _, c := range testCases { - c := c // nolint:tparallel,paralleltest // These share the same remainingPermissions map t.Run(c.Name, func(t *testing.T) { remainingSubjs := make(map[string]struct{}) @@ -984,7 +961,6 @@ func TestIsOrgRole(t *testing.T) { // nolint:paralleltest for _, c := range testCases { - c := c t.Run(c.Identifier.String(), func(t *testing.T) { t.Parallel() ok := c.Identifier.IsOrgRole() @@ -1081,7 +1057,6 @@ func TestChangeSet(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/subject_test.go b/coderd/rbac/subject_test.go index e2a2f24932c36..c1462b073ec35 100644 --- a/coderd/rbac/subject_test.go +++ b/coderd/rbac/subject_test.go @@ -119,7 +119,6 @@ func TestSubjectEqual(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/render/markdown_test.go b/coderd/render/markdown_test.go index 40f3dae137633..4095cac3f07e7 100644 --- a/coderd/render/markdown_test.go +++ b/coderd/render/markdown_test.go @@ -79,8 +79,6 @@ func TestHTML(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index 8b4fe969e59d7..85cc7b533a6ea 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -486,8 +486,6 @@ func TestCalculateAutoStop(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -622,7 +620,6 @@ func TestFindWeek(t *testing.T) { } for _, tz := range timezones { - tz := tz t.Run("Loc/"+tz, func(t *testing.T) { t.Parallel() diff --git a/coderd/schedule/cron/cron_test.go b/coderd/schedule/cron/cron_test.go index d3be423eace00..05e8ac21af9de 100644 --- a/coderd/schedule/cron/cron_test.go +++ b/coderd/schedule/cron/cron_test.go @@ -141,7 +141,6 @@ func Test_Weekly(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() actual, err := cron.Weekly(testCase.spec) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 5739ecab77525..dd879839e552f 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -297,7 +297,6 @@ func TestSearchWorkspace(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() // TODO: Replace this with the mock database. @@ -382,7 +381,6 @@ func TestSearchAudit(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() // Do not use a real database, this is only used for an @@ -550,7 +548,6 @@ func TestSearchUsers(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() values, errs := searchquery.Users(c.Query) @@ -622,7 +619,6 @@ func TestSearchTemplates(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() // Do not use a real database, this is only used for an diff --git a/coderd/tailnet_test.go b/coderd/tailnet_test.go index 28265404c3eae..55b212237479f 100644 --- a/coderd/tailnet_test.go +++ b/coderd/tailnet_test.go @@ -257,7 +257,6 @@ func TestServerTailnet_ReverseProxy(t *testing.T) { port := ":4444" for i, ag := range agents { - i := i ln, err := ag.TailnetConn().Listen("tcp", port) require.NoError(t, err) wln := &wrappedListener{Listener: ln} diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index ba67c0bd48835..747cf2cb47de1 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -33,6 +33,7 @@ import ( clitelemetry "github.com/coder/coder/v2/cli/telemetry" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" tailnetproto "github.com/coder/coder/v2/tailnet/proto" ) @@ -686,10 +687,6 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { return nil }) eg.Go(func() error { - if !r.options.Experiments.Enabled(codersdk.ExperimentWorkspacePrebuilds) { - return nil - } - metrics, err := r.options.Database.GetPrebuildMetrics(ctx) if err != nil { return xerrors.Errorf("get prebuild metrics: %w", err) @@ -1090,7 +1087,7 @@ func ConvertTemplate(dbTemplate database.Template) Template { AutostartAllowedDays: codersdk.BitmapToWeekdays(dbTemplate.AutostartAllowedDays()), RequireActiveVersion: dbTemplate.RequireActiveVersion, Deprecated: dbTemplate.Deprecated != "", - UseClassicParameterFlow: dbTemplate.UseClassicParameterFlow, + UseClassicParameterFlow: ptr.Ref(dbTemplate.UseClassicParameterFlow), } } @@ -1397,7 +1394,7 @@ type Template struct { AutostartAllowedDays []string `json:"autostart_allowed_days"` RequireActiveVersion bool `json:"require_active_version"` Deprecated bool `json:"deprecated"` - UseClassicParameterFlow bool `json:"use_classic_parameter_flow"` + UseClassicParameterFlow *bool `json:"use_classic_parameter_flow"` } type TemplateVersion struct { diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 498f97362c15b..ac836317b680e 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -408,7 +408,6 @@ func TestPrebuiltWorkspacesTelemetry(t *testing.T) { cases := []struct { name string - experimentEnabled bool storeFn func(store database.Store) database.Store expectedSnapshotEntries int expectedCreated int @@ -416,8 +415,7 @@ func TestPrebuiltWorkspacesTelemetry(t *testing.T) { expectedClaimed int }{ { - name: "experiment enabled", - experimentEnabled: true, + name: "prebuilds enabled", storeFn: func(store database.Store) database.Store { return &mockDB{Store: store} }, @@ -427,19 +425,11 @@ func TestPrebuiltWorkspacesTelemetry(t *testing.T) { expectedClaimed: 3, }, { - name: "experiment enabled, prebuilds not used", - experimentEnabled: true, + name: "prebuilds not used", storeFn: func(store database.Store) database.Store { return &emptyMockDB{Store: store} }, }, - { - name: "experiment disabled", - experimentEnabled: false, - storeFn: func(store database.Store) database.Store { - return &mockDB{Store: store} - }, - }, } for _, tc := range cases { @@ -448,11 +438,6 @@ func TestPrebuiltWorkspacesTelemetry(t *testing.T) { deployment, snapshot := collectSnapshot(ctx, t, db, func(opts telemetry.Options) telemetry.Options { opts.Database = tc.storeFn(db) - if tc.experimentEnabled { - opts.Experiments = codersdk.Experiments{ - codersdk.ExperimentWorkspacePrebuilds, - } - } return opts }) @@ -544,7 +529,6 @@ func TestRecordTelemetryStatus(t *testing.T) { {name: "Telemetry was disabled still disabled", recordedTelemetryEnabled: "false", telemetryEnabled: false, shouldReport: false}, {name: "Telemetry was disabled still disabled, invalid value", recordedTelemetryEnabled: "invalid", telemetryEnabled: false, shouldReport: false}, } { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/templateversions.go b/coderd/templateversions.go index d9f9c3db42dd6..d79f86f1f6626 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1730,12 +1730,6 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht String: req.ExampleID, Valid: req.ExampleID != "", }, - // appease the exhaustruct linter - // TODO: set this to whether the template version defines a `coder_ai_task` tf resource - HasAITask: sql.NullBool{ - Bool: false, - Valid: false, - }, }) if err != nil { if database.IsUniqueViolation(err, database.UniqueTemplateVersionsTemplateIDNameKey) { diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index e4027a1f14605..1ad06bae38aee 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -604,7 +604,6 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { }, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -1393,7 +1392,6 @@ func TestPaginatedTemplateVersions(t *testing.T) { file, err := client.Upload(egCtx, codersdk.ContentTypeTar, bytes.NewReader(data)) require.NoError(t, err) for i := 0; i < total; i++ { - i := i eg.Go(func() error { templateVersion, err := client.CreateTemplateVersion(egCtx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: uuid.NewString(), @@ -1466,7 +1464,6 @@ func TestPaginatedTemplateVersions(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/testdata/parameters/modules/main.tf b/coderd/testdata/parameters/modules/main.tf index 18f14ece154f2..21bb235574d3f 100644 --- a/coderd/testdata/parameters/modules/main.tf +++ b/coderd/testdata/parameters/modules/main.tf @@ -1,5 +1,47 @@ -terraform {} +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.5.3" + } + } +} module "jetbrains_gateway" { source = "jetbrains_gateway" } + +data "coder_parameter" "region" { + name = "region" + display_name = "Where would you like to travel to next?" + type = "string" + form_type = "dropdown" + mutable = true + default = "na" + order = 1000 + + option { + name = "North America" + value = "na" + } + + option { + name = "South America" + value = "sa" + } + + option { + name = "Europe" + value = "eu" + } + + option { + name = "Africa" + value = "af" + } + + option { + name = "Asia" + value = "as" + } +} diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 0583b29159ee5..ba1e2b879c345 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -77,8 +77,6 @@ func Test_Middleware(t *testing.T) { } for _, c := range cases { - c := c - name := strings.ReplaceAll(strings.TrimPrefix(c.path, "/"), "/", "_") t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/coderd/updatecheck/updatecheck_test.go b/coderd/updatecheck/updatecheck_test.go index 3e21309c5ff71..725ceb44d9d6f 100644 --- a/coderd/updatecheck/updatecheck_test.go +++ b/coderd/updatecheck/updatecheck_test.go @@ -112,7 +112,6 @@ func TestChecker_Latest(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/updatecheck_test.go b/coderd/updatecheck_test.go index c81dc0821a152..a81dcd63a2091 100644 --- a/coderd/updatecheck_test.go +++ b/coderd/updatecheck_test.go @@ -51,8 +51,6 @@ func TestUpdateCheck_NewVersion(t *testing.T) { }, } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 6d224818a6a46..4c9412fda3fb7 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1473,7 +1473,6 @@ func TestUserOIDC(t *testing.T) { }, }, } { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() opts := []oidctest.FakeIDPOpt{ diff --git a/coderd/userpassword/userpassword_test.go b/coderd/userpassword/userpassword_test.go index 41eebf49c974d..83a3bb532e606 100644 --- a/coderd/userpassword/userpassword_test.go +++ b/coderd/userpassword/userpassword_test.go @@ -27,7 +27,6 @@ func TestUserPasswordValidate(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() err := userpassword.Validate(tt.password) @@ -93,7 +92,6 @@ func TestUserPasswordCompare(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if tt.shouldHash { diff --git a/coderd/users_test.go b/coderd/users_test.go index 2e8eb5f3e842e..bd0f138b6a339 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1777,7 +1777,6 @@ func TestUsersFilter(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -2461,7 +2460,6 @@ func TestPaginatedUsers(t *testing.T) { eg, _ := errgroup.WithContext(ctx) // Create users for i := 0; i < total; i++ { - i := i eg.Go(func() error { email := fmt.Sprintf("%d@coder.com", i) username := fmt.Sprintf("user%d", i) @@ -2519,7 +2517,6 @@ func TestPaginatedUsers(t *testing.T) { {name: "username search", limit: 3, allUsers: specialUsers, opt: usernameSearch}, } for _, tt := range tests { - tt := tt t.Run(fmt.Sprintf("%s %d", tt.name, tt.limit), func(t *testing.T) { t.Parallel() diff --git a/coderd/util/maps/maps_test.go b/coderd/util/maps/maps_test.go index 543c100c210a5..f8ad8ddbc4b36 100644 --- a/coderd/util/maps/maps_test.go +++ b/coderd/util/maps/maps_test.go @@ -70,7 +70,6 @@ func TestSubset(t *testing.T) { expected: true, }, } { - tc := tc t.Run("#"+strconv.Itoa(idx), func(t *testing.T) { t.Parallel() diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index f3811650786b7..bb2011c05d1b2 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -217,3 +217,26 @@ func CountConsecutive[T comparable](needle T, haystack ...T) int { return max(maxLength, curLength) } + +// Convert converts a slice of type F to a slice of type T using the provided function f. +func Convert[F any, T any](a []F, f func(F) T) []T { + if a == nil { + return []T{} + } + + tmp := make([]T, 0, len(a)) + for _, v := range a { + tmp = append(tmp, f(v)) + } + return tmp +} + +func ToMapFunc[T any, K comparable, V any](a []T, cnv func(t T) (K, V)) map[K]V { + m := make(map[K]V, len(a)) + + for i := range a { + k, v := cnv(a[i]) + m[k] = v + } + return m +} diff --git a/coderd/util/strings/strings_test.go b/coderd/util/strings/strings_test.go index 2db9c9e236e43..5172fb08e1e69 100644 --- a/coderd/util/strings/strings_test.go +++ b/coderd/util/strings/strings_test.go @@ -30,7 +30,6 @@ func TestTruncate(t *testing.T) { {"foo", 0, ""}, {"foo", -1, ""}, } { - tt := tt t.Run(tt.expected, func(t *testing.T) { t.Parallel() actual := strings.Truncate(tt.s, tt.n) diff --git a/coderd/util/xio/limitwriter_test.go b/coderd/util/xio/limitwriter_test.go index 90d83f81e7d9e..552b38f71f487 100644 --- a/coderd/util/xio/limitwriter_test.go +++ b/coderd/util/xio/limitwriter_test.go @@ -107,7 +107,6 @@ func TestLimitWriter(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/webpush/webpush.go b/coderd/webpush/webpush.go index eb35685402c21..0f54a269cad00 100644 --- a/coderd/webpush/webpush.go +++ b/coderd/webpush/webpush.go @@ -103,7 +103,6 @@ func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, msg codersdk var mu sync.Mutex var eg errgroup.Group for _, subscription := range subscriptions { - subscription := subscription eg.Go(func() error { // TODO: Implement some retry logic here. For now, this is just a // best-effort attempt. diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index ed3f554a89b75..0ab28b340a1d1 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -359,7 +359,10 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req } switch req.State { - case codersdk.WorkspaceAppStatusStateComplete, codersdk.WorkspaceAppStatusStateFailure, codersdk.WorkspaceAppStatusStateWorking: // valid states + case codersdk.WorkspaceAppStatusStateComplete, + codersdk.WorkspaceAppStatusStateFailure, + codersdk.WorkspaceAppStatusStateWorking, + codersdk.WorkspaceAppStatusStateIdle: // valid states default: httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid state provided.", @@ -902,19 +905,19 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req // @Tags Agents // @Produce json // @Param workspaceagent path string true "Workspace agent ID" format(uuid) -// @Param container path string true "Container ID or name" +// @Param devcontainer path string true "Devcontainer ID" // @Success 202 {object} codersdk.Response -// @Router /workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate [post] +// @Router /workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}/recreate [post] func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgentParam(r) - container := chi.URLParam(r, "container") - if container == "" { + devcontainer := chi.URLParam(r, "devcontainer") + if devcontainer == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Container ID or name is required.", + Message: "Devcontainer ID is required.", Validations: []codersdk.ValidationError{ - {Field: "container", Detail: "Container ID or name is required."}, + {Field: "devcontainer", Detail: "Devcontainer ID is required."}, }, }) return @@ -958,7 +961,7 @@ func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *ht } defer release() - m, err := agentConn.RecreateDevcontainer(ctx, container) + m, err := agentConn.RecreateDevcontainer(ctx, devcontainer) if err != nil { if errors.Is(err, context.Canceled) { httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{ diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 6d53bd3df1140..4a37a1bf7bc52 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1065,7 +1065,6 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { }, }, } { - tc := tc t.Run("OK_"+tc.name, func(t *testing.T) { t.Parallel() @@ -1251,8 +1250,8 @@ func TestWorkspaceAgentContainers(t *testing.T) { return agents }).Do() _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) }) @@ -1340,7 +1339,6 @@ func TestWorkspaceAgentContainers(t *testing.T) { }, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -1360,8 +1358,8 @@ func TestWorkspaceAgentContainers(t *testing.T) { }).Do() _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { o.Logger = logger.Named("agent") - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mcl), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) @@ -1398,62 +1396,62 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { var ( workspaceFolder = t.TempDir() configFile = filepath.Join(workspaceFolder, ".devcontainer", "devcontainer.json") - dcLabels = map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder, - agentcontainers.DevcontainerConfigFileLabel: configFile, - } + devcontainerID = uuid.New() + + // Create a container that would be associated with the devcontainer devContainer = codersdk.WorkspaceAgentContainer{ ID: uuid.NewString(), CreatedAt: dbtime.Now(), FriendlyName: testutil.GetRandomName(t), Image: "busybox:latest", - Labels: dcLabels, - Running: true, - Status: "running", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder, + agentcontainers.DevcontainerConfigFileLabel: configFile, + }, + Running: true, + Status: "running", } - plainContainer = codersdk.WorkspaceAgentContainer{ - ID: uuid.NewString(), - CreatedAt: dbtime.Now(), - FriendlyName: testutil.GetRandomName(t), - Image: "busybox:latest", - Labels: map[string]string{}, - Running: true, - Status: "running", + + devcontainer = codersdk.WorkspaceAgentDevcontainer{ + ID: devcontainerID, + Name: "test-devcontainer", + WorkspaceFolder: workspaceFolder, + ConfigPath: configFile, + Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, + Container: &devContainer, } ) for _, tc := range []struct { - name string - setupMock func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) (status int) + name string + devcontainerID string + setupDevcontainers []codersdk.WorkspaceAgentDevcontainer + setupMock func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) (status int) }{ { - name: "Recreate", + name: "Recreate", + devcontainerID: devcontainerID.String(), + setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{devcontainer}, setupMock: func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int { mccli.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{devContainer}, }, nil).AnyTimes() // DetectArchitecture always returns "" for this test to disable agent injection. mccli.EXPECT().DetectArchitecture(gomock.Any(), devContainer.ID).Return("", nil).AnyTimes() + mdccli.EXPECT().ReadConfig(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return(agentcontainers.DevcontainerConfig{}, nil).AnyTimes() mdccli.EXPECT().Up(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return("someid", nil).Times(1) return 0 }, }, { - name: "Container does not exist", + name: "Devcontainer does not exist", + devcontainerID: uuid.NewString(), + setupDevcontainers: nil, setupMock: func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int { mccli.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, nil).AnyTimes() return http.StatusNotFound }, }, - { - name: "Not a devcontainer", - setupMock: func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int { - mccli.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{plainContainer}, - }, nil).AnyTimes() - return http.StatusNotFound - }, - }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -1473,16 +1471,21 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { return agents }).Do() + + devcontainerAPIOptions := []agentcontainers.Option{ + agentcontainers.WithContainerCLI(mccli), + agentcontainers.WithDevcontainerCLI(mdccli), + agentcontainers.WithWatcher(watcher.NewNoop()), + } + if tc.setupDevcontainers != nil { + devcontainerAPIOptions = append(devcontainerAPIOptions, + agentcontainers.WithDevcontainers(tc.setupDevcontainers, nil)) + } + _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { o.Logger = logger.Named("agent") - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append( - o.ContainerAPIOptions, - agentcontainers.WithContainerCLI(mccli), - agentcontainers.WithDevcontainerCLI(mdccli), - agentcontainers.WithWatcher(watcher.NewNoop()), - agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerLocalFolderLabel, workspaceFolder), - ) + o.Devcontainers = true + o.DevcontainerAPIOptions = devcontainerAPIOptions }) resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() require.Len(t, resources, 1, "expected one resource") @@ -1491,7 +1494,7 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - _, err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, devContainer.ID) + _, err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, tc.devcontainerID) if wantStatus > 0 { cerr, ok := codersdk.AsError(err) require.True(t, ok, "expected error to be a coder error") @@ -1692,7 +1695,6 @@ func TestWorkspaceAgent_LifecycleState(t *testing.T) { } //nolint:paralleltest // No race between setting the state and getting the workspace. for _, tt := range tests { - tt := tt t.Run(string(tt.state), func(t *testing.T) { state, err := agentsdk.ProtoFromLifecycleState(tt.state) if tt.wantErr { diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index 4e48e60d2d47f..d0f3acda77278 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -27,7 +27,6 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/workspaceapps" @@ -500,8 +499,6 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { } for _, c := range cases { - c := c - if c.name == "Path" && appHostIsPrimary { // Workspace application auth does not apply to path apps // served from the primary access URL as no smuggling needs @@ -1686,8 +1683,6 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -1824,7 +1819,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _ = coderdtest.MustTransitionWorkspace(t, appDetails.SDKClient, appDetails.Workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + _ = coderdtest.MustTransitionWorkspace(t, appDetails.SDKClient, appDetails.Workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) u := appDetails.PathAppURL(appDetails.Apps.Owner) resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, u.String(), nil) diff --git a/coderd/workspaceapps/appurl/appurl_test.go b/coderd/workspaceapps/appurl/appurl_test.go index 3924949cb30ad..9dfdb4452cdb9 100644 --- a/coderd/workspaceapps/appurl/appurl_test.go +++ b/coderd/workspaceapps/appurl/appurl_test.go @@ -56,7 +56,6 @@ func TestApplicationURLString(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -158,7 +157,6 @@ func TestParseSubdomainAppURL(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -378,7 +376,6 @@ func TestCompileHostnamePattern(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -390,7 +387,6 @@ func TestCompileHostnamePattern(t *testing.T) { require.Equal(t, expected, regex.String(), "generated regex does not match") for i, m := range c.matchCases { - m := m t.Run(fmt.Sprintf("MatchCase%d", i), func(t *testing.T) { t.Parallel() @@ -459,7 +455,6 @@ func TestConvertAppURLForCSP(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() require.Equal(t, c.expected, appurl.ConvertAppHostForCSP(c.host, c.wildcard)) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 597d1daadfa54..a1f3fb452fbe5 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -270,8 +270,6 @@ func Test_ResolveRequest(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/request_test.go b/coderd/workspaceapps/request_test.go index fbabc840745e9..f1b0df6ae064a 100644 --- a/coderd/workspaceapps/request_test.go +++ b/coderd/workspaceapps/request_test.go @@ -272,7 +272,6 @@ func Test_RequestValidate(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() req := c.req diff --git a/coderd/workspaceapps/stats_test.go b/coderd/workspaceapps/stats_test.go index 51a6d9eebf169..c98be2eb79142 100644 --- a/coderd/workspaceapps/stats_test.go +++ b/coderd/workspaceapps/stats_test.go @@ -280,7 +280,6 @@ func TestStatsCollector(t *testing.T) { // Run tests. for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/token_test.go b/coderd/workspaceapps/token_test.go index db070268fa196..94ee128bd9079 100644 --- a/coderd/workspaceapps/token_test.go +++ b/coderd/workspaceapps/token_test.go @@ -273,8 +273,6 @@ func Test_TokenMatchesRequest(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 91950ac855a1f..8db2858e01e32 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -57,7 +57,6 @@ func TestGetAppHost(t *testing.T) { }, } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -182,7 +181,6 @@ func TestWorkspaceApplicationAuth(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 4d90948a8f9a1..6c3321625c9b3 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -3,6 +3,7 @@ package coderd import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "math" @@ -26,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -384,59 +386,81 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { builder = builder.State(createBuild.ProvisionerState) } - if createBuild.EnableDynamicParameters != nil { - builder = builder.DynamicParameters(*createBuild.EnableDynamicParameters) - } - workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build( ctx, tx, + api.FileCache, func(action policy.Action, object rbac.Objecter) bool { - return api.Authorize(r, action, object) + if auth := api.Authorize(r, action, object); auth { + return true + } + // Special handling for prebuilt workspace deletion + if action == policy.ActionDelete { + if workspaceObj, ok := object.(database.PrebuiltWorkspaceResource); ok && workspaceObj.IsPrebuild() { + return api.Authorize(r, action, workspaceObj.AsPrebuild()) + } + } + return false }, audit.WorkspaceBuildBaggageFromRequest(r), ) return err }, nil) - var buildErr wsbuilder.BuildError - if xerrors.As(err, &buildErr) { - var authErr dbauthz.NotAuthorizedError - if xerrors.As(err, &authErr) { - buildErr.Status = http.StatusForbidden - } - - if buildErr.Status == http.StatusInternalServerError { - api.Logger.Error(ctx, "workspace build error", slog.Error(buildErr.Wrapped)) - } - - httpapi.Write(ctx, rw, buildErr.Status, codersdk.Response{ - Message: buildErr.Message, - Detail: buildErr.Error(), - }) - return - } if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Error posting new build", - Detail: err.Error(), - }) + httperror.WriteWorkspaceBuildError(ctx, rw, err) return } + var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow if provisionerJob != nil { + queuePos.ProvisionerJob = *provisionerJob + queuePos.QueuePosition = 0 if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil { // Client probably doesn't care about this error, so just log it. api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) } + + // We may need to complete the audit if wsbuilder determined that + // no provisioner could handle an orphan-delete job and completed it. + if createBuild.Orphan && createBuild.Transition == codersdk.WorkspaceTransitionDelete && provisionerJob.CompletedAt.Valid { + api.Logger.Warn(ctx, "orphan delete handled by wsbuilder due to no eligible provisioners", + slog.F("workspace_id", workspace.ID), + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("provisioner_job_id", provisionerJob.ID), + ) + buildResourceInfo := audit.AdditionalFields{ + WorkspaceName: workspace.Name, + BuildNumber: strconv.Itoa(int(workspaceBuild.BuildNumber)), + BuildReason: workspaceBuild.Reason, + WorkspaceID: workspace.ID, + WorkspaceOwner: workspace.OwnerName, + } + briBytes, err := json.Marshal(buildResourceInfo) + if err != nil { + api.Logger.Error(ctx, "failed to marshal build resource info for audit", slog.Error(err)) + } + auditor := api.Auditor.Load() + bag := audit.BaggageFromContext(ctx) + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceBuild]{ + Audit: *auditor, + Log: api.Logger, + UserID: provisionerJob.InitiatorID, + OrganizationID: workspace.OrganizationID, + RequestID: provisionerJob.ID, + IP: bag.IP, + Action: database.AuditActionDelete, + Old: previousWorkspaceBuild, + New: *workspaceBuild, + Status: http.StatusOK, + AdditionalFields: briBytes, + }) + } } apiBuild, err := api.convertWorkspaceBuild( *workspaceBuild, workspace, - database.GetProvisionerJobsByIDsWithQueuePositionRow{ - ProvisionerJob: *provisionerJob, - QueuePosition: 0, - }, + queuePos, []database.WorkspaceResource{}, []database.WorkspaceResourceMetadatum{}, []database.WorkspaceAgent{}, @@ -1078,6 +1102,14 @@ func (api *API) convertWorkspaceBuild( if build.TemplateVersionPresetID.Valid { presetID = &build.TemplateVersionPresetID.UUID } + var hasAITask *bool + if build.HasAITask.Valid { + hasAITask = &build.HasAITask.Bool + } + var aiTasksSidebarAppID *uuid.UUID + if build.AITaskSidebarAppID.Valid { + aiTasksSidebarAppID = &build.AITaskSidebarAppID.UUID + } apiJob := convertProvisionerJob(job) transition := codersdk.WorkspaceTransition(build.Transition) @@ -1105,6 +1137,8 @@ func (api *API) convertWorkspaceBuild( DailyCost: build.DailyCost, MatchedProvisioners: &matchedProvisioners, TemplateVersionPresetID: presetID, + HasAITask: hasAITask, + AITaskSidebarAppID: aiTasksSidebarAppID, }, nil } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index ac33c9e92c4f7..b9d32a00b139a 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "bytes" "context" "database/sql" "errors" @@ -25,6 +26,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -371,42 +373,174 @@ func TestWorkspaceBuildsProvisionerState(t *testing.T) { t.Run("Orphan", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - first := coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + t.Run("WithoutDelete", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Trying to orphan without delete transition fails. + _, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionStart, + Orphan: true, + }) + require.Error(t, err, "Orphan is only permitted when deleting a workspace.") + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + t.Run("WithState", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Providing both state and orphan fails. + _, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + ProvisionerState: []byte(" "), + Orphan: true, + }) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) - // Providing both state and orphan fails. - _, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - Transition: codersdk.WorkspaceTransitionDelete, - ProvisionerState: []byte(" "), - Orphan: true, + t.Run("NoPermission", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + member, memberUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: memberUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Trying to orphan without being a template admin fails. + _, err := member.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusForbidden, cerr.StatusCode()) }) - require.Error(t, err) - cerr := coderdtest.SDKError(t, err) - require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) - // Regular orphan operation succeeds. - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - Transition: codersdk.WorkspaceTransitionDelete, - Orphan: true, + t.Run("OK", func(t *testing.T) { + // Include a provisioner so that we can test that provisionerdserver + // performs deletion. + auditor := audit.NewMock() + client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + // This is a valid zip file. Without this the job will fail to complete. + // TODO: add this to dbfake by default. + zipBytes := make([]byte, 22) + zipBytes[0] = 80 + zipBytes[1] = 75 + zipBytes[2] = 0o5 + zipBytes[3] = 0o6 + uploadRes, err := client.Upload(ctx, codersdk.ContentTypeZip, bytes.NewReader(zipBytes)) + require.NoError(t, err) + + tv := dbfake.TemplateVersion(t, store). + FileID(uploadRes.ID). + Seed(database.TemplateVersion{ + OrganizationID: first.OrganizationID, + CreatedBy: templateAdminUser.ID, + }). + Do() + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + TemplateID: tv.Template.ID, + }).Do() + + auditor.ResetLogs() + // Regular orphan operation succeeds. + build, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Validate that the deletion was audited. + require.True(t, auditor.Contains(t, database.AuditLog{ + ResourceID: build.ID, + Action: database.AuditActionDelete, + })) }) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) - _, err = client.Workspace(ctx, workspace.ID) - require.Error(t, err) - require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode()) + t.Run("NoProvisioners", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor}) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + // nolint:gocritic // For testing + daemons, err := store.GetProvisionerDaemons(dbauthz.AsSystemReadProvisionerDaemons(ctx)) + require.NoError(t, err) + require.Empty(t, daemons, "Provisioner daemons should be empty for this test") + + // Orphan deletion still succeeds despite no provisioners being available. + build, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceTransitionDelete, build.Transition) + require.Equal(t, codersdk.ProvisionerJobSucceeded, build.Job.Status) + require.Empty(t, build.Job.Error) + + ws, err := client.Workspace(ctx, r.Workspace.ID) + require.Empty(t, ws) + require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode()) + + // Validate that the deletion was audited. + require.True(t, auditor.Contains(t, database.AuditLog{ + ResourceID: build.ID, + Action: database.AuditActionDelete, + })) + }) }) } @@ -585,7 +719,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) // Create a workspace using this template workspace := coderdtest.CreateWorkspace(t, userClient, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Create a new version of the template newVersion := coderdtest.CreateTemplateVersion(t, templateAdminClient, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { @@ -598,7 +732,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) cwbr.TemplateVersionID = newVersion.ID }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Create the workspace build _again_. We are doing this to // ensure we do not create _another_ notification. This is @@ -610,7 +744,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) cwbr.TemplateVersionID = newVersion.ID }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // We're going to have two notifications (one for the first user and one for the template admin) // By ensuring we only have these two, we are sure the second build didn't trigger more @@ -659,7 +793,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) // Create a workspace using this template workspace := coderdtest.CreateWorkspace(t, userClient, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Create a new version of the template newVersion := coderdtest.CreateTemplateVersion(t, templateAdminClient, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { @@ -675,7 +809,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Ensure we receive only 1 workspace manually updated notification and to the right user sent := notify.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceManuallyUpdated)) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index b1520776464c0..ecb624d1bc09f 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/prebuilds" @@ -717,13 +718,10 @@ func createWorkspace( builder = builder.MarkPrebuiltWorkspaceClaim() } - if req.EnableDynamicParameters { - builder = builder.DynamicParameters(req.EnableDynamicParameters) - } - workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build( ctx, db, + api.FileCache, func(action policy.Action, object rbac.Objecter) bool { return api.Authorize(r, action, object) }, @@ -731,21 +729,11 @@ func createWorkspace( ) return err }, nil) - var bldErr wsbuilder.BuildError - if xerrors.As(err, &bldErr) { - httpapi.Write(ctx, rw, bldErr.Status, codersdk.Response{ - Message: bldErr.Message, - Detail: bldErr.Error(), - }) - return - } if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error creating workspace.", - Detail: err.Error(), - }) + httperror.WriteWorkspaceBuildError(ctx, rw, err) return } + err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob) if err != nil { // Client probably doesn't care about this error, so just log it. diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index daabb12c25e14..d51a228a3f7a1 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -19,6 +19,8 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog" + "github.com/coder/terraform-provider-coder/v2/provider" + "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" @@ -42,7 +44,6 @@ import ( "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" - "github.com/coder/terraform-provider-coder/v2/provider" ) func TestWorkspace(t *testing.T) { @@ -652,7 +653,6 @@ func TestWorkspace(t *testing.T) { } for _, tc := range testCases { - tc := tc // Capture range variable t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -1802,7 +1802,6 @@ func TestWorkspaceFilter(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() workspaces, err := client.Workspaces(ctx, c.Filter) @@ -2583,7 +2582,6 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() var ( @@ -2763,7 +2761,6 @@ func TestWorkspaceUpdateTTL(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() @@ -2864,8 +2861,6 @@ func TestWorkspaceUpdateTTL(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { t.Parallel() @@ -3998,7 +3993,7 @@ func TestWorkspaceDormant(t *testing.T) { require.NoError(t, err) // Should be able to stop a workspace while it is dormant. - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Should not be able to start a workspace while it is dormant. _, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ @@ -4011,7 +4006,7 @@ func TestWorkspaceDormant(t *testing.T) { Dormant: false, }) require.NoError(t, err) - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) }) } @@ -4514,7 +4509,10 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { // Helper function to create workspace with AI task configuration createWorkspaceWithAIConfig := func(hasAITask sql.NullBool, jobCompleted bool, aiTaskPrompt *string) database.WorkspaceTable { - // When a provisioner job uses these tags, no provisioner will match it + // When a provisioner job uses these tags, no provisioner will match it. + // We do this so jobs will always be stuck in "pending", allowing us to exercise the intermediary state when + // has_ai_task is nil and we compensate by looking at pending provisioning jobs. + // See GetWorkspaces clauses. unpickableTags := database.StringMap{"custom": "true"} ws := dbgen.Workspace(t, db, database.WorkspaceTable{ @@ -4533,20 +4531,30 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { } job := dbgen.ProvisionerJob(t, db, pubsub, jobConfig) + res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: job.ID}) + agnt := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res.ID}) + + var sidebarAppID uuid.UUID + if hasAITask.Bool { + sidebarApp := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{AgentID: agnt.ID}) + sidebarAppID = sidebarApp.ID + } + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: ws.ID, - TemplateVersionID: version.ID, - InitiatorID: user.UserID, - JobID: job.ID, - BuildNumber: 1, - HasAITask: hasAITask, + WorkspaceID: ws.ID, + TemplateVersionID: version.ID, + InitiatorID: user.UserID, + JobID: job.ID, + BuildNumber: 1, + HasAITask: hasAITask, + AITaskSidebarAppID: uuid.NullUUID{UUID: sidebarAppID, Valid: sidebarAppID != uuid.Nil}, }) if aiTaskPrompt != nil { //nolint:gocritic // unit test err := db.InsertWorkspaceBuildParameters(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceBuildParametersParams{ WorkspaceBuildID: build.ID, - Name: []string{"AI Prompt"}, + Name: []string{provider.TaskPromptParameterName}, Value: []string{*aiTaskPrompt}, }) require.NoError(t, err) @@ -4556,7 +4564,7 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { } // Create test workspaces with different AI task configurations - wsWithAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: true, Valid: true}, false, nil) + wsWithAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: true, Valid: true}, true, nil) wsWithoutAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: false, Valid: true}, false, nil) aiTaskPrompt := "Build me a web app" @@ -4620,3 +4628,135 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { require.NoError(t, err) require.Len(t, res.Workspaces, 5) } + +func TestWorkspaceAppUpsertRestart(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + + // Define an app to be created with the workspace + apps := []*proto.App{ + { + Id: uuid.NewString(), + Slug: "test-app", + DisplayName: "Test App", + Command: "test-command", + Url: "http://localhost:8080", + Icon: "/test.svg", + }, + } + + // Create template version with workspace app + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "test-resource", + Type: "example", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "dev", + Auth: &proto.Agent_Token{}, + Apps: apps, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + // Create template and workspace + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Verify initial workspace has the app + workspace, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1) + agent := workspace.LatestBuild.Resources[0].Agents[0] + require.Len(t, agent.Apps, 1) + require.Equal(t, "test-app", agent.Apps[0].Slug) + require.Equal(t, "Test App", agent.Apps[0].DisplayName) + + // Stop the workspace + stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, stopBuild.ID) + + // Restart the workspace (this will trigger upsert for the app) + startBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, startBuild.ID) + + // Verify the workspace restarted successfully + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status) + + // Verify the app is still present after restart (upsert worked) + require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1) + agent = workspace.LatestBuild.Resources[0].Agents[0] + require.Len(t, agent.Apps, 1) + require.Equal(t, "test-app", agent.Apps[0].Slug) + require.Equal(t, "Test App", agent.Apps[0].DisplayName) + + // Verify the provisioner job completed successfully (no error) + require.Equal(t, codersdk.ProvisionerJobSucceeded, workspace.LatestBuild.Job.Status) + require.Empty(t, workspace.LatestBuild.Job.Error) +} + +func TestMultipleAITasksDisallowed(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + HasAiTasks: true, + AiTasks: []*proto.AITask{ + { + Id: uuid.NewString(), + SidebarApp: &proto.AITaskSidebarApp{ + Id: uuid.NewString(), + }, + }, + { + Id: uuid.NewString(), + SidebarApp: &proto.AITaskSidebarApp{ + Id: uuid.NewString(), + }, + }, + }, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + //nolint: gocritic // testing + ctx := dbauthz.AsSystemRestricted(t.Context()) + pj, err := db.GetProvisionerJobByID(ctx, ws.LatestBuild.Job.ID) + require.NoError(t, err) + require.Contains(t, pj.Error.String, "only one 'coder_ai_task' resource can be provisioned per template") +} diff --git a/coderd/workspacestats/activitybump_test.go b/coderd/workspacestats/activitybump_test.go index ccee299a46548..d778e2fbd0f8a 100644 --- a/coderd/workspacestats/activitybump_test.go +++ b/coderd/workspacestats/activitybump_test.go @@ -158,9 +158,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { expectedBump: 0, }, } { - tt := tt for _, tz := range timezones { - tz := tz t.Run(tt.name+"/"+tz, func(t *testing.T) { t.Parallel() nextAutostart := tt.nextAutostart diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 9605df58014de..bd3677614415e 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -13,12 +13,14 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/coder/coder/v2/apiversion" + "github.com/coder/coder/v2/coderd/dynamicparameters" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/provisioner/terraform/tfparse" "github.com/coder/coder/v2/provisionersdk" sdkproto "github.com/coder/coder/v2/provisionersdk/proto" + previewtypes "github.com/coder/preview/types" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" @@ -55,23 +57,22 @@ type Builder struct { deploymentValues *codersdk.DeploymentValues experiments codersdk.Experiments - richParameterValues []codersdk.WorkspaceBuildParameter - // dynamicParametersEnabled is non-nil if set externally - dynamicParametersEnabled *bool - initiator uuid.UUID - reason database.BuildReason - templateVersionPresetID uuid.UUID + richParameterValues []codersdk.WorkspaceBuildParameter + initiator uuid.UUID + reason database.BuildReason + templateVersionPresetID uuid.UUID // used during build, makes function arguments less verbose - ctx context.Context - store database.Store + ctx context.Context + store database.Store + fileCache *files.CacheCloser // cache of objects, so we only fetch once template *database.Template templateVersion *database.TemplateVersion templateVersionJob *database.ProvisionerJob terraformValues *database.TemplateVersionTerraformValue - templateVersionParameters *[]database.TemplateVersionParameter + templateVersionParameters *[]previewtypes.Parameter templateVersionVariables *[]database.TemplateVersionVariable templateVersionWorkspaceTags *[]database.TemplateVersionWorkspaceTag lastBuild *database.WorkspaceBuild @@ -80,7 +81,8 @@ type Builder struct { lastBuildJob *database.ProvisionerJob parameterNames *[]string parameterValues *[]string - templateVersionPresetParameterValues []database.TemplateVersionPresetParameter + templateVersionPresetParameterValues *[]database.TemplateVersionPresetParameter + parameterRender dynamicparameters.Renderer prebuiltWorkspaceBuildStage sdkproto.PrebuiltWorkspaceBuildStage verifyNoLegacyParametersOnce bool @@ -200,12 +202,6 @@ func (b Builder) MarkPrebuiltWorkspaceClaim() Builder { return b } -func (b Builder) DynamicParameters(using bool) Builder { - // nolint: revive - b.dynamicParametersEnabled = ptr.Ref(using) - return b -} - // SetLastWorkspaceBuildInTx prepopulates the Builder's cache with the last workspace build. This allows us // to avoid a repeated database query when the Builder's caller also needs the workspace build, e.g. auto-start & // auto-stop. @@ -256,6 +252,7 @@ func (e BuildError) Unwrap() error { func (b *Builder) Build( ctx context.Context, store database.Store, + fileCache *files.Cache, authFunc func(action policy.Action, object rbac.Objecter) bool, auditBaggage audit.WorkspaceBuildBaggage, ) ( @@ -267,6 +264,10 @@ func (b *Builder) Build( return nil, nil, nil, xerrors.Errorf("create audit baggage: %w", err) } + b.fileCache = files.NewCacheCloser(fileCache) + // Always close opened files during the build + defer b.fileCache.Close() + // Run the build in a transaction with RepeatableRead isolation, and retries. // RepeatableRead isolation ensures that we get a consistent view of the database while // computing the new build. This simplifies the logic so that we do not need to worry if @@ -425,12 +426,6 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object UUID: b.templateVersionPresetID, Valid: b.templateVersionPresetID != uuid.Nil, }, - // appease the exhaustruct linter - // TODO: set this to whether the build included a `coder_ai_task` tf resource - HasAITask: sql.NullBool{ - Bool: false, - Valid: false, - }, }) if err != nil { code := http.StatusInternalServerError @@ -464,6 +459,50 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object return BuildError{http.StatusInternalServerError, "get workspace build", err} } + // If the requestor is trying to orphan-delete a workspace and there are no + // provisioners available, we should complete the build and mark the + // workspace as deleted ourselves. + // There are cases where tagged provisioner daemons have been decommissioned + // without deleting the relevant workspaces, and without any provisioners + // available these workspaces cannot be deleted. + // Orphan-deleting a workspace sends an empty state to Terraform, which means + // it won't actually delete anything. So we actually don't need to execute a + // provisioner job at all for an orphan delete, but deleting without a workspace + // build or provisioner job would result in no audit log entry, which is a deal-breaker. + hasActiveEligibleProvisioner := false + for _, pd := range provisionerDaemons { + age := now.Sub(pd.ProvisionerDaemon.LastSeenAt.Time) + if age <= provisionerdserver.StaleInterval { + hasActiveEligibleProvisioner = true + break + } + } + if b.state.orphan && !hasActiveEligibleProvisioner { + // nolint: gocritic // At this moment, we are pretending to be provisionerd. + if err := store.UpdateProvisionerJobWithCompleteWithStartedAtByID(dbauthz.AsProvisionerd(b.ctx), database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams{ + CompletedAt: sql.NullTime{Valid: true, Time: now}, + Error: sql.NullString{Valid: false}, + ErrorCode: sql.NullString{Valid: false}, + ID: provisionerJob.ID, + StartedAt: sql.NullTime{Valid: true, Time: now}, + UpdatedAt: now, + }); err != nil { + return BuildError{http.StatusInternalServerError, "mark orphan-delete provisioner job as completed", err} + } + + // Re-fetch the completed provisioner job. + if pj, err := store.GetProvisionerJobByID(b.ctx, provisionerJob.ID); err == nil { + provisionerJob = pj + } + + if err := store.UpdateWorkspaceDeletedByID(b.ctx, database.UpdateWorkspaceDeletedByIDParams{ + ID: b.workspace.ID, + Deleted: true, + }); err != nil { + return BuildError{http.StatusInternalServerError, "mark workspace as deleted", err} + } + } + return nil }, nil) if err != nil { @@ -546,10 +585,54 @@ func (b *Builder) getTemplateTerraformValues() (*database.TemplateVersionTerrafo } vals, err := b.store.GetTemplateVersionTerraformValues(b.ctx, v.ID) if err != nil { - return nil, xerrors.Errorf("get template version terraform values %s: %w", v.JobID, err) + if !xerrors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("builder get template version terraform values %s: %w", v.JobID, err) + } + + // Old versions do not have terraform values, so we can ignore ErrNoRows and use an empty value. + vals = database.TemplateVersionTerraformValue{ + TemplateVersionID: v.ID, + UpdatedAt: time.Time{}, + CachedPlan: nil, + CachedModuleFiles: uuid.NullUUID{}, + ProvisionerdVersion: "", + } } b.terraformValues = &vals - return b.terraformValues, err + return b.terraformValues, nil +} + +func (b *Builder) getDynamicParameterRenderer() (dynamicparameters.Renderer, error) { + if b.parameterRender != nil { + return b.parameterRender, nil + } + + tv, err := b.getTemplateVersion() + if err != nil { + return nil, xerrors.Errorf("get template version to get parameters: %w", err) + } + + job, err := b.getTemplateVersionJob() + if err != nil { + return nil, xerrors.Errorf("get template version job to get parameters: %w", err) + } + + tfVals, err := b.getTemplateTerraformValues() + if err != nil { + return nil, xerrors.Errorf("get template version terraform values: %w", err) + } + + renderer, err := dynamicparameters.Prepare(b.ctx, b.store, b.fileCache, tv.ID, + dynamicparameters.WithTemplateVersion(*tv), + dynamicparameters.WithProvisionerJob(*job), + dynamicparameters.WithTerraformValues(*tfVals), + ) + if err != nil { + return nil, xerrors.Errorf("get template version renderer: %w", err) + } + + b.parameterRender = renderer + return renderer, nil } func (b *Builder) getLastBuild() (*database.WorkspaceBuild, error) { @@ -571,6 +654,19 @@ func (b *Builder) getLastBuild() (*database.WorkspaceBuild, error) { return b.lastBuild, nil } +// firstBuild returns true if this is the first build of the workspace, i.e. there are no prior builds. +func (b *Builder) firstBuild() (bool, error) { + _, err := b.getLastBuild() + if xerrors.Is(err, sql.ErrNoRows) { + // first build! + return true, nil + } + if err != nil { + return false, err + } + return false, nil +} + func (b *Builder) getBuildNumber() (int32, error) { bld, err := b.getLastBuild() if xerrors.Is(err, sql.ErrNoRows) { @@ -608,78 +704,94 @@ func (b *Builder) getParameters() (names, values []string, err error) { return *b.parameterNames, *b.parameterValues, nil } - templateVersionParameters, err := b.getTemplateVersionParameters() + // Always reject legacy parameters. + err = b.verifyNoLegacyParameters() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template version parameters", err} + return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err} + } + + if b.usingDynamicParameters() { + names, values, err = b.getDynamicParameters() + } else { + names, values, err = b.getClassicParameters() + } + + if err != nil { + return nil, nil, xerrors.Errorf("get parameters: %w", err) } + + b.parameterNames = &names + b.parameterValues = &values + return names, values, nil +} + +func (b *Builder) getDynamicParameters() (names, values []string, err error) { lastBuildParameters, err := b.getLastBuildParameters() if err != nil { return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch last build parameters", err} } - if b.templateVersionPresetID != uuid.Nil { - // Fetch and cache these, since we'll need them to override requested values if a preset was chosen - presetParameters, err := b.store.GetPresetParametersByPresetID(b.ctx, b.templateVersionPresetID) - if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "failed to get preset parameters", err} - } - b.templateVersionPresetParameterValues = presetParameters + + presetParameterValues, err := b.getPresetParameterValues() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch preset parameter values", err} } - err = b.verifyNoLegacyParameters() + + render, err := b.getDynamicParameterRenderer() if err != nil { - return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err} + return nil, nil, BuildError{http.StatusInternalServerError, "failed to get dynamic parameter renderer", err} } - lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters) - resolver := codersdk.ParameterResolver{ - Rich: lastBuildParameterValues, + firstBuild, err := b.firstBuild() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to check if first build", err} } - // Dynamic parameters skip all parameter validation. - // Deleting a workspace also should skip parameter validation. - // Pass the user's input as is. - if b.usingDynamicParameters() { - // TODO: The previous behavior was only to pass param values - // for parameters that exist. Since dynamic params can have - // conditional parameter existence, the static frame of reference - // is not sufficient. So assume the user is correct, or pull in the - // dynamic param code to find the actual parameters. - latestValues := make(map[string]string, len(b.richParameterValues)) - for _, latest := range b.richParameterValues { - latestValues[latest.Name] = latest.Value - } + buildValues, err := dynamicparameters.ResolveParameters(b.ctx, b.workspace.OwnerID, render, firstBuild, + lastBuildParameters, + b.richParameterValues, + presetParameterValues) + if err != nil { + return nil, nil, xerrors.Errorf("resolve parameters: %w", err) + } - // Merge the inputs with values from the previous build. - for _, last := range lastBuildParameterValues { - // TODO: Ideally we use the resolver here and look at parameter - // fields such as 'ephemeral'. This requires loading the terraform - // files. For now, just send the previous inputs as is. - if _, exists := latestValues[last.Name]; exists { - // latestValues take priority, so skip this previous value. - continue - } - names = append(names, last.Name) - values = append(values, last.Value) - } + names = make([]string, 0, len(buildValues)) + values = make([]string, 0, len(buildValues)) + for k, v := range buildValues { + names = append(names, k) + values = append(values, v) + } - for _, value := range b.richParameterValues { - names = append(names, value.Name) - values = append(values, value.Value) - } + return names, values, nil +} - b.parameterNames = &names - b.parameterValues = &values - return names, values, nil +func (b *Builder) getClassicParameters() (names, values []string, err error) { + templateVersionParameters, err := b.getTemplateVersionParameters() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template version parameters", err} + } + lastBuildParameters, err := b.getLastBuildParameters() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch last build parameters", err} + } + presetParameterValues, err := b.getPresetParameterValues() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch preset parameter values", err} + } + + lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters) + resolver := codersdk.ParameterResolver{ + Rich: lastBuildParameterValues, } for _, templateVersionParameter := range templateVersionParameters { - tvp, err := db2sdk.TemplateVersionParameter(templateVersionParameter) + tvp, err := db2sdk.TemplateVersionParameterFromPreview(templateVersionParameter) if err != nil { return nil, nil, BuildError{http.StatusInternalServerError, "failed to convert template version parameter", err} } value, err := resolver.ValidateResolve( tvp, - b.findNewBuildParameterValue(templateVersionParameter.Name), + b.findNewBuildParameterValue(templateVersionParameter.Name, presetParameterValues), ) if err != nil { // At this point, we've queried all the data we need from the database, @@ -697,8 +809,8 @@ func (b *Builder) getParameters() (names, values []string, err error) { return names, values, nil } -func (b *Builder) findNewBuildParameterValue(name string) *codersdk.WorkspaceBuildParameter { - for _, v := range b.templateVersionPresetParameterValues { +func (b *Builder) findNewBuildParameterValue(name string, presets []database.TemplateVersionPresetParameter) *codersdk.WorkspaceBuildParameter { + for _, v := range presets { if v.Name == name { return &codersdk.WorkspaceBuildParameter{ Name: v.Name, @@ -736,7 +848,7 @@ func (b *Builder) getLastBuildParameters() ([]database.WorkspaceBuildParameter, return values, nil } -func (b *Builder) getTemplateVersionParameters() ([]database.TemplateVersionParameter, error) { +func (b *Builder) getTemplateVersionParameters() ([]previewtypes.Parameter, error) { if b.templateVersionParameters != nil { return *b.templateVersionParameters, nil } @@ -748,8 +860,8 @@ func (b *Builder) getTemplateVersionParameters() ([]database.TemplateVersionPara if err != nil && !xerrors.Is(err, sql.ErrNoRows) { return nil, xerrors.Errorf("get template version %s parameters: %w", tvID, err) } - b.templateVersionParameters = &tvp - return tvp, nil + b.templateVersionParameters = ptr.Ref(db2sdk.List(tvp, dynamicparameters.TemplateVersionParameter)) + return *b.templateVersionParameters, nil } func (b *Builder) getTemplateVersionVariables() ([]database.TemplateVersionVariable, error) { @@ -903,6 +1015,24 @@ func (b *Builder) getTemplateVersionWorkspaceTags() ([]database.TemplateVersionW return *b.templateVersionWorkspaceTags, nil } +func (b *Builder) getPresetParameterValues() ([]database.TemplateVersionPresetParameter, error) { + if b.templateVersionPresetParameterValues != nil { + return *b.templateVersionPresetParameterValues, nil + } + + if b.templateVersionPresetID == uuid.Nil { + return []database.TemplateVersionPresetParameter{}, nil + } + + // Fetch and cache these, since we'll need them to override requested values if a preset was chosen + presetParameters, err := b.store.GetPresetParametersByPresetID(b.ctx, b.templateVersionPresetID) + if err != nil { + return nil, xerrors.Errorf("failed to get preset parameters: %w", err) + } + b.templateVersionPresetParameterValues = ptr.Ref(presetParameters) + return *b.templateVersionPresetParameterValues, nil +} + // authorize performs build authorization pre-checks using the provided authFunc func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Objecter) bool) error { // Doing this up front saves a lot of work if the user doesn't have permission. @@ -918,7 +1048,16 @@ func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Obje msg := fmt.Sprintf("Transition %q not supported.", b.trans) return BuildError{http.StatusBadRequest, msg, xerrors.New(msg)} } - if !authFunc(action, b.workspace) { + + // Try default workspace authorization first + authorized := authFunc(action, b.workspace) + + // Special handling for prebuilt workspace deletion + if !authorized && action == policy.ActionDelete && b.workspace.IsPrebuild() { + authorized = authFunc(action, b.workspace.AsPrebuild()) + } + + if !authorized { if authFunc(policy.ActionRead, b.workspace) { // If the user can read the workspace, but not delete/create/update. Show // a more helpful error. They are allowed to know the workspace exists. @@ -1048,10 +1187,6 @@ func (b *Builder) checkRunningBuild() error { } func (b *Builder) usingDynamicParameters() bool { - if b.dynamicParametersEnabled != nil { - return *b.dynamicParametersEnabled - } - tpl, err := b.getTemplate() if err != nil { return false // Let another part of the code get this error @@ -1060,21 +1195,5 @@ func (b *Builder) usingDynamicParameters() bool { return false } - vals, err := b.getTemplateTerraformValues() - if err != nil { - return false - } - - if !ProvisionerVersionSupportsDynamicParameters(vals.ProvisionerdVersion) { - return false - } - return true } - -func ProvisionerVersionSupportsDynamicParameters(version string) bool { - major, minor, err := apiversion.Parse(version) - // If the api version is not valid or less than 1.6, we need to use the static parameters - useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6) - return !useStaticParams -} diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index 58999a33e6e5e..f07e2f99f774a 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -8,6 +8,10 @@ import ( "testing" "time" + "github.com/prometheus/client_golang/prometheus" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/provisionersdk" "github.com/google/uuid" @@ -94,11 +98,12 @@ func TestBuilder_NoOptions(t *testing.T) { asrt.Empty(params.Value) }), ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -133,11 +138,12 @@ func TestBuilder_Initiator(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -178,11 +184,12 @@ func TestBuilder_Baggage(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) req.NoError(err) } @@ -216,11 +223,12 @@ func TestBuilder_Reason(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Reason(database.BuildReasonAutostart) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -259,11 +267,12 @@ func TestBuilder_ActiveVersion(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).ActiveVersion() // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -373,11 +382,12 @@ func TestWorkspaceBuildWithTags(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(buildParameters) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -455,11 +465,12 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) t.Run("UsePreviousParameterValues", func(t *testing.T) { @@ -502,11 +513,12 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -533,17 +545,17 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { mDB := expectDB(t, // Inputs withTemplate, - withInactiveVersion(richParameters), + withInactiveVersionNoParams(), withLastBuildFound, withTemplateVersionVariables(inactiveVersionID, nil), - withRichParameters(nil), withParameterSchemas(inactiveJobID, schemas), withWorkspaceTags(inactiveVersionID, nil), ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -575,11 +587,12 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { // Outputs // no transaction, since we failed fast while validation build parameters ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -639,12 +652,13 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -702,12 +716,13 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -763,13 +778,14 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) } @@ -829,40 +845,159 @@ func TestWorkspaceBuildWithPreset(t *testing.T) { asrt.Empty(params.Value) }), ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). ActiveVersion(). TemplateVersionPresetID(presetID) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } -func TestProvisionerVersionSupportsDynamicParameters(t *testing.T) { +func TestWorkspaceBuildDeleteOrphan(t *testing.T) { t.Parallel() - for v, dyn := range map[string]bool{ - "": false, - "na": false, - "0.0": false, - "0.10": false, - "1.4": false, - "1.5": false, - "1.6": true, - "1.7": true, - "1.8": true, - "2.0": true, - "2.17": true, - "4.0": true, - } { - t.Run(v, func(t *testing.T) { - t.Parallel() - - does := wsbuilder.ProvisionerVersionSupportsDynamicParameters(v) - require.Equal(t, dyn, does) - }) - } + t.Run("WithActiveProvisioners", func(t *testing.T) { + t.Parallel() + req := require.New(t) + asrt := assert.New(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var buildID uuid.UUID + + mDB := expectDB(t, + // Inputs + withTemplate, + withInactiveVersion(nil), + withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), + withRichParameters(nil), + withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{{ + JobID: inactiveJobID, + ProvisionerDaemon: database.ProvisionerDaemon{ + LastSeenAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + }, + }}), + + // Outputs + expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + asrt.Equal(userID, job.InitiatorID) + asrt.Equal(inactiveFileID, job.FileID) + input := provisionerdserver.WorkspaceProvisionJob{} + err := json.Unmarshal(job.Input, &input) + req.NoError(err) + // store build ID for later + buildID = input.WorkspaceBuildID + }), + + withInTx, + expectBuild(func(bld database.InsertWorkspaceBuildParams) { + asrt.Equal(inactiveVersionID, bld.TemplateVersionID) + asrt.Equal(workspaceID, bld.WorkspaceID) + asrt.Equal(int32(2), bld.BuildNumber) + asrt.Empty(string(bld.ProvisionerState)) + asrt.Equal(userID, bld.InitiatorID) + asrt.Equal(database.WorkspaceTransitionDelete, bld.Transition) + asrt.Equal(database.BuildReasonInitiator, bld.Reason) + asrt.Equal(buildID, bld.ID) + }), + withBuild, + expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) { + asrt.Equal(buildID, params.WorkspaceBuildID) + asrt.Empty(params.Name) + asrt.Empty(params.Value) + }), + ) + + ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} + uut := wsbuilder.New(ws, database.WorkspaceTransitionDelete).Orphan() + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) + req.NoError(err) + }) + + t.Run("NoActiveProvisioners", func(t *testing.T) { + t.Parallel() + req := require.New(t) + asrt := assert.New(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var buildID uuid.UUID + var jobID uuid.UUID + + mDB := expectDB(t, + // Inputs + withTemplate, + withInactiveVersion(nil), + withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), + withRichParameters(nil), + withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), + + // Outputs + expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + asrt.Equal(userID, job.InitiatorID) + asrt.Equal(inactiveFileID, job.FileID) + input := provisionerdserver.WorkspaceProvisionJob{} + err := json.Unmarshal(job.Input, &input) + req.NoError(err) + // store build ID for later + buildID = input.WorkspaceBuildID + // store job ID for later + jobID = job.ID + }), + + withInTx, + expectBuild(func(bld database.InsertWorkspaceBuildParams) { + asrt.Equal(inactiveVersionID, bld.TemplateVersionID) + asrt.Equal(workspaceID, bld.WorkspaceID) + asrt.Equal(int32(2), bld.BuildNumber) + asrt.Empty(string(bld.ProvisionerState)) + asrt.Equal(userID, bld.InitiatorID) + asrt.Equal(database.WorkspaceTransitionDelete, bld.Transition) + asrt.Equal(database.BuildReasonInitiator, bld.Reason) + asrt.Equal(buildID, bld.ID) + }), + withBuild, + expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) { + asrt.Equal(buildID, params.WorkspaceBuildID) + asrt.Empty(params.Name) + asrt.Empty(params.Value) + }), + + // Because no provisioners were available and the request was to delete --orphan + expectUpdateProvisionerJobWithCompleteWithStartedAtByID(func(params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) { + asrt.Equal(jobID, params.ID) + asrt.False(params.Error.Valid) + asrt.True(params.CompletedAt.Valid) + asrt.True(params.StartedAt.Valid) + }), + expectUpdateWorkspaceDeletedByID(func(params database.UpdateWorkspaceDeletedByIDParams) { + asrt.Equal(workspaceID, params.ID) + asrt.True(params.Deleted) + }), + expectGetProvisionerJobByID(func(job database.ProvisionerJob) { + asrt.Equal(jobID, job.ID) + }), + ) + + ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} + uut := wsbuilder.New(ws, database.WorkspaceTransitionDelete).Orphan() + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) + req.NoError(err) + }) } type txExpect func(mTx *dbmock.MockStore) @@ -911,7 +1046,7 @@ func withInTx(mTx *dbmock.MockStore) { ) } -func withActiveVersion(params []database.TemplateVersionParameter) func(mTx *dbmock.MockStore) { +func withActiveVersionNoParams() func(mTx *dbmock.MockStore) { return func(mTx *dbmock.MockStore) { mTx.EXPECT().GetTemplateVersionByID(gomock.Any(), activeVersionID). Times(1). @@ -941,6 +1076,12 @@ func withActiveVersion(params []database.TemplateVersionParameter) func(mTx *dbm UpdatedAt: time.Now(), CompletedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, }, nil) + } +} + +func withActiveVersion(params []database.TemplateVersionParameter) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + withActiveVersionNoParams()(mTx) paramsCall := mTx.EXPECT().GetTemplateVersionParameters(gomock.Any(), activeVersionID). Times(1) if len(params) > 0 { @@ -951,7 +1092,7 @@ func withActiveVersion(params []database.TemplateVersionParameter) func(mTx *dbm } } -func withInactiveVersion(params []database.TemplateVersionParameter) func(mTx *dbmock.MockStore) { +func withInactiveVersionNoParams() func(mTx *dbmock.MockStore) { return func(mTx *dbmock.MockStore) { mTx.EXPECT().GetTemplateVersionByID(gomock.Any(), inactiveVersionID). Times(1). @@ -981,6 +1122,13 @@ func withInactiveVersion(params []database.TemplateVersionParameter) func(mTx *d UpdatedAt: time.Now(), CompletedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, }, nil) + } +} + +func withInactiveVersion(params []database.TemplateVersionParameter) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + withInactiveVersionNoParams()(mTx) + paramsCall := mTx.EXPECT().GetTemplateVersionParameters(gomock.Any(), inactiveVersionID). Times(1) if len(params) > 0 { @@ -1107,6 +1255,53 @@ func expectProvisionerJob( } } +// expectUpdateProvisionerJobWithCompleteWithStartedAtByID asserts a call to +// expectUpdateProvisionerJobWithCompleteWithStartedAtByID and runs the provided +// assertions against it. +func expectUpdateProvisionerJobWithCompleteWithStartedAtByID(assertions func(params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().UpdateProvisionerJobWithCompleteWithStartedAtByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) error { + assertions(params) + return nil + }, + ) + } +} + +// expectUpdateWorkspaceDeletedByID asserts a call to UpdateWorkspaceDeletedByID +// and runs the provided assertions against it. +func expectUpdateWorkspaceDeletedByID(assertions func(params database.UpdateWorkspaceDeletedByIDParams)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().UpdateWorkspaceDeletedByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, params database.UpdateWorkspaceDeletedByIDParams) error { + assertions(params) + return nil + }, + ) + } +} + +// expectGetProvisionerJobByID asserts a call to GetProvisionerJobByID +// and runs the provided assertions against it. +func expectGetProvisionerJobByID(assertions func(job database.ProvisionerJob)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().GetProvisionerJobByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, id uuid.UUID) (database.ProvisionerJob, error) { + job := database.ProvisionerJob{ID: id} + assertions(job) + return job, nil + }, + ) + } +} + func withBuild(mTx *dbmock.MockStore) { mTx.EXPECT().GetWorkspaceBuildByID(gomock.Any(), gomock.Any()).Times(1). DoAndReturn(func(ctx context.Context, id uuid.UUID) (database.WorkspaceBuild, error) { diff --git a/codersdk/agentsdk/logs_test.go b/codersdk/agentsdk/logs_test.go index 2b3b934c8db3c..05e4bc574efde 100644 --- a/codersdk/agentsdk/logs_test.go +++ b/codersdk/agentsdk/logs_test.go @@ -168,7 +168,6 @@ func TestStartupLogsWriter_Write(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -253,7 +252,6 @@ func TestStartupLogsSender(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go new file mode 100644 index 0000000000000..89ca9c948f272 --- /dev/null +++ b/codersdk/aitasks.go @@ -0,0 +1,46 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/coder/terraform-provider-coder/v2/provider" +) + +const AITaskPromptParameterName = provider.TaskPromptParameterName + +type AITasksPromptsResponse struct { + // Prompts is a map of workspace build IDs to prompts. + Prompts map[string]string `json:"prompts"` +} + +// AITaskPrompts returns prompts for multiple workspace builds by their IDs. +func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.UUID) (AITasksPromptsResponse, error) { + if len(buildIDs) == 0 { + return AITasksPromptsResponse{ + Prompts: make(map[string]string), + }, nil + } + + // Convert UUIDs to strings and join them + buildIDStrings := make([]string, len(buildIDs)) + for i, id := range buildIDs { + buildIDStrings[i] = id.String() + } + buildIDsParam := strings.Join(buildIDStrings, ",") + + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/aitasks/prompts", nil, WithQueryParam("build_ids", buildIDsParam)) + if err != nil { + return AITasksPromptsResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return AITasksPromptsResponse{}, ReadBodyAsError(res) + } + var prompts AITasksPromptsResponse + return prompts, json.NewDecoder(res.Body).Decode(&prompts) +} diff --git a/codersdk/chat.go b/codersdk/chat.go deleted file mode 100644 index 2093adaff95e8..0000000000000 --- a/codersdk/chat.go +++ /dev/null @@ -1,153 +0,0 @@ -package codersdk - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/google/uuid" - "github.com/kylecarbs/aisdk-go" - "golang.org/x/xerrors" -) - -// CreateChat creates a new chat. -func (c *Client) CreateChat(ctx context.Context) (Chat, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/v2/chats", nil) - if err != nil { - return Chat{}, xerrors.Errorf("execute request: %w", err) - } - if res.StatusCode != http.StatusCreated { - return Chat{}, ReadBodyAsError(res) - } - defer res.Body.Close() - var chat Chat - return chat, json.NewDecoder(res.Body).Decode(&chat) -} - -type Chat struct { - ID uuid.UUID `json:"id" format:"uuid"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` - Title string `json:"title"` -} - -// ListChats lists all chats. -func (c *Client) ListChats(ctx context.Context) ([]Chat, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/chats", nil) - if err != nil { - return nil, xerrors.Errorf("execute request: %w", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, ReadBodyAsError(res) - } - - var chats []Chat - return chats, json.NewDecoder(res.Body).Decode(&chats) -} - -// Chat returns a chat by ID. -func (c *Client) Chat(ctx context.Context, id uuid.UUID) (Chat, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s", id), nil) - if err != nil { - return Chat{}, xerrors.Errorf("execute request: %w", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return Chat{}, ReadBodyAsError(res) - } - var chat Chat - return chat, json.NewDecoder(res.Body).Decode(&chat) -} - -// ChatMessages returns the messages of a chat. -func (c *Client) ChatMessages(ctx context.Context, id uuid.UUID) ([]ChatMessage, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/messages", id), nil) - if err != nil { - return nil, xerrors.Errorf("execute request: %w", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, ReadBodyAsError(res) - } - var messages []ChatMessage - return messages, json.NewDecoder(res.Body).Decode(&messages) -} - -type ChatMessage = aisdk.Message - -type CreateChatMessageRequest struct { - Model string `json:"model"` - Message ChatMessage `json:"message"` - Thinking bool `json:"thinking"` -} - -// CreateChatMessage creates a new chat message and streams the response. -// If the provided message has a conflicting ID with an existing message, -// it will be overwritten. -func (c *Client) CreateChatMessage(ctx context.Context, id uuid.UUID, req CreateChatMessageRequest) (<-chan aisdk.DataStreamPart, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/chats/%s/messages", id), req) - defer func() { - if res != nil && res.Body != nil { - _ = res.Body.Close() - } - }() - if err != nil { - return nil, xerrors.Errorf("execute request: %w", err) - } - if res.StatusCode != http.StatusOK { - return nil, ReadBodyAsError(res) - } - nextEvent := ServerSentEventReader(ctx, res.Body) - - wc := make(chan aisdk.DataStreamPart, 256) - go func() { - defer close(wc) - defer res.Body.Close() - - for { - select { - case <-ctx.Done(): - return - default: - sse, err := nextEvent() - if err != nil { - return - } - if sse.Type != ServerSentEventTypeData { - continue - } - var part aisdk.DataStreamPart - b, ok := sse.Data.([]byte) - if !ok { - return - } - err = json.Unmarshal(b, &part) - if err != nil { - return - } - select { - case <-ctx.Done(): - return - case wc <- part: - } - } - } - }() - - return wc, nil -} - -func (c *Client) DeleteChat(ctx context.Context, id uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/chats/%s", id), nil) - if err != nil { - return xerrors.Errorf("execute request: %w", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusNoContent { - return ReadBodyAsError(res) - } - return nil -} diff --git a/codersdk/client.go b/codersdk/client.go index b0fb4d9764b3c..2097225ff489c 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -354,7 +354,7 @@ func (c *Client) Dial(ctx context.Context, path string, opts *websocket.DialOpti if opts.HTTPHeader == nil { opts.HTTPHeader = http.Header{} } - if opts.HTTPHeader.Get("tokenHeader") == "" { + if opts.HTTPHeader.Get(tokenHeader) == "" { opts.HTTPHeader.Set(tokenHeader, c.SessionToken()) } diff --git a/codersdk/client_experimental.go b/codersdk/client_experimental.go new file mode 100644 index 0000000000000..e37b4d0c86a4f --- /dev/null +++ b/codersdk/client_experimental.go @@ -0,0 +1,14 @@ +package codersdk + +// ExperimentalClient is a client for the experimental API. +// Its interface is not guaranteed to be stable and may change at any time. +// @typescript-ignore ExperimentalClient +type ExperimentalClient struct { + *Client +} + +func NewExperimentalClient(client *Client) *ExperimentalClient { + return &ExperimentalClient{ + Client: client, + } +} diff --git a/codersdk/client_internal_test.go b/codersdk/client_internal_test.go index 0650c3c32097d..cfd8bdbf26086 100644 --- a/codersdk/client_internal_test.go +++ b/codersdk/client_internal_test.go @@ -76,8 +76,6 @@ func TestIsConnectionErr(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -298,7 +296,6 @@ func Test_readBodyAsError(t *testing.T) { } for _, c := range tests { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 90e8a4c879ec5..544e98f6f2a72 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -383,7 +383,6 @@ type DeploymentValues struct { DisablePasswordAuth serpent.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` Support SupportConfig `json:"support,omitempty" typescript:",notnull"` ExternalAuthConfigs serpent.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"` - AI serpent.Struct[AIConfig] `json:"ai,omitempty" typescript:",notnull"` SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` @@ -2681,15 +2680,6 @@ Write out the current server config as YAML to stdout.`, Value: &c.Support.Links, Hidden: false, }, - { - // Env handling is done in cli.ReadAIProvidersFromEnv - Name: "AI", - Description: "Configure AI providers.", - YAML: "ai", - Value: &c.AI, - // Hidden because this is experimental. - Hidden: true, - }, { // Env handling is done in cli.ReadGitAuthFromEnvironment Name: "External Auth Providers", @@ -3080,7 +3070,6 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupPrebuilds, YAML: "reconciliation_interval", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), - Hidden: ExperimentsSafe.Enabled(ExperimentWorkspacePrebuilds), // Hide setting while this feature is experimental. }, { Name: "Reconciliation Backoff Interval", @@ -3132,21 +3121,6 @@ Write out the current server config as YAML to stdout.`, return opts } -type AIProviderConfig struct { - // Type is the type of the API provider. - Type string `json:"type" yaml:"type"` - // APIKey is the API key to use for the API provider. - APIKey string `json:"-" yaml:"api_key"` - // Models is the list of models to use for the API provider. - Models []string `json:"models" yaml:"models"` - // BaseURL is the base URL to use for the API provider. - BaseURL string `json:"base_url" yaml:"base_url"` -} - -type AIConfig struct { - Providers []AIProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"` -} - type SupportConfig struct { Links serpent.Struct[[]LinkConfig] `json:"links" typescript:",notnull"` } @@ -3367,23 +3341,30 @@ const ( ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. - ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature. - ExperimentAgenticChat Experiment = "agentic-chat" // Enables the new agentic AI chat feature. - ExperimentAITasks Experiment = "ai-tasks" // Enables the new AI tasks feature. ) +// ExperimentsKnown should include all experiments defined above. +var ExperimentsKnown = Experiments{ + ExperimentExample, + ExperimentAutoFillParameters, + ExperimentNotifications, + ExperimentWorkspaceUsage, + ExperimentWebPush, +} + // ExperimentsSafe should include all experiments that are safe for // users to opt-in to via --experimental='*'. // Experiments that are not ready for consumption by all users should // not be included here and will be essentially hidden. -var ExperimentsSafe = Experiments{ - ExperimentWorkspacePrebuilds, -} +var ExperimentsSafe = Experiments{} // Experiments is a list of experiments. // Multiple experiments may be enabled at the same time. // Experiments are not safe for production use, and are not guaranteed to // be backwards compatible. They may be removed or renamed at any time. +// The below typescript-ignore annotation allows our typescript generator +// to generate an enum list, which is used in the frontend. +// @typescript-ignore Experiments type Experiments []Experiment // Returns a list of experiments that are enabled for the deployment. @@ -3584,32 +3565,6 @@ func (c *Client) SSHConfiguration(ctx context.Context) (SSHConfigResponse, error return sshConfig, json.NewDecoder(res.Body).Decode(&sshConfig) } -type LanguageModelConfig struct { - Models []LanguageModel `json:"models"` -} - -// LanguageModel is a language model that can be used for chat. -type LanguageModel struct { - // ID is used by the provider to identify the LLM. - ID string `json:"id"` - DisplayName string `json:"display_name"` - // Provider is the provider of the LLM. e.g. openai, anthropic, etc. - Provider string `json:"provider"` -} - -func (c *Client) LanguageModelConfig(ctx context.Context) (LanguageModelConfig, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/deployment/llms", nil) - if err != nil { - return LanguageModelConfig{}, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return LanguageModelConfig{}, ReadBodyAsError(res) - } - var llms LanguageModelConfig - return llms, json.NewDecoder(res.Body).Decode(&llms) -} - type CryptoKeyFeature string const ( diff --git a/codersdk/deployment_internal_test.go b/codersdk/deployment_internal_test.go index 09ee7f2a2cc71..d350447fd638a 100644 --- a/codersdk/deployment_internal_test.go +++ b/codersdk/deployment_internal_test.go @@ -28,8 +28,6 @@ func TestRemoveTrailingVersionInfo(t *testing.T) { } for _, tc := range testCases { - tc := tc - stripped := removeTrailingVersionInfo(tc.Version) require.Equal(t, tc.ExpectedAfterStrippingInfo, stripped) } diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 1d2af676596d3..c18e5775f7ae9 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -196,7 +196,6 @@ func TestSSHConfig_ParseOptions(t *testing.T) { } for _, tt := range testCases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() c := codersdk.SSHConfig{ @@ -277,7 +276,6 @@ func TestTimezoneOffsets(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -524,8 +522,6 @@ func TestFeatureComparison(t *testing.T) { } for _, tc := range testCases { - tc := tc - t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -619,8 +615,6 @@ func TestNotificationsCanBeDisabled(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/healthsdk/healthsdk_test.go b/codersdk/healthsdk/healthsdk_test.go index 517837e20a2de..4a062da03f24d 100644 --- a/codersdk/healthsdk/healthsdk_test.go +++ b/codersdk/healthsdk/healthsdk_test.go @@ -148,7 +148,6 @@ func TestSummarize(t *testing.T) { }, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() actual := tt.br.Summarize(tt.pfx, tt.docsURL) diff --git a/codersdk/healthsdk/interfaces_internal_test.go b/codersdk/healthsdk/interfaces_internal_test.go index f870e543166e1..e5c3978383b35 100644 --- a/codersdk/healthsdk/interfaces_internal_test.go +++ b/codersdk/healthsdk/interfaces_internal_test.go @@ -160,7 +160,6 @@ func Test_generateInterfacesReport(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() r := generateInterfacesReport(&tc.state) diff --git a/codersdk/name_test.go b/codersdk/name_test.go index 487f3778ac70e..b4903846c4c23 100644 --- a/codersdk/name_test.go +++ b/codersdk/name_test.go @@ -60,7 +60,6 @@ func TestUsernameValid(t *testing.T) { {"123456789012345678901234567890123123456789012345678901234567890123", false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Username, func(t *testing.T) { t.Parallel() valid := codersdk.NameValid(testCase.Username) @@ -115,7 +114,6 @@ func TestTemplateDisplayNameValid(t *testing.T) { {"12345678901234567890123456789012345678901234567890123456789012345", false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() valid := codersdk.DisplayNameValid(testCase.Name) @@ -156,7 +154,6 @@ func TestTemplateVersionNameValid(t *testing.T) { {"!!!!1 ?????", false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() valid := codersdk.TemplateVersionNameValid(testCase.Name) @@ -197,7 +194,6 @@ func TestFrom(t *testing.T) { {"", ""}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.From, func(t *testing.T) { t.Parallel() converted := codersdk.UsernameFrom(testCase.From) @@ -243,7 +239,6 @@ func TestUserRealNameValid(t *testing.T) { {strings.Repeat("a", 129), false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() err := codersdk.UserRealNameValid(testCase.Name) @@ -277,7 +272,6 @@ func TestGroupNameValid(t *testing.T) { {random256String, false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() err := codersdk.GroupNameValid(testCase.Name) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 728540ef2e6e1..08c1fb0034b46 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -227,7 +227,6 @@ type CreateWorkspaceRequest struct { RichParameterValues []WorkspaceBuildParameter `json:"rich_parameter_values,omitempty"` AutomaticUpdates AutomaticUpdates `json:"automatic_updates,omitempty"` TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"` - EnableDynamicParameters bool `json:"enable_dynamic_parameters,omitempty"` } func (c *Client) OrganizationByName(ctx context.Context, name string) (Organization, error) { diff --git a/codersdk/pagination_test.go b/codersdk/pagination_test.go index 53a3fcaebceb4..e5bb8002743f9 100644 --- a/codersdk/pagination_test.go +++ b/codersdk/pagination_test.go @@ -42,7 +42,6 @@ func TestPagination_asRequestOption(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/presets.go b/codersdk/presets.go index 110f6c605f026..60253d6595c51 100644 --- a/codersdk/presets.go +++ b/codersdk/presets.go @@ -14,6 +14,7 @@ type Preset struct { ID uuid.UUID Name string Parameters []PresetParameter + Default bool } type PresetParameter struct { diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 95792bb8e2a7b..5ffcfed6b4c35 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -9,7 +9,6 @@ const ( ResourceAssignOrgRole RBACResource = "assign_org_role" ResourceAssignRole RBACResource = "assign_role" ResourceAuditLog RBACResource = "audit_log" - ResourceChat RBACResource = "chat" ResourceCryptoKey RBACResource = "crypto_key" ResourceDebugInfo RBACResource = "debug_info" ResourceDeploymentConfig RBACResource = "deployment_config" @@ -28,6 +27,7 @@ const ( ResourceOauth2AppSecret RBACResource = "oauth2_app_secret" ResourceOrganization RBACResource = "organization" ResourceOrganizationMember RBACResource = "organization_member" + ResourcePrebuiltWorkspace RBACResource = "prebuilt_workspace" ResourceProvisionerDaemon RBACResource = "provisioner_daemon" ResourceProvisionerJobs RBACResource = "provisioner_jobs" ResourceReplicas RBACResource = "replicas" @@ -72,7 +72,6 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate}, ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, ResourceAuditLog: {ActionCreate, ActionRead}, - ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceDebugInfo: {ActionRead}, ResourceDeploymentConfig: {ActionRead, ActionUpdate}, @@ -91,6 +90,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceOauth2AppSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourcePrebuiltWorkspace: {ActionDelete, ActionUpdate}, ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceProvisionerJobs: {ActionCreate, ActionRead, ActionUpdate}, ResourceReplicas: {ActionRead}, diff --git a/codersdk/richparameters_test.go b/codersdk/richparameters_test.go index 5635a82beb6c6..66f23416115bd 100644 --- a/codersdk/richparameters_test.go +++ b/codersdk/richparameters_test.go @@ -322,7 +322,6 @@ func TestRichParameterValidation(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.parameterName+"-"+tc.value, func(t *testing.T) { t.Parallel() diff --git a/codersdk/time_test.go b/codersdk/time_test.go index a2d3b20622ba7..fd5314538d3d9 100644 --- a/codersdk/time_test.go +++ b/codersdk/time_test.go @@ -47,7 +47,6 @@ func TestNullTime_MarshalJSON(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -104,7 +103,6 @@ func TestNullTime_UnmarshalJSON(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -145,7 +143,6 @@ func TestNullTime_IsZero(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index bb1649efa1993..24433c1b2a6da 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -8,9 +8,10 @@ import ( "io" "github.com/google/uuid" - "github.com/kylecarbs/aisdk-go" "golang.org/x/xerrors" + "github.com/coder/aisdk-go" + "github.com/coder/coder/v2/codersdk" ) @@ -191,7 +192,7 @@ Bad Tasks Use the "state" field to indicate your progress. Periodically report progress with state "working" to keep the user updated. It is not possible to send too many updates! -ONLY report a "complete" or "failure" state if you have FULLY completed the task. +ONLY report an "idle" or "failure" state if you have FULLY completed the task. `, Schema: aisdk.Schema{ Properties: map[string]any{ @@ -205,10 +206,10 @@ ONLY report a "complete" or "failure" state if you have FULLY completed the task }, "state": map[string]any{ "type": "string", - "description": "The state of your task. This can be one of the following: working, complete, or failure. Select the state that best represents your current progress.", + "description": "The state of your task. This can be one of the following: working, idle, or failure. Select the state that best represents your current progress.", "enum": []string{ string(codersdk.WorkspaceAppStatusStateWorking), - string(codersdk.WorkspaceAppStatusStateComplete), + string(codersdk.WorkspaceAppStatusStateIdle), string(codersdk.WorkspaceAppStatusStateFailure), }, }, diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index e4c4239be51e2..d08191a614a99 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -10,11 +10,12 @@ import ( "time" "github.com/google/uuid" - "github.com/kylecarbs/aisdk-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" + "github.com/coder/aisdk-go" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 5fe648ce15045..77cc0aff9a6be 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -519,8 +519,8 @@ func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid. } // WorkspaceAgentRecreateDevcontainer recreates the devcontainer with the given ID. -func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, containerIDOrName string) (Response, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/container/%s/recreate", agentID, containerIDOrName), nil) +func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, devcontainerID string) (Response, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/%s/recreate", agentID, devcontainerID), nil) if err != nil { return Response{}, err } diff --git a/codersdk/workspaceapps.go b/codersdk/workspaceapps.go index 556b3adb27b2e..6e95377bbaf42 100644 --- a/codersdk/workspaceapps.go +++ b/codersdk/workspaceapps.go @@ -19,6 +19,7 @@ type WorkspaceAppStatusState string const ( WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working" + WorkspaceAppStatusStateIdle WorkspaceAppStatusState = "idle" WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete" WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure" ) diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index d3372b272548f..328b8bc26566f 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -75,6 +75,8 @@ type WorkspaceBuild struct { DailyCost int32 `json:"daily_cost"` MatchedProvisioners *MatchedProvisioners `json:"matched_provisioners,omitempty"` TemplateVersionPresetID *uuid.UUID `json:"template_version_preset_id" format:"uuid"` + HasAITask *bool `json:"has_ai_task,omitempty"` + AITaskSidebarAppID *uuid.UUID `json:"ai_task_sidebar_app_id,omitempty" format:"uuid"` } // WorkspaceResource describes resources used to create a workspace, for instance: diff --git a/codersdk/workspacedisplaystatus_internal_test.go b/codersdk/workspacedisplaystatus_internal_test.go index 2b910c89835fb..68e718a5f4cde 100644 --- a/codersdk/workspacedisplaystatus_internal_test.go +++ b/codersdk/workspacedisplaystatus_internal_test.go @@ -90,7 +90,6 @@ func TestWorkspaceDisplayStatus(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := WorkspaceDisplayStatus(tt.jobStatus, tt.transition); got != tt.want { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 2c73d60a2696c..c776f2cf5a473 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -110,10 +110,6 @@ type CreateWorkspaceBuildRequest struct { LogLevel ProvisionerLogLevel `json:"log_level,omitempty" validate:"omitempty,oneof=debug"` // TemplateVersionPresetID is the ID of the template version preset to use for the build. TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"` - // EnableDynamicParameters skips some of the static parameter checking. - // It will default to whatever the template has marked as the default experience. - // Requires the "dynamic-experiment" to be used. - EnableDynamicParameters *bool `json:"enable_dynamic_parameters,omitempty"` } type WorkspaceOptions struct { diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 3477ec98328ac..ee0b36e5a0c23 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -389,10 +389,10 @@ func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgent // RecreateDevcontainer recreates a devcontainer with the given container. // This is a blocking call and will wait for the container to be recreated. -func (c *AgentConn) RecreateDevcontainer(ctx context.Context, containerIDOrName string) (codersdk.Response, error) { +func (c *AgentConn) RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() - res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/containers/devcontainers/container/"+containerIDOrName+"/recreate", nil) + res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/containers/devcontainers/"+devcontainerID+"/recreate", nil) if err != nil { return codersdk.Response{}, xerrors.Errorf("do request: %w", err) } diff --git a/cryptorand/strings_test.go b/cryptorand/strings_test.go index 8557667457a6c..4a24f907a2dc8 100644 --- a/cryptorand/strings_test.go +++ b/cryptorand/strings_test.go @@ -92,7 +92,6 @@ func TestStringCharset(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.Name, func(t *testing.T) { t.Parallel() diff --git a/docker-compose.yaml b/docker-compose.yaml index 5f1a1c8b4779e..b5ab4cf0227ff 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -32,8 +32,9 @@ services: # Minimum supported version is 13. # More versions here: https://hub.docker.com/_/postgres image: "postgres:17" - ports: - - "5432:5432" + # Uncomment the next two lines to allow connections to the database from outside the server. + #ports: + # - "5432:5432" environment: POSTGRES_USER: ${POSTGRES_USER:-username} # The PostgreSQL user (useful to connect to the database) POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} # The PostgreSQL password (useful to connect to the database) diff --git a/docs/about/contributing/frontend.md b/docs/about/contributing/frontend.md index b121b01a26c59..ceddc5c2ff819 100644 --- a/docs/about/contributing/frontend.md +++ b/docs/about/contributing/frontend.md @@ -259,18 +259,16 @@ We use [Formik](https://formik.org/docs) for forms along with ## Testing -We use three types of testing in our app: **End-to-end (E2E)**, **Integration** +We use three types of testing in our app: **End-to-end (E2E)**, **Integration/Unit** and **Visual Testing**. -### End-to-End (E2E) +### End-to-End (E2E) – Playwright These are useful for testing complete flows like "Create a user", "Import -template", etc. We use [Playwright](https://playwright.dev/). If you only need -to test if the page is being rendered correctly, you should consider using the -**Visual Testing** approach. +template", etc. We use [Playwright](https://playwright.dev/). These tests run against a full Coder instance, backed by a database, and allows you to make sure that features work properly all the way through the stack. "End to end", so to speak. -For scenarios where you need to be authenticated, you can use -`test.use({ storageState: getStatePath("authState") })`. +For scenarios where you need to be authenticated as a certain user, you can use +`login` helper. Passing it some user credentials will log out of any other user account, and will attempt to login using those credentials. For ease of debugging, it's possible to run a Playwright test in headful mode running a Playwright server on your local machine, and executing the test inside @@ -289,22 +287,14 @@ local machine and forward the necessary ports to your workspace. At the end of the script, you will land _inside_ your workspace with environment variables set so you can simply execute the test (`pnpm run playwright:test`). -### Integration +### Integration/Unit – Jest -Test user interactions like "Click in a button shows a dialog", "Submit the form -sends the correct data", etc. For this, we use [Jest](https://jestjs.io/) and -[react-testing-library](https://testing-library.com/docs/react-testing-library/intro/). -If the test involves routing checks like redirects or maybe checking the info on -another page, you should probably consider using the **E2E** approach. +We use Jest mostly for testing code that does _not_ pertain to React. Functions and classes that contain notable app logic, and which are well abstracted from React should have accompanying tests. If the logic is tightly coupled to a React component, a Storybook test or an E2E test may be a better option depending on the scenario. -### Visual testing +### Visual Testing – Storybook -We use visual tests to test components without user interaction like testing if -a page/component is rendered correctly depending on some parameters, if a button -is showing a spinner, if `loading` props are passed correctly, etc. This should -always be your first option since it is way easier to maintain. For this, we use -[Storybook](https://storybook.js.org/) and -[Chromatic](https://www.chromatic.com/). +We use Storybook for testing all of our React code. For static components, you simply add a story that renders the components with the props that you would like to test, and Storybook will record snapshots of it to ensure that it isn't changed unintentionally. If you would like to test an interaction with the component, then you can add an interaction test by specifying a `play` function for the story. For stories with an interaction test, a snapshot will be recorded of the end state of the component. We use +[Chromatic](https://www.chromatic.com/) to manage and compare snapshots in CI. To learn more about testing components that fetch API data, refer to the [**Where to fetch data**](#where-to-fetch-data) section. diff --git a/docs/admin/integrations/dx-data-cloud.md b/docs/admin/integrations/dx-data-cloud.md new file mode 100644 index 0000000000000..62055d69f5f1a --- /dev/null +++ b/docs/admin/integrations/dx-data-cloud.md @@ -0,0 +1,87 @@ +# DX Data Cloud + +[DX](https://getdx.com) is a developer intelligence platform used by engineering +leaders and platform engineers. + +DX uses metadata attributes to assign information to individual users. +While it's common to segment users by `role`, `level`, or `geo`, it’s become increasingly +common to use DX attributes to better understand usage and adoption of tools. + +You can create a `Coder` attribute in DX to segment and analyze the impact of Coder usage on a developer’s work, including: + +- Understanding the needs of power users or low Coder usage across the org +- Correlate Coder usage with qualitative and quantitative engineering metrics, + such as PR throughput, deployment frequency, deep work, dev environment toil, and more. +- Personalize user experiences + +## Requirements + +- A DX subscription +- Access to Coder user data through the Coder CLI, Coder API, an IdP, or an existing Coder-DX integration +- Coordination with your DX Customer Success Manager + +## Extract Your Coder User List + +
+ +You can use the Coder CLI, Coder API, or your Identity Provider (IdP) to extract your list of users. + +If your organization already uses the Coder-DX integration, you can find a list of active Coder users directly within DX. + +### CLI + +Use `users list` to export the list of users to a CSV file: + +```shell +coder users list > users.csv +``` + +Visit the [users list](../../reference/cli/users_list.md) documentation for more options. + +### API + +Use [get users](../../reference/api/users.md#get-users): + +```bash +curl -X GET http://coder-server:8080/api/v2/users \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +To export the results to a CSV file, you can use the `jq` tool to process the JSON response: + +```bash +curl -X GET http://coder-server:8080/api/v2/users \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' | \ + jq -r '.users | (map(keys) | add | unique) as $cols | $cols, (.[] | [.[$cols[]]] | @csv)' > users.csv +``` + +Visit the [get users](../../reference/api/users.md#get-users) documentation for more options. + +### IdP + +If your organization uses a centralized IdP to manage user accounts, you can extract user data directly from your IdP. + +This is particularly useful if you need additional user attributes managed within your IdP. + +
+ +## Contact your DX Customer Success Manager + +Provide the file to your dedicated DX Customer Success Manager (CSM). + +Your CSM will import the CSV of individuals using Coder, as well as usage frequency (if applicable) into DX to create a `Coder` attribute. + +After the attribute is uploaded, you'll have a Coder filter option within your DX reports allowing you to: + +- Perform cohort analysis (Coder user vs non-user) +- Understand unique behaviors and patterns across your Coder users +- Run a [study](https://getdx.com/studies/) or setup a [PlatformX](https://getdx.com/platformx/) event for deeper analysis + +## Related Resources + +- [DX Data Cloud Documentation](https://help.getdx.com/en/) +- [Coder CLI](../../reference/cli/users.md) +- [Coder API](../../reference/api/users.md) +- [PlatformX Integration](./platformx.md) diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 7b0b852419f21..01b3d8e61d595 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -31,7 +31,7 @@ We track the following resources: | User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | WorkspaceAgent
connect, disconnect | |
FieldTracked
api_key_scopefalse
api_versionfalse
architecturefalse
auth_instance_idfalse
auth_tokenfalse
connection_timeout_secondsfalse
created_atfalse
deletedfalse
directoryfalse
disconnected_atfalse
display_appsfalse
display_orderfalse
environment_variablesfalse
expanded_directoryfalse
first_connected_atfalse
idfalse
instance_metadatafalse
last_connected_atfalse
last_connected_replica_idfalse
lifecycle_statefalse
logs_lengthfalse
logs_overflowedfalse
motd_filefalse
namefalse
operating_systemfalse
parent_idfalse
ready_atfalse
resource_idfalse
resource_metadatafalse
started_atfalse
subsystemsfalse
troubleshooting_urlfalse
updated_atfalse
versionfalse
| | WorkspaceApp
open, close | |
FieldTracked
agent_idfalse
commandfalse
created_atfalse
display_groupfalse
display_namefalse
display_orderfalse
externalfalse
healthfalse
healthcheck_intervalfalse
healthcheck_thresholdfalse
healthcheck_urlfalse
hiddenfalse
iconfalse
idfalse
open_infalse
sharing_levelfalse
slugfalse
subdomainfalse
urlfalse
| -| WorkspaceBuild
start, stop | |
FieldTracked
ai_tasks_sidebar_app_idfalse
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceBuild
start, stop | |
FieldTracked
ai_task_sidebar_app_idfalse
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| | WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| diff --git a/docs/admin/security/database-encryption.md b/docs/admin/security/database-encryption.md index 289c18a7c11dd..ecdea90dba499 100644 --- a/docs/admin/security/database-encryption.md +++ b/docs/admin/security/database-encryption.md @@ -118,11 +118,10 @@ data: This command will re-encrypt all tokens with the specified new encryption key. We recommend performing this action during a maintenance window. - > [!IMPORTANT] - > This command requires direct access to the database. If you are using - > the built-in PostgreSQL database, you can run - > [`coder server postgres-builtin-url`](../../reference/cli/server_postgres-builtin-url.md) - > to get the connection URL. + This command requires direct access to the database. + If you are using the built-in PostgreSQL database, you can run + [`coder server postgres-builtin-url`](../../reference/cli/server_postgres-builtin-url.md) + to get the connection URL. - Once the above command completes successfully, remove the old encryption key from Coder's configuration and restart Coder once more. You can now safely diff --git a/docs/admin/templates/creating-templates.md b/docs/admin/templates/creating-templates.md index a0a6b54366948..6387cc0368c35 100644 --- a/docs/admin/templates/creating-templates.md +++ b/docs/admin/templates/creating-templates.md @@ -25,10 +25,8 @@ Give your template a name, description, and icon and press `Create template`. ![Name and icon](../../images/admin/templates/import-template.png) -> [!NOTE] -> If template creation fails, Coder is likely not authorized to -> deploy infrastructure in the given location. Learn how to configure -> [provisioner authentication](./extending-templates/provider-authentication.md). +If template creation fails, it's likely that Coder is not authorized to deploy infrastructure in the given location. +Learn how to configure [provisioner authentication](./extending-templates/provider-authentication.md). ### CLI @@ -65,10 +63,8 @@ Next, push it to Coder with the coder templates push ``` -> [!NOTE] -> If `template push` fails, Coder is likely not authorized to deploy -> infrastructure in the given location. Learn how to configure -> [provisioner authentication](../provisioners/index.md). +If `template push` fails, it's likely that Coder is not authorized to deploy infrastructure in the given location. +Learn how to configure [provisioner authentication](../provisioners/index.md). You can edit the metadata of the template such as the display name with the [`templates edit`](../../reference/cli/templates_edit.md) command: diff --git a/docs/admin/templates/extending-templates/prebuilt-workspaces.md b/docs/admin/templates/extending-templates/prebuilt-workspaces.md index 361a75f4b9ff4..9d33425019b50 100644 --- a/docs/admin/templates/extending-templates/prebuilt-workspaces.md +++ b/docs/admin/templates/extending-templates/prebuilt-workspaces.md @@ -12,6 +12,7 @@ Prebuilt workspaces are: - Created and maintained automatically by Coder to match your specified preset configurations. - Claimed transparently when developers create workspaces. - Monitored and replaced automatically to maintain your desired pool size. +- Automatically scaled based on time-based schedules to optimize resource usage. ## Relationship to workspace presets @@ -26,7 +27,6 @@ Prebuilt workspaces are tightly integrated with [workspace presets](./parameters - [**Premium license**](../../licensing/index.md) - **Compatible Terraform provider**: Use `coder/coder` Terraform provider `>= 2.4.1`. -- **Feature flag**: Enable the `workspace-prebuilds` [experiment](../../../reference/cli/server.md#--experiments). ## Enable prebuilt workspaces for template presets @@ -111,6 +111,105 @@ prebuilt workspace can remain before it is considered expired and eligible for c Expired prebuilt workspaces are removed during the reconciliation loop to avoid stale environments and resource waste. New prebuilt workspaces are only created to maintain the desired count if needed. +### Scheduling + +Prebuilt workspaces support time-based scheduling to scale the number of instances up or down. +This allows you to reduce resource costs during off-hours while maintaining availability during peak usage times. + +Configure scheduling by adding a `scheduling` block within your `prebuilds` configuration: + +```tf +data "coder_workspace_preset" "goland" { + name = "GoLand: Large" + parameters { + jetbrains_ide = "GO" + cpus = 8 + memory = 16 + } + + prebuilds { + instances = 0 # default to 0 instances + + scheduling { + timezone = "UTC" # only a single timezone may be used for simplicity + + # scale to 3 instances during the work week + schedule { + cron = "* 8-18 * * 1-5" # from 8AM-6:59PM, Mon-Fri, UTC + instances = 3 # scale to 3 instances + } + + # scale to 1 instance on Saturdays for urgent support queries + schedule { + cron = "* 8-14 * * 6" # from 8AM-2:59PM, Sat, UTC + instances = 1 # scale to 1 instance + } + } + } +} +``` + +**Scheduling configuration:** + +- **`timezone`**: The timezone for all cron expressions (required). Only a single timezone is supported per scheduling configuration. +- **`schedule`**: One or more schedule blocks defining when to scale to specific instance counts. + - **`cron`**: Cron expression interpreted as continuous time ranges (required). + - **`instances`**: Number of prebuilt workspaces to maintain during this schedule (required). + +**How scheduling works:** + +1. The reconciliation loop evaluates all active schedules every reconciliation interval (`CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL`). +2. The schedule that matches the current time becomes active. Overlapping schedules are disallowed by validation rules. +3. If no schedules match the current time, the base `instances` count is used. +4. The reconciliation loop automatically creates or destroys prebuilt workspaces to match the target count. + +**Cron expression format:** + +Cron expressions follow the format: `* HOUR DOM MONTH DAY-OF-WEEK` + +- `*` (minute): Must always be `*` to ensure the schedule covers entire hours rather than specific minute intervals +- `HOUR`: 0-23, range (e.g., 8-18 for 8AM-6:59PM), or `*` +- `DOM` (day-of-month): 1-31, range, or `*` +- `MONTH`: 1-12, range, or `*` +- `DAY-OF-WEEK`: 0-6 (Sunday=0, Saturday=6), range (e.g., 1-5 for Monday to Friday), or `*` + +**Important notes about cron expressions:** + +- **Minutes must always be `*`**: To ensure the schedule covers entire hours +- **Time ranges are continuous**: A range like `8-18` means from 8AM to 6:59PM (inclusive of both start and end hours) +- **Weekday ranges**: `1-5` means Monday through Friday (Monday=1, Friday=5) +- **No overlapping schedules**: The validation system prevents overlapping schedules. + +**Example schedules:** + +```tf +# Business hours only (8AM-6:59PM, Mon-Fri) +schedule { + cron = "* 8-18 * * 1-5" + instances = 5 +} + +# 24/7 coverage with reduced capacity overnight and on weekends +schedule { + cron = "* 8-18 * * 1-5" # Business hours (8AM-6:59PM, Mon-Fri) + instances = 10 +} +schedule { + cron = "* 19-23,0-7 * * 1,5" # Evenings and nights (7PM-11:59PM, 12AM-7:59AM, Mon-Fri) + instances = 2 +} +schedule { + cron = "* * * * 6,0" # Weekends + instances = 2 +} + +# Weekend support (10AM-4:59PM, Sat-Sun) +schedule { + cron = "* 10-16 * * 6,0" + instances = 1 +} +``` + ### Template updates and the prebuilt workspace lifecycle Prebuilt workspaces are not updated after they are provisioned. @@ -195,12 +294,6 @@ The prebuilt workspaces feature has these current limitations: [View issue](https://github.com/coder/internal/issues/364) -- **Autoscaling** - - Prebuilt workspaces remain running until claimed. There's no automated mechanism to reduce instances during off-hours. - - [View issue](https://github.com/coder/internal/issues/312) - ### Monitoring and observability #### Available metrics diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index b59431c5f0026..e893bf91bb8ef 100644 --- a/docs/admin/users/idp-sync.md +++ b/docs/admin/users/idp-sync.md @@ -107,10 +107,9 @@ Below is an example that uses the `groups` claim and maps all groups prefixed by } ``` -> [!IMPORTANT] -> You must specify Coder group IDs instead of group names. The fastest way to find -> the ID for a corresponding group is by visiting -> `https://coder.example.com/api/v2/groups`. +You must specify Coder group IDs instead of group names. +You can find the ID for a corresponding group by visiting +`https://coder.example.com/api/v2/groups`. Here is another example which maps `coder-admins` from the identity provider to two groups in Coder and `coder-users` from the identity provider to another diff --git a/docs/images/k8s.svg b/docs/images/k8s.svg index 5cc8c7442f823..9a61c190a09af 100644 --- a/docs/images/k8s.svg +++ b/docs/images/k8s.svg @@ -1,6 +1,91 @@ - - - - + + + + + Codestin Search App + + + + + + image/svg+xml + + Kubernetes logo with no border + "kubectl" is pronounced "kyoob kuttel" + + + + + + + + diff --git a/docs/images/logo-black.png b/docs/images/logo-black.png index 88b15b7634b5f..4071884acd1d6 100644 Binary files a/docs/images/logo-black.png and b/docs/images/logo-black.png differ diff --git a/docs/images/logo-white.png b/docs/images/logo-white.png index 595edfa9dd341..cccf82fcd8d86 100644 Binary files a/docs/images/logo-white.png and b/docs/images/logo-white.png differ diff --git a/docs/install/cli.md b/docs/install/cli.md index 9ee914a80f326..9193c9a103a19 100644 --- a/docs/install/cli.md +++ b/docs/install/cli.md @@ -22,11 +22,9 @@ alternate installation methods (e.g. standalone binaries, system packages). ## Windows -> [!IMPORTANT] -> If you plan to use the built-in PostgreSQL database, you will -> need to ensure that the -> [Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) -> is installed. +If you plan to use the built-in PostgreSQL database, ensure that the +[Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) +is installed. Use [GitHub releases](https://github.com/coder/coder/releases) to download the Windows installer (`.msi`) or standalone binary (`.exe`). diff --git a/docs/install/index.md b/docs/install/index.md index ae64dd2bf5915..c1d12dd779276 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -29,11 +29,9 @@ alternate installation methods (e.g. standalone binaries, system packages). ## Windows -> [!IMPORTANT] -> If you plan to use the built-in PostgreSQL database, you will -> need to ensure that the -> [Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) -> is installed. +If you plan to use the built-in PostgreSQL database, ensure that the +[Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) +is installed. Use [GitHub releases](https://github.com/coder/coder/releases) to download the Windows installer (`.msi`) or standalone binary (`.exe`). diff --git a/docs/install/other/index.md b/docs/install/other/index.md index f727e5c34bf55..1aa6d195bcdbb 100644 --- a/docs/install/other/index.md +++ b/docs/install/other/index.md @@ -1,6 +1,6 @@ # Alternate install methods -Coder has a number of alternate unofficial install methods. Contributions are +Coder has a number of alternate unofficial installation methods. Contributions are welcome! | Platform Name | Status | Documentation | @@ -9,7 +9,7 @@ welcome! | Terraform (GKE, AKS, LKE, DOKS, IBMCloud K8s, OVHCloud K8s, Scaleway K8s Kapsule) | Unofficial | [GitHub: coder-oss-terraform](https://github.com/ElliotG/coder-oss-tf) | | Fly.io | Unofficial | [Blog: Run Coder on Fly.io](https://coder.com/blog/remote-developer-environments-on-fly-io) | | Garden.io | Unofficial | [GitHub: garden-coder-example](https://github.com/garden-io/garden-coder-example) | -| Railway.app | Unofficial | [Blog: Run Coder on Railway.app](https://coder.com/blog/deploy-coder-on-railway-app) | +| Railway.com | Unofficial | [Run Coder on Railway.com](https://railway.com/deploy/coder) | | Heroku | Unofficial | [Docs: Deploy Coder on Heroku](https://github.com/coder/packages/blob/main/heroku/README.md) | | Render | Unofficial | [Docs: Deploy Coder on Render](https://github.com/coder/packages/blob/main/render/README.md) | | Snapcraft | Unofficial | [Get it from the Snap Store](https://snapcraft.io/coder) | diff --git a/docs/manifest.json b/docs/manifest.json index 2aa9cb0ead9ce..ef42dfbbce510 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -697,6 +697,11 @@ "description": "Integrate Coder with DX PlatformX", "path": "./admin/integrations/platformx.md" }, + { + "title": "DX Data Cloud", + "description": "Tag Coder Users with DX Data Cloud", + "path": "./admin/integrations/dx-data-cloud.md" + }, { "title": "Hashicorp Vault", "description": "Integrate Coder with Hashicorp Vault", @@ -814,61 +819,61 @@ }, { "title": "Run AI Coding Agents in Coder", - "description": "Learn how to run and integrate AI coding agents like GPT-Code, OpenDevin, or SWE-Agent in Coder workspaces to boost developer productivity.", + "description": "Learn how to run and integrate agentic AI coding agents like GPT-Code, OpenDevin, or SWE-Agent in Coder workspaces to boost developer productivity.", "path": "./ai-coder/index.md", "icon_path": "./images/icons/wand.svg", "state": ["beta"], "children": [ { "title": "Learn about coding agents", - "description": "Learn about the different AI agents and their tradeoffs", + "description": "Learn about the different agentic AI agents and their tradeoffs", "path": "./ai-coder/agents.md" }, { "title": "Create a Coder template for agents", - "description": "Create a purpose-built template for your AI agents", + "description": "Create a purpose-built template for your agentic AI agents", "path": "./ai-coder/create-template.md", "state": ["beta"] }, { "title": "Integrate with your issue tracker", - "description": "Assign tickets to AI agents and interact via code reviews", + "description": "Assign tickets to agentic AI agents and interact via code reviews", "path": "./ai-coder/issue-tracker.md", "state": ["beta"] }, { "title": "Model Context Protocols (MCP) and adding AI tools", - "description": "Improve results by adding tools to your AI agents", + "description": "Improve results by adding tools to your agentic AI agents", "path": "./ai-coder/best-practices.md", "state": ["beta"] }, { "title": "Supervise agents via Coder UI", - "description": "Interact with agents via the Coder UI", + "description": "Interact with agentic agents via the Coder UI", "path": "./ai-coder/coder-dashboard.md", "state": ["beta"] }, { "title": "Supervise agents via the IDE", - "description": "Interact with agents via VS Code or Cursor", + "description": "Interact with agentic agents via VS Code or Cursor", "path": "./ai-coder/ide-integration.md", "state": ["beta"] }, { "title": "Programmatically manage agents", - "description": "Manage agents via MCP, the Coder CLI, and/or REST API", + "description": "Manage agentic agents via MCP, the Coder CLI, and/or REST API", "path": "./ai-coder/headless.md", "state": ["beta"] }, { "title": "Securing agents in Coder", - "description": "Learn how to secure agents with boundaries", + "description": "Learn how to secure agentic agents with boundaries", "path": "./ai-coder/securing.md", "state": ["early access"] }, { "title": "Custom agents", - "description": "Learn how to use custom agents with Coder", + "description": "Learn how to use custom agentic agents with Coder", "path": "./ai-coder/custom-agents.md", "state": ["beta"] } @@ -1694,7 +1699,7 @@ }, { "title": "update", - "description": "Will update and start a given workspace if it is out of date", + "description": "Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first.", "path": "reference/cli/update.md" }, { diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 1c0534ad4c2bf..c32e8202fa945 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -859,19 +859,19 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate \ +curl -X POST http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}/recreate \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate` +`POST /workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}/recreate` ### Parameters -| Name | In | Type | Required | Description | -|------------------|------|--------------|----------|----------------------| -| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | -| `container` | path | string | true | Container ID or name | +| Name | In | Type | Required | Description | +|------------------|------|--------------|----------|--------------------| +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | +| `devcontainer` | path | string | true | Devcontainer ID | ### Example responses diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index 7df27dca8fd4d..bb279f5825f6e 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -27,10 +27,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam ```json { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -262,10 +264,12 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ ```json { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -929,6 +933,7 @@ Status Code **200** | `sharing_level` | `organization` | | `sharing_level` | `public` | | `state` | `working` | +| `state` | `idle` | | `state` | `complete` | | `state` | `failure` | | `lifecycle_state` | `created` | @@ -977,10 +982,12 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta ```json { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1285,10 +1292,12 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ ```json [ { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1501,10 +1510,12 @@ Status Code **200** | Name | Type | Required | Restrictions | Description | |----------------------------------|--------------------------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `[array item]` | array | false | | | +| `Β» ai_task_sidebar_app_id` | string(uuid) | false | | | | `Β» build_number` | integer | false | | | | `Β» created_at` | string(date-time) | false | | | | `Β» daily_cost` | integer | false | | | | `Β» deadline` | string(date-time) | false | | | +| `Β» has_ai_task` | boolean | false | | | | `Β» id` | string(uuid) | false | | | | `Β» initiator_id` | string(uuid) | false | | | | `Β» initiator_name` | string | false | | | @@ -1685,6 +1696,7 @@ Status Code **200** | `sharing_level` | `organization` | | `sharing_level` | `public` | | `state` | `working` | +| `state` | `idle` | | `state` | `complete` | | `state` | `failure` | | `lifecycle_state` | `created` | @@ -1740,7 +1752,6 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ ```json { "dry_run": true, - "enable_dynamic_parameters": true, "log_level": "debug", "orphan": true, "rich_parameter_values": [ @@ -1771,10 +1782,12 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ ```json { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", diff --git a/docs/reference/api/chat.md b/docs/reference/api/chat.md deleted file mode 100644 index 4b5ad8c23adae..0000000000000 --- a/docs/reference/api/chat.md +++ /dev/null @@ -1,372 +0,0 @@ -# Chat - -## List chats - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/chats \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /chats` - -### Example responses - -> 200 Response - -```json -[ - { - "created_at": "2019-08-24T14:15:22Z", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "title": "string", - "updated_at": "2019-08-24T14:15:22Z" - } -] -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|---------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Chat](schemas.md#codersdkchat) | - -

Response Schema

- -Status Code **200** - -| Name | Type | Required | Restrictions | Description | -|----------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `Β» created_at` | string(date-time) | false | | | -| `Β» id` | string(uuid) | false | | | -| `Β» title` | string | false | | | -| `Β» updated_at` | string(date-time) | false | | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Create a chat - -### Code samples - -```shell -# Example request using curl -curl -X POST http://coder-server:8080/api/v2/chats \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`POST /chats` - -### Example responses - -> 201 Response - -```json -{ - "created_at": "2019-08-24T14:15:22Z", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "title": "string", - "updated_at": "2019-08-24T14:15:22Z" -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|--------------------------------------------------------------|-------------|------------------------------------------| -| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Chat](schemas.md#codersdkchat) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Get a chat - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/chats/{chat} \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /chats/{chat}` - -### Parameters - -| Name | In | Type | Required | Description | -|--------|------|--------|----------|-------------| -| `chat` | path | string | true | Chat ID | - -### Example responses - -> 200 Response - -```json -{ - "created_at": "2019-08-24T14:15:22Z", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "title": "string", - "updated_at": "2019-08-24T14:15:22Z" -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Get chat messages - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/chats/{chat}/messages \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /chats/{chat}/messages` - -### Parameters - -| Name | In | Type | Required | Description | -|--------|------|--------|----------|-------------| -| `chat` | path | string | true | Chat ID | - -### Example responses - -> 200 Response - -```json -[ - { - "annotations": [ - null - ], - "content": "string", - "createdAt": [ - 0 - ], - "experimental_attachments": [ - { - "contentType": "string", - "name": "string", - "url": "string" - } - ], - "id": "string", - "parts": [ - { - "data": [ - 0 - ], - "details": [ - { - "data": "string", - "signature": "string", - "text": "string", - "type": "string" - } - ], - "mimeType": "string", - "reasoning": "string", - "source": { - "contentType": "string", - "data": "string", - "metadata": { - "property1": null, - "property2": null - }, - "uri": "string" - }, - "text": "string", - "toolInvocation": { - "args": null, - "result": null, - "state": "call", - "step": 0, - "toolCallId": "string", - "toolName": "string" - }, - "type": "text" - } - ], - "role": "string" - } -] -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|---------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [aisdk.Message](schemas.md#aisdkmessage) | - -

Response Schema

- -Status Code **200** - -| Name | Type | Required | Restrictions | Description | -|------------------------------|------------------------------------------------------------------|----------|--------------|-------------------------| -| `[array item]` | array | false | | | -| `» annotations` | array | false | | | -| `» content` | string | false | | | -| `» createdAt` | array | false | | | -| `» experimental_attachments` | array | false | | | -| `»» contentType` | string | false | | | -| `»» name` | string | false | | | -| `»» url` | string | false | | | -| `» id` | string | false | | | -| `» parts` | array | false | | | -| `»» data` | array | false | | | -| `»» details` | array | false | | | -| `»»» data` | string | false | | | -| `»»» signature` | string | false | | | -| `»»» text` | string | false | | | -| `»»» type` | string | false | | | -| `»» mimeType` | string | false | | Type: "file" | -| `»» reasoning` | string | false | | Type: "reasoning" | -| `»» source` | [aisdk.SourceInfo](schemas.md#aisdksourceinfo) | false | | Type: "source" | -| `»»» contentType` | string | false | | | -| `»»» data` | string | false | | | -| `»»» metadata` | object | false | | | -| `»»»» [any property]` | any | false | | | -| `»»» uri` | string | false | | | -| `»» text` | string | false | | Type: "text" | -| `»» toolInvocation` | [aisdk.ToolInvocation](schemas.md#aisdktoolinvocation) | false | | Type: "tool-invocation" | -| `»»» args` | any | false | | | -| `»»» result` | any | false | | | -| `»»» state` | [aisdk.ToolInvocationState](schemas.md#aisdktoolinvocationstate) | false | | | -| `»»» step` | integer | false | | | -| `»»» toolCallId` | string | false | | | -| `»»» toolName` | string | false | | | -| `»» type` | [aisdk.PartType](schemas.md#aisdkparttype) | false | | | -| `» role` | string | false | | | - -#### Enumerated Values - -| Property | Value | -|----------|-------------------| -| `state` | `call` | -| `state` | `partial-call` | -| `state` | `result` | -| `type` | `text` | -| `type` | `reasoning` | -| `type` | `tool-invocation` | -| `type` | `source` | -| `type` | `file` | -| `type` | `step-start` | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Create a chat message - -### Code samples - -```shell -# Example request using curl -curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`POST /chats/{chat}/messages` - -> Body parameter - -```json -{ - "message": { - "annotations": [ - null - ], - "content": "string", - "createdAt": [ - 0 - ], - "experimental_attachments": [ - { - "contentType": "string", - "name": "string", - "url": "string" - } - ], - "id": "string", - "parts": [ - { - "data": [ - 0 - ], - "details": [ - { - "data": "string", - "signature": "string", - "text": "string", - "type": "string" - } - ], - "mimeType": "string", - "reasoning": "string", - "source": { - "contentType": "string", - "data": "string", - "metadata": { - "property1": null, - "property2": null - }, - "uri": "string" - }, - "text": "string", - "toolInvocation": { - "args": null, - "result": null, - "state": "call", - "step": 0, - "toolCallId": "string", - "toolName": "string" - }, - "type": "text" - } - ], - "role": "string" - }, - "model": "string", - "thinking": true -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -|--------|------|----------------------------------------------------------------------------------|----------|--------------| -| `chat` | path | string | true | Chat ID | -| `body` | body | [codersdk.CreateChatMessageRequest](schemas.md#codersdkcreatechatmessagerequest) | true | Request body | - -### Example responses - -> 200 Response - -```json -[ - null -] -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|--------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of undefined | - -

Response Schema

- -To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 92ee1c60b554b..8f440c55b42d6 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -161,19 +161,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "user": {} }, "agent_stat_refresh_interval": 0, - "ai": { - "value": { - "providers": [ - { - "base_url": "string", - "models": [ - "string" - ], - "type": "string" - } - ] - } - }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, @@ -586,43 +573,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get language models - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/deployment/llms \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /deployment/llms` - -### Example responses - -> 200 Response - -```json -{ - "models": [ - { - "display_name": "string", - "id": "string", - "provider": "string" - } - ] -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.LanguageModelConfig](schemas.md#codersdklanguagemodelconfig) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## SSH Config ### Code samples diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 6b5d124753bc0..b19c859aa10c1 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -187,7 +187,6 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | -| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -206,6 +205,7 @@ Status Code **200** | `resource_type` | `oauth2_app_secret` | | `resource_type` | `organization` | | `resource_type` | `organization_member` | +| `resource_type` | `prebuilt_workspace` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | | `resource_type` | `replicas` | @@ -356,7 +356,6 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | -| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -375,6 +374,7 @@ Status Code **200** | `resource_type` | `oauth2_app_secret` | | `resource_type` | `organization` | | `resource_type` | `organization_member` | +| `resource_type` | `prebuilt_workspace` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | | `resource_type` | `replicas` | @@ -525,7 +525,6 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | -| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -544,6 +543,7 @@ Status Code **200** | `resource_type` | `oauth2_app_secret` | | `resource_type` | `organization` | | `resource_type` | `organization_member` | +| `resource_type` | `prebuilt_workspace` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | | `resource_type` | `replicas` | @@ -663,7 +663,6 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | -| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -682,6 +681,7 @@ Status Code **200** | `resource_type` | `oauth2_app_secret` | | `resource_type` | `organization` | | `resource_type` | `organization_member` | +| `resource_type` | `prebuilt_workspace` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | | `resource_type` | `replicas` | @@ -1023,7 +1023,6 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | -| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -1042,6 +1041,7 @@ Status Code **200** | `resource_type` | `oauth2_app_secret` | | `resource_type` | `organization` | | `resource_type` | `organization_member` | +| `resource_type` | `prebuilt_workspace` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | | `resource_type` | `replicas` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e5ac986413d2c..79c6f817bc776 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -212,250 +212,6 @@ |--------------------| | `prebuild_claimed` | -## aisdk.Attachment - -```json -{ - "contentType": "string", - "name": "string", - "url": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|---------------|--------|----------|--------------|-------------| -| `contentType` | string | false | | | -| `name` | string | false | | | -| `url` | string | false | | | - -## aisdk.Message - -```json -{ - "annotations": [ - null - ], - "content": "string", - "createdAt": [ - 0 - ], - "experimental_attachments": [ - { - "contentType": "string", - "name": "string", - "url": "string" - } - ], - "id": "string", - "parts": [ - { - "data": [ - 0 - ], - "details": [ - { - "data": "string", - "signature": "string", - "text": "string", - "type": "string" - } - ], - "mimeType": "string", - "reasoning": "string", - "source": { - "contentType": "string", - "data": "string", - "metadata": { - "property1": null, - "property2": null - }, - "uri": "string" - }, - "text": "string", - "toolInvocation": { - "args": null, - "result": null, - "state": "call", - "step": 0, - "toolCallId": "string", - "toolName": "string" - }, - "type": "text" - } - ], - "role": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------------------------|-----------------------------------------------|----------|--------------|-------------| -| `annotations` | array of undefined | false | | | -| `content` | string | false | | | -| `createdAt` | array of integer | false | | | -| `experimental_attachments` | array of [aisdk.Attachment](#aisdkattachment) | false | | | -| `id` | string | false | | | -| `parts` | array of [aisdk.Part](#aisdkpart) | false | | | -| `role` | string | false | | | - -## aisdk.Part - -```json -{ - "data": [ - 0 - ], - "details": [ - { - "data": "string", - "signature": "string", - "text": "string", - "type": "string" - } - ], - "mimeType": "string", - "reasoning": "string", - "source": { - "contentType": "string", - "data": "string", - "metadata": { - "property1": null, - "property2": null - }, - "uri": "string" - }, - "text": "string", - "toolInvocation": { - "args": null, - "result": null, - "state": "call", - "step": 0, - "toolCallId": "string", - "toolName": "string" - }, - "type": "text" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|------------------|---------------------------------------------------------|----------|--------------|-------------------------| -| `data` | array of integer | false | | | -| `details` | array of [aisdk.ReasoningDetail](#aisdkreasoningdetail) | false | | | -| `mimeType` | string | false | | Type: "file" | -| `reasoning` | string | false | | Type: "reasoning" | -| `source` | [aisdk.SourceInfo](#aisdksourceinfo) | false | | Type: "source" | -| `text` | string | false | | Type: "text" | -| `toolInvocation` | [aisdk.ToolInvocation](#aisdktoolinvocation) | false | | Type: "tool-invocation" | -| `type` | [aisdk.PartType](#aisdkparttype) | false | | | - -## aisdk.PartType - -```json -"text" -``` - -### Properties - -#### Enumerated Values - -| Value | -|-------------------| -| `text` | -| `reasoning` | -| `tool-invocation` | -| `source` | -| `file` | -| `step-start` | - -## aisdk.ReasoningDetail - -```json -{ - "data": "string", - "signature": "string", - "text": "string", - "type": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|-------------|--------|----------|--------------|-------------| -| `data` | string | false | | | -| `signature` | string | false | | | -| `text` | string | false | | | -| `type` | string | false | | | - -## aisdk.SourceInfo - -```json -{ - "contentType": "string", - "data": "string", - "metadata": { - "property1": null, - "property2": null - }, - "uri": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|--------------------|--------|----------|--------------|-------------| -| `contentType` | string | false | | | -| `data` | string | false | | | -| `metadata` | object | false | | | -| Β» `[any property]` | any | false | | | -| `uri` | string | false | | | - -## aisdk.ToolInvocation - -```json -{ - "args": null, - "result": null, - "state": "call", - "step": 0, - "toolCallId": "string", - "toolName": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|--------------|--------------------------------------------------------|----------|--------------|-------------| -| `args` | any | false | | | -| `result` | any | false | | | -| `state` | [aisdk.ToolInvocationState](#aisdktoolinvocationstate) | false | | | -| `step` | integer | false | | | -| `toolCallId` | string | false | | | -| `toolName` | string | false | | | - -## aisdk.ToolInvocationState - -```json -"call" -``` - -### Properties - -#### Enumerated Values - -| Value | -|----------------| -| `call` | -| `partial-call` | -| `result` | - ## coderd.SCIMUser ```json @@ -579,48 +335,6 @@ | `groups` | array of [codersdk.Group](#codersdkgroup) | false | | | | `users` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | | -## codersdk.AIConfig - -```json -{ - "providers": [ - { - "base_url": "string", - "models": [ - "string" - ], - "type": "string" - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|-------------|-----------------------------------------------------------------|----------|--------------|-------------| -| `providers` | array of [codersdk.AIProviderConfig](#codersdkaiproviderconfig) | false | | | - -## codersdk.AIProviderConfig - -```json -{ - "base_url": "string", - "models": [ - "string" - ], - "type": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|------------|-----------------|----------|--------------|-----------------------------------------------------------| -| `base_url` | string | false | | Base URL is the base URL to use for the API provider. | -| `models` | array of string | false | | Models is the list of models to use for the API provider. | -| `type` | string | false | | Type is the type of the API provider. | - ## codersdk.APIKey ```json @@ -1354,97 +1068,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `one_time_passcode` | string | true | | | | `password` | string | true | | | -## codersdk.Chat - -```json -{ - "created_at": "2019-08-24T14:15:22Z", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "title": "string", - "updated_at": "2019-08-24T14:15:22Z" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|--------------|--------|----------|--------------|-------------| -| `created_at` | string | false | | | -| `id` | string | false | | | -| `title` | string | false | | | -| `updated_at` | string | false | | | - -## codersdk.ChatMessage - -```json -{ - "annotations": [ - null - ], - "content": "string", - "createdAt": [ - 0 - ], - "experimental_attachments": [ - { - "contentType": "string", - "name": "string", - "url": "string" - } - ], - "id": "string", - "parts": [ - { - "data": [ - 0 - ], - "details": [ - { - "data": "string", - "signature": "string", - "text": "string", - "type": "string" - } - ], - "mimeType": "string", - "reasoning": "string", - "source": { - "contentType": "string", - "data": "string", - "metadata": { - "property1": null, - "property2": null - }, - "uri": "string" - }, - "text": "string", - "toolInvocation": { - "args": null, - "result": null, - "state": "call", - "step": 0, - "toolCallId": "string", - "toolName": "string" - }, - "type": "text" - } - ], - "role": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------------------------|-----------------------------------------------|----------|--------------|-------------| -| `annotations` | array of undefined | false | | | -| `content` | string | false | | | -| `createdAt` | array of integer | false | | | -| `experimental_attachments` | array of [aisdk.Attachment](#aisdkattachment) | false | | | -| `id` | string | false | | | -| `parts` | array of [aisdk.Part](#aisdkpart) | false | | | -| `role` | string | false | | | - ## codersdk.ConnectionLatency ```json @@ -1477,77 +1100,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `password` | string | true | | | | `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. | -## codersdk.CreateChatMessageRequest - -```json -{ - "message": { - "annotations": [ - null - ], - "content": "string", - "createdAt": [ - 0 - ], - "experimental_attachments": [ - { - "contentType": "string", - "name": "string", - "url": "string" - } - ], - "id": "string", - "parts": [ - { - "data": [ - 0 - ], - "details": [ - { - "data": "string", - "signature": "string", - "text": "string", - "type": "string" - } - ], - "mimeType": "string", - "reasoning": "string", - "source": { - "contentType": "string", - "data": "string", - "metadata": { - "property1": null, - "property2": null - }, - "uri": "string" - }, - "text": "string", - "toolInvocation": { - "args": null, - "result": null, - "state": "call", - "step": 0, - "toolCallId": "string", - "toolName": "string" - }, - "type": "text" - } - ], - "role": "string" - }, - "model": "string", - "thinking": true -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|------------|----------------------------------------------|----------|--------------|-------------| -| `message` | [codersdk.ChatMessage](#codersdkchatmessage) | false | | | -| `model` | string | false | | | -| `thinking` | boolean | false | | | - ## codersdk.CreateFirstUserRequest ```json @@ -1812,52 +1364,12 @@ This is required on creation to enable a user-flow of validating a template work ## codersdk.CreateTestAuditLogRequest ```json -{ - "action": "create", - "additional_fields": [ - 0 - ], - "build_reason": "autostart", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "request_id": "266ea41d-adf5-480b-af50-15b940c2b846", - "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", - "resource_type": "template", - "time": "2019-08-24T14:15:22Z" -} +{} ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------|------------------------------------------------|----------|--------------|-------------| -| `action` | [codersdk.AuditAction](#codersdkauditaction) | false | | | -| `additional_fields` | array of integer | false | | | -| `build_reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | -| `organization_id` | string | false | | | -| `request_id` | string | false | | | -| `resource_id` | string | false | | | -| `resource_type` | [codersdk.ResourceType](#codersdkresourcetype) | false | | | -| `time` | string | false | | | - -#### Enumerated Values - -| Property | Value | -|-----------------|--------------------| -| `action` | `create` | -| `action` | `write` | -| `action` | `delete` | -| `action` | `start` | -| `action` | `stop` | -| `build_reason` | `autostart` | -| `build_reason` | `autostop` | -| `build_reason` | `initiator` | -| `resource_type` | `template` | -| `resource_type` | `template_version` | -| `resource_type` | `user` | -| `resource_type` | `workspace` | -| `resource_type` | `workspace_build` | -| `resource_type` | `git_ssh_key` | -| `resource_type` | `auditable_group` | +None ## codersdk.CreateTokenRequest @@ -1917,7 +1429,6 @@ This is required on creation to enable a user-flow of validating a template work ```json { "dry_run": true, - "enable_dynamic_parameters": true, "log_level": "debug", "orphan": true, "rich_parameter_values": [ @@ -1940,7 +1451,6 @@ This is required on creation to enable a user-flow of validating a template work | Name | Type | Required | Restrictions | Description | |------------------------------|-------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `dry_run` | boolean | false | | | -| `enable_dynamic_parameters` | boolean | false | | Enable dynamic parameters skips some of the static parameter checking. It will default to whatever the template has marked as the default experience. Requires the "dynamic-experiment" to be used. | | `log_level` | [codersdk.ProvisionerLogLevel](#codersdkprovisionerloglevel) | false | | Log level changes the default logging verbosity of a provider ("info" if empty). | | `orphan` | boolean | false | | Orphan may be set for the Destroy transition. | | `rich_parameter_values` | array of [codersdk.WorkspaceBuildParameter](#codersdkworkspacebuildparameter) | false | | Rich parameter values are optional. It will write params to the 'workspace' scope. This will overwrite any existing parameters with the same name. This will not delete old params not included in this list. | @@ -1982,7 +1492,6 @@ This is required on creation to enable a user-flow of validating a template work { "automatic_updates": "always", "autostart_schedule": "string", - "enable_dynamic_parameters": true, "name": "string", "rich_parameter_values": [ { @@ -2005,7 +1514,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o |------------------------------|-------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------| | `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | | `autostart_schedule` | string | false | | | -| `enable_dynamic_parameters` | boolean | false | | | | `name` | string | true | | | | `rich_parameter_values` | array of [codersdk.WorkspaceBuildParameter](#codersdkworkspacebuildparameter) | false | | Rich parameter values allows for additional parameters to be provided during the initial provision. | | `template_id` | string | false | | Template ID specifies which template should be used for creating the workspace. | @@ -2332,19 +1840,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "user": {} }, "agent_stat_refresh_interval": 0, - "ai": { - "value": { - "providers": [ - { - "base_url": "string", - "models": [ - "string" - ], - "type": "string" - } - ] - } - }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, @@ -2833,19 +2328,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "user": {} }, "agent_stat_refresh_interval": 0, - "ai": { - "value": { - "providers": [ - { - "base_url": "string", - "models": [ - "string" - ], - "type": "string" - } - ] - } - }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, @@ -3225,7 +2707,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `address` | [serpent.HostPort](#serpenthostport) | false | | Deprecated: Use HTTPAddress or TLS.Address instead. | | `agent_fallback_troubleshooting_url` | [serpent.URL](#serpenturl) | false | | | | `agent_stat_refresh_interval` | integer | false | | | -| `ai` | [serpent.Struct-codersdk_AIConfig](#serpentstruct-codersdk_aiconfig) | false | | | | `allow_workspace_renames` | boolean | false | | | | `autobuild_poll_interval` | integer | false | | | | `browser_only` | boolean | false | | | @@ -3515,9 +2996,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `notifications` | | `workspace-usage` | | `web-push` | -| `workspace-prebuilds` | -| `agentic-chat` | -| `ai-tasks` | ## codersdk.ExternalAuth @@ -4157,44 +3635,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-------------------------------| | `REQUIRED_TEMPLATE_VARIABLES` | -## codersdk.LanguageModel - -```json -{ - "display_name": "string", - "id": "string", - "provider": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------------|--------|----------|--------------|-------------------------------------------------------------------| -| `display_name` | string | false | | | -| `id` | string | false | | ID is used by the provider to identify the LLM. | -| `provider` | string | false | | Provider is the provider of the LLM. e.g. openai, anthropic, etc. | - -## codersdk.LanguageModelConfig - -```json -{ - "models": [ - { - "display_name": "string", - "id": "string", - "provider": "string" - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------|-----------------------------------------------------------|----------|--------------|-------------| -| `models` | array of [codersdk.LanguageModel](#codersdklanguagemodel) | false | | | - ## codersdk.License ```json @@ -5501,6 +4941,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ```json { + "default": true, "id": "string", "name": "string", "parameters": [ @@ -5516,6 +4957,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | Name | Type | Required | Restrictions | Description | |--------------|---------------------------------------------------------------|----------|--------------|-------------| +| `default` | boolean | false | | | | `id` | string | false | | | | `name` | string | false | | | | `parameters` | array of [codersdk.PresetParameter](#codersdkpresetparameter) | false | | | @@ -6310,7 +5752,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `assign_org_role` | | `assign_role` | | `audit_log` | -| `chat` | | `crypto_key` | | `debug_info` | | `deployment_config` | @@ -6329,6 +5770,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `oauth2_app_secret` | | `organization` | | `organization_member` | +| `prebuilt_workspace` | | `provisioner_daemon` | | `provisioner_jobs` | | `replicas` | @@ -8588,10 +8030,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "latest_build": { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -9683,6 +9127,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | Value | |------------| | `working` | +| `idle` | | `complete` | | `failure` | @@ -9690,10 +9135,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -9896,10 +9343,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| | Name | Type | Required | Restrictions | Description | |------------------------------|-------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| +| `ai_task_sidebar_app_id` | string | false | | | | `build_number` | integer | false | | | | `created_at` | string | false | | | | `daily_cost` | integer | false | | | | `deadline` | string | false | | | +| `has_ai_task` | boolean | false | | | | `id` | string | false | | | | `initiator_id` | string | false | | | | `initiator_name` | string | false | | | @@ -10416,10 +9865,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "latest_build": { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -12262,30 +11713,6 @@ None |---------|-----------------------------------------------------|----------|--------------|-------------| | `value` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | -## serpent.Struct-codersdk_AIConfig - -```json -{ - "value": { - "providers": [ - { - "base_url": "string", - "models": [ - "string" - ], - "type": "string" - } - ] - } -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|---------|----------------------------------------|----------|--------------|-------------| -| `value` | [codersdk.AIConfig](#codersdkaiconfig) | false | | | - ## serpent.URL ```json diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index d695be4122951..dc4605532ff41 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -2557,6 +2557,7 @@ Status Code **200** | `sharing_level` | `organization` | | `sharing_level` | `public` | | `state` | `working` | +| `state` | `idle` | | `state` | `complete` | | `state` | `failure` | | `lifecycle_state` | `created` | @@ -2910,6 +2911,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/p ```json [ { + "default": true, "id": "string", "name": "string", "parameters": [ @@ -2932,14 +2934,15 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/p Status Code **200** -| Name | Type | Required | Restrictions | Description | -|----------------|--------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `Β» id` | string | false | | | -| `Β» name` | string | false | | | -| `Β» parameters` | array | false | | | -| `»» name` | string | false | | | -| `»» value` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------|---------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `Β» default` | boolean | false | | | +| `Β» id` | string | false | | | +| `Β» name` | string | false | | | +| `Β» parameters` | array | false | | | +| `»» name` | string | false | | | +| `»» value` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -3233,6 +3236,7 @@ Status Code **200** | `sharing_level` | `organization` | | `sharing_level` | `public` | | `state` | `working` | +| `state` | `idle` | | `state` | `complete` | | `state` | `failure` | | `lifecycle_state` | `created` | diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index a43e992fe8756..a43a5f2c8fe18 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -25,7 +25,6 @@ of the template will be used. { "automatic_updates": "always", "autostart_schedule": "string", - "enable_dynamic_parameters": true, "name": "string", "rich_parameter_values": [ { @@ -82,10 +81,12 @@ of the template will be used. "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "latest_build": { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -366,10 +367,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "latest_build": { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -620,7 +623,6 @@ of the template will be used. { "automatic_updates": "always", "autostart_schedule": "string", - "enable_dynamic_parameters": true, "name": "string", "rich_parameter_values": [ { @@ -676,10 +678,12 @@ of the template will be used. "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "latest_build": { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -963,10 +967,12 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "latest_build": { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1231,10 +1237,12 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "latest_build": { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1631,10 +1639,12 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" }, "latest_build": { + "ai_task_sidebar_app_id": "852ddafb-2cb9-4cbf-8a8c-075389fb3d3d", "build_number": 0, "created_at": "2019-08-24T14:15:22Z", "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index d72790fc3bfdb..6dae32c4c615c 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -22,50 +22,50 @@ Coder β€” A tool for provisioning self-hosted development environments with Terr ## Subcommands -| Name | Purpose | -|----------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | -| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | -| [external-auth](./external-auth.md) | Manage external authentication | -| [login](./login.md) | Authenticate with Coder deployment | -| [logout](./logout.md) | Unauthenticate your local session | -| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | -| [notifications](./notifications.md) | Manage Coder notifications | -| [organizations](./organizations.md) | Organization related commands | -| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | -| [publickey](./publickey.md) | Output your Coder public key used for Git operations | -| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | -| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | -| [templates](./templates.md) | Manage templates | -| [tokens](./tokens.md) | Manage personal access tokens | -| [users](./users.md) | Manage users | -| [version](./version.md) | Show coder version | -| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | -| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | -| [create](./create.md) | Create a workspace | -| [delete](./delete.md) | Delete a workspace | -| [favorite](./favorite.md) | Add a workspace to your favorites | -| [list](./list.md) | List workspaces | -| [open](./open.md) | Open a workspace | -| [ping](./ping.md) | Ping a workspace | -| [rename](./rename.md) | Rename a workspace | -| [restart](./restart.md) | Restart a workspace | -| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | -| [show](./show.md) | Display details of a workspace's resources and agents | -| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | -| [ssh](./ssh.md) | Start a shell into a workspace or run a command | -| [start](./start.md) | Start a workspace | -| [stat](./stat.md) | Show resource usage for the current workspace. | -| [stop](./stop.md) | Stop a workspace | -| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | -| [update](./update.md) | Will update and start a given workspace if it is out of date | -| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | -| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | -| [server](./server.md) | Start a Coder server | -| [features](./features.md) | List Enterprise features | -| [licenses](./licenses.md) | Add, delete, and list licenses | -| [groups](./groups.md) | Manage groups | -| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | +| Name | Purpose | +|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| +| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | +| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | +| [external-auth](./external-auth.md) | Manage external authentication | +| [login](./login.md) | Authenticate with Coder deployment | +| [logout](./logout.md) | Unauthenticate your local session | +| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | +| [notifications](./notifications.md) | Manage Coder notifications | +| [organizations](./organizations.md) | Organization related commands | +| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | +| [publickey](./publickey.md) | Output your Coder public key used for Git operations | +| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | +| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | +| [templates](./templates.md) | Manage templates | +| [tokens](./tokens.md) | Manage personal access tokens | +| [users](./users.md) | Manage users | +| [version](./version.md) | Show coder version | +| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | +| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | +| [create](./create.md) | Create a workspace | +| [delete](./delete.md) | Delete a workspace | +| [favorite](./favorite.md) | Add a workspace to your favorites | +| [list](./list.md) | List workspaces | +| [open](./open.md) | Open a workspace | +| [ping](./ping.md) | Ping a workspace | +| [rename](./rename.md) | Rename a workspace | +| [restart](./restart.md) | Restart a workspace | +| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | +| [show](./show.md) | Display details of a workspace's resources and agents | +| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | +| [ssh](./ssh.md) | Start a shell into a workspace or run a command | +| [start](./start.md) | Start a workspace | +| [stat](./stat.md) | Show resource usage for the current workspace. | +| [stop](./stop.md) | Stop a workspace | +| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | +| [update](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. | +| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | +| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | +| [server](./server.md) | Start a Coder server | +| [features](./features.md) | List Enterprise features | +| [licenses](./licenses.md) | Add, delete, and list licenses | +| [groups](./groups.md) | Manage groups | +| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | ## Options diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 644065d35076f..f52b3666a866e 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1615,6 +1615,17 @@ Enable Coder Inbox. The upper limit of attempts to send a notification. +### --workspace-prebuilds-reconciliation-interval + +| | | +|-------------|-----------------------------------------------------------------| +| Type | duration | +| Environment | $CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL | +| YAML | workspace_prebuilds.reconciliation_interval | +| Default | 15s | + +How often to reconcile workspace prebuilds state. + ### --hide-ai-tasks | | | diff --git a/docs/reference/cli/update.md b/docs/reference/cli/update.md index dd2bfa5ff76b5..35c5b34312420 100644 --- a/docs/reference/cli/update.md +++ b/docs/reference/cli/update.md @@ -1,7 +1,7 @@ # update -Will update and start a given workspace if it is out of date +Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. ## Usage diff --git a/docs/start/local-deploy.md b/docs/start/local-deploy.md index 3fe501c02b8eb..eb3b2af131853 100644 --- a/docs/start/local-deploy.md +++ b/docs/start/local-deploy.md @@ -29,11 +29,9 @@ curl -L https://coder.com/install.sh | sh ## Windows -> [!IMPORTANT] -> If you plan to use the built-in PostgreSQL database, you will -> need to ensure that the -> [Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) -> is installed. +If you plan to use the built-in PostgreSQL database, ensure that the +[Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) +is installed. You can use the [`winget`](https://learn.microsoft.com/en-us/windows/package-manager/winget/#use-winget) diff --git a/docs/tutorials/quickstart.md b/docs/tutorials/quickstart.md index a09bb95d478b7..595414fd63ccd 100644 --- a/docs/tutorials/quickstart.md +++ b/docs/tutorials/quickstart.md @@ -57,10 +57,9 @@ persistent environment from your main device, a tablet, or your phone. ## Windows -> [!IMPORTANT] -> If you plan to use the built-in PostgreSQL database, ensure that the -> [Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) -> is installed. +If you plan to use the built-in PostgreSQL database, ensure that the +[Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) +is installed. 1. [Install Docker](https://docs.docker.com/desktop/install/windows-install/). diff --git a/docs/user-guides/workspace-access/jetbrains/toolbox.md b/docs/user-guides/workspace-access/jetbrains/toolbox.md index 52de09330346a..219eb63e6b4d4 100644 --- a/docs/user-guides/workspace-access/jetbrains/toolbox.md +++ b/docs/user-guides/workspace-access/jetbrains/toolbox.md @@ -55,3 +55,29 @@ To connect to a Coder deployment that uses internal certificates, configure the 1. Select **Settings**. 1. Add your certificate path in the **CA Path** field. ![JetBrains Toolbox Coder Provider certificate path](../../../images/user-guides/jetbrains/toolbox/certificate.png) + +## Troubleshooting + +If you encounter issues connecting to your Coder workspace via JetBrains Toolbox, follow these steps to enable and capture debug logs: + +### Enable Debug Logging + +1. Open Toolbox +1. Navigate to the **Toolbox App Menu (hexagonal menu icon) > Settings > Advanced**. +1. In the screen that appears, select `DEBUG` for the Log level: section. +1. Hit the back button at the top. +1. Retry the same operation + +### Capture Debug Logs + +1. Access logs via **Toolbox App Menu > About > Show log files**. +2. Locate the log file named `jetbrains-toolbox.log` and attach it to your support ticket. +3. If you need to capture logs for a specific workspace, you can also generate a ZIP file using the Workspace action menu, available either on the main Workspaces page in Coder view or within the individual workspace view, under the option labeled **Collect logs**. + +> [!WARNING] +> Toolbox does not persist log level configuration between restarts. + +## Additional Resources + +- [JetBrains Toolbox documentation](https://www.jetbrains.com/help/toolbox-app) +- [Coder JetBrains Toolbox Plugin Github](https://github.com/coder/coder-jetbrains-toolbox) diff --git a/docs/user-guides/workspace-access/remote-desktops.md b/docs/user-guides/workspace-access/remote-desktops.md index 1d5df4e7f8d7f..a60e943cea86a 100644 --- a/docs/user-guides/workspace-access/remote-desktops.md +++ b/docs/user-guides/workspace-access/remote-desktops.md @@ -15,14 +15,13 @@ Installation instructions vary depending on your workspace's operating system, platform, and build system. As a starting point, see the -[desktop-container](https://github.com/bpmct/coder-templates/tree/main/desktop-container) -community template. It builds and provisions a Dockerized workspace with the +[enterprise-desktop](https://github.com/coder/images/tree/main/images/desktop) +image. It can be used to provision a Dockerized workspace with the following software: -- Ubuntu 20.04 -- TigerVNC server -- noVNC client +- Ubuntu 24.04 - XFCE Desktop +- KasmVNC Server and Web Client ## RDP Desktop @@ -30,23 +29,19 @@ To use RDP with Coder, you'll need to install an [RDP client](https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/remote-desktop-clients) on your local machine, and enable RDP on your workspace. -Use the following command to forward the RDP port to your local machine: +
-```console -coder port-forward --tcp 3399:3389 -``` +### CLI -Then, connect to your workspace via RDP: +Use the following command to forward the RDP port to your local machine: ```console -mstsc /v localhost:3399 +coder port-forward --tcp 3399:3389 ``` -Or use your favorite RDP client to connect to `localhost:3399`. +Then, connect to your workspace via RDP at `localhost:3399`. ![windows-rdp](../../images/ides/windows_rdp_client.png) -The default username is `Administrator` and password is `coderRDP!`. - ### RDP with Coder Desktop (Beta) [Coder Desktop](../desktop/index.md)'s Coder Connect feature creates a connection to your workspaces in the background. @@ -57,7 +52,7 @@ Use your favorite RDP client to connect to `.coder` instead of ` > [!NOTE] > Some versions of Windows, including Windows Server 2022, do not communicate correctly over UDP > when using Coder Connect because they do not respect the maximum transmission unit (MTU) of the link. -> When this happens the RDP client will appear to connect, but displays a blank screen. +> When this happens, the RDP client will appear to connect, but displays a blank screen. > > To avoid this error, Coder's [Windows RDP](https://registry.coder.com/modules/windows-rdp) module > [disables RDP over UDP automatically](https://github.com/coder/registry/blob/b58bfebcf3bcdcde4f06a183f92eb3e01842d270/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl#L22). @@ -83,7 +78,7 @@ For example: coder://coder.example.com/v0/open/ws/myworkspace/agent/main/rdp?username=Administrator&password=coderRDP! ``` -To include a Coder Desktop button to the workspace dashboard page, add a `coder_app` resource to the template: +To include a Coder Desktop button on the workspace dashboard page, add a `coder_app` resource to the template: ```tf locals { @@ -100,6 +95,11 @@ resource "coder_app" "rdp-coder-desktop" { } ``` +
+ +> [!NOTE] +> The default username is `Administrator` and the password is `coderRDP!`. + ## RDP Web Our [Windows RDP](https://registry.coder.com/modules/windows-rdp) module in the Coder @@ -107,7 +107,7 @@ Registry adds a one-click button to open an RDP session in the browser. This requires just a few lines of Terraform in your template, see the documentation on our registry for setup. -![Web RDP Module in a Workspace](../../images/user-guides/web-rdp-demo.png) +![Windows RDP Module in a Workspace](../../images/user-guides/web-rdp-demo.png) ## Amazon DCV Windows diff --git a/enterprise/audit/audit_test.go b/enterprise/audit/audit_test.go index 6d825306c3346..bf9393612d65c 100644 --- a/enterprise/audit/audit_test.go +++ b/enterprise/audit/audit_test.go @@ -84,7 +84,6 @@ func TestAuditor(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index d5c191c8907fa..afbd1b37844cc 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -417,7 +417,6 @@ func runDiffTests(t *testing.T, tests []diffTest) { t.Helper() for _, test := range tests { - test := test typName := reflect.TypeOf(test.left).Name() t.Run(typName+"/"+test.name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index bd4987bae24e2..120b4ed684bdf 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -195,7 +195,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "initiator_by_name": ActionIgnore, "template_version_preset_id": ActionIgnore, // Never changes. "has_ai_task": ActionIgnore, // Never changes. - "ai_tasks_sidebar_app_id": ActionIgnore, // Never changes. + "ai_task_sidebar_app_id": ActionIgnore, // Never changes. }, &database.AuditableGroup{}: { "id": ActionTrack, diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 9063af40fcf8f..1a730e1e82940 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -8,12 +8,11 @@ import ( "regexp" "strconv" "strings" - "time" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -137,76 +136,7 @@ func validJWT(s string) error { } func (r *RootCmd) licensesList() *serpent.Command { - type tableLicense struct { - ID int32 `table:"id,default_sort"` - UUID uuid.UUID `table:"uuid" format:"uuid"` - UploadedAt time.Time `table:"uploaded at" format:"date-time"` - // Features is the formatted string for the license claims. - // Used for the table view. - Features string `table:"features"` - ExpiresAt time.Time `table:"expires at" format:"date-time"` - Trial bool `table:"trial"` - } - - formatter := cliui.NewOutputFormatter( - cliui.ChangeFormatterData( - cliui.TableFormat([]tableLicense{}, []string{"ID", "UUID", "Expires At", "Uploaded At", "Features"}), - func(data any) (any, error) { - list, ok := data.([]codersdk.License) - if !ok { - return nil, xerrors.Errorf("invalid data type %T", data) - } - out := make([]tableLicense, 0, len(list)) - for _, lic := range list { - var formattedFeatures string - features, err := lic.FeaturesClaims() - if err != nil { - formattedFeatures = xerrors.Errorf("invalid license: %w", err).Error() - } else { - var strs []string - if lic.AllFeaturesClaim() { - // If all features are enabled, just include that - strs = append(strs, "all features") - } else { - for k, v := range features { - if v > 0 { - // Only include claims > 0 - strs = append(strs, fmt.Sprintf("%s=%v", k, v)) - } - } - } - formattedFeatures = strings.Join(strs, ", ") - } - // If this returns an error, a zero time is returned. - exp, _ := lic.ExpiresAt() - - out = append(out, tableLicense{ - ID: lic.ID, - UUID: lic.UUID, - UploadedAt: lic.UploadedAt, - Features: formattedFeatures, - ExpiresAt: exp, - Trial: lic.Trial(), - }) - } - return out, nil - }), - cliui.ChangeFormatterData(cliui.JSONFormat(), func(data any) (any, error) { - list, ok := data.([]codersdk.License) - if !ok { - return nil, xerrors.Errorf("invalid data type %T", data) - } - for i := range list { - humanExp, err := list[i].ExpiresAt() - if err == nil { - list[i].Claims[codersdk.LicenseExpiryClaim+"_human"] = humanExp.Format(time.RFC3339) - } - } - - return list, nil - }), - ) - + formatter := cliutil.NewLicenseFormatter() client := new(codersdk.Client) cmd := &serpent.Command{ Use: "list", diff --git a/enterprise/cli/start_test.go b/enterprise/cli/start_test.go index dd86b20d44fb6..2ef3b8cd801c4 100644 --- a/enterprise/cli/start_test.go +++ b/enterprise/cli/start_test.go @@ -9,7 +9,6 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -121,11 +120,9 @@ func TestStart(t *testing.T) { } for _, cmd := range []string{"start", "restart"} { - cmd := cmd t.Run(cmd, func(t *testing.T) { t.Parallel() for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -146,7 +143,7 @@ func TestStart(t *testing.T) { if cmd == "start" { // Stop the workspace so that we can start it. - coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) } // Start the workspace. Every test permutation should // pass. @@ -198,7 +195,7 @@ func TestStart(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) workspace := coderdtest.CreateWorkspace(t, memberClient, template.ID) _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace.LatestBuild.ID) - _ = coderdtest.MustTransitionWorkspace(t, memberClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + _ = coderdtest.MustTransitionWorkspace(t, memberClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) err := memberClient.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ Dormant: true, }) diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 3e3868c5ae432..d7c26bc537693 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -678,6 +678,12 @@ workspaces stopping during the day due to template scheduling. must be *. Only one hour and minute can be specified (ranges or comma separated values are not supported). +WORKSPACE PREBUILDS OPTIONS: +Configure how workspace prebuilds behave. + + --workspace-prebuilds-reconciliation-interval duration, $CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL (default: 15s) + How often to reconcile workspace prebuilds state. + ⚠️ DANGEROUS OPTIONS: --dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING Allow workspace apps that are not served from subdomains to be shared. diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go index 670df18916aaa..d64cdb58c2e8e 100644 --- a/enterprise/coderd/authorize_test.go +++ b/enterprise/coderd/authorize_test.go @@ -96,8 +96,6 @@ func TestCheckACLPermissions(t *testing.T) { } for _, c := range testCases { - c := c - t.Run("CheckAuthorization/"+c.Name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index f46848812a69e..601700403f326 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -1150,21 +1150,14 @@ func (api *API) Authorize(r *http.Request, action policy.Action, object rbac.Obj // nolint:revive // featureEnabled is a legit control flag. func (api *API) setupPrebuilds(featureEnabled bool) (agplprebuilds.ReconciliationOrchestrator, agplprebuilds.Claimer) { - experimentEnabled := api.AGPL.Experiments.Enabled(codersdk.ExperimentWorkspacePrebuilds) - if !experimentEnabled || !featureEnabled { - levelFn := api.Logger.Debug - // If the experiment is enabled but the license does not entitle the feature, operators should be warned. - if !featureEnabled { - levelFn = api.Logger.Warn - } - - levelFn(context.Background(), "prebuilds not enabled; ensure you have a premium license and the 'workspace-prebuilds' experiment set", - slog.F("experiment_enabled", experimentEnabled), slog.F("feature_enabled", featureEnabled)) + if !featureEnabled { + api.Logger.Warn(context.Background(), "prebuilds not enabled; ensure you have a premium license", + slog.F("feature_enabled", featureEnabled)) return agplprebuilds.DefaultReconciler, agplprebuilds.DefaultClaimer } - reconciler := prebuilds.NewStoreReconciler(api.Database, api.Pubsub, api.DeploymentValues.Prebuilds, + reconciler := prebuilds.NewStoreReconciler(api.Database, api.Pubsub, api.AGPL.FileCache, api.DeploymentValues.Prebuilds, api.Logger.Named("prebuilds"), quartz.NewReal(), api.PrometheusRegistry, api.NotificationsEnqueuer) return reconciler, prebuilds.NewEnterpriseClaimer(api.Database) } diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 446fce042d70f..89a61c657e21a 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -260,40 +260,23 @@ func TestEntitlements_Prebuilds(t *testing.T) { t.Parallel() cases := []struct { - name string - experimentEnabled bool - featureEnabled bool - expectedEnabled bool + name string + featureEnabled bool + expectedEnabled bool }{ { - name: "Fully enabled", - featureEnabled: true, - experimentEnabled: true, - expectedEnabled: true, + name: "Feature enabled", + featureEnabled: true, + expectedEnabled: true, }, { - name: "Feature disabled", - featureEnabled: false, - experimentEnabled: true, - expectedEnabled: false, - }, - { - name: "Experiment disabled", - featureEnabled: true, - experimentEnabled: false, - expectedEnabled: false, - }, - { - name: "Fully disabled", - featureEnabled: false, - experimentEnabled: false, - expectedEnabled: false, + name: "Feature disabled", + featureEnabled: false, + expectedEnabled: false, }, } for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -304,11 +287,7 @@ func TestEntitlements_Prebuilds(t *testing.T) { _, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ Options: &coderdtest.Options{ - DeploymentValues: coderdtest.DeploymentValues(t, func(values *codersdk.DeploymentValues) { - if tc.experimentEnabled { - values.Experiments = serpent.StringArray{string(codersdk.ExperimentWorkspacePrebuilds)} - } - }), + DeploymentValues: coderdtest.DeploymentValues(t), }, EntitlementsUpdateInterval: time.Second, @@ -618,7 +597,6 @@ func TestSCIMDisabled(t *testing.T) { } for _, p := range checkPaths { - p := p t.Run(p, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/dynamicparameters_test.go b/enterprise/coderd/dynamicparameters_test.go new file mode 100644 index 0000000000000..87d115034f247 --- /dev/null +++ b/enterprise/coderd/dynamicparameters_test.go @@ -0,0 +1,491 @@ +package coderd_test + +import ( + "context" + _ "embed" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" + "github.com/coder/websocket" +) + +func TestDynamicParameterBuild(t *testing.T) { + t.Parallel() + + owner, _, _, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{IncludeProvisionerDaemon: true}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + orgID := first.OrganizationID + + templateAdmin, templateAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID)) + + coderdtest.CreateGroup(t, owner, orgID, "developer") + coderdtest.CreateGroup(t, owner, orgID, "admin", templateAdminData) + coderdtest.CreateGroup(t, owner, orgID, "auditor") + + // Create a set of templates to test with + numberValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/numbers/main.tf"))), + }) + + regexValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/regex/main.tf"))), + }) + + ephemeralValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/ephemeral/main.tf"))), + }) + + // complexValidation does conditional parameters, conditional options, and more. + complexValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/dynamic/main.tf"))), + }) + + t.Run("NumberValidation", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: numberValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `7`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + }) + + t.Run("TooLow", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: numberValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `-10`}, + }, + }) + require.ErrorContains(t, err, "Number must be between 0 and 10") + }) + + t.Run("TooHigh", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: numberValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `15`}, + }, + }) + require.ErrorContains(t, err, "Number must be between 0 and 10") + }) + }) + + t.Run("RegexValidation", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: regexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "string", Value: `Hello World!`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + }) + + t.Run("NoValue", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: regexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{}, + }) + require.ErrorContains(t, err, "All messages must start with 'Hello'") + }) + + t.Run("Invalid", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: regexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "string", Value: `Goodbye!`}, + }, + }) + require.ErrorContains(t, err, "All messages must start with 'Hello'") + }) + }) + + t.Run("EphemeralValidation", func(t *testing.T) { + t.Parallel() + + t.Run("OK_EphemeralNoPrevious", func(t *testing.T) { + t.Parallel() + + // Ephemeral params do not take the previous values into account. + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: ephemeralValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "required", Value: `Hello World!`}, + {Name: "defaulted", Value: `Changed`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + assertWorkspaceBuildParameters(ctx, t, templateAdmin, wrk.LatestBuild.ID, map[string]string{ + "required": "Hello World!", + "defaulted": "Changed", + }) + + bld, err := templateAdmin.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "required", Value: `Hello World, Again!`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, bld.ID) + assertWorkspaceBuildParameters(ctx, t, templateAdmin, bld.ID, map[string]string{ + "required": "Hello World, Again!", + "defaulted": "original", // Reverts back to the original default value. + }) + }) + + t.Run("Immutable", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: numberValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `7`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + assertWorkspaceBuildParameters(ctx, t, templateAdmin, wrk.LatestBuild.ID, map[string]string{ + "number": "7", + }) + + _, err = templateAdmin.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `8`}, + }, + }) + require.ErrorContains(t, err, `Parameter "number" is not mutable`) + }) + + t.Run("RequiredMissing", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: ephemeralValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{}, + }) + require.ErrorContains(t, err, "Required parameter not provided") + }) + }) + + t.Run("ComplexValidation", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["admin"]`}, + {Name: "colors", Value: `["red"]`}, + {Name: "thing", Value: "apple"}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + }) + + t.Run("BadGroup", func(t *testing.T) { + // Template admin is not in the "auditor" group, so this should fail. + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["auditor", "admin"]`}, + {Name: "colors", Value: `["red"]`}, + {Name: "thing", Value: "apple"}, + }, + }) + require.ErrorContains(t, err, "is not a valid option") + }) + + t.Run("BadColor", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["admin"]`}, + {Name: "colors", Value: `["purple"]`}, + }, + }) + require.ErrorContains(t, err, "is not a valid option") + require.ErrorContains(t, err, "purple") + }) + + t.Run("BadThing", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["admin"]`}, + {Name: "colors", Value: `["red"]`}, + {Name: "thing", Value: "leaf"}, + }, + }) + require.ErrorContains(t, err, "must be defined as one of options") + require.ErrorContains(t, err, "leaf") + }) + + t.Run("BadNumber", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["admin"]`}, + {Name: "colors", Value: `["green"]`}, + {Name: "thing", Value: "leaf"}, + {Name: "number", Value: "100"}, + }, + }) + require.ErrorContains(t, err, "Number must be between 0 and 10") + }) + }) + + t.Run("ImmutableValidation", func(t *testing.T) { + t.Parallel() + + // NewImmutable tests the case where a new immutable parameter is added to a template + // after a workspace has been created with an older version of the template. + // The test tries to delete the workspace, which should succeed. + t.Run("NewImmutable", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + // Start with a new template that has 0 parameters + empty, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/none/main.tf"))), + }) + + // Create the workspace with 0 parameters + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: empty.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{}, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + + // Update the template with a new immutable parameter + _, immutable := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/immutable/main.tf"))), + TemplateID: empty.ID, + }) + + bld, err := templateAdmin.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: immutable.ID, // Use the new template version with the immutable parameter + Transition: codersdk.WorkspaceTransitionDelete, + DryRun: false, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, bld.ID) + + // Verify the immutable parameter is set on the workspace build + params, err := templateAdmin.WorkspaceBuildParameters(ctx, bld.ID) + require.NoError(t, err) + require.Len(t, params, 1) + require.Equal(t, "Hello World", params[0].Value) + + // Verify the workspace is deleted + deleted, err := templateAdmin.DeletedWorkspace(ctx, wrk.ID) + require.NoError(t, err) + require.Equal(t, wrk.ID, deleted.ID, "workspace should be deleted") + }) + }) +} + +// TestDynamicParameterTemplate uses a template with some dynamic elements, and +// tests the parameters, values, etc are all as expected. +func TestDynamicParameterTemplate(t *testing.T) { + t.Parallel() + + owner, _, api, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{IncludeProvisionerDaemon: true}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + orgID := first.OrganizationID + + _, userData := coderdtest.CreateAnotherUser(t, owner, orgID) + templateAdmin, templateAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID)) + userAdmin, userAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgUserAdmin(orgID)) + _, auditorData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgAuditor(orgID)) + + coderdtest.CreateGroup(t, owner, orgID, "developer", auditorData, userData) + coderdtest.CreateGroup(t, owner, orgID, "admin", templateAdminData, userAdminData) + coderdtest.CreateGroup(t, owner, orgID, "auditor", auditorData, templateAdminData, userAdminData) + + dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/dynamic/main.tf") + require.NoError(t, err) + + _, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(dynamicParametersTerraformSource), + Plan: nil, + ModulesArchive: nil, + StaticParams: nil, + }) + + _ = userAdmin + + ctx := testutil.Context(t, testutil.WaitLong) + + stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, userData.ID.String(), version.ID) + require.NoError(t, err) + defer func() { + _ = stream.Close(websocket.StatusNormalClosure) + + // Wait until the cache ends up empty. This verifies the cache does not + // leak any files. + require.Eventually(t, func() bool { + return api.AGPL.FileCache.Count() == 0 + }, testutil.WaitShort, testutil.IntervalFast, "file cache should be empty after the test") + }() + + // Initial response + preview, pop := coderdtest.SynchronousStream(stream) + init := pop() + require.Len(t, init.Diagnostics, 0, "no top level diags") + coderdtest.AssertParameter(t, "isAdmin", init.Parameters). + Exists().Value("false") + coderdtest.AssertParameter(t, "adminonly", init.Parameters). + NotExists() + coderdtest.AssertParameter(t, "groups", init.Parameters). + Exists().Options(database.EveryoneGroup, "developer") + + // Switch to an admin + resp, err := preview(codersdk.DynamicParametersRequest{ + ID: 1, + Inputs: map[string]string{ + "colors": `["red"]`, + "thing": "apple", + }, + OwnerID: userAdminData.ID, + }) + require.NoError(t, err) + require.Equal(t, resp.ID, 1) + require.Len(t, resp.Diagnostics, 0, "no top level diags") + + coderdtest.AssertParameter(t, "isAdmin", resp.Parameters). + Exists().Value("true") + coderdtest.AssertParameter(t, "adminonly", resp.Parameters). + Exists() + coderdtest.AssertParameter(t, "groups", resp.Parameters). + Exists().Options(database.EveryoneGroup, "admin", "auditor") + coderdtest.AssertParameter(t, "colors", resp.Parameters). + Exists().Value(`["red"]`) + coderdtest.AssertParameter(t, "thing", resp.Parameters). + Exists().Value("apple").Options("apple", "ruby") + coderdtest.AssertParameter(t, "cool", resp.Parameters). + NotExists() + + // Try some other colors + resp, err = preview(codersdk.DynamicParametersRequest{ + ID: 2, + Inputs: map[string]string{ + "colors": `["yellow", "blue"]`, + "thing": "banana", + }, + OwnerID: userAdminData.ID, + }) + require.NoError(t, err) + require.Equal(t, resp.ID, 2) + require.Len(t, resp.Diagnostics, 0, "no top level diags") + + coderdtest.AssertParameter(t, "cool", resp.Parameters). + Exists() + coderdtest.AssertParameter(t, "isAdmin", resp.Parameters). + Exists().Value("true") + coderdtest.AssertParameter(t, "colors", resp.Parameters). + Exists().Value(`["yellow", "blue"]`) + coderdtest.AssertParameter(t, "thing", resp.Parameters). + Exists().Value("banana").Options("banana", "ocean", "sky") +} + +func assertWorkspaceBuildParameters(ctx context.Context, t *testing.T, client *codersdk.Client, buildID uuid.UUID, values map[string]string) { + t.Helper() + + params, err := client.WorkspaceBuildParameters(ctx, buildID) + require.NoError(t, err) + + for name, value := range values { + param, ok := slice.Find(params, func(parameter codersdk.WorkspaceBuildParameter) bool { + return parameter.Name == name + }) + if !ok { + assert.Failf(t, "parameter not found", "expected parameter %q to exist with value %q", name, value) + continue + } + assert.Equalf(t, value, param.Value, "parameter %q should have value %q", name, value) + } + + for _, param := range params { + if _, ok := values[param.Name]; !ok { + assert.Failf(t, "unexpected parameter", "parameter %q should not exist", param.Name) + } + } +} diff --git a/enterprise/coderd/enidpsync/organizations_test.go b/enterprise/coderd/enidpsync/organizations_test.go index b2e120592b582..d2a5aafece558 100644 --- a/enterprise/coderd/enidpsync/organizations_test.go +++ b/enterprise/coderd/enidpsync/organizations_test.go @@ -296,7 +296,6 @@ func TestOrganizationSync(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index cfe5d081271e3..89671e00bd65c 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -171,6 +171,12 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { }) return } + // Skip membership checks for the prebuilds user. There is a valid use case + // for adding the prebuilds user to a single group: in order to set a quota + // allowance specifically for prebuilds. + if id == database.PrebuildsSystemUserID.String() { + continue + } _, err := database.ExpectOne(api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ OrganizationID: group.OrganizationID, UserID: uuid.MustParse(id), diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 028aa3328535f..568825adcd0ea 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -6,8 +6,6 @@ import ( "testing" "time" - "github.com/coder/coder/v2/coderd/prebuilds" - "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -465,6 +463,32 @@ func TestPatchGroup(t *testing.T) { require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) + // For quotas to work with prebuilds, it's currently required to add the + // prebuilds user into a group with a quota allowance. + // See: docs/admin/templates/extending-templates/prebuilt-workspaces.md + t.Run("PrebuildsUser", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) + ctx := testutil.Context(t, testutil.WaitLong) + group, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "prebuilds", + QuotaAllowance: 123, + }) + require.NoError(t, err) + + group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + Name: "prebuilds", + AddUsers: []string{database.PrebuildsSystemUserID.String()}, + }) + require.NoError(t, err) + }) + t.Run("Everyone", func(t *testing.T) { t.Parallel() t.Run("NoUpdateName", func(t *testing.T) { @@ -833,7 +857,7 @@ func TestGroup(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // nolint:gocritic // "This client is operating as the owner user" is fine in this case. - prebuildsUser, err := client.User(ctx, prebuilds.SystemUserID.String()) + prebuildsUser, err := client.User(ctx, database.PrebuildsSystemUserID.String()) require.NoError(t, err) // The 'Everyone' group always has an ID that matches the organization ID. group, err := userAdminClient.Group(ctx, user.OrganizationID) diff --git a/enterprise/coderd/httpmw/provisionerdaemon_test.go b/enterprise/coderd/httpmw/provisionerdaemon_test.go index 84da7f546fa35..4d9575c72491a 100644 --- a/enterprise/coderd/httpmw/provisionerdaemon_test.go +++ b/enterprise/coderd/httpmw/provisionerdaemon_test.go @@ -126,7 +126,6 @@ func TestExtractProvisionerDaemonAuthenticated(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() routeCtx := chi.NewRouteContext() diff --git a/enterprise/coderd/insights_test.go b/enterprise/coderd/insights_test.go index 044c5988eb036..d38eefc593926 100644 --- a/enterprise/coderd/insights_test.go +++ b/enterprise/coderd/insights_test.go @@ -34,7 +34,6 @@ func TestTemplateInsightsWithTemplateAdminACL(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(fmt.Sprintf("with interval=%q", tt.interval), func(t *testing.T) { t.Parallel() @@ -94,7 +93,6 @@ func TestTemplateInsightsWithRole(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(fmt.Sprintf("with interval=%q role=%q", tt.interval, tt.role), func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 184a611c40949..bf6d6448205e0 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -848,8 +848,6 @@ func TestLicenseEntitlements(t *testing.T) { } for _, tc := range testCases { - tc := tc - t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/parameters_test.go b/enterprise/coderd/parameters_test.go index 93f5057206527..bda9e3c59e021 100644 --- a/enterprise/coderd/parameters_test.go +++ b/enterprise/coderd/parameters_test.go @@ -31,7 +31,7 @@ func TestDynamicParametersOwnerGroups(t *testing.T) { Options: &coderdtest.Options{IncludeProvisionerDaemon: true}, }, ) - templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID)) _, noGroupUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) // Create the group to be asserted @@ -79,10 +79,10 @@ func TestDynamicParametersOwnerGroups(t *testing.T) { require.NoError(t, err) defer stream.Close(websocket.StatusGoingAway) - previews := stream.Chan() + previews, pop := coderdtest.SynchronousStream(stream) // Should automatically send a form state with all defaulted/empty values - preview := testutil.RequireReceive(ctx, t, previews) + preview := pop() require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) require.Equal(t, "group", preview.Parameters[0].Name) @@ -90,12 +90,11 @@ func TestDynamicParametersOwnerGroups(t *testing.T) { require.Equal(t, database.EveryoneGroup, preview.Parameters[0].Value.Value) // Send a new value, and see it reflected - err = stream.Send(codersdk.DynamicParametersRequest{ + preview, err = previews(codersdk.DynamicParametersRequest{ ID: 1, Inputs: map[string]string{"group": group.Name}, }) require.NoError(t, err) - preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 1, preview.ID) require.Empty(t, preview.Diagnostics) require.Equal(t, "group", preview.Parameters[0].Name) @@ -103,12 +102,11 @@ func TestDynamicParametersOwnerGroups(t *testing.T) { require.Equal(t, group.Name, preview.Parameters[0].Value.Value) // Back to default - err = stream.Send(codersdk.DynamicParametersRequest{ + preview, err = previews(codersdk.DynamicParametersRequest{ ID: 3, Inputs: map[string]string{}, }) require.NoError(t, err) - preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 3, preview.ID) require.Empty(t, preview.Diagnostics) require.Equal(t, "group", preview.Parameters[0].Name) diff --git a/enterprise/coderd/prebuilds/claim.go b/enterprise/coderd/prebuilds/claim.go index f040ee756e678..b6a85ae1fc094 100644 --- a/enterprise/coderd/prebuilds/claim.go +++ b/enterprise/coderd/prebuilds/claim.go @@ -47,7 +47,7 @@ func (c EnterpriseClaimer) Claim( } func (EnterpriseClaimer) Initiator() uuid.UUID { - return prebuilds.SystemUserID + return database.PrebuildsSystemUserID } var _ prebuilds.Claimer = &EnterpriseClaimer{} diff --git a/enterprise/coderd/prebuilds/claim_test.go b/enterprise/coderd/prebuilds/claim_test.go index 83933f3a98cd3..67c1f0dd21ade 100644 --- a/enterprise/coderd/prebuilds/claim_test.go +++ b/enterprise/coderd/prebuilds/claim_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" "github.com/coder/coder/v2/coderd/coderdtest" @@ -128,7 +129,6 @@ func TestClaimPrebuild(t *testing.T) { for name, tc := range cases { // Ensure that prebuilt workspaces can be claimed in non-default organizations: for _, useDefaultOrg := range []bool{true, false} { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -165,7 +165,8 @@ func TestClaimPrebuild(t *testing.T) { }) defer provisionerCloser.Close() - reconciler := prebuilds.NewStoreReconciler(spy, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(spy, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(spy) api.AGPL.PrebuildsClaimer.Store(&claimer) diff --git a/enterprise/coderd/prebuilds/id.go b/enterprise/coderd/prebuilds/id.go deleted file mode 100644 index b6513942447c2..0000000000000 --- a/enterprise/coderd/prebuilds/id.go +++ /dev/null @@ -1 +0,0 @@ -package prebuilds diff --git a/enterprise/coderd/prebuilds/membership_test.go b/enterprise/coderd/prebuilds/membership_test.go index 6caa7178d9d60..82d2abf92a4d8 100644 --- a/enterprise/coderd/prebuilds/membership_test.go +++ b/enterprise/coderd/prebuilds/membership_test.go @@ -12,7 +12,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" - agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/enterprise/coderd/prebuilds" ) @@ -57,7 +56,6 @@ func TestReconcileAll(t *testing.T) { } for _, tc := range tests { - tc := tc // capture t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -74,14 +72,14 @@ func TestReconcileAll(t *testing.T) { // dbmem doesn't ensure membership to the default organization dbgen.OrganizationMember(t, db, database.OrganizationMember{ OrganizationID: defaultOrg.ID, - UserID: agplprebuilds.SystemUserID, + UserID: database.PrebuildsSystemUserID, }) } - dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: agplprebuilds.SystemUserID}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: database.PrebuildsSystemUserID}) if tc.preExistingMembership { // System user already a member of both orgs. - dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: targetOrg.ID, UserID: agplprebuilds.SystemUserID}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: targetOrg.ID, UserID: database.PrebuildsSystemUserID}) } presets := []database.GetTemplatePresetsWithPrebuildsRow{newPresetRow(unrelatedOrg.ID)} @@ -91,7 +89,7 @@ func TestReconcileAll(t *testing.T) { // Verify memberships before reconciliation. preReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ - UserID: agplprebuilds.SystemUserID, + UserID: database.PrebuildsSystemUserID, }) require.NoError(t, err) expectedMembershipsBefore := []uuid.UUID{defaultOrg.ID, unrelatedOrg.ID} @@ -102,11 +100,11 @@ func TestReconcileAll(t *testing.T) { // Reconcile reconciler := prebuilds.NewStoreMembershipReconciler(db, clock) - require.NoError(t, reconciler.ReconcileAll(ctx, agplprebuilds.SystemUserID, presets)) + require.NoError(t, reconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, presets)) // Verify memberships after reconciliation. postReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ - UserID: agplprebuilds.SystemUserID, + UserID: database.PrebuildsSystemUserID, }) require.NoError(t, err) expectedMembershipsAfter := expectedMembershipsBefore diff --git a/enterprise/coderd/prebuilds/metricscollector_test.go b/enterprise/coderd/prebuilds/metricscollector_test.go index dce9e07dd110f..057f310fa2bc2 100644 --- a/enterprise/coderd/prebuilds/metricscollector_test.go +++ b/enterprise/coderd/prebuilds/metricscollector_test.go @@ -13,6 +13,8 @@ import ( prometheus_client "github.com/prometheus/client_model/go" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" "github.com/coder/coder/v2/coderd/database" @@ -20,7 +22,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" - agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/prebuilds" "github.com/coder/coder/v2/testutil" @@ -55,8 +56,8 @@ func TestMetricsCollector(t *testing.T) { name: "prebuild provisioned but not completed", transitions: allTransitions, jobStatuses: allJobStatusesExcept(database.ProvisionerJobStatusPending, database.ProvisionerJobStatusRunning, database.ProvisionerJobStatusCanceling), - initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID}, - ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID}, + initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID}, + ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID}, metrics: []metricCheck{ {prebuilds.MetricCreatedCount, ptr.To(1.0), true}, {prebuilds.MetricClaimedCount, ptr.To(0.0), true}, @@ -72,8 +73,8 @@ func TestMetricsCollector(t *testing.T) { name: "prebuild running", transitions: []database.WorkspaceTransition{database.WorkspaceTransitionStart}, jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusSucceeded}, - initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID}, - ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID}, + initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID}, + ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID}, metrics: []metricCheck{ {prebuilds.MetricCreatedCount, ptr.To(1.0), true}, {prebuilds.MetricClaimedCount, ptr.To(0.0), true}, @@ -89,8 +90,8 @@ func TestMetricsCollector(t *testing.T) { name: "prebuild failed", transitions: allTransitions, jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusFailed}, - initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID}, - ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID, uuid.New()}, + initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID}, + ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID, uuid.New()}, metrics: []metricCheck{ {prebuilds.MetricCreatedCount, ptr.To(1.0), true}, {prebuilds.MetricFailedCount, ptr.To(1.0), true}, @@ -105,8 +106,8 @@ func TestMetricsCollector(t *testing.T) { name: "prebuild eligible", transitions: []database.WorkspaceTransition{database.WorkspaceTransitionStart}, jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusSucceeded}, - initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID}, - ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID}, + initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID}, + ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID}, metrics: []metricCheck{ {prebuilds.MetricCreatedCount, ptr.To(1.0), true}, {prebuilds.MetricClaimedCount, ptr.To(0.0), true}, @@ -122,8 +123,8 @@ func TestMetricsCollector(t *testing.T) { name: "prebuild ineligible", transitions: allTransitions, jobStatuses: allJobStatusesExcept(database.ProvisionerJobStatusSucceeded), - initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID}, - ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID}, + initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID}, + ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID}, metrics: []metricCheck{ {prebuilds.MetricCreatedCount, ptr.To(1.0), true}, {prebuilds.MetricClaimedCount, ptr.To(0.0), true}, @@ -139,7 +140,7 @@ func TestMetricsCollector(t *testing.T) { name: "prebuild claimed", transitions: allTransitions, jobStatuses: allJobStatuses, - initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID}, + initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID}, ownerIDs: []uuid.UUID{uuid.New()}, metrics: []metricCheck{ {prebuilds.MetricCreatedCount, ptr.To(1.0), true}, @@ -169,27 +170,20 @@ func TestMetricsCollector(t *testing.T) { name: "deleted templates should not be included in exported metrics", transitions: allTransitions, jobStatuses: allJobStatuses, - initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID}, - ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID, uuid.New()}, + initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID}, + ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID, uuid.New()}, metrics: nil, templateDeleted: []bool{true}, eligible: []bool{false}, }, } for _, test := range tests { - test := test // capture for parallel for _, transition := range test.transitions { - transition := transition // capture for parallel for _, jobStatus := range test.jobStatuses { - jobStatus := jobStatus // capture for parallel for _, initiatorID := range test.initiatorIDs { - initiatorID := initiatorID // capture for parallel for _, ownerID := range test.ownerIDs { - ownerID := ownerID // capture for parallel for _, templateDeleted := range test.templateDeleted { - templateDeleted := templateDeleted // capture for parallel for _, eligible := range test.eligible { - eligible := eligible // capture for parallel t.Run(fmt.Sprintf("%v/transition:%s/jobStatus:%s", test.name, transition, jobStatus), func(t *testing.T) { t.Parallel() @@ -206,10 +200,11 @@ func TestMetricsCollector(t *testing.T) { }) clock := quartz.NewMock(t) db, pubsub := dbtestutil.NewDB(t) - reconciler := prebuilds.NewStoreReconciler(db, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ctx := testutil.Context(t, testutil.WaitLong) - createdUsers := []uuid.UUID{agplprebuilds.SystemUserID} + createdUsers := []uuid.UUID{database.PrebuildsSystemUserID} for _, user := range slices.Concat(test.ownerIDs, test.initiatorIDs) { if !slices.Contains(createdUsers, user) { dbgen.User(t, db, database.User{ @@ -260,7 +255,6 @@ func TestMetricsCollector(t *testing.T) { require.Equal(t, 1, len(presets)) for _, preset := range presets { - preset := preset // capture for parallel labels := map[string]string{ "template_name": template.Name, "preset_name": preset.Name, @@ -327,8 +321,8 @@ func TestMetricsCollector_DuplicateTemplateNames(t *testing.T) { test := testCase{ transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded, - initiatorID: agplprebuilds.SystemUserID, - ownerID: agplprebuilds.SystemUserID, + initiatorID: database.PrebuildsSystemUserID, + ownerID: database.PrebuildsSystemUserID, metrics: []metricCheck{ {prebuilds.MetricCreatedCount, ptr.To(1.0), true}, {prebuilds.MetricClaimedCount, ptr.To(0.0), true}, @@ -343,7 +337,8 @@ func TestMetricsCollector_DuplicateTemplateNames(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) clock := quartz.NewMock(t) db, pubsub := dbtestutil.NewDB(t) - reconciler := prebuilds.NewStoreReconciler(db, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ctx := testutil.Context(t, testutil.WaitLong) collector := prebuilds.NewMetricsCollector(db, logger, reconciler) diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go index a9f8bd014b3e9..343a639845f44 100644 --- a/enterprise/coderd/prebuilds/reconcile.go +++ b/enterprise/coderd/prebuilds/reconcile.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/prometheus/client_golang/prometheus" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" "github.com/coder/coder/v2/coderd/audit" @@ -40,6 +41,7 @@ type StoreReconciler struct { store database.Store cfg codersdk.PrebuildsConfig pubsub pubsub.Pubsub + fileCache *files.Cache logger slog.Logger clock quartz.Clock registerer prometheus.Registerer @@ -57,6 +59,7 @@ var _ prebuilds.ReconciliationOrchestrator = &StoreReconciler{} func NewStoreReconciler(store database.Store, ps pubsub.Pubsub, + fileCache *files.Cache, cfg codersdk.PrebuildsConfig, logger slog.Logger, clock quartz.Clock, @@ -66,6 +69,7 @@ func NewStoreReconciler(store database.Store, reconciler := &StoreReconciler{ store: store, pubsub: ps, + fileCache: fileCache, logger: logger, cfg: cfg, clock: clock, @@ -265,7 +269,7 @@ func (c *StoreReconciler) ReconcileAll(ctx context.Context) error { } membershipReconciler := NewStoreMembershipReconciler(c.store, c.clock) - err = membershipReconciler.ReconcileAll(ctx, prebuilds.SystemUserID, snapshot.Presets) + err = membershipReconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, snapshot.Presets) if err != nil { return xerrors.Errorf("reconcile prebuild membership: %w", err) } @@ -423,7 +427,6 @@ func (c *StoreReconciler) ReconcilePreset(ctx context.Context, ps prebuilds.Pres // If the preset reached the hard failure limit for the first time during this iteration: // - Mark it as hard-limited in the database - // - Send notifications to template admins // - Continue execution, we disallow only creation operation for hard-limited presets. Deletion is allowed. if ps.Preset.PrebuildStatus != database.PrebuildStatusHardLimited && ps.IsHardLimited { logger.Warn(ctx, "preset is hard limited, notifying template admins") @@ -435,11 +438,6 @@ func (c *StoreReconciler) ReconcilePreset(ctx context.Context, ps prebuilds.Pres if err != nil { return xerrors.Errorf("failed to update preset prebuild status: %w", err) } - - err = c.notifyPrebuildFailureLimitReached(ctx, ps) - if err != nil { - logger.Error(ctx, "failed to notify that number of prebuild failures reached the limit", slog.Error(err)) - } } state := ps.CalculateState() @@ -470,55 +468,12 @@ func (c *StoreReconciler) ReconcilePreset(ctx context.Context, ps prebuilds.Pres return multiErr.ErrorOrNil() } -func (c *StoreReconciler) notifyPrebuildFailureLimitReached(ctx context.Context, ps prebuilds.PresetSnapshot) error { - // nolint:gocritic // Necessary to query all the required data. - ctx = dbauthz.AsSystemRestricted(ctx) - - // Send notification to template admins. - if c.notifEnq == nil { - c.logger.Warn(ctx, "notification enqueuer not set, cannot send prebuild is hard limited notification(s)") - return nil - } - - templateAdmins, err := c.store.GetUsers(ctx, database.GetUsersParams{ - RbacRole: []string{codersdk.RoleTemplateAdmin}, - }) - if err != nil { - return xerrors.Errorf("fetch template admins: %w", err) - } - - for _, templateAdmin := range templateAdmins { - if _, err := c.notifEnq.EnqueueWithData(ctx, templateAdmin.ID, notifications.PrebuildFailureLimitReached, - map[string]string{ - "org": ps.Preset.OrganizationName, - "template": ps.Preset.TemplateName, - "template_version": ps.Preset.TemplateVersionName, - "preset": ps.Preset.Name, - }, - map[string]any{}, - "prebuilds_reconciler", - // Associate this notification with all the related entities. - ps.Preset.TemplateID, ps.Preset.TemplateVersionID, ps.Preset.ID, ps.Preset.OrganizationID, - ); err != nil { - c.logger.Error(ctx, - "failed to send notification", - slog.Error(err), - slog.F("template_admin_id", templateAdmin.ID.String()), - ) - - continue - } - } - - return nil -} - func (c *StoreReconciler) CalculateActions(ctx context.Context, snapshot prebuilds.PresetSnapshot) ([]*prebuilds.ReconciliationActions, error) { if ctx.Err() != nil { return nil, ctx.Err() } - return snapshot.CalculateActions(c.clock, c.cfg.ReconciliationBackoffInterval.Value()) + return snapshot.CalculateActions(c.cfg.ReconciliationBackoffInterval.Value()) } func (c *StoreReconciler) WithReconciliationLock( @@ -676,7 +631,7 @@ func (c *StoreReconciler) createPrebuiltWorkspace(ctx context.Context, prebuiltW ID: prebuiltWorkspaceID, CreatedAt: now, UpdatedAt: now, - OwnerID: prebuilds.SystemUserID, + OwnerID: database.PrebuildsSystemUserID, OrganizationID: template.OrganizationID, TemplateID: template.ID, Name: name, @@ -718,7 +673,7 @@ func (c *StoreReconciler) deletePrebuiltWorkspace(ctx context.Context, prebuiltW return xerrors.Errorf("failed to get template: %w", err) } - if workspace.OwnerID != prebuilds.SystemUserID { + if workspace.OwnerID != database.PrebuildsSystemUserID { return xerrors.Errorf("prebuilt workspace is not owned by prebuild user anymore, probably it was claimed") } @@ -761,7 +716,7 @@ func (c *StoreReconciler) provision( builder := wsbuilder.New(workspace, transition). Reason(database.BuildReasonInitiator). - Initiator(prebuilds.SystemUserID). + Initiator(database.PrebuildsSystemUserID). MarkPrebuild() if transition != database.WorkspaceTransitionDelete { @@ -780,6 +735,7 @@ func (c *StoreReconciler) provision( _, provisionerJob, _, err := builder.Build( ctx, db, + c.fileCache, func(_ policy.Action, _ rbac.Objecter) bool { return true // TODO: harden? }, diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index 702a0769b548f..44ecf168a03ca 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -13,7 +13,9 @@ import ( "github.com/stretchr/testify/assert" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/util/slice" @@ -33,7 +35,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/pubsub" - agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/prebuilds" "github.com/coder/coder/v2/testutil" @@ -54,7 +55,8 @@ func TestNoReconciliationActionsIfNoPresets(t *testing.T) { ReconciliationInterval: serpent.Duration(testutil.WaitLong), } logger := testutil.Logger(t) - controller := prebuilds.NewStoreReconciler(db, ps, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, ps, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) // given a template version with no presets org := dbgen.Organization(t, db, database.Organization{}) @@ -99,7 +101,8 @@ func TestNoReconciliationActionsIfNoPrebuilds(t *testing.T) { ReconciliationInterval: serpent.Duration(testutil.WaitLong), } logger := testutil.Logger(t) - controller := prebuilds.NewStoreReconciler(db, ps, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, ps, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) // given there are presets, but no prebuilds org := dbgen.Organization(t, db, database.Organization{}) @@ -309,7 +312,6 @@ func TestPrebuildReconciliation(t *testing.T) { }, } for _, tc := range testCases { - tc := tc // capture for parallel for _, templateVersionActive := range tc.templateVersionActive { for _, prebuildLatestTransition := range tc.prebuildLatestTransitions { for _, prebuildJobStatus := range tc.prebuildJobStatuses { @@ -377,7 +379,8 @@ func TestPrebuildReconciliation(t *testing.T) { if useBrokenPubsub { pubSub = &brokenPublisher{Pubsub: pubSub} } - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) // Run the reconciliation multiple times to ensure idempotency // 8 was arbitrary, but large enough to reasonably trust the result @@ -454,7 +457,8 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -579,7 +583,8 @@ func TestPrebuildScheduling(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -683,7 +688,8 @@ func TestInvalidPreset(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -747,7 +753,8 @@ func TestDeletionOfPrebuiltWorkspaceWithInvalidPreset(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -843,12 +850,8 @@ func TestSkippingHardLimitedPresets(t *testing.T) { db, pubSub := dbtestutil.NewDB(t) fakeEnqueuer := newFakeEnqueuer() registry := prometheus.NewRegistry() - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, registry, fakeEnqueuer) - - // Template admin to receive a notification. - templateAdmin := dbgen.User(t, db, database.User{ - RBACRoles: []string{codersdk.RoleTemplateAdmin}, - }) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, registry, fakeEnqueuer) // Set up test environment with a template, version, and preset. ownerID := uuid.New() @@ -930,20 +933,6 @@ func TestSkippingHardLimitedPresets(t *testing.T) { require.Equal(t, 1, len(workspaces)) require.Equal(t, database.PrebuildStatusHardLimited, updatedPreset.PrebuildStatus) - // When hard limit is reached, a notification should be sent. - matching := fakeEnqueuer.Sent(func(notification *notificationstest.FakeNotification) bool { - if !assert.Equal(t, notifications.PrebuildFailureLimitReached, notification.TemplateID, "unexpected template") { - return false - } - - if !assert.Equal(t, templateAdmin.ID, notification.UserID, "unexpected receiver") { - return false - } - - return true - }) - require.Len(t, matching, 1) - // When hard limit is reached, metric is set to 1. mf, err = registry.Gather() require.NoError(t, err) @@ -1005,12 +994,8 @@ func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { db, pubSub := dbtestutil.NewDB(t) fakeEnqueuer := newFakeEnqueuer() registry := prometheus.NewRegistry() - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, registry, fakeEnqueuer) - - // Template admin to receive a notification. - templateAdmin := dbgen.User(t, db, database.User{ - RBACRoles: []string{codersdk.RoleTemplateAdmin}, - }) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, registry, fakeEnqueuer) // Set up test environment with a template, version, and preset. ownerID := uuid.New() @@ -1116,20 +1101,6 @@ func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { require.NoError(t, err) require.Equal(t, database.PrebuildStatusHardLimited, updatedPreset.PrebuildStatus) - // When hard limit is reached, a notification should be sent. - matching := fakeEnqueuer.Sent(func(notification *notificationstest.FakeNotification) bool { - if !assert.Equal(t, notifications.PrebuildFailureLimitReached, notification.TemplateID, "unexpected template") { - return false - } - - if !assert.Equal(t, templateAdmin.ID, notification.UserID, "unexpected receiver") { - return false - } - - return true - }) - require.Len(t, matching, 1) - // When hard limit is reached, metric is set to 1. mf, err = registry.Gather() require.NoError(t, err) @@ -1217,7 +1188,8 @@ func TestRunLoop(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - reconciler := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -1347,7 +1319,8 @@ func TestFailedBuildBackoff(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, ps := dbtestutil.NewDB(t) - reconciler := prebuilds.NewStoreReconciler(db, ps, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, ps, cache, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) // Given: an active template version with presets and prebuilds configured. const desiredInstances = 2 @@ -1463,9 +1436,11 @@ func TestReconciliationLock(t *testing.T) { wg.Add(1) go func() { defer wg.Done() + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) reconciler := prebuilds.NewStoreReconciler( db, ps, + cache, codersdk.PrebuildsConfig{}, slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), quartz.NewMock(t), @@ -1503,7 +1478,8 @@ func TestTrackResourceReplacement(t *testing.T) { fakeEnqueuer := newFakeEnqueuer() registry := prometheus.NewRegistry() - reconciler := prebuilds.NewStoreReconciler(db, ps, codersdk.PrebuildsConfig{}, logger, clock, registry, fakeEnqueuer) + cache := files.New(registry, &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, ps, cache, codersdk.PrebuildsConfig{}, logger, clock, registry, fakeEnqueuer) // Given: a template admin to receive a notification. templateAdmin := dbgen.User(t, db, database.User{ @@ -1658,7 +1634,8 @@ func TestExpiredPrebuildsMultipleActions(t *testing.T) { db, pubSub := dbtestutil.NewDB(t) fakeEnqueuer := newFakeEnqueuer() registry := prometheus.NewRegistry() - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, registry, fakeEnqueuer) + cache := files.New(registry, &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, registry, fakeEnqueuer) // Set up test environment with a template, version, and preset ownerID := uuid.New() @@ -2021,7 +1998,7 @@ func setupTestDBPrebuild( opts ...prebuildOption, ) (database.WorkspaceTable, database.WorkspaceBuild) { t.Helper() - return setupTestDBWorkspace(t, clock, db, ps, transition, prebuildStatus, orgID, preset, templateID, templateVersionID, agplprebuilds.SystemUserID, agplprebuilds.SystemUserID, opts...) + return setupTestDBWorkspace(t, clock, db, ps, transition, prebuildStatus, orgID, preset, templateID, templateVersionID, database.PrebuildsSystemUserID, database.PrebuildsSystemUserID, opts...) } func setupTestDBWorkspace( diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index cdc6267d90971..a94a60ffff3c2 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -921,7 +921,6 @@ func TestGetProvisionerDaemons(t *testing.T) { }, } for _, tt := range testCases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) diff --git a/enterprise/coderd/provisionerkeys_test.go b/enterprise/coderd/provisionerkeys_test.go index e3f5839bf8b02..daca6625d620f 100644 --- a/enterprise/coderd/provisionerkeys_test.go +++ b/enterprise/coderd/provisionerkeys_test.go @@ -167,7 +167,6 @@ func TestGetProvisionerKey(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/proxyhealth/proxyhealth.go b/enterprise/coderd/proxyhealth/proxyhealth.go index 7faac6a9e8147..ef721841362c8 100644 --- a/enterprise/coderd/proxyhealth/proxyhealth.go +++ b/enterprise/coderd/proxyhealth/proxyhealth.go @@ -240,7 +240,6 @@ func (p *ProxyHealth) runOnce(ctx context.Context, now time.Time) (map[uuid.UUID } // Each proxy needs to have a status set. Make a local copy for the // call to be run async. - proxy := proxy status := ProxyStatus{ Proxy: proxy, CheckedAt: now, diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 57b66a368248c..70c432755f7fa 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -517,7 +517,6 @@ func TestListRoles(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index 712fa032c8c1b..4af06042b031f 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -191,8 +191,6 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -778,8 +776,6 @@ func TestTemplateTTL(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index b6c2048190e9a..6c7a20f85a642 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -470,8 +470,6 @@ func TestTemplates(t *testing.T) { } for _, c := range cases { - c := c - // nolint: paralleltest // context is from parent t.Run t.Run(c.Name, func(t *testing.T) { _, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ diff --git a/enterprise/coderd/testdata/parameters/dynamic/main.tf b/enterprise/coderd/testdata/parameters/dynamic/main.tf new file mode 100644 index 0000000000000..a6926f46b66a2 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/dynamic/main.tf @@ -0,0 +1,115 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + +locals { + isAdmin = contains(data.coder_workspace_owner.me.groups, "admin") +} + +data "coder_parameter" "isAdmin" { + name = "isAdmin" + type = "bool" + form_type = "switch" + default = local.isAdmin + order = 1 +} + +data "coder_parameter" "adminonly" { + count = local.isAdmin ? 1 : 0 + name = "adminonly" + form_type = "input" + type = "string" + default = "I am an admin!" + order = 2 +} + + +data "coder_parameter" "groups" { + name = "groups" + type = "list(string)" + form_type = "multi-select" + default = jsonencode([data.coder_workspace_owner.me.groups[0]]) + order = 50 + + dynamic "option" { + for_each = data.coder_workspace_owner.me.groups + content { + name = option.value + value = option.value + } + } +} + +locals { + colors = { + "red" : ["apple", "ruby"] + "yellow" : ["banana"] + "blue" : ["ocean", "sky"] + "green" : ["grass", "leaf"] + } +} + +data "coder_parameter" "colors" { + name = "colors" + type = "list(string)" + form_type = "multi-select" + order = 100 + + dynamic "option" { + for_each = keys(local.colors) + content { + name = option.value + value = option.value + } + } +} + +locals { + selected = jsondecode(data.coder_parameter.colors.value) + things = flatten([ + for color in local.selected : local.colors[color] + ]) +} + +data "coder_parameter" "thing" { + name = "thing" + type = "string" + form_type = "dropdown" + order = 101 + + dynamic "option" { + for_each = local.things + content { + name = option.value + value = option.value + } + } +} + +// Cool people like blue. Idk what to tell you. +data "coder_parameter" "cool" { + count = contains(local.selected, "blue") ? 1 : 0 + name = "cool" + type = "bool" + form_type = "switch" + order = 102 + default = "true" +} + +data "coder_parameter" "number" { + count = contains(local.selected, "green") ? 1 : 0 + name = "number" + type = "number" + order = 103 + validation { + error = "Number must be between 0 and 10" + min = 0 + max = 10 + } +} diff --git a/enterprise/coderd/testdata/parameters/ephemeral/main.tf b/enterprise/coderd/testdata/parameters/ephemeral/main.tf new file mode 100644 index 0000000000000..f632fcf11aea4 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/ephemeral/main.tf @@ -0,0 +1,25 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "required" { + name = "required" + type = "string" + mutable = true + ephemeral = true +} + + +data "coder_parameter" "defaulted" { + name = "defaulted" + type = "string" + mutable = true + ephemeral = true + default = "original" +} diff --git a/enterprise/coderd/testdata/parameters/immutable/main.tf b/enterprise/coderd/testdata/parameters/immutable/main.tf new file mode 100644 index 0000000000000..84b8967ac305e --- /dev/null +++ b/enterprise/coderd/testdata/parameters/immutable/main.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "immutable" { + name = "immutable" + type = "string" + mutable = false + default = "Hello World" +} diff --git a/enterprise/coderd/testdata/parameters/none/main.tf b/enterprise/coderd/testdata/parameters/none/main.tf new file mode 100644 index 0000000000000..74a83f752f4d8 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/none/main.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + diff --git a/enterprise/coderd/testdata/parameters/numbers/main.tf b/enterprise/coderd/testdata/parameters/numbers/main.tf new file mode 100644 index 0000000000000..c4950db326419 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/numbers/main.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "number" { + name = "number" + type = "number" + mutable = false + validation { + error = "Number must be between 0 and 10" + min = 0 + max = 10 + } +} diff --git a/enterprise/coderd/testdata/parameters/regex/main.tf b/enterprise/coderd/testdata/parameters/regex/main.tf new file mode 100644 index 0000000000000..9fbaa5e245056 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/regex/main.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "string" { + name = "string" + type = "string" + validation { + error = "All messages must start with 'Hello'" + regex = "^Hello" + } +} diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 267e1168f84cf..46207f319dbe1 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -902,7 +902,6 @@ func TestGroupSync(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() runner := setupOIDCTest(t, oidcTestConfig{ diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index 5aa1ab1e8215c..7cfef59fa9e5f 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -426,7 +426,6 @@ func TestGrantSiteRoles(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index 1eea9ecda9ca8..f4f0670cd150e 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -112,7 +112,6 @@ func TestReinitializeAgent(t *testing.T) { Pubsub: ps, DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { dv.Prebuilds.ReconciliationInterval = serpent.Duration(time.Second) - dv.Experiments.Append(string(codersdk.ExperimentWorkspacePrebuilds)) }), }, LicenseOptions: &coderdenttest.LicenseOptions{ diff --git a/enterprise/coderd/workspaceproxy_internal_test.go b/enterprise/coderd/workspaceproxy_internal_test.go index 9654e0ecc3e2f..1bb84b4026ca6 100644 --- a/enterprise/coderd/workspaceproxy_internal_test.go +++ b/enterprise/coderd/workspaceproxy_internal_test.go @@ -47,7 +47,6 @@ func Test_validateProxyURL(t *testing.T) { } for _, tt := range testcases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index ce86151f9b883..3bed052702637 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -32,7 +32,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" - "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -288,11 +287,9 @@ func TestCreateUserWorkspace(t *testing.T) { OrganizationID: first.OrganizationID, }) - version := coderdtest.CreateTemplateVersion(t, admin, first.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, admin, version.ID) - template := coderdtest.CreateTemplate(t, admin, first.OrganizationID, version.ID) + template, _ := coderdtest.DynamicParameterTemplate(t, admin, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{}) - ctx = testutil.Context(t, testutil.WaitLong*1000) // Reset the context to avoid timeouts. + ctx = testutil.Context(t, testutil.WaitLong) wrk, err := creator.CreateUserWorkspace(ctx, adminID.ID.String(), codersdk.CreateWorkspaceRequest{ TemplateID: template.ID, @@ -307,6 +304,66 @@ func TestCreateUserWorkspace(t *testing.T) { require.NoError(t, err) }) + t.Run("ForANonOrgMember", func(t *testing.T) { + t.Parallel() + + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + ctx := testutil.Context(t, testutil.WaitShort) + //nolint:gocritic // using owner to setup roles + r, err := owner.CreateOrganizationRole(ctx, codersdk.Role{ + Name: "creator", + OrganizationID: first.OrganizationID.String(), + DisplayName: "Creator", + OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionCreate, codersdk.ActionWorkspaceStart, codersdk.ActionUpdate, codersdk.ActionRead}, + codersdk.ResourceOrganizationMember: {codersdk.ActionRead}, + }), + }) + require.NoError(t, err) + + // user to make the workspace for, **note** the user is not a member of the first org. + // This is strange, but technically valid. The creator can create a workspace for + // this user in this org, even though the user cannot access the workspace. + secondOrg := coderdenttest.CreateOrganization(t, owner, coderdenttest.CreateOrganizationOptions{}) + _, forUser := coderdtest.CreateAnotherUser(t, owner, secondOrg.ID) + + // try the test action with this user & custom role + creator, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleMember(), + rbac.RoleTemplateAdmin(), // Need site wide access to make workspace for non-org + rbac.RoleIdentifier{ + Name: r.Name, + OrganizationID: first.OrganizationID, + }, + ) + + template, _ := coderdtest.DynamicParameterTemplate(t, creator, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{}) + + ctx = testutil.Context(t, testutil.WaitLong) + + wrk, err := creator.CreateUserWorkspace(ctx, forUser.ID.String(), codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "workspace", + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, creator, wrk.LatestBuild.ID) + + _, err = creator.WorkspaceByOwnerAndName(ctx, forUser.Username, wrk.Name, codersdk.WorkspaceOptions{ + IncludeDeleted: false, + }) + require.NoError(t, err) + }) + // Asserting some authz calls when creating a workspace. t.Run("AuthzStory", func(t *testing.T) { t.Parallel() @@ -474,10 +531,7 @@ func TestCreateUserWorkspace(t *testing.T) { client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ - DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { - err := dv.Experiments.Append(string(codersdk.ExperimentWorkspacePrebuilds)) - require.NoError(t, err) - }), + DeploymentValues: coderdtest.DeploymentValues(t), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ @@ -496,7 +550,7 @@ func TestCreateUserWorkspace(t *testing.T) { }).Do() r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OwnerID: prebuilds.SystemUserID, + OwnerID: database.PrebuildsSystemUserID, TemplateID: tv.Template.ID, }).Seed(database.WorkspaceBuild{ TemplateVersionID: tv.TemplateVersion.ID, @@ -963,7 +1017,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Stop the workspace so we can assert autobuild does nothing // if we breach our inactivity threshold. - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Simulate not having accessed the workspace in a while. ticker <- ws.LastUsedAt.Add(2 * inactiveTTL) @@ -1151,7 +1205,7 @@ func TestWorkspaceAutobuild(t *testing.T) { cwr.AutostartSchedule = ptr.Ref(sched.String()) }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Assert that autostart works when the workspace isn't dormant.. tickCh <- sched.Next(ws.LatestBuild.CreatedAt) @@ -1320,7 +1374,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Create a new version so that we can assert we don't update // to the latest by default. @@ -1361,7 +1415,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Reset the workspace to the stopped state so we can try // to autostart again. - coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { + coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { req.TemplateVersionID = ws.LatestBuild.TemplateVersionID }) @@ -1421,7 +1475,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) next := ws.LatestBuild.CreatedAt // For each day of the week (Monday-Sunday) @@ -1449,7 +1503,7 @@ func TestWorkspaceAutobuild(t *testing.T) { assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[ws.ID]) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) } // Ensure that there is a valid next start at and that is is after @@ -1512,7 +1566,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Our next start at should be Monday require.NotNil(t, ws.NextStartAt) @@ -1574,7 +1628,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Check we have a 'NextStartAt' require.NotNil(t, ws.NextStartAt) @@ -1760,7 +1814,6 @@ func TestWorkspaceTemplateParamsChange(t *testing.T) { Value: "7", }, }, - EnableDynamicParameters: true, }) // Then: the build should succeed. The updated value of param_min should be @@ -1953,7 +2006,6 @@ func TestWorkspaceTagsTerraform(t *testing.T) { }`, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { client, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ @@ -2101,7 +2153,7 @@ func TestExecutorAutostartBlocked(t *testing.T) { ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks into the future go func() { diff --git a/enterprise/provisionerd/remoteprovisioners_test.go b/enterprise/provisionerd/remoteprovisioners_test.go index 5d0de5ae396b7..7b89d696ee20e 100644 --- a/enterprise/provisionerd/remoteprovisioners_test.go +++ b/enterprise/provisionerd/remoteprovisioners_test.go @@ -33,7 +33,6 @@ func TestRemoteConnector_Mainline(t *testing.T) { {name: "Smokescreen", smokescreen: true}, } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go index 0a60ccfd0a1fc..528540a262464 100644 --- a/enterprise/replicasync/replicasync.go +++ b/enterprise/replicasync/replicasync.go @@ -408,9 +408,6 @@ func (m *Manager) AllPrimary() []database.Replica { continue } - // When we assign the non-pointer to a - // variable it loses the reference. - replica := replica replicas = append(replicas, replica) } return replicas diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index 65de627a1fb06..b49dfd6c1ceaa 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -1,6 +1,7 @@ package wsproxy_test import ( + "bytes" "context" "encoding/json" "fmt" @@ -282,7 +283,6 @@ resourceLoop: // Connect to each region. for _, r := range connInfo.DERPMap.Regions { - r := r if len(r.Nodes) == 1 && r.Nodes[0].STUNOnly { // Skip STUN-only regions. continue @@ -498,8 +498,8 @@ func TestDERPMesh(t *testing.T) { proxyURL, err := url.Parse("https://proxy.test.coder.com") require.NoError(t, err) - // Create 6 proxy replicas. - const count = 6 + // Create 3 proxy replicas. + const count = 3 var ( sessionToken = "" proxies = [count]coderdenttest.WorkspaceProxy{} @@ -654,7 +654,6 @@ func TestWorkspaceProxyDERPMeshProbe(t *testing.T) { replicaPingDone = [count]bool{} ) for i := range proxies { - i := i proxies[i] = coderdenttest.NewWorkspaceProxyReplica(t, api, client, &coderdenttest.ProxyOptions{ Name: "proxy-1", Token: sessionToken, @@ -840,27 +839,33 @@ func TestWorkspaceProxyDERPMeshProbe(t *testing.T) { require.NoError(t, err) // Create 1 real proxy replica. - replicaPingErr := make(chan string, 4) + replicaPingRes := make(chan replicaPingCallback, 4) proxy := coderdenttest.NewWorkspaceProxyReplica(t, api, client, &coderdenttest.ProxyOptions{ Name: "proxy-2", ProxyURL: proxyURL, - ReplicaPingCallback: func(_ []codersdk.Replica, err string) { - replicaPingErr <- err + ReplicaPingCallback: func(replicas []codersdk.Replica, err string) { + t.Logf("got wsproxy ping callback: replica count: %v, ping error: %s", len(replicas), err) + replicaPingRes <- replicaPingCallback{ + replicas: replicas, + err: err, + } }, }) + // Create a second proxy replica that isn't working. ctx := testutil.Context(t, testutil.WaitLong) otherReplicaID := registerBrokenProxy(ctx, t, api.AccessURL, proxyURL.String(), proxy.Options.ProxySessionToken) - // Force the proxy to re-register immediately. - err = proxy.RegisterNow() - require.NoError(t, err, "failed to force proxy to re-register") - - // Wait for the ping to fail. + // Force the proxy to re-register and wait for the ping to fail. for { - replicaErr := testutil.TryReceive(ctx, t, replicaPingErr) - t.Log("replica ping error:", replicaErr) - if replicaErr != "" { + err = proxy.RegisterNow() + require.NoError(t, err, "failed to force proxy to re-register") + + pingRes := testutil.TryReceive(ctx, t, replicaPingRes) + // We want to ensure that we know about the other replica, and the + // ping failed. + if len(pingRes.replicas) == 1 && pingRes.err != "" { + t.Log("got failed ping callback for other replica, continuing") break } } @@ -886,17 +891,17 @@ func TestWorkspaceProxyDERPMeshProbe(t *testing.T) { }) require.NoError(t, err) - // Force the proxy to re-register immediately. - err = proxy.RegisterNow() - require.NoError(t, err, "failed to force proxy to re-register") - - // Wait for the ping to be skipped. + // Force the proxy to re-register and wait for the ping to be skipped + // because there are no more siblings. for { - replicaErr := testutil.TryReceive(ctx, t, replicaPingErr) - t.Log("replica ping error:", replicaErr) + err = proxy.RegisterNow() + require.NoError(t, err, "failed to force proxy to re-register") + + replicaErr := testutil.TryReceive(ctx, t, replicaPingRes) // Should be empty because there are no more peers. This was where // the regression was. - if replicaErr == "" { + if len(replicaErr.replicas) == 0 && replicaErr.err == "" { + t.Log("got empty ping callback with no sibling replicas, continuing") break } } @@ -995,6 +1000,11 @@ func TestWorkspaceProxyWorkspaceApps(t *testing.T) { }) } +type replicaPingCallback struct { + replicas []codersdk.Replica + err string +} + func TestWorkspaceProxyWorkspaceApps_BlockDirect(t *testing.T) { t.Parallel() @@ -1120,7 +1130,7 @@ func createDERPClient(t *testing.T, ctx context.Context, name string, derpURL st // received on dstCh. // // If the packet doesn't arrive within 500ms, it will try to send it again until -// testutil.WaitLong is reached. +// the context expires. // //nolint:revive func testDERPSend(t *testing.T, ctx context.Context, dstKey key.NodePublic, dstCh <-chan derp.ReceivedPacket, src *derphttp.Client) { @@ -1141,11 +1151,17 @@ func testDERPSend(t *testing.T, ctx context.Context, dstKey key.NodePublic, dstC for { select { case pkt := <-dstCh: - require.Equal(t, src.SelfPublicKey(), pkt.Source, "packet came from wrong source") - require.Equal(t, msg, pkt.Data, "packet data is wrong") + if pkt.Source != src.SelfPublicKey() { + t.Logf("packet came from wrong source: %s", pkt.Source) + continue + } + if !bytes.Equal(pkt.Data, msg) { + t.Logf("packet data is wrong: %s", pkt.Data) + continue + } return case <-ctx.Done(): - t.Fatal("timed out waiting for packet") + t.Fatal("timed out waiting for valid packet") return case <-ticker.C: } diff --git a/examples/examples_test.go b/examples/examples_test.go index 1a558b6506c73..779835eec66d5 100644 --- a/examples/examples_test.go +++ b/examples/examples_test.go @@ -19,7 +19,6 @@ func TestTemplate(t *testing.T) { require.NoError(t, err, "error listing examples, run \"make gen\" to ensure examples are up to date") require.NotEmpty(t, list) for _, eg := range list { - eg := eg t.Run(eg.ID, func(t *testing.T) { t.Parallel() assert.NotEmpty(t, eg.ID, "example ID should not be empty") diff --git a/go.mod b/go.mod index ef52718460cdd..724015fd73c93 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,9 @@ replace github.com/aquasecurity/trivy => github.com/coder/trivy v0.0.0-202505271 // https://github.com/spf13/afero/pull/487 replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713-f06e86036696 +// TODO: replace once we cut release. +replace github.com/coder/terraform-provider-coder/v2 => github.com/coder/terraform-provider-coder/v2 v2.7.1-0.20250623193313-e890833351e2 + require ( cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb cloud.google.com/go/compute/metadata v0.7.0 @@ -101,7 +104,7 @@ require ( github.com/coder/quartz v0.2.1 github.com/coder/retry v1.5.1 github.com/coder/serpent v0.10.0 - github.com/coder/terraform-provider-coder/v2 v2.6.0 + github.com/coder/terraform-provider-coder/v2 v2.8.0 github.com/coder/websocket v1.8.13 github.com/coder/wgtunnel v0.1.13-0.20240522110300-ade90dfb2da0 github.com/coreos/go-oidc/v3 v3.14.1 @@ -309,7 +312,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-test/deep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect @@ -478,14 +481,11 @@ require ( ) require ( - github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 - github.com/coder/preview v0.0.2-0.20250611164554-2e5caa65a54a + github.com/coder/aisdk-go v0.0.9 + github.com/coder/preview v1.0.1 github.com/fsnotify/fsnotify v1.9.0 - github.com/kylecarbs/aisdk-go v0.0.8 github.com/mark3labs/mcp-go v0.32.0 - github.com/openai/openai-go v0.1.0-beta.10 - google.golang.org/genai v0.7.0 ) require ( @@ -502,6 +502,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect + github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/trivy v0.58.2 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect @@ -519,6 +520,7 @@ require ( github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/openai/openai-go v1.7.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/samber/lo v1.50.0 // indirect @@ -533,5 +535,6 @@ require ( go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + google.golang.org/genai v1.12.0 // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect ) diff --git a/go.sum b/go.sum index ee7587bfdd7b1..1e5f203dc1004 100644 --- a/go.sum +++ b/go.sum @@ -720,8 +720,8 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 h1:b5t1ZJMvV/l99y4jbz7kRFdUp3BSDkI8EhSlHczivtw= -github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= +github.com/anthropics/anthropic-sdk-go v1.4.0 h1:fU1jKxYbQdQDiEXCxeW5XZRIOwKevn/PMg8Ay1nnUx0= +github.com/anthropics/anthropic-sdk-go v1.4.0/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= @@ -897,6 +897,8 @@ github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73l github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4= +github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo= +github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M= github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwuWwPHPYoCZ/KLAjHv5g4h2MS4f2/MTI= github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4= github.com/coder/clistat v1.0.0 h1:MjiS7qQ1IobuSSgDnxcCSyBPESs44hExnh2TEqMcGnA= @@ -914,8 +916,8 @@ github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048 h1:3jzYUlGH7ZELIH4XggX github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= -github.com/coder/preview v0.0.2-0.20250611164554-2e5caa65a54a h1:rArAOPl5zHB7lhT2sy+jfcmyLeDlm6tXDoGkGdWNq7g= -github.com/coder/preview v0.0.2-0.20250611164554-2e5caa65a54a/go.mod h1:nXz3bBwbU8/9NYI4OISUsoLDFlEREtTozYhJq6FAE8E= +github.com/coder/preview v1.0.1 h1:f6q+RjNelwnkyXfGbmVlb4dcUOQ0z4mPsb2kuQpFHuU= +github.com/coder/preview v1.0.1/go.mod h1:efDWGlO/PZPrvdt5QiDhMtTUTkPxejXo9c0wmYYLLjM= github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE= github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA= github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc= @@ -928,8 +930,8 @@ github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c h1:d/qBIi3Ez7Kko github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c/go.mod h1:l7ml5uu7lFh5hY28lGYM4b/oFSmuPHYX6uk4RAu23Lc= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= -github.com/coder/terraform-provider-coder/v2 v2.6.0 h1:ybSVxkblpFdanNX7hibex41yvwjswUlA3RPh4BAHjBI= -github.com/coder/terraform-provider-coder/v2 v2.6.0/go.mod h1:WrdLSbihuzH1RZhwrU+qmkqEhUbdZT/sjHHdarm5b5g= +github.com/coder/terraform-provider-coder/v2 v2.7.1-0.20250623193313-e890833351e2 h1:vtGzECz5CyzuxMODexWdIRxhYLqyTcHafuJpH60PYhM= +github.com/coder/terraform-provider-coder/v2 v2.7.1-0.20250623193313-e890833351e2/go.mod h1:WrdLSbihuzH1RZhwrU+qmkqEhUbdZT/sjHHdarm5b5g= github.com/coder/trivy v0.0.0-20250527170238-9416a59d7019 h1:MHkv/W7l9eRAN9gOG0qZ1TLRGWIIfNi92273vPAQ8Fs= github.com/coder/trivy v0.0.0-20250527170238-9416a59d7019/go.mod h1:eqk+w9RLBmbd/cB5XfPZFuVn77cf/A6fB7qmEVeSmXk= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= @@ -1146,8 +1148,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -1470,8 +1472,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylecarbs/aisdk-go v0.0.8 h1:hnKVbLM6U8XqX3t5I26J8k5saXdra595bGt1HP0PvKA= -github.com/kylecarbs/aisdk-go v0.0.8/go.mod h1:3nAhClwRNo6ZfU44GrBZ8O2fCCrxJdaHb9JIz+P3LR8= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= @@ -1613,8 +1613,8 @@ github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.120.1 github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.120.1/go.mod h1:01TvyaK8x640crO2iFwW/6CFCZgNsOvOGH3B5J239m0= github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.120.1 h1:TCyOus9tym82PD1VYtthLKMVMlVyRwtDI4ck4SR2+Ok= github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.120.1/go.mod h1:Z/S1brD5gU2Ntht/bHxBVnGxXKTvZDr0dNv/riUzPmY= -github.com/openai/openai-go v0.1.0-beta.10 h1:CknhGXe8aXQMRuqg255PFnWzgRY9nEryMxoNIBBM9tU= -github.com/openai/openai-go v0.1.0-beta.10/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/openai/openai-go v1.7.0 h1:M1JfDjQgo3d3PsLyZgpGUG0wUAaUAitqJPM4Rl56dCA= +github.com/openai/openai-go v1.7.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -2495,8 +2495,8 @@ google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genai v0.7.0 h1:TINBYXnP+K+D8b16LfVyb6XR3kdtieXy6nJsGoEXcBc= -google.golang.org/genai v0.7.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY= +google.golang.org/genai v1.12.0 h1:0JjAdwvEAha9ZpPH5hL6dVG8bpMnRbAMCgv2f2LDnz4= +google.golang.org/genai v1.12.0/go.mod h1:HFXR1zT3LCdLxd/NW6IOSCczOYyRAxwaShvYbgPSeVw= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= diff --git a/helm/coder/tests/chart_test.go b/helm/coder/tests/chart_test.go index 638b9e5005d6f..a11d631a2f247 100644 --- a/helm/coder/tests/chart_test.go +++ b/helm/coder/tests/chart_test.go @@ -163,10 +163,7 @@ func TestRenderChart(t *testing.T) { require.NoError(t, err, "failed to build Helm dependencies") for _, tc := range testCases { - tc := tc - for _, ns := range namespaces { - tc := tc tc.namespace = ns t.Run(tc.namespace+"/"+tc.name, func(t *testing.T) { @@ -213,14 +210,12 @@ func TestUpdateGoldenFiles(t *testing.T) { require.NoError(t, err, "failed to build Helm dependencies") for _, tc := range testCases { - tc := tc if tc.expectedError != "" { t.Logf("skipping test case %q with render error", tc.name) continue } for _, ns := range namespaces { - tc := tc tc.namespace = ns valuesPath := tc.valuesFilePath() diff --git a/helm/provisioner/tests/chart_test.go b/helm/provisioner/tests/chart_test.go index a6f3ba7370bac..8b0cc5cabaa1e 100644 --- a/helm/provisioner/tests/chart_test.go +++ b/helm/provisioner/tests/chart_test.go @@ -141,9 +141,7 @@ func TestRenderChart(t *testing.T) { require.NoError(t, err, "failed to build Helm dependencies") for _, tc := range testCases { - tc := tc for _, ns := range namespaces { - tc := tc tc.namespace = ns t.Run(tc.namespace+"/"+tc.name, func(t *testing.T) { @@ -190,14 +188,12 @@ func TestUpdateGoldenFiles(t *testing.T) { require.NoError(t, err, "failed to build Helm dependencies") for _, tc := range testCases { - tc := tc if tc.expectedError != "" { t.Logf("skipping test case %q with render error", tc.name) continue } for _, ns := range namespaces { - tc := tc tc.namespace = ns valuesPath := tc.valuesFilePath() diff --git a/provisioner/terraform/diagnostic_test.go b/provisioner/terraform/diagnostic_test.go index 8727256b75376..0fd353ae540a5 100644 --- a/provisioner/terraform/diagnostic_test.go +++ b/provisioner/terraform/diagnostic_test.go @@ -47,8 +47,6 @@ func TestFormatDiagnostic(t *testing.T) { } for name, tc := range tests { - tc := tc - t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index b29c21eff000c..ea63f8c59877e 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -361,6 +361,8 @@ func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr l Plan: planJSON, ResourceReplacements: resReps, ModuleFiles: moduleFiles, + HasAiTasks: state.HasAITasks, + AiTasks: state.AITasks, } return msg, nil @@ -577,6 +579,7 @@ func (e *executor) apply( ExternalAuthProviders: state.ExternalAuthProviders, State: stateContent, Timings: e.timings.aggregate(), + AiTasks: state.AITasks, }, nil } diff --git a/provisioner/terraform/executor_internal_test.go b/provisioner/terraform/executor_internal_test.go index 97cb5285372f2..a39d8758893b8 100644 --- a/provisioner/terraform/executor_internal_test.go +++ b/provisioner/terraform/executor_internal_test.go @@ -157,8 +157,6 @@ func TestOnlyDataResources(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/parse_test.go b/provisioner/terraform/parse_test.go index 7d176cb886469..d2a505235f688 100644 --- a/provisioner/terraform/parse_test.go +++ b/provisioner/terraform/parse_test.go @@ -376,7 +376,6 @@ func TestParse(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 505fd2df41400..d067965997308 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -23,6 +23,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/coder/terraform-provider-coder/v2/provider" + "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" @@ -312,7 +314,6 @@ func TestProvision_Cancel(t *testing.T) { }, } for _, tt := range tests { - tt := tt // below we exec fake_cancel.sh, which causes the kernel to execute it, and if more than // one process tries to do this, it can cause "text file busy" // nolint: paralleltest @@ -1047,6 +1048,93 @@ func TestProvision(t *testing.T) { }}, }, }, + { + Name: "ai-task-required-prompt-param", + Files: map[string]string{ + "main.tf": `terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7.0" + } + } + } + resource "coder_ai_task" "a" { + sidebar_app { + id = "7128be08-8722-44cb-bbe1-b5a391c4d94b" # fake ID, irrelevant here anyway but needed for validation + } + } + `, + }, + Request: &proto.PlanRequest{}, + Response: &proto.PlanComplete{ + Error: fmt.Sprintf("plan resources: coder_parameter named '%s' is required when 'coder_ai_task' resource is defined", provider.TaskPromptParameterName), + }, + }, + { + Name: "ai-task-multiple-allowed-in-plan", + Files: map[string]string{ + "main.tf": fmt.Sprintf(`terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7.0" + } + } + } + data "coder_parameter" "prompt" { + name = "%s" + type = "string" + } + resource "coder_ai_task" "a" { + sidebar_app { + id = "7128be08-8722-44cb-bbe1-b5a391c4d94b" # fake ID, irrelevant here anyway but needed for validation + } + } + resource "coder_ai_task" "b" { + sidebar_app { + id = "7128be08-8722-44cb-bbe1-b5a391c4d94b" # fake ID, irrelevant here anyway but needed for validation + } + } + `, provider.TaskPromptParameterName), + }, + Request: &proto.PlanRequest{}, + Response: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Name: "a", + Type: "coder_ai_task", + }, + { + Name: "b", + Type: "coder_ai_task", + }, + }, + Parameters: []*proto.RichParameter{ + { + Name: provider.TaskPromptParameterName, + Type: "string", + Required: true, + FormType: proto.ParameterFormType_INPUT, + }, + }, + AiTasks: []*proto.AITask{ + { + Id: "a", + SidebarApp: &proto.AITaskSidebarApp{ + Id: "7128be08-8722-44cb-bbe1-b5a391c4d94b", + }, + }, + { + Id: "b", + SidebarApp: &proto.AITaskSidebarApp{ + Id: "7128be08-8722-44cb-bbe1-b5a391c4d94b", + }, + }, + }, + HasAiTasks: true, + }, + }, } // Remove unused cache dirs before running tests. @@ -1067,7 +1155,6 @@ func TestProvision(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() @@ -1148,6 +1235,8 @@ func TestProvision(t *testing.T) { modulesWant, err := json.Marshal(testCase.Response.Modules) require.NoError(t, err) require.Equal(t, string(modulesWant), string(modulesGot)) + + require.Equal(t, planComplete.HasAiTasks, testCase.Response.HasAiTasks) } if testCase.Apply { diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 686a947f7fcaa..84174c90b435d 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "math" + "slices" "strings" "github.com/awalterschulze/gographviz" + "github.com/google/uuid" tfjson "github.com/hashicorp/terraform-json" "github.com/mitchellh/mapstructure" "golang.org/x/xerrors" @@ -93,6 +95,7 @@ type agentDisplayAppsAttributes struct { // A mapping of attributes on the "coder_app" resource. type agentAppAttributes struct { + ID string `mapstructure:"id"` AgentID string `mapstructure:"agent_id"` // Slug is required in terraform, but to avoid breaking existing users we // will default to the resource name if it is not specified. @@ -160,10 +163,31 @@ type State struct { Parameters []*proto.RichParameter Presets []*proto.Preset ExternalAuthProviders []*proto.ExternalAuthProviderResource + AITasks []*proto.AITask + HasAITasks bool } var ErrInvalidTerraformAddr = xerrors.New("invalid terraform address") +// hasAITaskResources is used to determine if a template has *any* `coder_ai_task` resources defined. During template +// import, it's possible that none of these have `count=1` since count may be dependent on the value of a `coder_parameter` +// or something else. +// We need to know at template import if these resources exist to inform the frontend of their existence. +func hasAITaskResources(graph *gographviz.Graph) bool { + for _, node := range graph.Nodes.Lookup { + // Check if this node is a coder_ai_task resource + if label, exists := node.Attrs["label"]; exists { + labelValue := strings.Trim(label, `"`) + // The first condition is for the case where the resource is in the root module. + // The second condition is for the case where the resource is in a child module. + if strings.HasPrefix(labelValue, "coder_ai_task.") || strings.Contains(labelValue, ".coder_ai_task.") { + return true + } + } + } + return false +} + // ConvertState consumes Terraform state and a GraphViz representation // produced by `terraform graph` to produce resources consumable by Coder. // nolint:gocognit // This function makes more sense being large for now, until refactored. @@ -187,6 +211,7 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s // Extra array to preserve the order of rich parameters. tfResourcesRichParameters := make([]*tfjson.StateResource, 0) tfResourcesPresets := make([]*tfjson.StateResource, 0) + tfResourcesAITasks := make([]*tfjson.StateResource, 0) var findTerraformResources func(mod *tfjson.StateModule) findTerraformResources = func(mod *tfjson.StateModule) { for _, module := range mod.ChildModules { @@ -199,6 +224,9 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s if resource.Type == "coder_workspace_preset" { tfResourcesPresets = append(tfResourcesPresets, resource) } + if resource.Type == "coder_ai_task" { + tfResourcesAITasks = append(tfResourcesAITasks, resource) + } label := convertAddressToLabel(resource.Address) if tfResourcesByLabel[label] == nil { @@ -522,7 +550,17 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s continue } + id := attrs.ID + if id == "" { + // This should never happen since the "id" attribute is set on creation: + // https://github.com/coder/terraform-provider-coder/blob/cfa101df4635e405e66094fa7779f9a89d92f400/provider/app.go#L37 + logger.Warn(ctx, "coder_app's id was unexpectedly empty", slog.F("name", attrs.Name)) + + id = uuid.NewString() + } + agent.Apps = append(agent.Apps, &proto.App{ + Id: id, Slug: attrs.Slug, DisplayName: attrs.DisplayName, Command: attrs.Command, @@ -927,6 +965,7 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s ExpirationPolicy: expirationPolicy, Scheduling: scheduling, }, + Default: preset.Default, } if slice.Contains(duplicatedPresetNames, preset.Name) { @@ -945,6 +984,38 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s ) } + // Validate that only one preset is marked as default. + var defaultPresets int + for _, preset := range presets { + if preset.Default { + defaultPresets++ + } + } + if defaultPresets > 1 { + return nil, xerrors.Errorf("a maximum of 1 coder_workspace_preset can be marked as default, but %d are set", defaultPresets) + } + + // This will only pick up resources which will actually be created. + aiTasks := make([]*proto.AITask, 0, len(tfResourcesAITasks)) + for _, resource := range tfResourcesAITasks { + var task provider.AITask + err = mapstructure.Decode(resource.AttributeValues, &task) + if err != nil { + return nil, xerrors.Errorf("decode coder_ai_task attributes: %w", err) + } + + if len(task.SidebarApp) < 1 { + return nil, xerrors.Errorf("coder_ai_task has no sidebar_app defined") + } + + aiTasks = append(aiTasks, &proto.AITask{ + Id: task.ID, + SidebarApp: &proto.AITaskSidebarApp{ + Id: task.SidebarApp[0].ID, + }, + }) + } + // A map is used to ensure we don't have duplicates! externalAuthProvidersMap := map[string]*proto.ExternalAuthProviderResource{} for _, tfResources := range tfResourcesByLabel { @@ -975,11 +1046,23 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s externalAuthProviders = append(externalAuthProviders, it) } + hasAITasks := hasAITaskResources(graph) + if hasAITasks { + hasPromptParam := slices.ContainsFunc(parameters, func(param *proto.RichParameter) bool { + return param.Name == provider.TaskPromptParameterName + }) + if !hasPromptParam { + return nil, xerrors.Errorf("coder_parameter named '%s' is required when 'coder_ai_task' resource is defined", provider.TaskPromptParameterName) + } + } + return &State{ Resources: resources, Parameters: parameters, Presets: presets, ExternalAuthProviders: externalAuthProviders, + HasAITasks: hasAITasks, + AITasks: aiTasks, }, nil } diff --git a/provisioner/terraform/resources_test.go b/provisioner/terraform/resources_test.go index 772256032be3c..1575c6c9c159e 100644 --- a/provisioner/terraform/resources_test.go +++ b/provisioner/terraform/resources_test.go @@ -16,8 +16,11 @@ import ( "github.com/stretchr/testify/require" protobuf "google.golang.org/protobuf/proto" + "github.com/coder/terraform-provider-coder/v2/provider" + "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/testutil" "github.com/coder/coder/v2/cryptorand" @@ -930,8 +933,6 @@ func TestConvertResources(t *testing.T) { }, }, } { - folderName := folderName - expected := expected t.Run(folderName, func(t *testing.T) { t.Parallel() dir := filepath.Join(filepath.Dir(filename), "testdata", "resources", folderName) @@ -969,6 +970,9 @@ func TestConvertResources(t *testing.T) { if agent.GetInstanceId() != "" { agent.Auth = &proto.Agent_InstanceId{} } + for _, app := range agent.Apps { + app.Id = "" + } } } @@ -1039,6 +1043,9 @@ func TestConvertResources(t *testing.T) { if agent.GetInstanceId() != "" { agent.Auth = &proto.Agent_InstanceId{} } + for _, app := range agent.Apps { + app.Id = "" + } } } // Convert expectedNoMetadata and resources into a @@ -1114,7 +1121,6 @@ func TestAppSlugValidation(t *testing.T) { //nolint:paralleltest for i, c := range cases { - c := c t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { // Change the first app slug to match the current case. for _, resource := range tfPlan.PlannedValues.RootModule.Resources { @@ -1191,7 +1197,6 @@ func TestAgentNameInvalid(t *testing.T) { //nolint:paralleltest for i, c := range cases { - c := c t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { // Change the first agent name to match the current case. for _, resource := range tfPlan.PlannedValues.RootModule.Resources { @@ -1321,6 +1326,80 @@ func TestParameterValidation(t *testing.T) { require.ErrorContains(t, err, "coder_parameter names must be unique but \"identical-0\", \"identical-1\" and \"identical-2\" appear multiple times") } +func TestDefaultPresets(t *testing.T) { + t.Parallel() + + // nolint:dogsled + _, filename, _, _ := runtime.Caller(0) + dir := filepath.Join(filepath.Dir(filename), "testdata", "resources") + + cases := map[string]struct { + fixtureFile string + expectError bool + errorMsg string + validate func(t *testing.T, state *terraform.State) + }{ + "multiple defaults should fail": { + fixtureFile: "presets-multiple-defaults", + expectError: true, + errorMsg: "a maximum of 1 coder_workspace_preset can be marked as default, but 2 are set", + }, + "single default should succeed": { + fixtureFile: "presets-single-default", + expectError: false, + validate: func(t *testing.T, state *terraform.State) { + require.Len(t, state.Presets, 2) + var defaultCount int + for _, preset := range state.Presets { + if preset.Default { + defaultCount++ + require.Equal(t, "development", preset.Name) + } + } + require.Equal(t, 1, defaultCount) + }, + }, + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx, logger := ctxAndLogger(t) + + tfPlanRaw, err := os.ReadFile(filepath.Join(dir, tc.fixtureFile, tc.fixtureFile+".tfplan.json")) + require.NoError(t, err) + var tfPlan tfjson.Plan + err = json.Unmarshal(tfPlanRaw, &tfPlan) + require.NoError(t, err) + tfPlanGraph, err := os.ReadFile(filepath.Join(dir, tc.fixtureFile, tc.fixtureFile+".tfplan.dot")) + require.NoError(t, err) + + modules := []*tfjson.StateModule{tfPlan.PlannedValues.RootModule} + if tfPlan.PriorState != nil { + modules = append(modules, tfPlan.PriorState.Values.RootModule) + } else { + modules = append(modules, tfPlan.PlannedValues.RootModule) + } + state, err := terraform.ConvertState(ctx, modules, string(tfPlanGraph), logger) + + if tc.expectError { + require.Error(t, err) + require.Nil(t, state) + if tc.errorMsg != "" { + require.ErrorContains(t, err, tc.errorMsg) + } + } else { + require.NoError(t, err) + require.NotNil(t, state) + if tc.validate != nil { + tc.validate(t, state) + } + } + }) + } +} + func TestInstanceTypeAssociation(t *testing.T) { t.Parallel() type tc struct { @@ -1343,7 +1422,6 @@ func TestInstanceTypeAssociation(t *testing.T) { ResourceType: "azurerm_windows_virtual_machine", InstanceTypeKey: "size", }} { - tc := tc t.Run(tc.ResourceType, func(t *testing.T) { t.Parallel() ctx, logger := ctxAndLogger(t) @@ -1402,7 +1480,6 @@ func TestInstanceIDAssociation(t *testing.T) { ResourceType: "azurerm_windows_virtual_machine", InstanceIDKey: "virtual_machine_id", }} { - tc := tc t.Run(tc.ResourceType, func(t *testing.T) { t.Parallel() ctx, logger := ctxAndLogger(t) @@ -1447,6 +1524,55 @@ func TestInstanceIDAssociation(t *testing.T) { } } +func TestAITasks(t *testing.T) { + t.Parallel() + ctx, logger := ctxAndLogger(t) + + t.Run("Prompt parameter is required", func(t *testing.T) { + t.Parallel() + + // nolint:dogsled + _, filename, _, _ := runtime.Caller(0) + + dir := filepath.Join(filepath.Dir(filename), "testdata", "resources", "ai-tasks-missing-prompt") + tfPlanRaw, err := os.ReadFile(filepath.Join(dir, "ai-tasks-missing-prompt.tfplan.json")) + require.NoError(t, err) + var tfPlan tfjson.Plan + err = json.Unmarshal(tfPlanRaw, &tfPlan) + require.NoError(t, err) + tfPlanGraph, err := os.ReadFile(filepath.Join(dir, "ai-tasks-missing-prompt.tfplan.dot")) + require.NoError(t, err) + + state, err := terraform.ConvertState(ctx, []*tfjson.StateModule{tfPlan.PlannedValues.RootModule, tfPlan.PriorState.Values.RootModule}, string(tfPlanGraph), logger) + require.Nil(t, state) + require.ErrorContains(t, err, fmt.Sprintf("coder_parameter named '%s' is required when 'coder_ai_task' resource is defined", provider.TaskPromptParameterName)) + }) + + t.Run("Multiple tasks can be defined", func(t *testing.T) { + t.Parallel() + + // nolint:dogsled + _, filename, _, _ := runtime.Caller(0) + + dir := filepath.Join(filepath.Dir(filename), "testdata", "resources", "ai-tasks-multiple") + tfPlanRaw, err := os.ReadFile(filepath.Join(dir, "ai-tasks-multiple.tfplan.json")) + require.NoError(t, err) + var tfPlan tfjson.Plan + err = json.Unmarshal(tfPlanRaw, &tfPlan) + require.NoError(t, err) + tfPlanGraph, err := os.ReadFile(filepath.Join(dir, "ai-tasks-multiple.tfplan.dot")) + require.NoError(t, err) + + state, err := terraform.ConvertState(ctx, []*tfjson.StateModule{tfPlan.PlannedValues.RootModule, tfPlan.PriorState.Values.RootModule}, string(tfPlanGraph), logger) + require.NotNil(t, state) + require.NoError(t, err) + require.True(t, state.HasAITasks) + // Multiple coder_ai_tasks resources can be defined, but only 1 is allowed. + // This is validated once all parameters are resolved etc as part of the workspace build, but for now we can allow it. + require.Len(t, state.AITasks, 2) + }) +} + // sortResource ensures resources appear in a consistent ordering // to prevent tests from flaking. func sortResources(resources []*proto.Resource) { diff --git a/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfplan.dot b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfplan.dot new file mode 100644 index 0000000000000..758e6c990ec1e --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfplan.dot @@ -0,0 +1,22 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.main (expand)" [label = "coder_agent.main", shape = "box"] + "[root] coder_ai_task.a (expand)" [label = "coder_ai_task.a", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_agent.main (expand)" -> "[root] data.coder_provisioner.me (expand)" + "[root] coder_ai_task.a (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_agent.main (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.a (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfplan.json b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfplan.json new file mode 100644 index 0000000000000..ad255c7db7624 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfplan.json @@ -0,0 +1,307 @@ +{ + "format_version": "1.2", + "terraform_version": "1.12.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "coder_agent.main", + "mode": "managed", + "type": "coder_agent", + "name": "main", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "coder_ai_task.a", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "sensitive_values": { + "sidebar_app": [ + {} + ] + } + } + ] + } + }, + "resource_changes": [ + { + "address": "coder_agent.main", + "mode": "managed", + "type": "coder_agent", + "name": "main", + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "after_unknown": { + "display_apps": true, + "id": true, + "init_script": true, + "metadata": [], + "resources_monitoring": [], + "token": true + }, + "before_sensitive": false, + "after_sensitive": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + } + }, + { + "address": "coder_ai_task.a", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "after_unknown": { + "id": true, + "sidebar_app": [ + {} + ] + }, + "before_sensitive": false, + "after_sensitive": { + "sidebar_app": [ + {} + ] + } + } + } + ], + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "5a6ecb8b-fd26-4cfc-91b1-651d06bee98c", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "bf9ee323-4f3a-4d45-9841-2dcd6265e830", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "sebenza-nonix", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "default", + "groups": [], + "id": "0b8cbfb8-3925-41fe-9f21-21b76d21edc7", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "rbac_roles": [], + "ssh_private_key": true + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "coder": { + "name": "coder", + "full_name": "registry.terraform.io/coder/coder", + "version_constraint": ">= 2.0.0" + } + }, + "root_module": { + "resources": [ + { + "address": "coder_agent.main", + "mode": "managed", + "type": "coder_agent", + "name": "main", + "provider_config_key": "coder", + "expressions": { + "arch": { + "references": [ + "data.coder_provisioner.me.arch", + "data.coder_provisioner.me" + ] + }, + "os": { + "references": [ + "data.coder_provisioner.me.os", + "data.coder_provisioner.me" + ] + } + }, + "schema_version": 1 + }, + { + "address": "coder_ai_task.a", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "provider_config_key": "coder", + "expressions": { + "sidebar_app": [ + { + "id": { + "constant_value": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + } + ] + }, + "schema_version": 1 + }, + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 0 + } + ] + } + }, + "relevant_attributes": [ + { + "resource": "data.coder_provisioner.me", + "attribute": [ + "arch" + ] + }, + { + "resource": "data.coder_provisioner.me", + "attribute": [ + "os" + ] + } + ], + "timestamp": "2025-06-19T14:30:00Z", + "applyable": true, + "complete": true, + "errored": false +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfstate.dot b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfstate.dot new file mode 100644 index 0000000000000..758e6c990ec1e --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfstate.dot @@ -0,0 +1,22 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.main (expand)" [label = "coder_agent.main", shape = "box"] + "[root] coder_ai_task.a (expand)" [label = "coder_ai_task.a", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_agent.main (expand)" -> "[root] data.coder_provisioner.me (expand)" + "[root] coder_ai_task.a (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_agent.main (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.a (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfstate.json b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfstate.json new file mode 100644 index 0000000000000..4d3affb7a31ed --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/ai-tasks-missing-prompt.tfstate.json @@ -0,0 +1,142 @@ +{ + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "e838328b-8dc2-49d0-b16c-42d6375cfb34", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "6a64b978-bd81-424a-92e5-d4004296f420", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "sebenza-nonix", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "default", + "groups": [], + "id": "ffb2e99a-efa7-4fb9-bb2c-aa282bb636c9", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "rbac_roles": [], + "ssh_private_key": true + } + }, + { + "address": "coder_agent.main", + "mode": "managed", + "type": "coder_agent", + "name": "main", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "display_apps": [ + { + "port_forwarding_helper": true, + "ssh_helper": true, + "vscode": true, + "vscode_insiders": false, + "web_terminal": true + } + ], + "env": null, + "id": "0c95434c-9e4b-40aa-bd9b-2a0cc86f11af", + "init_script": "", + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "token": "ac32e23e-336a-4e63-a7a4-71ab85f16831", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [ + {} + ], + "metadata": [], + "resources_monitoring": [], + "token": true + }, + "depends_on": [ + "data.coder_provisioner.me" + ] + }, + { + "address": "coder_ai_task.a", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "id": "b83734ee-765f-45db-a37b-a1e89414be5f", + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "sensitive_values": { + "sidebar_app": [ + {} + ] + } + } + ] + } + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/main.tf b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/main.tf new file mode 100644 index 0000000000000..236baa1c9535b --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-missing-prompt/main.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.0.0" + } + } +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = data.coder_provisioner.me.os +} + +resource "coder_ai_task" "a" { + sidebar_app { + id = "5ece4674-dd35-4f16-88c8-82e40e72e2fd" # fake ID to satisfy requirement, irrelevant otherwise + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfplan.dot b/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfplan.dot new file mode 100644 index 0000000000000..4e660599e7a9b --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfplan.dot @@ -0,0 +1,26 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_ai_task.a (expand)" [label = "coder_ai_task.a", shape = "box"] + "[root] coder_ai_task.b (expand)" [label = "coder_ai_task.b", shape = "box"] + "[root] data.coder_parameter.prompt (expand)" [label = "data.coder_parameter.prompt", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_ai_task.a (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] coder_ai_task.b (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_parameter.prompt (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.a (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.b (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_parameter.prompt (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfplan.json b/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfplan.json new file mode 100644 index 0000000000000..c9f8fded6c192 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfplan.json @@ -0,0 +1,319 @@ +{ + "format_version": "1.2", + "terraform_version": "1.12.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "coder_ai_task.a[0]", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "index": 0, + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "sensitive_values": { + "sidebar_app": [ + {} + ] + } + }, + { + "address": "coder_ai_task.b[0]", + "mode": "managed", + "type": "coder_ai_task", + "name": "b", + "index": 0, + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "sensitive_values": { + "sidebar_app": [ + {} + ] + } + } + ] + } + }, + "resource_changes": [ + { + "address": "coder_ai_task.a[0]", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "index": 0, + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "after_unknown": { + "id": true, + "sidebar_app": [ + {} + ] + }, + "before_sensitive": false, + "after_sensitive": { + "sidebar_app": [ + {} + ] + } + } + }, + { + "address": "coder_ai_task.b[0]", + "mode": "managed", + "type": "coder_ai_task", + "name": "b", + "index": 0, + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "after_unknown": { + "id": true, + "sidebar_app": [ + {} + ] + }, + "before_sensitive": false, + "after_sensitive": { + "sidebar_app": [ + {} + ] + } + } + } + ], + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_parameter.prompt", + "mode": "data", + "type": "coder_parameter", + "name": "prompt", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": null, + "description": null, + "display_name": null, + "ephemeral": false, + "form_type": "input", + "icon": null, + "id": "f9502ef2-226e-49a6-8831-99a74f8415e7", + "mutable": false, + "name": "AI Prompt", + "option": null, + "optional": false, + "order": null, + "styling": "{}", + "type": "string", + "validation": [], + "value": "" + }, + "sensitive_values": { + "validation": [] + } + }, + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "6b538d81-f0db-4e2b-8d85-4b87a1563d89", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "344575c1-55b9-43bb-89b5-35f547e2cf08", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "sebenza-nonix", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "default", + "groups": [], + "id": "acb465b5-2709-4392-9486-4ad6eb1c06e0", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "rbac_roles": [], + "ssh_private_key": true + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "coder": { + "name": "coder", + "full_name": "registry.terraform.io/coder/coder", + "version_constraint": ">= 2.0.0" + } + }, + "root_module": { + "resources": [ + { + "address": "coder_ai_task.a", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "provider_config_key": "coder", + "expressions": { + "sidebar_app": [ + { + "id": { + "constant_value": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + } + ] + }, + "schema_version": 1, + "count_expression": { + "constant_value": 1 + } + }, + { + "address": "coder_ai_task.b", + "mode": "managed", + "type": "coder_ai_task", + "name": "b", + "provider_config_key": "coder", + "expressions": { + "sidebar_app": [ + { + "id": { + "constant_value": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + } + ] + }, + "schema_version": 1, + "count_expression": { + "constant_value": 1 + } + }, + { + "address": "data.coder_parameter.prompt", + "mode": "data", + "type": "coder_parameter", + "name": "prompt", + "provider_config_key": "coder", + "expressions": { + "name": { + "constant_value": "AI Prompt" + }, + "type": { + "constant_value": "string" + } + }, + "schema_version": 1 + }, + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 0 + } + ] + } + }, + "timestamp": "2025-06-19T14:30:00Z", + "applyable": true, + "complete": true, + "errored": false +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfstate.dot b/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfstate.dot new file mode 100644 index 0000000000000..4e660599e7a9b --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfstate.dot @@ -0,0 +1,26 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_ai_task.a (expand)" [label = "coder_ai_task.a", shape = "box"] + "[root] coder_ai_task.b (expand)" [label = "coder_ai_task.b", shape = "box"] + "[root] data.coder_parameter.prompt (expand)" [label = "data.coder_parameter.prompt", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_ai_task.a (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] coder_ai_task.b (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_parameter.prompt (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.a (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.b (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_parameter.prompt (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfstate.json b/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfstate.json new file mode 100644 index 0000000000000..8eb9ccecd7636 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-multiple/ai-tasks-multiple.tfstate.json @@ -0,0 +1,146 @@ +{ + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_parameter.prompt", + "mode": "data", + "type": "coder_parameter", + "name": "prompt", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": null, + "description": null, + "display_name": null, + "ephemeral": false, + "form_type": "input", + "icon": null, + "id": "d3c415c0-c6bc-42e3-806e-8c792a7e7b3b", + "mutable": false, + "name": "AI Prompt", + "option": null, + "optional": false, + "order": null, + "styling": "{}", + "type": "string", + "validation": [], + "value": "" + }, + "sensitive_values": { + "validation": [] + } + }, + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "764f8b0b-d931-4356-b1a8-446fa95fbeb0", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "b6713709-6736-4d2f-b3da-7b5b242df5f4", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "sebenza-nonix", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "default", + "groups": [], + "id": "0cc15fa2-24fc-4249-bdc7-56cf0af0f782", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "rbac_roles": [], + "ssh_private_key": true + } + }, + { + "address": "coder_ai_task.a[0]", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "index": 0, + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "id": "89e6ab36-2e98-4d13-9b4c-69b7588b7e1d", + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "sensitive_values": { + "sidebar_app": [ + {} + ] + } + }, + { + "address": "coder_ai_task.b[0]", + "mode": "managed", + "type": "coder_ai_task", + "name": "b", + "index": 0, + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "id": "d4643699-519d-44c8-a878-556789256cbd", + "sidebar_app": [ + { + "id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + ] + }, + "sensitive_values": { + "sidebar_app": [ + {} + ] + } + } + ] + } + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-multiple/main.tf b/provisioner/terraform/testdata/resources/ai-tasks-multiple/main.tf new file mode 100644 index 0000000000000..6c15ee4dc5a69 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-multiple/main.tf @@ -0,0 +1,31 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.0.0" + } + } +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "prompt" { + name = "AI Prompt" + type = "string" +} + +resource "coder_ai_task" "a" { + count = 1 + sidebar_app { + id = "5ece4674-dd35-4f16-88c8-82e40e72e2fd" # fake ID to satisfy requirement, irrelevant otherwise + } +} + +resource "coder_ai_task" "b" { + count = 1 + sidebar_app { + id = "5ece4674-dd35-4f16-88c8-82e40e72e2fd" # fake ID to satisfy requirement, irrelevant otherwise + } +} diff --git a/provisioner/terraform/testdata/resources/external-auth-providers/external-auth-providers.tfplan.json b/provisioner/terraform/testdata/resources/external-auth-providers/external-auth-providers.tfplan.json index 18ccfa17a86fc..696a7ee61f2c2 100644 --- a/provisioner/terraform/testdata/resources/external-auth-providers/external-auth-providers.tfplan.json +++ b/provisioner/terraform/testdata/resources/external-auth-providers/external-auth-providers.tfplan.json @@ -130,7 +130,7 @@ "type": "coder_external_auth", "name": "github", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "access_token": "", "id": "github", @@ -144,7 +144,7 @@ "type": "coder_external_auth", "name": "gitlab", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "access_token": "", "id": "gitlab", @@ -208,7 +208,7 @@ "constant_value": "github" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_external_auth.gitlab", @@ -224,7 +224,7 @@ "constant_value": true } }, - "schema_version": 0 + "schema_version": 1 } ] } diff --git a/provisioner/terraform/testdata/resources/external-auth-providers/external-auth-providers.tfstate.json b/provisioner/terraform/testdata/resources/external-auth-providers/external-auth-providers.tfstate.json index afe4bd90fb722..35e407dff4667 100644 --- a/provisioner/terraform/testdata/resources/external-auth-providers/external-auth-providers.tfstate.json +++ b/provisioner/terraform/testdata/resources/external-auth-providers/external-auth-providers.tfstate.json @@ -10,7 +10,7 @@ "type": "coder_external_auth", "name": "github", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "access_token": "", "id": "github", @@ -24,7 +24,7 @@ "type": "coder_external_auth", "name": "gitlab", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "access_token": "", "id": "gitlab", diff --git a/provisioner/terraform/testdata/resources/presets-multiple-defaults/multiple-defaults.tf b/provisioner/terraform/testdata/resources/presets-multiple-defaults/multiple-defaults.tf new file mode 100644 index 0000000000000..9e21f5d28bc89 --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-multiple-defaults/multiple-defaults.tf @@ -0,0 +1,46 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.3.0" + } + } +} + +data "coder_parameter" "instance_type" { + name = "instance_type" + type = "string" + description = "Instance type" + default = "t3.micro" +} + +data "coder_workspace_preset" "development" { + name = "development" + default = true + parameters = { + (data.coder_parameter.instance_type.name) = "t3.micro" + } + prebuilds { + instances = 1 + } +} + +data "coder_workspace_preset" "production" { + name = "production" + default = true + parameters = { + (data.coder_parameter.instance_type.name) = "t3.large" + } + prebuilds { + instances = 2 + } +} + +resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" +} + +resource "null_resource" "dev" { + depends_on = [coder_agent.dev] +} diff --git a/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfplan.dot b/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfplan.dot new file mode 100644 index 0000000000000..e37a48a8430e4 --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfplan.dot @@ -0,0 +1,25 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.dev (expand)" [label = "coder_agent.dev", shape = "box"] + "[root] data.coder_parameter.instance_type (expand)" [label = "data.coder_parameter.instance_type", shape = "box"] + "[root] data.coder_workspace_preset.development (expand)" [label = "data.coder_workspace_preset.development", shape = "box"] + "[root] data.coder_workspace_preset.production (expand)" [label = "data.coder_workspace_preset.production", shape = "box"] + "[root] null_resource.dev (expand)" [label = "null_resource.dev", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] provider[\"registry.terraform.io/hashicorp/null\"]" [label = "provider[\"registry.terraform.io/hashicorp/null\"]", shape = "diamond"] + "[root] coder_agent.dev (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_parameter.instance_type (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_preset.development (expand)" -> "[root] data.coder_parameter.instance_type (expand)" + "[root] data.coder_workspace_preset.production (expand)" -> "[root] data.coder_parameter.instance_type (expand)" + "[root] null_resource.dev (expand)" -> "[root] coder_agent.dev (expand)" + "[root] null_resource.dev (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_agent.dev (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_preset.development (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_preset.production (expand)" + "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" -> "[root] null_resource.dev (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + "[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfplan.json b/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfplan.json new file mode 100644 index 0000000000000..5be0935b3f63f --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfplan.json @@ -0,0 +1,352 @@ +{ + "format_version": "1.2", + "terraform_version": "1.12.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "null_resource.dev", + "mode": "managed", + "type": "null_resource", + "name": "dev", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "values": { + "triggers": null + }, + "sensitive_values": {} + } + ] + } + }, + "resource_changes": [ + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "after_unknown": { + "display_apps": true, + "id": true, + "init_script": true, + "metadata": [], + "resources_monitoring": [], + "token": true + }, + "before_sensitive": false, + "after_sensitive": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + } + }, + { + "address": "null_resource.dev", + "mode": "managed", + "type": "null_resource", + "name": "dev", + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "triggers": null + }, + "after_unknown": { + "id": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + } + ], + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_parameter.instance_type", + "mode": "data", + "type": "coder_parameter", + "name": "instance_type", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": "t3.micro", + "description": "Instance type", + "display_name": null, + "ephemeral": false, + "form_type": "input", + "icon": null, + "id": "618511d1-8fe3-4acc-92cd-d98955303039", + "mutable": false, + "name": "instance_type", + "option": null, + "optional": true, + "order": null, + "styling": "{}", + "type": "string", + "validation": [], + "value": "t3.micro" + }, + "sensitive_values": { + "validation": [] + } + }, + { + "address": "data.coder_workspace_preset.development", + "mode": "data", + "type": "coder_workspace_preset", + "name": "development", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": true, + "id": "development", + "name": "development", + "parameters": { + "instance_type": "t3.micro" + }, + "prebuilds": [ + { + "expiration_policy": [], + "instances": 1, + "scheduling": [] + } + ] + }, + "sensitive_values": { + "parameters": {}, + "prebuilds": [ + { + "expiration_policy": [], + "scheduling": [] + } + ] + } + }, + { + "address": "data.coder_workspace_preset.production", + "mode": "data", + "type": "coder_workspace_preset", + "name": "production", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": true, + "id": "production", + "name": "production", + "parameters": { + "instance_type": "t3.large" + }, + "prebuilds": [ + { + "expiration_policy": [], + "instances": 2, + "scheduling": [] + } + ] + }, + "sensitive_values": { + "parameters": {}, + "prebuilds": [ + { + "expiration_policy": [], + "scheduling": [] + } + ] + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "coder": { + "name": "coder", + "full_name": "registry.terraform.io/coder/coder", + "version_constraint": ">= 2.3.0" + }, + "null": { + "name": "null", + "full_name": "registry.terraform.io/hashicorp/null" + } + }, + "root_module": { + "resources": [ + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_config_key": "coder", + "expressions": { + "arch": { + "constant_value": "amd64" + }, + "os": { + "constant_value": "linux" + } + }, + "schema_version": 1 + }, + { + "address": "null_resource.dev", + "mode": "managed", + "type": "null_resource", + "name": "dev", + "provider_config_key": "null", + "schema_version": 0, + "depends_on": [ + "coder_agent.dev" + ] + }, + { + "address": "data.coder_parameter.instance_type", + "mode": "data", + "type": "coder_parameter", + "name": "instance_type", + "provider_config_key": "coder", + "expressions": { + "default": { + "constant_value": "t3.micro" + }, + "description": { + "constant_value": "Instance type" + }, + "name": { + "constant_value": "instance_type" + }, + "type": { + "constant_value": "string" + } + }, + "schema_version": 1 + }, + { + "address": "data.coder_workspace_preset.development", + "mode": "data", + "type": "coder_workspace_preset", + "name": "development", + "provider_config_key": "coder", + "expressions": { + "default": { + "constant_value": true + }, + "name": { + "constant_value": "development" + }, + "parameters": { + "references": [ + "data.coder_parameter.instance_type.name", + "data.coder_parameter.instance_type" + ] + }, + "prebuilds": [ + { + "instances": { + "constant_value": 1 + } + } + ] + }, + "schema_version": 1 + }, + { + "address": "data.coder_workspace_preset.production", + "mode": "data", + "type": "coder_workspace_preset", + "name": "production", + "provider_config_key": "coder", + "expressions": { + "default": { + "constant_value": true + }, + "name": { + "constant_value": "production" + }, + "parameters": { + "references": [ + "data.coder_parameter.instance_type.name", + "data.coder_parameter.instance_type" + ] + }, + "prebuilds": [ + { + "instances": { + "constant_value": 2 + } + } + ] + }, + "schema_version": 1 + } + ] + } + }, + "timestamp": "2025-06-19T12:43:59Z", + "applyable": true, + "complete": true, + "errored": false +} diff --git a/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfstate.dot b/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfstate.dot new file mode 100644 index 0000000000000..e37a48a8430e4 --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfstate.dot @@ -0,0 +1,25 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.dev (expand)" [label = "coder_agent.dev", shape = "box"] + "[root] data.coder_parameter.instance_type (expand)" [label = "data.coder_parameter.instance_type", shape = "box"] + "[root] data.coder_workspace_preset.development (expand)" [label = "data.coder_workspace_preset.development", shape = "box"] + "[root] data.coder_workspace_preset.production (expand)" [label = "data.coder_workspace_preset.production", shape = "box"] + "[root] null_resource.dev (expand)" [label = "null_resource.dev", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] provider[\"registry.terraform.io/hashicorp/null\"]" [label = "provider[\"registry.terraform.io/hashicorp/null\"]", shape = "diamond"] + "[root] coder_agent.dev (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_parameter.instance_type (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_preset.development (expand)" -> "[root] data.coder_parameter.instance_type (expand)" + "[root] data.coder_workspace_preset.production (expand)" -> "[root] data.coder_parameter.instance_type (expand)" + "[root] null_resource.dev (expand)" -> "[root] coder_agent.dev (expand)" + "[root] null_resource.dev (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_agent.dev (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_preset.development (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_preset.production (expand)" + "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" -> "[root] null_resource.dev (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + "[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfstate.json b/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfstate.json new file mode 100644 index 0000000000000..ccad929f2adbb --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-multiple-defaults/presets-multiple-defaults.tfstate.json @@ -0,0 +1,164 @@ +{ + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_parameter.instance_type", + "mode": "data", + "type": "coder_parameter", + "name": "instance_type", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": "t3.micro", + "description": "Instance type", + "display_name": null, + "ephemeral": false, + "form_type": "input", + "icon": null, + "id": "90b10074-c53d-4b0b-9c82-feb0e14e54f5", + "mutable": false, + "name": "instance_type", + "option": null, + "optional": true, + "order": null, + "styling": "{}", + "type": "string", + "validation": [], + "value": "t3.micro" + }, + "sensitive_values": { + "validation": [] + } + }, + { + "address": "data.coder_workspace_preset.development", + "mode": "data", + "type": "coder_workspace_preset", + "name": "development", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": true, + "id": "development", + "name": "development", + "parameters": { + "instance_type": "t3.micro" + }, + "prebuilds": [ + { + "expiration_policy": [], + "instances": 1, + "scheduling": [] + } + ] + }, + "sensitive_values": { + "parameters": {}, + "prebuilds": [ + { + "expiration_policy": [], + "scheduling": [] + } + ] + } + }, + { + "address": "data.coder_workspace_preset.production", + "mode": "data", + "type": "coder_workspace_preset", + "name": "production", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": true, + "id": "production", + "name": "production", + "parameters": { + "instance_type": "t3.large" + }, + "prebuilds": [ + { + "expiration_policy": [], + "instances": 2, + "scheduling": [] + } + ] + }, + "sensitive_values": { + "parameters": {}, + "prebuilds": [ + { + "expiration_policy": [], + "scheduling": [] + } + ] + } + }, + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "display_apps": [ + { + "port_forwarding_helper": true, + "ssh_helper": true, + "vscode": true, + "vscode_insiders": false, + "web_terminal": true + } + ], + "env": null, + "id": "a6599d5f-c6b4-4f27-ae8f-0ec39e56747f", + "init_script": "", + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "token": "25368365-1ee0-4a55-b410-8dc98f1be40c", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [ + {} + ], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "null_resource.dev", + "mode": "managed", + "type": "null_resource", + "name": "dev", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "values": { + "id": "3793102304452173529", + "triggers": null + }, + "sensitive_values": {}, + "depends_on": [ + "coder_agent.dev" + ] + } + ] + } + } +} diff --git a/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfplan.dot b/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfplan.dot new file mode 100644 index 0000000000000..e37a48a8430e4 --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfplan.dot @@ -0,0 +1,25 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.dev (expand)" [label = "coder_agent.dev", shape = "box"] + "[root] data.coder_parameter.instance_type (expand)" [label = "data.coder_parameter.instance_type", shape = "box"] + "[root] data.coder_workspace_preset.development (expand)" [label = "data.coder_workspace_preset.development", shape = "box"] + "[root] data.coder_workspace_preset.production (expand)" [label = "data.coder_workspace_preset.production", shape = "box"] + "[root] null_resource.dev (expand)" [label = "null_resource.dev", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] provider[\"registry.terraform.io/hashicorp/null\"]" [label = "provider[\"registry.terraform.io/hashicorp/null\"]", shape = "diamond"] + "[root] coder_agent.dev (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_parameter.instance_type (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_preset.development (expand)" -> "[root] data.coder_parameter.instance_type (expand)" + "[root] data.coder_workspace_preset.production (expand)" -> "[root] data.coder_parameter.instance_type (expand)" + "[root] null_resource.dev (expand)" -> "[root] coder_agent.dev (expand)" + "[root] null_resource.dev (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_agent.dev (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_preset.development (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_preset.production (expand)" + "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" -> "[root] null_resource.dev (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + "[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfplan.json b/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfplan.json new file mode 100644 index 0000000000000..8c8bea87d8a1b --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfplan.json @@ -0,0 +1,352 @@ +{ + "format_version": "1.2", + "terraform_version": "1.12.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "null_resource.dev", + "mode": "managed", + "type": "null_resource", + "name": "dev", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "values": { + "triggers": null + }, + "sensitive_values": {} + } + ] + } + }, + "resource_changes": [ + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "after_unknown": { + "display_apps": true, + "id": true, + "init_script": true, + "metadata": [], + "resources_monitoring": [], + "token": true + }, + "before_sensitive": false, + "after_sensitive": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + } + }, + { + "address": "null_resource.dev", + "mode": "managed", + "type": "null_resource", + "name": "dev", + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "triggers": null + }, + "after_unknown": { + "id": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + } + ], + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_parameter.instance_type", + "mode": "data", + "type": "coder_parameter", + "name": "instance_type", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": "t3.micro", + "description": "Instance type", + "display_name": null, + "ephemeral": false, + "form_type": "input", + "icon": null, + "id": "9d27c698-0262-4681-9f34-3a43ecf50111", + "mutable": false, + "name": "instance_type", + "option": null, + "optional": true, + "order": null, + "styling": "{}", + "type": "string", + "validation": [], + "value": "t3.micro" + }, + "sensitive_values": { + "validation": [] + } + }, + { + "address": "data.coder_workspace_preset.development", + "mode": "data", + "type": "coder_workspace_preset", + "name": "development", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": true, + "id": "development", + "name": "development", + "parameters": { + "instance_type": "t3.micro" + }, + "prebuilds": [ + { + "expiration_policy": [], + "instances": 1, + "scheduling": [] + } + ] + }, + "sensitive_values": { + "parameters": {}, + "prebuilds": [ + { + "expiration_policy": [], + "scheduling": [] + } + ] + } + }, + { + "address": "data.coder_workspace_preset.production", + "mode": "data", + "type": "coder_workspace_preset", + "name": "production", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": false, + "id": "production", + "name": "production", + "parameters": { + "instance_type": "t3.large" + }, + "prebuilds": [ + { + "expiration_policy": [], + "instances": 2, + "scheduling": [] + } + ] + }, + "sensitive_values": { + "parameters": {}, + "prebuilds": [ + { + "expiration_policy": [], + "scheduling": [] + } + ] + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "coder": { + "name": "coder", + "full_name": "registry.terraform.io/coder/coder", + "version_constraint": ">= 2.3.0" + }, + "null": { + "name": "null", + "full_name": "registry.terraform.io/hashicorp/null" + } + }, + "root_module": { + "resources": [ + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_config_key": "coder", + "expressions": { + "arch": { + "constant_value": "amd64" + }, + "os": { + "constant_value": "linux" + } + }, + "schema_version": 1 + }, + { + "address": "null_resource.dev", + "mode": "managed", + "type": "null_resource", + "name": "dev", + "provider_config_key": "null", + "schema_version": 0, + "depends_on": [ + "coder_agent.dev" + ] + }, + { + "address": "data.coder_parameter.instance_type", + "mode": "data", + "type": "coder_parameter", + "name": "instance_type", + "provider_config_key": "coder", + "expressions": { + "default": { + "constant_value": "t3.micro" + }, + "description": { + "constant_value": "Instance type" + }, + "name": { + "constant_value": "instance_type" + }, + "type": { + "constant_value": "string" + } + }, + "schema_version": 1 + }, + { + "address": "data.coder_workspace_preset.development", + "mode": "data", + "type": "coder_workspace_preset", + "name": "development", + "provider_config_key": "coder", + "expressions": { + "default": { + "constant_value": true + }, + "name": { + "constant_value": "development" + }, + "parameters": { + "references": [ + "data.coder_parameter.instance_type.name", + "data.coder_parameter.instance_type" + ] + }, + "prebuilds": [ + { + "instances": { + "constant_value": 1 + } + } + ] + }, + "schema_version": 1 + }, + { + "address": "data.coder_workspace_preset.production", + "mode": "data", + "type": "coder_workspace_preset", + "name": "production", + "provider_config_key": "coder", + "expressions": { + "default": { + "constant_value": false + }, + "name": { + "constant_value": "production" + }, + "parameters": { + "references": [ + "data.coder_parameter.instance_type.name", + "data.coder_parameter.instance_type" + ] + }, + "prebuilds": [ + { + "instances": { + "constant_value": 2 + } + } + ] + }, + "schema_version": 1 + } + ] + } + }, + "timestamp": "2025-06-19T12:43:58Z", + "applyable": true, + "complete": true, + "errored": false +} diff --git a/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfstate.dot b/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfstate.dot new file mode 100644 index 0000000000000..e37a48a8430e4 --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfstate.dot @@ -0,0 +1,25 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.dev (expand)" [label = "coder_agent.dev", shape = "box"] + "[root] data.coder_parameter.instance_type (expand)" [label = "data.coder_parameter.instance_type", shape = "box"] + "[root] data.coder_workspace_preset.development (expand)" [label = "data.coder_workspace_preset.development", shape = "box"] + "[root] data.coder_workspace_preset.production (expand)" [label = "data.coder_workspace_preset.production", shape = "box"] + "[root] null_resource.dev (expand)" [label = "null_resource.dev", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] provider[\"registry.terraform.io/hashicorp/null\"]" [label = "provider[\"registry.terraform.io/hashicorp/null\"]", shape = "diamond"] + "[root] coder_agent.dev (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_parameter.instance_type (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_preset.development (expand)" -> "[root] data.coder_parameter.instance_type (expand)" + "[root] data.coder_workspace_preset.production (expand)" -> "[root] data.coder_parameter.instance_type (expand)" + "[root] null_resource.dev (expand)" -> "[root] coder_agent.dev (expand)" + "[root] null_resource.dev (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_agent.dev (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_preset.development (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_preset.production (expand)" + "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" -> "[root] null_resource.dev (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + "[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfstate.json b/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfstate.json new file mode 100644 index 0000000000000..f871abdc20fc2 --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-single-default/presets-single-default.tfstate.json @@ -0,0 +1,164 @@ +{ + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_parameter.instance_type", + "mode": "data", + "type": "coder_parameter", + "name": "instance_type", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": "t3.micro", + "description": "Instance type", + "display_name": null, + "ephemeral": false, + "form_type": "input", + "icon": null, + "id": "1c507aa1-6626-4b68-b68f-fadd95421004", + "mutable": false, + "name": "instance_type", + "option": null, + "optional": true, + "order": null, + "styling": "{}", + "type": "string", + "validation": [], + "value": "t3.micro" + }, + "sensitive_values": { + "validation": [] + } + }, + { + "address": "data.coder_workspace_preset.development", + "mode": "data", + "type": "coder_workspace_preset", + "name": "development", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": true, + "id": "development", + "name": "development", + "parameters": { + "instance_type": "t3.micro" + }, + "prebuilds": [ + { + "expiration_policy": [], + "instances": 1, + "scheduling": [] + } + ] + }, + "sensitive_values": { + "parameters": {}, + "prebuilds": [ + { + "expiration_policy": [], + "scheduling": [] + } + ] + } + }, + { + "address": "data.coder_workspace_preset.production", + "mode": "data", + "type": "coder_workspace_preset", + "name": "production", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "default": false, + "id": "production", + "name": "production", + "parameters": { + "instance_type": "t3.large" + }, + "prebuilds": [ + { + "expiration_policy": [], + "instances": 2, + "scheduling": [] + } + ] + }, + "sensitive_values": { + "parameters": {}, + "prebuilds": [ + { + "expiration_policy": [], + "scheduling": [] + } + ] + } + }, + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "display_apps": [ + { + "port_forwarding_helper": true, + "ssh_helper": true, + "vscode": true, + "vscode_insiders": false, + "web_terminal": true + } + ], + "env": null, + "id": "5d66372f-a526-44ee-9eac-0c16bcc57aa2", + "init_script": "", + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "token": "70ab06e5-ef86-4ac2-a1d9-58c8ad85d379", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [ + {} + ], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "null_resource.dev", + "mode": "managed", + "type": "null_resource", + "name": "dev", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "values": { + "id": "3636304087019022806", + "triggers": null + }, + "sensitive_values": {}, + "depends_on": [ + "coder_agent.dev" + ] + } + ] + } + } +} diff --git a/provisioner/terraform/testdata/resources/presets-single-default/single-default.tf b/provisioner/terraform/testdata/resources/presets-single-default/single-default.tf new file mode 100644 index 0000000000000..a3ad0bff18d75 --- /dev/null +++ b/provisioner/terraform/testdata/resources/presets-single-default/single-default.tf @@ -0,0 +1,46 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.3.0" + } + } +} + +data "coder_parameter" "instance_type" { + name = "instance_type" + type = "string" + description = "Instance type" + default = "t3.micro" +} + +data "coder_workspace_preset" "development" { + name = "development" + default = true + parameters = { + (data.coder_parameter.instance_type.name) = "t3.micro" + } + prebuilds { + instances = 1 + } +} + +data "coder_workspace_preset" "production" { + name = "production" + default = false + parameters = { + (data.coder_parameter.instance_type.name) = "t3.large" + } + prebuilds { + instances = 2 + } +} + +resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" +} + +resource "null_resource" "dev" { + depends_on = [coder_agent.dev] +} diff --git a/provisioner/terraform/testdata/resources/presets/external-module/child-external-module/main.tf b/provisioner/terraform/testdata/resources/presets/external-module/child-external-module/main.tf index 395f766d48c4c..3b65c682cf3ec 100644 --- a/provisioner/terraform/testdata/resources/presets/external-module/child-external-module/main.tf +++ b/provisioner/terraform/testdata/resources/presets/external-module/child-external-module/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "2.3.0-pre2" + version = ">= 2.3.0" } docker = { source = "kreuzwerker/docker" diff --git a/provisioner/terraform/testdata/resources/presets/external-module/main.tf b/provisioner/terraform/testdata/resources/presets/external-module/main.tf index bdfd29c301c06..6769712a08335 100644 --- a/provisioner/terraform/testdata/resources/presets/external-module/main.tf +++ b/provisioner/terraform/testdata/resources/presets/external-module/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "2.3.0-pre2" + version = ">= 2.3.0" } docker = { source = "kreuzwerker/docker" diff --git a/provisioner/terraform/testdata/resources/presets/presets.tf b/provisioner/terraform/testdata/resources/presets/presets.tf index ff0db7d924d86..cbb62c0140bed 100644 --- a/provisioner/terraform/testdata/resources/presets/presets.tf +++ b/provisioner/terraform/testdata/resources/presets/presets.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "2.3.0-pre2" + version = ">= 2.3.0" } } } diff --git a/provisioner/terraform/testdata/resources/presets/presets.tfplan.json b/provisioner/terraform/testdata/resources/presets/presets.tfplan.json index f98b90d073439..7254a3d177df8 100644 --- a/provisioner/terraform/testdata/resources/presets/presets.tfplan.json +++ b/provisioner/terraform/testdata/resources/presets/presets.tfplan.json @@ -130,7 +130,7 @@ "type": "coder_parameter", "name": "sample", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ok", "description": "blah blah", @@ -159,8 +159,9 @@ "type": "coder_workspace_preset", "name": "MyFirstProject", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { + "default": false, "id": "My First Project", "name": "My First Project", "parameters": { @@ -221,7 +222,7 @@ "type": "coder_parameter", "name": "first_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "abcdef", "description": "First parameter from module", @@ -250,7 +251,7 @@ "type": "coder_parameter", "name": "second_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ghijkl", "description": "Second parameter from module", @@ -284,7 +285,7 @@ "type": "coder_parameter", "name": "child_first_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "abcdef", "description": "First parameter from child module", @@ -313,7 +314,7 @@ "type": "coder_parameter", "name": "child_second_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ghijkl", "description": "Second parameter from child module", @@ -350,7 +351,7 @@ "coder": { "name": "coder", "full_name": "registry.terraform.io/coder/coder", - "version_constraint": "2.3.0-pre2" + "version_constraint": ">= 2.3.0" }, "module.this_is_external_module:docker": { "name": "docker", @@ -412,7 +413,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_workspace_preset.MyFirstProject", @@ -470,7 +471,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 } ], "module_calls": { @@ -501,7 +502,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.second_parameter_from_module", @@ -526,7 +527,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 } ], "module_calls": { @@ -557,7 +558,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.child_second_parameter_from_module", @@ -582,7 +583,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 } ] } diff --git a/provisioner/terraform/testdata/resources/presets/presets.tfstate.json b/provisioner/terraform/testdata/resources/presets/presets.tfstate.json index f5dae972c774d..5d52e6f5f199b 100644 --- a/provisioner/terraform/testdata/resources/presets/presets.tfstate.json +++ b/provisioner/terraform/testdata/resources/presets/presets.tfstate.json @@ -10,7 +10,7 @@ "type": "coder_parameter", "name": "sample", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ok", "description": "blah blah", @@ -39,8 +39,9 @@ "type": "coder_workspace_preset", "name": "MyFirstProject", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { + "default": false, "id": "My First Project", "name": "My First Project", "parameters": { @@ -162,7 +163,7 @@ "type": "coder_parameter", "name": "first_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "abcdef", "description": "First parameter from module", @@ -191,7 +192,7 @@ "type": "coder_parameter", "name": "second_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ghijkl", "description": "Second parameter from module", @@ -225,7 +226,7 @@ "type": "coder_parameter", "name": "child_first_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "abcdef", "description": "First parameter from child module", @@ -254,7 +255,7 @@ "type": "coder_parameter", "name": "child_second_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ghijkl", "description": "Second parameter from child module", diff --git a/provisioner/terraform/testdata/resources/rich-parameters-order/rich-parameters-order.tfplan.json b/provisioner/terraform/testdata/resources/rich-parameters-order/rich-parameters-order.tfplan.json index 55226481d34b5..14b5d186fa93a 100644 --- a/provisioner/terraform/testdata/resources/rich-parameters-order/rich-parameters-order.tfplan.json +++ b/provisioner/terraform/testdata/resources/rich-parameters-order/rich-parameters-order.tfplan.json @@ -130,7 +130,7 @@ "type": "coder_parameter", "name": "example", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": null, "description": null, @@ -159,7 +159,7 @@ "type": "coder_parameter", "name": "sample", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ok", "description": "blah blah", @@ -244,7 +244,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.sample", @@ -269,7 +269,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 } ] } diff --git a/provisioner/terraform/testdata/resources/rich-parameters-order/rich-parameters-order.tfstate.json b/provisioner/terraform/testdata/resources/rich-parameters-order/rich-parameters-order.tfstate.json index 58b0950e84d73..c44de8192d7f9 100644 --- a/provisioner/terraform/testdata/resources/rich-parameters-order/rich-parameters-order.tfstate.json +++ b/provisioner/terraform/testdata/resources/rich-parameters-order/rich-parameters-order.tfstate.json @@ -10,7 +10,7 @@ "type": "coder_parameter", "name": "example", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": null, "description": null, @@ -39,7 +39,7 @@ "type": "coder_parameter", "name": "sample", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ok", "description": "blah blah", diff --git a/provisioner/terraform/testdata/resources/rich-parameters-validation/rich-parameters-validation.tfplan.json b/provisioner/terraform/testdata/resources/rich-parameters-validation/rich-parameters-validation.tfplan.json index 54e4e33c826c3..19c8ca91656f2 100644 --- a/provisioner/terraform/testdata/resources/rich-parameters-validation/rich-parameters-validation.tfplan.json +++ b/provisioner/terraform/testdata/resources/rich-parameters-validation/rich-parameters-validation.tfplan.json @@ -130,7 +130,7 @@ "type": "coder_parameter", "name": "number_example", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -159,7 +159,7 @@ "type": "coder_parameter", "name": "number_example_max", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -200,7 +200,7 @@ "type": "coder_parameter", "name": "number_example_max_zero", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "-3", "description": null, @@ -241,7 +241,7 @@ "type": "coder_parameter", "name": "number_example_min", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -282,7 +282,7 @@ "type": "coder_parameter", "name": "number_example_min_max", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -323,7 +323,7 @@ "type": "coder_parameter", "name": "number_example_min_zero", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -426,7 +426,7 @@ "constant_value": "number" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example_max", @@ -452,7 +452,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example_max_zero", @@ -478,7 +478,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example_min", @@ -504,7 +504,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example_min_max", @@ -533,7 +533,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example_min_zero", @@ -559,7 +559,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 } ] } diff --git a/provisioner/terraform/testdata/resources/rich-parameters-validation/rich-parameters-validation.tfstate.json b/provisioner/terraform/testdata/resources/rich-parameters-validation/rich-parameters-validation.tfstate.json index 2c718e2b48b21..d7a0d3ce03ddb 100644 --- a/provisioner/terraform/testdata/resources/rich-parameters-validation/rich-parameters-validation.tfstate.json +++ b/provisioner/terraform/testdata/resources/rich-parameters-validation/rich-parameters-validation.tfstate.json @@ -10,7 +10,7 @@ "type": "coder_parameter", "name": "number_example", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -39,7 +39,7 @@ "type": "coder_parameter", "name": "number_example_max", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -80,7 +80,7 @@ "type": "coder_parameter", "name": "number_example_max_zero", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "-3", "description": null, @@ -121,7 +121,7 @@ "type": "coder_parameter", "name": "number_example_min", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -162,7 +162,7 @@ "type": "coder_parameter", "name": "number_example_min_max", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -203,7 +203,7 @@ "type": "coder_parameter", "name": "number_example_min_zero", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, diff --git a/provisioner/terraform/testdata/resources/rich-parameters/rich-parameters.tfplan.json b/provisioner/terraform/testdata/resources/rich-parameters/rich-parameters.tfplan.json index 66151089fb40a..bfd6afb3bbd82 100644 --- a/provisioner/terraform/testdata/resources/rich-parameters/rich-parameters.tfplan.json +++ b/provisioner/terraform/testdata/resources/rich-parameters/rich-parameters.tfplan.json @@ -130,7 +130,7 @@ "type": "coder_parameter", "name": "example", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": null, "description": null, @@ -176,7 +176,7 @@ "type": "coder_parameter", "name": "number_example", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -205,7 +205,7 @@ "type": "coder_parameter", "name": "number_example_max_zero", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "-2", "description": null, @@ -246,7 +246,7 @@ "type": "coder_parameter", "name": "number_example_min_max", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -287,7 +287,7 @@ "type": "coder_parameter", "name": "number_example_min_zero", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -328,7 +328,7 @@ "type": "coder_parameter", "name": "sample", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ok", "description": "blah blah", @@ -361,7 +361,7 @@ "type": "coder_parameter", "name": "first_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "abcdef", "description": "First parameter from module", @@ -390,7 +390,7 @@ "type": "coder_parameter", "name": "second_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ghijkl", "description": "Second parameter from module", @@ -424,7 +424,7 @@ "type": "coder_parameter", "name": "child_first_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "abcdef", "description": "First parameter from child module", @@ -453,7 +453,7 @@ "type": "coder_parameter", "name": "child_second_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ghijkl", "description": "Second parameter from child module", @@ -564,7 +564,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example", @@ -583,7 +583,7 @@ "constant_value": "number" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example_max_zero", @@ -612,7 +612,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example_min_max", @@ -641,7 +641,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.number_example_min_zero", @@ -670,7 +670,7 @@ } ] }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.sample", @@ -692,7 +692,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 } ], "module_calls": { @@ -723,7 +723,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.second_parameter_from_module", @@ -748,7 +748,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 } ], "module_calls": { @@ -779,7 +779,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 }, { "address": "data.coder_parameter.child_second_parameter_from_module", @@ -804,7 +804,7 @@ "constant_value": "string" } }, - "schema_version": 0 + "schema_version": 1 } ] } diff --git a/provisioner/terraform/testdata/resources/rich-parameters/rich-parameters.tfstate.json b/provisioner/terraform/testdata/resources/rich-parameters/rich-parameters.tfstate.json index 3c1725721d774..5e1b0c1fc8884 100644 --- a/provisioner/terraform/testdata/resources/rich-parameters/rich-parameters.tfstate.json +++ b/provisioner/terraform/testdata/resources/rich-parameters/rich-parameters.tfstate.json @@ -10,7 +10,7 @@ "type": "coder_parameter", "name": "example", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": null, "description": null, @@ -56,7 +56,7 @@ "type": "coder_parameter", "name": "number_example", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -85,7 +85,7 @@ "type": "coder_parameter", "name": "number_example_max_zero", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "-2", "description": null, @@ -126,7 +126,7 @@ "type": "coder_parameter", "name": "number_example_min_max", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -167,7 +167,7 @@ "type": "coder_parameter", "name": "number_example_min_zero", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "4", "description": null, @@ -208,7 +208,7 @@ "type": "coder_parameter", "name": "sample", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ok", "description": "blah blah", @@ -302,7 +302,7 @@ "type": "coder_parameter", "name": "first_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "abcdef", "description": "First parameter from module", @@ -331,7 +331,7 @@ "type": "coder_parameter", "name": "second_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ghijkl", "description": "Second parameter from module", @@ -365,7 +365,7 @@ "type": "coder_parameter", "name": "child_first_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "abcdef", "description": "First parameter from child module", @@ -394,7 +394,7 @@ "type": "coder_parameter", "name": "child_second_parameter_from_module", "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, + "schema_version": 1, "values": { "default": "ghijkl", "description": "Second parameter from child module", diff --git a/provisioner/terraform/tfparse/tfparse_test.go b/provisioner/terraform/tfparse/tfparse_test.go index ceefc484b2169..41182b9aa2dac 100644 --- a/provisioner/terraform/tfparse/tfparse_test.go +++ b/provisioner/terraform/tfparse/tfparse_test.go @@ -587,7 +587,6 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { expectTags: map[string]string{"foo": "bar", "a": "1"}, }, } { - tc := tc t.Run(tc.name+"/tar", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index b4343eafbfdac..9960105c78962 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -1309,6 +1309,7 @@ type CompletedJob_WorkspaceBuild struct { Timings []*proto.Timing `protobuf:"bytes,3,rep,name=timings,proto3" json:"timings,omitempty"` Modules []*proto.Module `protobuf:"bytes,4,rep,name=modules,proto3" json:"modules,omitempty"` ResourceReplacements []*proto.ResourceReplacement `protobuf:"bytes,5,rep,name=resource_replacements,json=resourceReplacements,proto3" json:"resource_replacements,omitempty"` + AiTasks []*proto.AITask `protobuf:"bytes,6,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"` } func (x *CompletedJob_WorkspaceBuild) Reset() { @@ -1378,6 +1379,13 @@ func (x *CompletedJob_WorkspaceBuild) GetResourceReplacements() []*proto.Resourc return nil } +func (x *CompletedJob_WorkspaceBuild) GetAiTasks() []*proto.AITask { + if x != nil { + return x.AiTasks + } + return nil +} + type CompletedJob_TemplateImport struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1394,6 +1402,7 @@ type CompletedJob_TemplateImport struct { Plan []byte `protobuf:"bytes,9,opt,name=plan,proto3" json:"plan,omitempty"` ModuleFiles []byte `protobuf:"bytes,10,opt,name=module_files,json=moduleFiles,proto3" json:"module_files,omitempty"` ModuleFilesHash []byte `protobuf:"bytes,11,opt,name=module_files_hash,json=moduleFilesHash,proto3" json:"module_files_hash,omitempty"` + HasAiTasks bool `protobuf:"varint,12,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` } func (x *CompletedJob_TemplateImport) Reset() { @@ -1505,6 +1514,13 @@ func (x *CompletedJob_TemplateImport) GetModuleFilesHash() []byte { return nil } +func (x *CompletedJob_TemplateImport) GetHasAiTasks() bool { + if x != nil { + return x.HasAiTasks + } + return false +} + type CompletedJob_TemplateDryRun struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1694,7 +1710,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x6d, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, - 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xb9, 0x0a, 0x0a, 0x0c, 0x43, 0x6f, + 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x8b, 0x0b, 0x0a, 0x0c, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, @@ -1713,7 +1729,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x48, 0x00, 0x52, 0x0e, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, - 0x79, 0x52, 0x75, 0x6e, 0x1a, 0x90, 0x02, 0x0a, 0x0e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x79, 0x52, 0x75, 0x6e, 0x1a, 0xc0, 0x02, 0x0a, 0x0e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, @@ -1730,7 +1746,10 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x14, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x61, - 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x1a, 0xfd, 0x04, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, + 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x69, 0x5f, 0x74, 0x61, + 0x73, 0x6b, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x49, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x07, + 0x61, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x1a, 0x9f, 0x05, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x3e, 0x0a, 0x0f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, @@ -1770,122 +1789,124 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, - 0x6c, 0x65, 0x73, 0x48, 0x61, 0x73, 0x68, 0x1a, 0x74, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x2d, - 0x0a, 0x07, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x6f, - 0x64, 0x75, 0x6c, 0x65, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x73, 0x42, 0x06, 0x0a, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2f, 0x0a, - 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, - 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2b, - 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, - 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x63, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, - 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, - 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0xa6, 0x03, 0x0a, 0x10, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, - 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, - 0x6f, 0x62, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x12, 0x4c, 0x0a, 0x12, 0x74, - 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, - 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, - 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, - 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x12, 0x4c, 0x0a, 0x14, 0x75, 0x73, 0x65, - 0x72, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x52, 0x12, 0x75, 0x73, 0x65, 0x72, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, - 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, - 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, 0x65, 0x12, - 0x58, 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x61, 0x67, - 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x1a, 0x40, 0x0a, 0x12, 0x57, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, 0x03, 0x10, - 0x04, 0x22, 0x7a, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, - 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, - 0x65, 0x64, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, - 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, - 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x22, 0x4a, 0x0a, - 0x12, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x61, - 0x69, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, - 0x64, 0x61, 0x69, 0x6c, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x22, 0x68, 0x0a, 0x13, 0x43, 0x6f, 0x6d, - 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, - 0x12, 0x29, 0x0a, 0x10, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, - 0x75, 0x6d, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0f, 0x63, 0x72, 0x65, 0x64, - 0x69, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x62, - 0x75, 0x64, 0x67, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x62, 0x75, 0x64, - 0x67, 0x65, 0x74, 0x22, 0x0f, 0x0a, 0x0d, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x41, 0x63, 0x71, - 0x75, 0x69, 0x72, 0x65, 0x22, 0x93, 0x01, 0x0a, 0x11, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x46, - 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3a, 0x0a, 0x0b, 0x64, 0x61, - 0x74, 0x61, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x44, 0x61, - 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x48, 0x00, 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, - 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x3a, 0x0a, 0x0b, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, - 0x70, 0x69, 0x65, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x50, - 0x69, 0x65, 0x63, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x50, 0x69, 0x65, - 0x63, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a, 0x34, 0x0a, 0x09, 0x4c, 0x6f, - 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x56, 0x49, - 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, 0x4d, 0x4f, 0x4e, 0x10, 0x00, 0x12, - 0x0f, 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x10, 0x01, - 0x32, 0x8b, 0x04, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x41, 0x0a, 0x0a, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, - 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, - 0x64, 0x4a, 0x6f, 0x62, 0x22, 0x03, 0x88, 0x02, 0x01, 0x12, 0x52, 0x0a, 0x14, 0x41, 0x63, 0x71, - 0x75, 0x69, 0x72, 0x65, 0x4a, 0x6f, 0x62, 0x57, 0x69, 0x74, 0x68, 0x43, 0x61, 0x6e, 0x63, 0x65, - 0x6c, 0x12, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, - 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x1a, 0x19, - 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, - 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x28, 0x01, 0x30, 0x01, 0x12, 0x52, 0x0a, - 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x12, 0x20, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, - 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x6c, 0x65, 0x73, 0x48, 0x61, 0x73, 0x68, 0x12, 0x20, 0x0a, 0x0c, 0x68, 0x61, 0x73, 0x5f, 0x61, + 0x69, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x68, + 0x61, 0x73, 0x41, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x1a, 0x74, 0x0a, 0x0e, 0x54, 0x65, 0x6d, + 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, + 0x12, 0x2d, 0x0a, 0x07, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x4d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x73, 0x42, + 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, + 0x2f, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x4c, + 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x12, 0x2b, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, + 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x14, 0x0a, 0x05, + 0x73, 0x74, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, + 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0xa6, 0x03, 0x0a, 0x10, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x12, 0x4c, 0x0a, + 0x12, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, + 0x6c, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, + 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, + 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x12, 0x4c, 0x0a, 0x14, 0x75, + 0x73, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x12, 0x75, 0x73, 0x65, 0x72, 0x56, 0x61, 0x72, 0x69, 0x61, + 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, + 0x64, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, + 0x65, 0x12, 0x58, 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, + 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, + 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x1a, 0x40, 0x0a, 0x12, 0x57, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, + 0x03, 0x10, 0x04, 0x22, 0x7a, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6e, 0x63, + 0x65, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x61, 0x6e, 0x63, + 0x65, 0x6c, 0x65, 0x64, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, + 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, + 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, 0x61, 0x72, 0x69, 0x61, + 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x22, + 0x4a, 0x0a, 0x12, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, + 0x64, 0x61, 0x69, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x09, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x22, 0x68, 0x0a, 0x13, 0x43, + 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, + 0x6f, 0x6b, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x5f, 0x63, 0x6f, + 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0f, 0x63, 0x72, + 0x65, 0x64, 0x69, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x12, 0x16, 0x0a, + 0x06, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x62, + 0x75, 0x64, 0x67, 0x65, 0x74, 0x22, 0x0f, 0x0a, 0x0d, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x41, + 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x22, 0x93, 0x01, 0x0a, 0x11, 0x55, 0x70, 0x6c, 0x6f, 0x61, + 0x64, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3a, 0x0a, 0x0b, + 0x64, 0x61, 0x74, 0x61, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x44, 0x61, 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x48, 0x00, 0x52, 0x0a, 0x64, 0x61, + 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x3a, 0x0a, 0x0b, 0x63, 0x68, 0x75, 0x6e, + 0x6b, 0x5f, 0x70, 0x69, 0x65, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x43, 0x68, 0x75, 0x6e, + 0x6b, 0x50, 0x69, 0x65, 0x63, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x50, + 0x69, 0x65, 0x63, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a, 0x34, 0x0a, 0x09, + 0x4c, 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, + 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, 0x4d, 0x4f, 0x4e, 0x10, + 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, + 0x10, 0x01, 0x32, 0x8b, 0x04, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x41, 0x0a, 0x0a, 0x41, 0x63, 0x71, 0x75, + 0x69, 0x72, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71, 0x75, 0x69, + 0x72, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x22, 0x03, 0x88, 0x02, 0x01, 0x12, 0x52, 0x0a, 0x14, 0x41, + 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x4a, 0x6f, 0x62, 0x57, 0x69, 0x74, 0x68, 0x43, 0x61, 0x6e, + 0x63, 0x65, 0x6c, 0x12, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x64, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, + 0x1a, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, + 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x28, 0x01, 0x30, 0x01, 0x12, + 0x52, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, - 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1e, - 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, - 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, 0x4a, 0x6f, 0x62, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, - 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3e, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, - 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0a, 0x55, 0x70, 0x6c, 0x6f, - 0x61, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x46, 0x69, 0x6c, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x28, 0x01, 0x42, 0x2e, - 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, + 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, + 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, 0x4a, 0x6f, 0x62, 0x12, 0x17, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, + 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3e, 0x0a, 0x0b, 0x43, 0x6f, + 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, + 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0a, 0x55, 0x70, + 0x6c, 0x6f, 0x61, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x46, 0x69, + 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x28, 0x01, + 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1938,9 +1959,10 @@ var file_provisionerd_proto_provisionerd_proto_goTypes = []interface{}{ (*proto.Resource)(nil), // 32: provisioner.Resource (*proto.Module)(nil), // 33: provisioner.Module (*proto.ResourceReplacement)(nil), // 34: provisioner.ResourceReplacement - (*proto.RichParameter)(nil), // 35: provisioner.RichParameter - (*proto.ExternalAuthProviderResource)(nil), // 36: provisioner.ExternalAuthProviderResource - (*proto.Preset)(nil), // 37: provisioner.Preset + (*proto.AITask)(nil), // 35: provisioner.AITask + (*proto.RichParameter)(nil), // 36: provisioner.RichParameter + (*proto.ExternalAuthProviderResource)(nil), // 37: provisioner.ExternalAuthProviderResource + (*proto.Preset)(nil), // 38: provisioner.Preset } var file_provisionerd_proto_provisionerd_proto_depIdxs = []int32{ 12, // 0: provisionerd.AcquiredJob.workspace_build:type_name -> provisionerd.AcquiredJob.WorkspaceBuild @@ -1977,34 +1999,35 @@ var file_provisionerd_proto_provisionerd_proto_depIdxs = []int32{ 31, // 31: provisionerd.CompletedJob.WorkspaceBuild.timings:type_name -> provisioner.Timing 33, // 32: provisionerd.CompletedJob.WorkspaceBuild.modules:type_name -> provisioner.Module 34, // 33: provisionerd.CompletedJob.WorkspaceBuild.resource_replacements:type_name -> provisioner.ResourceReplacement - 32, // 34: provisionerd.CompletedJob.TemplateImport.start_resources:type_name -> provisioner.Resource - 32, // 35: provisionerd.CompletedJob.TemplateImport.stop_resources:type_name -> provisioner.Resource - 35, // 36: provisionerd.CompletedJob.TemplateImport.rich_parameters:type_name -> provisioner.RichParameter - 36, // 37: provisionerd.CompletedJob.TemplateImport.external_auth_providers:type_name -> provisioner.ExternalAuthProviderResource - 33, // 38: provisionerd.CompletedJob.TemplateImport.start_modules:type_name -> provisioner.Module - 33, // 39: provisionerd.CompletedJob.TemplateImport.stop_modules:type_name -> provisioner.Module - 37, // 40: provisionerd.CompletedJob.TemplateImport.presets:type_name -> provisioner.Preset - 32, // 41: provisionerd.CompletedJob.TemplateDryRun.resources:type_name -> provisioner.Resource - 33, // 42: provisionerd.CompletedJob.TemplateDryRun.modules:type_name -> provisioner.Module - 1, // 43: provisionerd.ProvisionerDaemon.AcquireJob:input_type -> provisionerd.Empty - 10, // 44: provisionerd.ProvisionerDaemon.AcquireJobWithCancel:input_type -> provisionerd.CancelAcquire - 8, // 45: provisionerd.ProvisionerDaemon.CommitQuota:input_type -> provisionerd.CommitQuotaRequest - 6, // 46: provisionerd.ProvisionerDaemon.UpdateJob:input_type -> provisionerd.UpdateJobRequest - 3, // 47: provisionerd.ProvisionerDaemon.FailJob:input_type -> provisionerd.FailedJob - 4, // 48: provisionerd.ProvisionerDaemon.CompleteJob:input_type -> provisionerd.CompletedJob - 11, // 49: provisionerd.ProvisionerDaemon.UploadFile:input_type -> provisionerd.UploadFileRequest - 2, // 50: provisionerd.ProvisionerDaemon.AcquireJob:output_type -> provisionerd.AcquiredJob - 2, // 51: provisionerd.ProvisionerDaemon.AcquireJobWithCancel:output_type -> provisionerd.AcquiredJob - 9, // 52: provisionerd.ProvisionerDaemon.CommitQuota:output_type -> provisionerd.CommitQuotaResponse - 7, // 53: provisionerd.ProvisionerDaemon.UpdateJob:output_type -> provisionerd.UpdateJobResponse - 1, // 54: provisionerd.ProvisionerDaemon.FailJob:output_type -> provisionerd.Empty - 1, // 55: provisionerd.ProvisionerDaemon.CompleteJob:output_type -> provisionerd.Empty - 1, // 56: provisionerd.ProvisionerDaemon.UploadFile:output_type -> provisionerd.Empty - 50, // [50:57] is the sub-list for method output_type - 43, // [43:50] is the sub-list for method input_type - 43, // [43:43] is the sub-list for extension type_name - 43, // [43:43] is the sub-list for extension extendee - 0, // [0:43] is the sub-list for field type_name + 35, // 34: provisionerd.CompletedJob.WorkspaceBuild.ai_tasks:type_name -> provisioner.AITask + 32, // 35: provisionerd.CompletedJob.TemplateImport.start_resources:type_name -> provisioner.Resource + 32, // 36: provisionerd.CompletedJob.TemplateImport.stop_resources:type_name -> provisioner.Resource + 36, // 37: provisionerd.CompletedJob.TemplateImport.rich_parameters:type_name -> provisioner.RichParameter + 37, // 38: provisionerd.CompletedJob.TemplateImport.external_auth_providers:type_name -> provisioner.ExternalAuthProviderResource + 33, // 39: provisionerd.CompletedJob.TemplateImport.start_modules:type_name -> provisioner.Module + 33, // 40: provisionerd.CompletedJob.TemplateImport.stop_modules:type_name -> provisioner.Module + 38, // 41: provisionerd.CompletedJob.TemplateImport.presets:type_name -> provisioner.Preset + 32, // 42: provisionerd.CompletedJob.TemplateDryRun.resources:type_name -> provisioner.Resource + 33, // 43: provisionerd.CompletedJob.TemplateDryRun.modules:type_name -> provisioner.Module + 1, // 44: provisionerd.ProvisionerDaemon.AcquireJob:input_type -> provisionerd.Empty + 10, // 45: provisionerd.ProvisionerDaemon.AcquireJobWithCancel:input_type -> provisionerd.CancelAcquire + 8, // 46: provisionerd.ProvisionerDaemon.CommitQuota:input_type -> provisionerd.CommitQuotaRequest + 6, // 47: provisionerd.ProvisionerDaemon.UpdateJob:input_type -> provisionerd.UpdateJobRequest + 3, // 48: provisionerd.ProvisionerDaemon.FailJob:input_type -> provisionerd.FailedJob + 4, // 49: provisionerd.ProvisionerDaemon.CompleteJob:input_type -> provisionerd.CompletedJob + 11, // 50: provisionerd.ProvisionerDaemon.UploadFile:input_type -> provisionerd.UploadFileRequest + 2, // 51: provisionerd.ProvisionerDaemon.AcquireJob:output_type -> provisionerd.AcquiredJob + 2, // 52: provisionerd.ProvisionerDaemon.AcquireJobWithCancel:output_type -> provisionerd.AcquiredJob + 9, // 53: provisionerd.ProvisionerDaemon.CommitQuota:output_type -> provisionerd.CommitQuotaResponse + 7, // 54: provisionerd.ProvisionerDaemon.UpdateJob:output_type -> provisionerd.UpdateJobResponse + 1, // 55: provisionerd.ProvisionerDaemon.FailJob:output_type -> provisionerd.Empty + 1, // 56: provisionerd.ProvisionerDaemon.CompleteJob:output_type -> provisionerd.Empty + 1, // 57: provisionerd.ProvisionerDaemon.UploadFile:output_type -> provisionerd.Empty + 51, // [51:58] is the sub-list for method output_type + 44, // [44:51] is the sub-list for method input_type + 44, // [44:44] is the sub-list for extension type_name + 44, // [44:44] is the sub-list for extension extendee + 0, // [0:44] is the sub-list for field type_name } func init() { file_provisionerd_proto_provisionerd_proto_init() } diff --git a/provisionerd/proto/provisionerd.proto b/provisionerd/proto/provisionerd.proto index 1b5ede6e13c71..eeeb5f02da0fb 100644 --- a/provisionerd/proto/provisionerd.proto +++ b/provisionerd/proto/provisionerd.proto @@ -80,6 +80,7 @@ message CompletedJob { repeated provisioner.Timing timings = 3; repeated provisioner.Module modules = 4; repeated provisioner.ResourceReplacement resource_replacements = 5; + repeated provisioner.AITask ai_tasks = 6; } message TemplateImport { repeated provisioner.Resource start_resources = 1; @@ -93,6 +94,7 @@ message CompletedJob { bytes plan = 9; bytes module_files = 10; bytes module_files_hash = 11; + bool has_ai_tasks = 12; } message TemplateDryRun { repeated provisioner.Resource resources = 1; diff --git a/provisionerd/proto/version.go b/provisionerd/proto/version.go index d5ecba99030b3..e61aac0c5abd8 100644 --- a/provisionerd/proto/version.go +++ b/provisionerd/proto/version.go @@ -37,6 +37,13 @@ import "github.com/coder/coder/v2/apiversion" // - Add new field named `scheduling` to `Prebuild`, with fields for timezone // and schedule rules to define cron-based scaling of prebuilt workspace // instances based on time patterns. +// - Added new field named `id` to `App`, which transports the ID generated by the coder_app provider to be persisted. +// - Added new field named `default` to `Preset`. +// - Added various fields in support of AI Tasks: +// -> `ai_tasks` in `CompleteJob.WorkspaceBuild` +// -> `has_ai_tasks` in `CompleteJob.TemplateImport` +// -> `has_ai_tasks` and `ai_tasks` in `PlanComplete` +// -> new message types `AITaskSidebarApp` and `AITask` const ( CurrentMajor = 1 CurrentMinor = 7 diff --git a/provisionerd/runner/runner.go b/provisionerd/runner/runner.go index 305bd2543dd82..b80cf9060b358 100644 --- a/provisionerd/runner/runner.go +++ b/provisionerd/runner/runner.go @@ -584,8 +584,6 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p externalAuthProviderNames = append(externalAuthProviderNames, it.Id) } - // fmt.Println("completed job: template import: graph:", startProvision.Graph) - return &proto.CompletedJob{ JobId: r.job.JobId, Type: &proto.CompletedJob_TemplateImport_{ @@ -603,6 +601,7 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p ModuleFiles: startProvision.ModuleFiles, // ModuleFileHash will be populated if the file is uploaded async ModuleFilesHash: []byte{}, + HasAiTasks: startProvision.HasAITasks, }, }, }, nil @@ -666,6 +665,7 @@ type templateImportProvision struct { Presets []*sdkproto.Preset Plan json.RawMessage ModuleFiles []byte + HasAITasks bool } // Performs a dry-run provision when importing a template. @@ -798,7 +798,6 @@ func (r *Runner) runTemplateImportProvisionWithRichParameters( return nil, xerrors.Errorf("module files hash mismatch, uploaded: %x, expected: %x", moduleFilesUpload.Hash, c.ModuleFilesHash) } } - return &templateImportProvision{ Resources: c.Resources, Parameters: c.Parameters, @@ -807,6 +806,7 @@ func (r *Runner) runTemplateImportProvisionWithRichParameters( Presets: c.Presets, Plan: c.Plan, ModuleFiles: moduleFilesData, + HasAITasks: c.HasAiTasks, }, nil default: return nil, xerrors.Errorf("invalid message type %q received from provisioner", @@ -1047,6 +1047,9 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p }, } } + if len(planComplete.AiTasks) > 1 { + return nil, r.failedWorkspaceBuildf("only one 'coder_ai_task' resource can be provisioned per template") + } r.logger.Info(context.Background(), "plan request successful", slog.F("resource_count", len(planComplete.Resources)), @@ -1124,6 +1127,7 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p Modules: planComplete.Modules, // Resource replacements are discovered at plan time, only. ResourceReplacements: planComplete.ResourceReplacements, + AiTasks: applyComplete.AiTasks, }, }, }, nil diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index 81ca588efaf93..7412cb6155610 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -1110,6 +1110,7 @@ type Preset struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Parameters []*PresetParameter `protobuf:"bytes,2,rep,name=parameters,proto3" json:"parameters,omitempty"` Prebuild *Prebuild `protobuf:"bytes,3,opt,name=prebuild,proto3" json:"prebuild,omitempty"` + Default bool `protobuf:"varint,4,opt,name=default,proto3" json:"default,omitempty"` } func (x *Preset) Reset() { @@ -1165,6 +1166,13 @@ func (x *Preset) GetPrebuild() *Prebuild { return nil } +func (x *Preset) GetDefault() bool { + if x != nil { + return x.Default + } + return false +} + type PresetParameter struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2285,6 +2293,7 @@ type App struct { Hidden bool `protobuf:"varint,11,opt,name=hidden,proto3" json:"hidden,omitempty"` OpenIn AppOpenIn `protobuf:"varint,12,opt,name=open_in,json=openIn,proto3,enum=provisioner.AppOpenIn" json:"open_in,omitempty"` Group string `protobuf:"bytes,13,opt,name=group,proto3" json:"group,omitempty"` + Id string `protobuf:"bytes,14,opt,name=id,proto3" json:"id,omitempty"` // If nil, new UUID will be generated. } func (x *App) Reset() { @@ -2410,6 +2419,13 @@ func (x *App) GetGroup() string { return "" } +func (x *App) GetId() string { + if x != nil { + return x.Id + } + return "" +} + // Healthcheck represents configuration for checking for app readiness. type Healthcheck struct { state protoimpl.MessageState @@ -2767,6 +2783,108 @@ func (x *RunningAgentAuthToken) GetToken() string { return "" } +type AITaskSidebarApp struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *AITaskSidebarApp) Reset() { + *x = AITaskSidebarApp{} + if protoimpl.UnsafeEnabled { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AITaskSidebarApp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AITaskSidebarApp) ProtoMessage() {} + +func (x *AITaskSidebarApp) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[31] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AITaskSidebarApp.ProtoReflect.Descriptor instead. +func (*AITaskSidebarApp) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{31} +} + +func (x *AITaskSidebarApp) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type AITask struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + SidebarApp *AITaskSidebarApp `protobuf:"bytes,2,opt,name=sidebar_app,json=sidebarApp,proto3" json:"sidebar_app,omitempty"` +} + +func (x *AITask) Reset() { + *x = AITask{} + if protoimpl.UnsafeEnabled { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AITask) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AITask) ProtoMessage() {} + +func (x *AITask) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[32] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AITask.ProtoReflect.Descriptor instead. +func (*AITask) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{32} +} + +func (x *AITask) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *AITask) GetSidebarApp() *AITaskSidebarApp { + if x != nil { + return x.SidebarApp + } + return nil +} + // Metadata is information about a workspace used in the execution of a build type Metadata struct { state protoimpl.MessageState @@ -2799,7 +2917,7 @@ type Metadata struct { func (x *Metadata) Reset() { *x = Metadata{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[31] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2812,7 +2930,7 @@ func (x *Metadata) String() string { func (*Metadata) ProtoMessage() {} func (x *Metadata) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[31] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[33] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2825,7 +2943,7 @@ func (x *Metadata) ProtoReflect() protoreflect.Message { // Deprecated: Use Metadata.ProtoReflect.Descriptor instead. func (*Metadata) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{31} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{33} } func (x *Metadata) GetCoderUrl() string { @@ -2991,7 +3109,7 @@ type Config struct { func (x *Config) Reset() { *x = Config{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[32] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3004,7 +3122,7 @@ func (x *Config) String() string { func (*Config) ProtoMessage() {} func (x *Config) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[32] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[34] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3017,7 +3135,7 @@ func (x *Config) ProtoReflect() protoreflect.Message { // Deprecated: Use Config.ProtoReflect.Descriptor instead. func (*Config) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{32} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{34} } func (x *Config) GetTemplateSourceArchive() []byte { @@ -3051,7 +3169,7 @@ type ParseRequest struct { func (x *ParseRequest) Reset() { *x = ParseRequest{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[33] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3064,7 +3182,7 @@ func (x *ParseRequest) String() string { func (*ParseRequest) ProtoMessage() {} func (x *ParseRequest) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[33] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[35] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3077,7 +3195,7 @@ func (x *ParseRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ParseRequest.ProtoReflect.Descriptor instead. func (*ParseRequest) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{33} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{35} } // ParseComplete indicates a request to parse completed. @@ -3095,7 +3213,7 @@ type ParseComplete struct { func (x *ParseComplete) Reset() { *x = ParseComplete{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[34] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3108,7 +3226,7 @@ func (x *ParseComplete) String() string { func (*ParseComplete) ProtoMessage() {} func (x *ParseComplete) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[34] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[36] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3121,7 +3239,7 @@ func (x *ParseComplete) ProtoReflect() protoreflect.Message { // Deprecated: Use ParseComplete.ProtoReflect.Descriptor instead. func (*ParseComplete) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{34} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{36} } func (x *ParseComplete) GetError() string { @@ -3174,7 +3292,7 @@ type PlanRequest struct { func (x *PlanRequest) Reset() { *x = PlanRequest{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[35] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3187,7 +3305,7 @@ func (x *PlanRequest) String() string { func (*PlanRequest) ProtoMessage() {} func (x *PlanRequest) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[35] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[37] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3200,7 +3318,7 @@ func (x *PlanRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PlanRequest.ProtoReflect.Descriptor instead. func (*PlanRequest) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{35} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{37} } func (x *PlanRequest) GetMetadata() *Metadata { @@ -3262,12 +3380,19 @@ type PlanComplete struct { ResourceReplacements []*ResourceReplacement `protobuf:"bytes,10,rep,name=resource_replacements,json=resourceReplacements,proto3" json:"resource_replacements,omitempty"` ModuleFiles []byte `protobuf:"bytes,11,opt,name=module_files,json=moduleFiles,proto3" json:"module_files,omitempty"` ModuleFilesHash []byte `protobuf:"bytes,12,opt,name=module_files_hash,json=moduleFilesHash,proto3" json:"module_files_hash,omitempty"` + // Whether a template has any `coder_ai_task` resources defined, even if not planned for creation. + // During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we + // still need to know that such resources are defined. + // + // See `hasAITaskResources` in provisioner/terraform/resources.go for more details. + HasAiTasks bool `protobuf:"varint,13,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` + AiTasks []*AITask `protobuf:"bytes,14,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"` } func (x *PlanComplete) Reset() { *x = PlanComplete{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[36] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3280,7 +3405,7 @@ func (x *PlanComplete) String() string { func (*PlanComplete) ProtoMessage() {} func (x *PlanComplete) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[36] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[38] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3293,7 +3418,7 @@ func (x *PlanComplete) ProtoReflect() protoreflect.Message { // Deprecated: Use PlanComplete.ProtoReflect.Descriptor instead. func (*PlanComplete) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{36} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{38} } func (x *PlanComplete) GetError() string { @@ -3373,6 +3498,20 @@ func (x *PlanComplete) GetModuleFilesHash() []byte { return nil } +func (x *PlanComplete) GetHasAiTasks() bool { + if x != nil { + return x.HasAiTasks + } + return false +} + +func (x *PlanComplete) GetAiTasks() []*AITask { + if x != nil { + return x.AiTasks + } + return nil +} + // ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response // in the same Session. The plan data is not transmitted over the wire and is cached by the provisioner in the Session. type ApplyRequest struct { @@ -3386,7 +3525,7 @@ type ApplyRequest struct { func (x *ApplyRequest) Reset() { *x = ApplyRequest{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[37] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3399,7 +3538,7 @@ func (x *ApplyRequest) String() string { func (*ApplyRequest) ProtoMessage() {} func (x *ApplyRequest) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[37] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[39] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3412,7 +3551,7 @@ func (x *ApplyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyRequest.ProtoReflect.Descriptor instead. func (*ApplyRequest) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{37} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{39} } func (x *ApplyRequest) GetMetadata() *Metadata { @@ -3434,12 +3573,13 @@ type ApplyComplete struct { Parameters []*RichParameter `protobuf:"bytes,4,rep,name=parameters,proto3" json:"parameters,omitempty"` ExternalAuthProviders []*ExternalAuthProviderResource `protobuf:"bytes,5,rep,name=external_auth_providers,json=externalAuthProviders,proto3" json:"external_auth_providers,omitempty"` Timings []*Timing `protobuf:"bytes,6,rep,name=timings,proto3" json:"timings,omitempty"` + AiTasks []*AITask `protobuf:"bytes,7,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"` } func (x *ApplyComplete) Reset() { *x = ApplyComplete{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[38] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3452,7 +3592,7 @@ func (x *ApplyComplete) String() string { func (*ApplyComplete) ProtoMessage() {} func (x *ApplyComplete) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[38] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[40] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3465,7 +3605,7 @@ func (x *ApplyComplete) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyComplete.ProtoReflect.Descriptor instead. func (*ApplyComplete) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{38} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{40} } func (x *ApplyComplete) GetState() []byte { @@ -3510,6 +3650,13 @@ func (x *ApplyComplete) GetTimings() []*Timing { return nil } +func (x *ApplyComplete) GetAiTasks() []*AITask { + if x != nil { + return x.AiTasks + } + return nil +} + type Timing struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -3527,7 +3674,7 @@ type Timing struct { func (x *Timing) Reset() { *x = Timing{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[39] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3540,7 +3687,7 @@ func (x *Timing) String() string { func (*Timing) ProtoMessage() {} func (x *Timing) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[39] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[41] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3553,7 +3700,7 @@ func (x *Timing) ProtoReflect() protoreflect.Message { // Deprecated: Use Timing.ProtoReflect.Descriptor instead. func (*Timing) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{39} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{41} } func (x *Timing) GetStart() *timestamppb.Timestamp { @@ -3615,7 +3762,7 @@ type CancelRequest struct { func (x *CancelRequest) Reset() { *x = CancelRequest{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[40] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3628,7 +3775,7 @@ func (x *CancelRequest) String() string { func (*CancelRequest) ProtoMessage() {} func (x *CancelRequest) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[40] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[42] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3641,7 +3788,7 @@ func (x *CancelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelRequest.ProtoReflect.Descriptor instead. func (*CancelRequest) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{40} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{42} } type Request struct { @@ -3662,7 +3809,7 @@ type Request struct { func (x *Request) Reset() { *x = Request{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[41] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3675,7 +3822,7 @@ func (x *Request) String() string { func (*Request) ProtoMessage() {} func (x *Request) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[41] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[43] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3688,7 +3835,7 @@ func (x *Request) ProtoReflect() protoreflect.Message { // Deprecated: Use Request.ProtoReflect.Descriptor instead. func (*Request) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{41} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{43} } func (m *Request) GetType() isRequest_Type { @@ -3786,7 +3933,7 @@ type Response struct { func (x *Response) Reset() { *x = Response{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[42] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3799,7 +3946,7 @@ func (x *Response) String() string { func (*Response) ProtoMessage() {} func (x *Response) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[42] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[44] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3812,7 +3959,7 @@ func (x *Response) ProtoReflect() protoreflect.Message { // Deprecated: Use Response.ProtoReflect.Descriptor instead. func (*Response) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{42} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{44} } func (m *Response) GetType() isResponse_Type { @@ -3922,7 +4069,7 @@ type DataUpload struct { func (x *DataUpload) Reset() { *x = DataUpload{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[43] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3935,7 +4082,7 @@ func (x *DataUpload) String() string { func (*DataUpload) ProtoMessage() {} func (x *DataUpload) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[43] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[45] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3948,7 +4095,7 @@ func (x *DataUpload) ProtoReflect() protoreflect.Message { // Deprecated: Use DataUpload.ProtoReflect.Descriptor instead. func (*DataUpload) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{43} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{45} } func (x *DataUpload) GetUploadType() DataUploadType { @@ -3995,7 +4142,7 @@ type ChunkPiece struct { func (x *ChunkPiece) Reset() { *x = ChunkPiece{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[44] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4008,7 +4155,7 @@ func (x *ChunkPiece) String() string { func (*ChunkPiece) ProtoMessage() {} func (x *ChunkPiece) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[44] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[46] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4021,7 +4168,7 @@ func (x *ChunkPiece) ProtoReflect() protoreflect.Message { // Deprecated: Use ChunkPiece.ProtoReflect.Descriptor instead. func (*ChunkPiece) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{44} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{46} } func (x *ChunkPiece) GetData() []byte { @@ -4061,7 +4208,7 @@ type Agent_Metadata struct { func (x *Agent_Metadata) Reset() { *x = Agent_Metadata{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[45] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4074,7 +4221,7 @@ func (x *Agent_Metadata) String() string { func (*Agent_Metadata) ProtoMessage() {} func (x *Agent_Metadata) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[45] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[47] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4146,7 +4293,7 @@ type Resource_Metadata struct { func (x *Resource_Metadata) Reset() { *x = Resource_Metadata{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[47] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4159,7 +4306,7 @@ func (x *Resource_Metadata) String() string { func (*Resource_Metadata) ProtoMessage() {} func (x *Resource_Metadata) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[47] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[49] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4302,7 +4449,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x68, 0x65, 0x64, 0x75, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x0a, 0x73, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, - 0x69, 0x6e, 0x67, 0x22, 0x8d, 0x01, 0x0a, 0x06, 0x50, 0x72, 0x65, 0x73, 0x65, 0x74, 0x12, 0x12, + 0x69, 0x6e, 0x67, 0x22, 0xa7, 0x01, 0x0a, 0x06, 0x50, 0x72, 0x65, 0x73, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x3c, 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, @@ -4311,551 +4458,570 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x12, 0x31, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x52, 0x08, 0x70, 0x72, 0x65, 0x62, 0x75, - 0x69, 0x6c, 0x64, 0x22, 0x3b, 0x0a, 0x0f, 0x50, 0x72, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x72, - 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x22, 0x47, 0x0a, 0x13, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, 0x6c, - 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x61, 0x74, 0x68, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x05, 0x70, 0x61, 0x74, 0x68, 0x73, 0x22, 0x57, 0x0a, 0x0d, 0x56, 0x61, 0x72, - 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, - 0x76, 0x65, 0x22, 0x4a, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2b, 0x0a, 0x05, 0x6c, 0x65, 0x76, - 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, - 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x37, - 0x0a, 0x14, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x41, 0x75, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, - 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x22, 0x4a, 0x0a, 0x1c, 0x45, 0x78, 0x74, 0x65, 0x72, - 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x61, 0x6c, 0x22, 0x49, 0x0a, 0x14, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, - 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, - 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, - 0x08, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2d, 0x0a, 0x03, - 0x65, 0x6e, 0x76, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12, 0x29, 0x0a, 0x10, 0x6f, - 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, - 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x72, 0x63, 0x68, 0x69, 0x74, - 0x65, 0x63, 0x74, 0x75, 0x72, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x72, - 0x63, 0x68, 0x69, 0x74, 0x65, 0x63, 0x74, 0x75, 0x72, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, - 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x24, 0x0a, 0x04, 0x61, 0x70, 0x70, 0x73, - 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x52, 0x04, 0x61, 0x70, 0x70, 0x73, 0x12, 0x16, - 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, - 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, - 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x69, - 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1a, 0x63, 0x6f, 0x6e, - 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, - 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x18, 0x63, - 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, - 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x2f, 0x0a, 0x13, 0x74, 0x72, 0x6f, 0x75, 0x62, - 0x6c, 0x65, 0x73, 0x68, 0x6f, 0x6f, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x74, 0x72, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x73, 0x68, 0x6f, - 0x6f, 0x74, 0x69, 0x6e, 0x67, 0x55, 0x72, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x6f, 0x74, 0x64, - 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x6f, 0x74, - 0x64, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x37, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x3b, - 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x61, 0x70, 0x70, 0x73, 0x18, 0x14, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, 0x70, 0x73, 0x52, 0x0b, - 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, 0x70, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x73, - 0x63, 0x72, 0x69, 0x70, 0x74, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x72, 0x69, 0x70, - 0x74, 0x52, 0x07, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x73, 0x12, 0x2f, 0x0a, 0x0a, 0x65, 0x78, - 0x74, 0x72, 0x61, 0x5f, 0x65, 0x6e, 0x76, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, - 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x45, 0x6e, 0x76, - 0x52, 0x09, 0x65, 0x78, 0x74, 0x72, 0x61, 0x45, 0x6e, 0x76, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6f, - 0x72, 0x64, 0x65, 0x72, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, - 0x72, 0x12, 0x53, 0x0a, 0x14, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x5f, 0x6d, - 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, - 0x67, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, - 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x12, 0x3f, 0x0a, 0x0d, 0x64, 0x65, 0x76, 0x63, 0x6f, 0x6e, - 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x19, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x44, 0x65, 0x76, 0x63, - 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x0d, 0x64, 0x65, 0x76, 0x63, 0x6f, 0x6e, - 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x70, 0x69, 0x5f, 0x6b, - 0x65, 0x79, 0x5f, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x1a, 0xa3, 0x01, 0x0a, 0x08, - 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, - 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, - 0x06, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, - 0x63, 0x72, 0x69, 0x70, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6f, - 0x72, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, - 0x72, 0x1a, 0x36, 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x61, 0x75, 0x74, - 0x68, 0x4a, 0x04, 0x08, 0x0e, 0x10, 0x0f, 0x52, 0x12, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x62, - 0x65, 0x66, 0x6f, 0x72, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x79, 0x22, 0x8f, 0x01, 0x0a, 0x13, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, - 0x69, 0x6e, 0x67, 0x12, 0x3a, 0x0a, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x2e, 0x4d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x12, - 0x3c, 0x0a, 0x07, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, - 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4d, 0x6f, 0x6e, - 0x69, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x22, 0x4f, 0x0a, - 0x15, 0x4d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4d, - 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, - 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0x63, - 0x0a, 0x15, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x18, 0x0a, 0x07, 0x65, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, - 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, - 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, - 0x6f, 0x6c, 0x64, 0x22, 0xc6, 0x01, 0x0a, 0x0b, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, - 0x70, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x06, 0x76, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x76, - 0x73, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x76, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x49, 0x6e, 0x73, 0x69, - 0x64, 0x65, 0x72, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x65, 0x62, 0x5f, 0x74, 0x65, 0x72, 0x6d, - 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x77, 0x65, 0x62, 0x54, - 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x5f, 0x68, - 0x65, 0x6c, 0x70, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x73, 0x68, - 0x48, 0x65, 0x6c, 0x70, 0x65, 0x72, 0x12, 0x34, 0x0a, 0x16, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x66, - 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x68, 0x65, 0x6c, 0x70, 0x65, 0x72, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x70, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x48, 0x65, 0x6c, 0x70, 0x65, 0x72, 0x22, 0x2f, 0x0a, 0x03, - 0x45, 0x6e, 0x76, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x9f, 0x02, - 0x0a, 0x06, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, - 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x69, - 0x63, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, - 0x16, 0x0a, 0x06, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x72, 0x6f, 0x6e, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x72, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, - 0x74, 0x61, 0x72, 0x74, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x5f, 0x6c, 0x6f, 0x67, 0x69, - 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x74, 0x61, 0x72, 0x74, 0x42, 0x6c, - 0x6f, 0x63, 0x6b, 0x73, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x20, 0x0a, 0x0c, 0x72, 0x75, 0x6e, - 0x5f, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0a, 0x72, 0x75, 0x6e, 0x4f, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x1e, 0x0a, 0x0b, 0x72, - 0x75, 0x6e, 0x5f, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x6f, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x09, 0x72, 0x75, 0x6e, 0x4f, 0x6e, 0x53, 0x74, 0x6f, 0x70, 0x12, 0x27, 0x0a, 0x0f, 0x74, - 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x08, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x0e, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, 0x65, 0x63, - 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x5f, 0x70, 0x61, 0x74, 0x68, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x50, 0x61, 0x74, 0x68, 0x22, - 0x6e, 0x0a, 0x0c, 0x44, 0x65, 0x76, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, - 0x29, 0x0a, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x66, 0x6f, 0x6c, - 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x50, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, - 0xaa, 0x03, 0x0a, 0x03, 0x41, 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x21, 0x0a, 0x0c, 0x64, - 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, - 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, - 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, 0x1c, - 0x0a, 0x09, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x09, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x3a, 0x0a, 0x0b, - 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x0b, 0x68, 0x65, 0x61, - 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x41, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, - 0x69, 0x6e, 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, - 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x0c, 0x73, - 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x65, - 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x65, - 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, - 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, - 0x06, 0x68, 0x69, 0x64, 0x64, 0x65, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x68, - 0x69, 0x64, 0x64, 0x65, 0x6e, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x6e, - 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x52, 0x06, - 0x6f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, - 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x22, 0x59, 0x0a, 0x0b, - 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, - 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, - 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, - 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, - 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, - 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0x92, 0x03, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, - 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x69, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x04, 0x68, 0x69, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, - 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, - 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x43, 0x6f, 0x73, 0x74, - 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x50, 0x61, 0x74, - 0x68, 0x1a, 0x69, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, - 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, - 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, 0x5e, 0x0a, 0x06, - 0x4d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, - 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x69, - 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x69, 0x72, 0x22, 0x31, 0x0a, 0x04, - 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x15, 0x0a, 0x06, 0x6f, 0x72, 0x67, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6f, 0x72, 0x67, 0x49, 0x64, 0x22, - 0x48, 0x0a, 0x15, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x41, - 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xca, 0x09, 0x0a, 0x08, 0x4d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, - 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, - 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, - 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, - 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, - 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, - 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, - 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, - 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, - 0x0d, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x74, 0x65, - 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x48, 0x0a, - 0x21, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, - 0x5f, 0x6f, 0x69, 0x64, 0x63, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x4f, 0x69, 0x64, 0x63, 0x41, 0x63, 0x63, 0x65, - 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x41, 0x0a, 0x1d, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1a, - 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x65, - 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0a, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x34, 0x0a, - 0x16, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, - 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, 0x52, 0x14, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x47, 0x72, 0x6f, - 0x75, 0x70, 0x73, 0x12, 0x42, 0x0a, 0x1e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, - 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1a, 0x77, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x53, 0x73, 0x68, 0x50, 0x75, - 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x44, 0x0a, 0x1f, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, - 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x2c, 0x0a, - 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, - 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x64, 0x12, 0x3b, 0x0a, 0x1a, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6c, - 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x4c, - 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x4e, 0x0a, 0x1a, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x72, 0x62, 0x61, 0x63, - 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x13, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x52, - 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x52, - 0x62, 0x61, 0x63, 0x52, 0x6f, 0x6c, 0x65, 0x73, 0x12, 0x6d, 0x0a, 0x1e, 0x70, 0x72, 0x65, 0x62, - 0x75, 0x69, 0x6c, 0x74, 0x5f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, - 0x75, 0x69, 0x6c, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x67, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x28, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, - 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x42, 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x1b, 0x70, 0x72, 0x65, 0x62, - 0x75, 0x69, 0x6c, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, - 0x6c, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x12, 0x5d, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, - 0x6e, 0x67, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, - 0x41, 0x67, 0x65, 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x16, - 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0x8a, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x5f, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x15, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x53, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x41, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, - 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, - 0x32, 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x5f, 0x6c, - 0x6f, 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x4c, 0x65, - 0x76, 0x65, 0x6c, 0x22, 0x0e, 0x0a, 0x0c, 0x50, 0x61, 0x72, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x22, 0xa3, 0x02, 0x0a, 0x0d, 0x50, 0x61, 0x72, 0x73, 0x65, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x4c, 0x0a, 0x12, 0x74, - 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, - 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, - 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, - 0x64, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, - 0x65, 0x12, 0x54, 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, - 0x61, 0x67, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, - 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x1a, 0x40, 0x0a, 0x12, 0x57, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xbe, 0x03, 0x0a, 0x0b, 0x50, 0x6c, - 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x53, 0x0a, 0x15, - 0x72, 0x69, 0x63, 0x68, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, - 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x13, 0x72, 0x69, - 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, - 0x73, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, - 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x59, 0x0a, 0x17, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, - 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, - 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, - 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x15, 0x65, 0x78, 0x74, 0x65, - 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, - 0x73, 0x12, 0x5b, 0x0a, 0x19, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x70, 0x61, - 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x17, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x50, - 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x2a, - 0x0a, 0x11, 0x6f, 0x6d, 0x69, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x66, 0x69, - 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x6f, 0x6d, 0x69, 0x74, 0x4d, - 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0xbf, 0x04, 0x0a, 0x0c, 0x50, - 0x6c, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x3a, 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, - 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, - 0x72, 0x73, 0x12, 0x61, 0x0a, 0x17, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, - 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, - 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x15, - 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, - 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, - 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x74, 0x69, 0x6d, - 0x69, 0x6e, 0x67, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x73, 0x18, - 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x75, - 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x70, 0x72, 0x65, 0x73, 0x65, 0x74, 0x73, 0x18, 0x08, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x65, 0x74, 0x52, 0x07, 0x70, 0x72, 0x65, 0x73, 0x65, - 0x74, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x55, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, - 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, 0x6c, - 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x14, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x21, 0x0a, - 0x0c, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x0b, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, - 0x12, 0x2a, 0x0a, 0x11, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x73, - 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x6d, 0x6f, 0x64, - 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x48, 0x61, 0x73, 0x68, 0x22, 0x41, 0x0a, 0x0c, - 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, - 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, - 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, - 0xbe, 0x02, 0x0a, 0x0d, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, - 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x73, 0x12, 0x3a, 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, - 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, - 0x65, 0x72, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x61, + 0x69, 0x6c, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x22, 0x3b, 0x0a, + 0x0f, 0x50, 0x72, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, + 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x47, 0x0a, 0x13, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x70, 0x61, 0x74, 0x68, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x70, 0x61, + 0x74, 0x68, 0x73, 0x22, 0x57, 0x0a, 0x0d, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, + 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x22, 0x4a, 0x0a, 0x03, + 0x4c, 0x6f, 0x67, 0x12, 0x2b, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, + 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x37, 0x0a, 0x14, 0x49, 0x6e, 0x73, 0x74, + 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x41, 0x75, 0x74, 0x68, + 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, + 0x64, 0x22, 0x4a, 0x0a, 0x1c, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, + 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x22, 0x49, 0x0a, + 0x14, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, + 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, 0x08, 0x0a, 0x05, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2d, 0x0a, 0x03, 0x65, 0x6e, 0x76, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12, 0x29, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6e, 0x67, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, + 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x72, 0x63, 0x68, 0x69, 0x74, 0x65, 0x63, 0x74, 0x75, 0x72, 0x65, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x72, 0x63, 0x68, 0x69, 0x74, 0x65, 0x63, + 0x74, 0x75, 0x72, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x79, 0x12, 0x24, 0x0a, 0x04, 0x61, 0x70, 0x70, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, + 0x70, 0x70, 0x52, 0x04, 0x61, 0x70, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x21, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, + 0x65, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1a, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, + 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x18, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, + 0x73, 0x12, 0x2f, 0x0a, 0x13, 0x74, 0x72, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x73, 0x68, 0x6f, 0x6f, + 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, + 0x74, 0x72, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x73, 0x68, 0x6f, 0x6f, 0x74, 0x69, 0x6e, 0x67, 0x55, + 0x72, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x6f, 0x74, 0x64, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x18, + 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x6f, 0x74, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x12, + 0x37, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x12, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, + 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x3b, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, + 0x6c, 0x61, 0x79, 0x5f, 0x61, 0x70, 0x70, 0x73, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, + 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x44, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, 0x70, 0x73, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, + 0x79, 0x41, 0x70, 0x70, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x73, + 0x18, 0x15, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x52, 0x07, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x73, 0x12, 0x2f, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x72, 0x61, 0x5f, 0x65, 0x6e, + 0x76, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x45, 0x6e, 0x76, 0x52, 0x09, 0x65, 0x78, 0x74, 0x72, + 0x61, 0x45, 0x6e, 0x76, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x17, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x53, 0x0a, 0x14, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x5f, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, + 0x69, 0x6e, 0x67, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x13, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, + 0x12, 0x3f, 0x0a, 0x0d, 0x64, 0x65, 0x76, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, + 0x73, 0x18, 0x19, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x44, 0x65, 0x76, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, + 0x65, 0x72, 0x52, 0x0d, 0x64, 0x65, 0x76, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, + 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x73, 0x63, 0x6f, + 0x70, 0x65, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, + 0x53, 0x63, 0x6f, 0x70, 0x65, 0x1a, 0xa3, 0x01, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, + 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, 0x72, 0x69, 0x70, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x12, + 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x74, + 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x74, 0x69, + 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x1a, 0x36, 0x0a, 0x08, 0x45, + 0x6e, 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x4a, 0x04, 0x08, 0x0e, 0x10, + 0x0f, 0x52, 0x12, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x62, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x5f, + 0x72, 0x65, 0x61, 0x64, 0x79, 0x22, 0x8f, 0x01, 0x0a, 0x13, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x12, 0x3a, 0x0a, + 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x6d, 0x6f, + 0x72, 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, + 0x72, 0x52, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x12, 0x3c, 0x0a, 0x07, 0x76, 0x6f, 0x6c, + 0x75, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x07, + 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x22, 0x4f, 0x0a, 0x15, 0x4d, 0x65, 0x6d, 0x6f, 0x72, + 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, + 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, + 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, + 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0x63, 0x0a, 0x15, 0x56, 0x6f, 0x6c, 0x75, + 0x6d, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, + 0x72, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0xc6, 0x01, + 0x0a, 0x0b, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, 0x70, 0x73, 0x12, 0x16, 0x0a, + 0x06, 0x76, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x76, + 0x73, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x76, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x5f, + 0x69, 0x6e, 0x73, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, + 0x76, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x49, 0x6e, 0x73, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x21, + 0x0a, 0x0c, 0x77, 0x65, 0x62, 0x5f, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x77, 0x65, 0x62, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, + 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x5f, 0x68, 0x65, 0x6c, 0x70, 0x65, 0x72, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x73, 0x68, 0x48, 0x65, 0x6c, 0x70, 0x65, 0x72, + 0x12, 0x34, 0x0a, 0x16, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x69, 0x6e, 0x67, 0x5f, 0x68, 0x65, 0x6c, 0x70, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x14, 0x70, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, + 0x48, 0x65, 0x6c, 0x70, 0x65, 0x72, 0x22, 0x2f, 0x0a, 0x03, 0x45, 0x6e, 0x76, 0x12, 0x12, 0x0a, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x9f, 0x02, 0x0a, 0x06, 0x53, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, + 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x72, 0x69, 0x70, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x72, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x63, 0x72, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x62, + 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x10, 0x73, 0x74, 0x61, 0x72, 0x74, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x4c, 0x6f, + 0x67, 0x69, 0x6e, 0x12, 0x20, 0x0a, 0x0c, 0x72, 0x75, 0x6e, 0x5f, 0x6f, 0x6e, 0x5f, 0x73, 0x74, + 0x61, 0x72, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x72, 0x75, 0x6e, 0x4f, 0x6e, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x75, 0x6e, 0x5f, 0x6f, 0x6e, 0x5f, + 0x73, 0x74, 0x6f, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x72, 0x75, 0x6e, 0x4f, + 0x6e, 0x53, 0x74, 0x6f, 0x70, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, + 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0e, + 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x19, + 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x50, 0x61, 0x74, 0x68, 0x22, 0x6e, 0x0a, 0x0c, 0x44, 0x65, 0x76, + 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x29, 0x0a, 0x10, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x46, 0x6f, + 0x6c, 0x64, 0x65, 0x72, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x70, + 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x50, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0xba, 0x03, 0x0a, 0x03, 0x41, 0x70, + 0x70, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x75, 0x62, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x75, 0x62, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x3a, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, + 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, + 0x63, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, + 0x63, 0x6b, 0x12, 0x41, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x6c, 0x65, + 0x76, 0x65, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, + 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x0c, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, + 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x69, 0x64, 0x64, 0x65, + 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x68, 0x69, 0x64, 0x64, 0x65, 0x6e, 0x12, + 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, + 0x70, 0x70, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x52, 0x06, 0x6f, 0x70, 0x65, 0x6e, 0x49, 0x6e, + 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, + 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x76, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, + 0x64, 0x22, 0x92, 0x03, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, + 0x0a, 0x04, 0x68, 0x69, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x68, 0x69, + 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, + 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x69, + 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x64, + 0x61, 0x69, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x09, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x6f, + 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x1a, 0x69, 0x0a, 0x08, 0x4d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, + 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, + 0x07, 0x69, 0x73, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, + 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, 0x5e, 0x0a, 0x06, 0x4d, 0x6f, 0x64, 0x75, 0x6c, 0x65, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x69, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x64, 0x69, 0x72, 0x22, 0x31, 0x0a, 0x04, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x15, 0x0a, 0x06, 0x6f, 0x72, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x6f, 0x72, 0x67, 0x49, 0x64, 0x22, 0x48, 0x0a, 0x15, 0x52, 0x75, 0x6e, + 0x6e, 0x69, 0x6e, 0x67, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, + 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x22, 0x22, 0x0a, 0x10, 0x41, 0x49, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x69, 0x64, + 0x65, 0x62, 0x61, 0x72, 0x41, 0x70, 0x70, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x58, 0x0a, 0x06, 0x41, 0x49, 0x54, 0x61, 0x73, + 0x6b, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x3e, 0x0a, 0x0b, 0x73, 0x69, 0x64, 0x65, 0x62, 0x61, 0x72, 0x5f, 0x61, 0x70, 0x70, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x49, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x69, 0x64, 0x65, 0x62, + 0x61, 0x72, 0x41, 0x70, 0x70, 0x52, 0x0a, 0x73, 0x69, 0x64, 0x65, 0x62, 0x61, 0x72, 0x41, 0x70, + 0x70, 0x22, 0xca, 0x09, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, + 0x0a, 0x09, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, + 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, + 0x64, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, + 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, + 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, + 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x65, + 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x74, 0x65, + 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x48, 0x0a, 0x21, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6f, 0x69, 0x64, 0x63, 0x5f, 0x61, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x1d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, + 0x4f, 0x69, 0x64, 0x63, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, + 0x41, 0x0a, 0x1d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, + 0x65, 0x72, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1a, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x69, + 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, + 0x65, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, + 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x34, 0x0a, 0x16, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, + 0x0e, 0x20, 0x03, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x42, 0x0a, 0x1e, 0x77, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x73, + 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x0f, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x1a, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, + 0x6e, 0x65, 0x72, 0x53, 0x73, 0x68, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, + 0x44, 0x0a, 0x1f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, + 0x65, 0x72, 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x6b, + 0x65, 0x79, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x53, 0x73, 0x68, 0x50, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, + 0x64, 0x49, 0x64, 0x12, 0x3b, 0x0a, 0x1a, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x4e, 0x0a, 0x1a, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, + 0x6e, 0x65, 0x72, 0x5f, 0x72, 0x62, 0x61, 0x63, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x13, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x52, 0x62, 0x61, 0x63, 0x52, 0x6f, 0x6c, 0x65, 0x73, + 0x12, 0x6d, 0x0a, 0x1e, 0x70, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x74, 0x5f, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x5f, 0x73, 0x74, 0x61, + 0x67, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x28, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x74, 0x57, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, + 0x67, 0x65, 0x52, 0x1b, 0x70, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x74, 0x57, 0x6f, 0x72, 0x6b, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x12, + 0x5d, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x15, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x41, 0x75, 0x74, + 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x16, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x41, + 0x67, 0x65, 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0x8a, + 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x65, 0x6d, + 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x61, 0x72, 0x63, + 0x68, 0x69, 0x76, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x15, 0x74, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x41, 0x72, 0x63, 0x68, 0x69, 0x76, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x32, 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x22, 0x0e, 0x0a, 0x0c, 0x50, + 0x61, 0x72, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xa3, 0x02, 0x0a, 0x0d, + 0x50, 0x61, 0x72, 0x73, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x12, 0x4c, 0x0a, 0x12, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, + 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, + 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, + 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, + 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, 0x65, 0x12, 0x54, 0x0a, 0x0e, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x61, 0x67, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x50, 0x61, 0x72, 0x73, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x2e, 0x57, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, 0x1a, + 0x40, 0x0a, 0x12, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x61, 0x67, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x22, 0xbe, 0x03, 0x0a, 0x0b, 0x50, 0x6c, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x12, 0x53, 0x0a, 0x15, 0x72, 0x69, 0x63, 0x68, 0x5f, 0x70, 0x61, 0x72, + 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x52, 0x13, 0x72, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, + 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, + 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, + 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x59, 0x0a, 0x17, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x29, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x45, 0x78, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x15, 0x65, 0x78, 0x74, 0x65, - 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, - 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x06, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x54, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, - 0x22, 0xfa, 0x01, 0x0a, 0x06, 0x54, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x12, 0x30, 0x0a, 0x05, 0x73, - 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x2c, 0x0a, - 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x61, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, 0x12, 0x2e, 0x0a, - 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x70, + 0x65, 0x72, 0x52, 0x15, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x5b, 0x0a, 0x19, 0x70, 0x72, 0x65, + 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, + 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x17, 0x70, + 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x6f, 0x6d, 0x69, 0x74, 0x5f, 0x6d, + 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0f, 0x6f, 0x6d, 0x69, 0x74, 0x4d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, + 0x65, 0x73, 0x22, 0x91, 0x05, 0x0a, 0x0c, 0x50, 0x6c, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, + 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x3a, + 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0a, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x61, 0x0a, 0x17, 0x65, 0x78, + 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x15, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x2d, 0x0a, + 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, + 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x69, 0x6d, + 0x69, 0x6e, 0x67, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x2d, 0x0a, 0x07, + 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x6f, 0x64, 0x75, + 0x6c, 0x65, 0x52, 0x07, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x70, + 0x72, 0x65, 0x73, 0x65, 0x74, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x65, + 0x74, 0x52, 0x07, 0x70, 0x72, 0x65, 0x73, 0x65, 0x74, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6c, + 0x61, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x55, + 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x61, + 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, + 0x14, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, + 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x6d, 0x6f, 0x64, + 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x6d, 0x6f, 0x64, 0x75, + 0x6c, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, + 0x48, 0x61, 0x73, 0x68, 0x12, 0x20, 0x0a, 0x0c, 0x68, 0x61, 0x73, 0x5f, 0x61, 0x69, 0x5f, 0x74, + 0x61, 0x73, 0x6b, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x68, 0x61, 0x73, 0x41, + 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x69, 0x5f, 0x74, 0x61, 0x73, + 0x6b, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x49, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x07, 0x61, + 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x22, 0x41, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, + 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xee, 0x02, 0x0a, 0x0d, 0x41, 0x70, + 0x70, 0x6c, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, + 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x3a, 0x0a, 0x0a, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, + 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0a, 0x70, 0x61, + 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x61, 0x0a, 0x17, 0x65, 0x78, 0x74, 0x65, + 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x52, 0x15, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, + 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x74, + 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x69, 0x6d, 0x69, 0x6e, - 0x67, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x0f, 0x0a, - 0x0d, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x8c, - 0x02, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2d, 0x0a, 0x06, 0x63, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x48, - 0x00, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x31, 0x0a, 0x05, 0x70, 0x61, 0x72, - 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x05, 0x70, 0x61, 0x72, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x04, - 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x31, 0x0a, 0x05, - 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x12, - 0x34, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x43, 0x61, - 0x6e, 0x63, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x06, 0x63, - 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xc9, 0x02, - 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, - 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, - 0x12, 0x32, 0x0a, 0x05, 0x70, 0x61, 0x72, 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, - 0x72, 0x73, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x05, 0x70, - 0x61, 0x72, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x67, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x69, + 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x49, 0x54, 0x61, 0x73, + 0x6b, 0x52, 0x07, 0x61, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x22, 0xfa, 0x01, 0x0a, 0x06, 0x54, + 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x12, 0x30, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, + 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x65, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x0f, 0x0a, 0x0d, 0x43, 0x61, 0x6e, 0x63, 0x65, + 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x8c, 0x02, 0x0a, 0x07, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x2d, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x48, 0x00, 0x52, 0x06, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x31, 0x0a, 0x05, 0x70, 0x61, 0x72, 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, - 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x32, 0x0a, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, - 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x3a, 0x0a, 0x0b, 0x64, 0x61, 0x74, - 0x61, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, - 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x44, 0x61, 0x74, - 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x48, 0x00, 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x55, - 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x3a, 0x0a, 0x0b, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x70, - 0x69, 0x65, 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x50, 0x69, - 0x65, 0x63, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x50, 0x69, 0x65, 0x63, - 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x0a, 0x44, 0x61, - 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x3c, 0x0a, 0x0b, 0x75, 0x70, 0x6c, 0x6f, - 0x61, 0x64, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x44, 0x61, 0x74, 0x61, - 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x79, 0x70, 0x65, 0x52, 0x0a, 0x75, 0x70, 0x6c, 0x6f, - 0x61, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x68, - 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x64, 0x61, 0x74, 0x61, 0x48, - 0x61, 0x73, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x53, 0x69, 0x7a, 0x65, - 0x12, 0x16, 0x0a, 0x06, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, - 0x52, 0x06, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x22, 0x67, 0x0a, 0x0a, 0x43, 0x68, 0x75, 0x6e, - 0x6b, 0x50, 0x69, 0x65, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x24, 0x0a, 0x0e, 0x66, 0x75, - 0x6c, 0x6c, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x0c, 0x66, 0x75, 0x6c, 0x6c, 0x44, 0x61, 0x74, 0x61, 0x48, 0x61, 0x73, 0x68, - 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x69, 0x65, 0x63, 0x65, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x70, 0x69, 0x65, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, - 0x78, 0x2a, 0xa8, 0x01, 0x0a, 0x11, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x46, - 0x6f, 0x72, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x46, 0x41, 0x55, - 0x4c, 0x54, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x46, 0x4f, 0x52, 0x4d, 0x5f, 0x45, 0x52, 0x52, - 0x4f, 0x52, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x52, 0x41, 0x44, 0x49, 0x4f, 0x10, 0x02, 0x12, - 0x0c, 0x0a, 0x08, 0x44, 0x52, 0x4f, 0x50, 0x44, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, - 0x05, 0x49, 0x4e, 0x50, 0x55, 0x54, 0x10, 0x04, 0x12, 0x0c, 0x0a, 0x08, 0x54, 0x45, 0x58, 0x54, - 0x41, 0x52, 0x45, 0x41, 0x10, 0x05, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x4c, 0x49, 0x44, 0x45, 0x52, - 0x10, 0x06, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x42, 0x4f, 0x58, 0x10, 0x07, - 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x57, 0x49, 0x54, 0x43, 0x48, 0x10, 0x08, 0x12, 0x0d, 0x0a, 0x09, - 0x54, 0x41, 0x47, 0x53, 0x45, 0x4c, 0x45, 0x43, 0x54, 0x10, 0x09, 0x12, 0x0f, 0x0a, 0x0b, 0x4d, - 0x55, 0x4c, 0x54, 0x49, 0x53, 0x45, 0x4c, 0x45, 0x43, 0x54, 0x10, 0x0a, 0x2a, 0x3f, 0x0a, 0x08, - 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, - 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, - 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, - 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, - 0x0f, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, - 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, - 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, - 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x2a, 0x35, 0x0a, 0x09, 0x41, 0x70, - 0x70, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x12, 0x0e, 0x0a, 0x06, 0x57, 0x49, 0x4e, 0x44, 0x4f, - 0x57, 0x10, 0x00, 0x1a, 0x02, 0x08, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x53, 0x4c, 0x49, 0x4d, 0x5f, - 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x41, 0x42, 0x10, - 0x02, 0x2a, 0x37, 0x0a, 0x13, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, - 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, - 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x54, 0x4f, 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, - 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10, 0x02, 0x2a, 0x3e, 0x0a, 0x1b, 0x50, 0x72, - 0x65, 0x62, 0x75, 0x69, 0x6c, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, - 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4e, - 0x45, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, - 0x09, 0x0a, 0x05, 0x43, 0x4c, 0x41, 0x49, 0x4d, 0x10, 0x02, 0x2a, 0x35, 0x0a, 0x0b, 0x54, 0x69, - 0x6d, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x41, - 0x52, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x4f, 0x4d, 0x50, 0x4c, 0x45, - 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, - 0x02, 0x2a, 0x47, 0x0a, 0x0e, 0x44, 0x61, 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, - 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x4c, 0x4f, 0x41, 0x44, 0x5f, 0x54, 0x59, - 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x1c, 0x0a, 0x18, - 0x55, 0x50, 0x4c, 0x4f, 0x41, 0x44, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x55, - 0x4c, 0x45, 0x5f, 0x46, 0x49, 0x4c, 0x45, 0x53, 0x10, 0x01, 0x32, 0x49, 0x0a, 0x0b, 0x50, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x12, 0x3a, 0x0a, 0x07, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x30, 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, - 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, - 0x76, 0x32, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x73, 0x64, - 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, + 0x05, 0x70, 0x61, 0x72, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, + 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x31, 0x0a, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x34, 0x0a, 0x06, 0x63, 0x61, 0x6e, + 0x63, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x42, + 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xc9, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x61, + 0x72, 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x43, 0x6f, 0x6d, + 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x05, 0x70, 0x61, 0x72, 0x73, 0x65, 0x12, 0x2f, + 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x43, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, + 0x32, 0x0a, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, + 0x6c, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, + 0x70, 0x6c, 0x79, 0x12, 0x3a, 0x0a, 0x0b, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x75, 0x70, 0x6c, 0x6f, + 0x61, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, + 0x64, 0x48, 0x00, 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, + 0x3a, 0x0a, 0x0b, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x70, 0x69, 0x65, 0x63, 0x65, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x50, 0x69, 0x65, 0x63, 0x65, 0x48, 0x00, 0x52, + 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x50, 0x69, 0x65, 0x63, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, + 0x79, 0x70, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x0a, 0x44, 0x61, 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, + 0x61, 0x64, 0x12, 0x3c, 0x0a, 0x0b, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, + 0x54, 0x79, 0x70, 0x65, 0x52, 0x0a, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x08, 0x64, 0x61, 0x74, 0x61, 0x48, 0x61, 0x73, 0x68, 0x12, 0x1b, 0x0a, + 0x09, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x68, + 0x75, 0x6e, 0x6b, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x63, 0x68, 0x75, 0x6e, + 0x6b, 0x73, 0x22, 0x67, 0x0a, 0x0a, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x50, 0x69, 0x65, 0x63, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, + 0x64, 0x61, 0x74, 0x61, 0x12, 0x24, 0x0a, 0x0e, 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x64, 0x61, 0x74, + 0x61, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x66, 0x75, + 0x6c, 0x6c, 0x44, 0x61, 0x74, 0x61, 0x48, 0x61, 0x73, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x69, + 0x65, 0x63, 0x65, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x0a, 0x70, 0x69, 0x65, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x2a, 0xa8, 0x01, 0x0a, 0x11, + 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x46, 0x6f, 0x72, 0x6d, 0x54, 0x79, 0x70, + 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x0e, + 0x0a, 0x0a, 0x46, 0x4f, 0x52, 0x4d, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x01, 0x12, 0x09, + 0x0a, 0x05, 0x52, 0x41, 0x44, 0x49, 0x4f, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x52, 0x4f, + 0x50, 0x44, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x50, 0x55, 0x54, + 0x10, 0x04, 0x12, 0x0c, 0x0a, 0x08, 0x54, 0x45, 0x58, 0x54, 0x41, 0x52, 0x45, 0x41, 0x10, 0x05, + 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x4c, 0x49, 0x44, 0x45, 0x52, 0x10, 0x06, 0x12, 0x0c, 0x0a, 0x08, + 0x43, 0x48, 0x45, 0x43, 0x4b, 0x42, 0x4f, 0x58, 0x10, 0x07, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x57, + 0x49, 0x54, 0x43, 0x48, 0x10, 0x08, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x41, 0x47, 0x53, 0x45, 0x4c, + 0x45, 0x43, 0x54, 0x10, 0x09, 0x12, 0x0f, 0x0a, 0x0b, 0x4d, 0x55, 0x4c, 0x54, 0x49, 0x53, 0x45, + 0x4c, 0x45, 0x43, 0x54, 0x10, 0x0a, 0x2a, 0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, + 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, + 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, + 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, + 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, 0x0f, 0x41, 0x70, 0x70, 0x53, 0x68, + 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, + 0x4e, 0x45, 0x52, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, + 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, + 0x49, 0x43, 0x10, 0x02, 0x2a, 0x35, 0x0a, 0x09, 0x41, 0x70, 0x70, 0x4f, 0x70, 0x65, 0x6e, 0x49, + 0x6e, 0x12, 0x0e, 0x0a, 0x06, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x10, 0x00, 0x1a, 0x02, 0x08, + 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x53, 0x4c, 0x49, 0x4d, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, + 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x41, 0x42, 0x10, 0x02, 0x2a, 0x37, 0x0a, 0x13, 0x57, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, + 0x04, 0x53, 0x54, 0x4f, 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, + 0x4f, 0x59, 0x10, 0x02, 0x2a, 0x3e, 0x0a, 0x1b, 0x50, 0x72, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x74, + 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, + 0x61, 0x67, 0x65, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4e, 0x45, 0x10, 0x00, 0x12, 0x0a, 0x0a, + 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x43, 0x4c, 0x41, + 0x49, 0x4d, 0x10, 0x02, 0x2a, 0x35, 0x0a, 0x0b, 0x54, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x53, 0x74, + 0x61, 0x74, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x41, 0x52, 0x54, 0x45, 0x44, 0x10, 0x00, + 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x4f, 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, + 0x0a, 0x0a, 0x06, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x47, 0x0a, 0x0e, 0x44, + 0x61, 0x74, 0x61, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, + 0x13, 0x55, 0x50, 0x4c, 0x4f, 0x41, 0x44, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x4b, + 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x1c, 0x0a, 0x18, 0x55, 0x50, 0x4c, 0x4f, 0x41, 0x44, + 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x55, 0x4c, 0x45, 0x5f, 0x46, 0x49, 0x4c, + 0x45, 0x53, 0x10, 0x01, 0x32, 0x49, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x12, 0x3a, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, + 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, + 0x30, 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -4871,7 +5037,7 @@ func file_provisionersdk_proto_provisioner_proto_rawDescGZIP() []byte { } var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 49) +var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 51) var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ (ParameterFormType)(0), // 0: provisioner.ParameterFormType (LogLevel)(0), // 1: provisioner.LogLevel @@ -4912,25 +5078,27 @@ var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ (*Module)(nil), // 36: provisioner.Module (*Role)(nil), // 37: provisioner.Role (*RunningAgentAuthToken)(nil), // 38: provisioner.RunningAgentAuthToken - (*Metadata)(nil), // 39: provisioner.Metadata - (*Config)(nil), // 40: provisioner.Config - (*ParseRequest)(nil), // 41: provisioner.ParseRequest - (*ParseComplete)(nil), // 42: provisioner.ParseComplete - (*PlanRequest)(nil), // 43: provisioner.PlanRequest - (*PlanComplete)(nil), // 44: provisioner.PlanComplete - (*ApplyRequest)(nil), // 45: provisioner.ApplyRequest - (*ApplyComplete)(nil), // 46: provisioner.ApplyComplete - (*Timing)(nil), // 47: provisioner.Timing - (*CancelRequest)(nil), // 48: provisioner.CancelRequest - (*Request)(nil), // 49: provisioner.Request - (*Response)(nil), // 50: provisioner.Response - (*DataUpload)(nil), // 51: provisioner.DataUpload - (*ChunkPiece)(nil), // 52: provisioner.ChunkPiece - (*Agent_Metadata)(nil), // 53: provisioner.Agent.Metadata - nil, // 54: provisioner.Agent.EnvEntry - (*Resource_Metadata)(nil), // 55: provisioner.Resource.Metadata - nil, // 56: provisioner.ParseComplete.WorkspaceTagsEntry - (*timestamppb.Timestamp)(nil), // 57: google.protobuf.Timestamp + (*AITaskSidebarApp)(nil), // 39: provisioner.AITaskSidebarApp + (*AITask)(nil), // 40: provisioner.AITask + (*Metadata)(nil), // 41: provisioner.Metadata + (*Config)(nil), // 42: provisioner.Config + (*ParseRequest)(nil), // 43: provisioner.ParseRequest + (*ParseComplete)(nil), // 44: provisioner.ParseComplete + (*PlanRequest)(nil), // 45: provisioner.PlanRequest + (*PlanComplete)(nil), // 46: provisioner.PlanComplete + (*ApplyRequest)(nil), // 47: provisioner.ApplyRequest + (*ApplyComplete)(nil), // 48: provisioner.ApplyComplete + (*Timing)(nil), // 49: provisioner.Timing + (*CancelRequest)(nil), // 50: provisioner.CancelRequest + (*Request)(nil), // 51: provisioner.Request + (*Response)(nil), // 52: provisioner.Response + (*DataUpload)(nil), // 53: provisioner.DataUpload + (*ChunkPiece)(nil), // 54: provisioner.ChunkPiece + (*Agent_Metadata)(nil), // 55: provisioner.Agent.Metadata + nil, // 56: provisioner.Agent.EnvEntry + (*Resource_Metadata)(nil), // 57: provisioner.Resource.Metadata + nil, // 58: provisioner.ParseComplete.WorkspaceTagsEntry + (*timestamppb.Timestamp)(nil), // 59: google.protobuf.Timestamp } var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 10, // 0: provisioner.RichParameter.options:type_name -> provisioner.RichParameterOption @@ -4941,9 +5109,9 @@ var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 18, // 5: provisioner.Preset.parameters:type_name -> provisioner.PresetParameter 16, // 6: provisioner.Preset.prebuild:type_name -> provisioner.Prebuild 1, // 7: provisioner.Log.level:type_name -> provisioner.LogLevel - 54, // 8: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry + 56, // 8: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry 33, // 9: provisioner.Agent.apps:type_name -> provisioner.App - 53, // 10: provisioner.Agent.metadata:type_name -> provisioner.Agent.Metadata + 55, // 10: provisioner.Agent.metadata:type_name -> provisioner.Agent.Metadata 29, // 11: provisioner.Agent.display_apps:type_name -> provisioner.DisplayApps 31, // 12: provisioner.Agent.scripts:type_name -> provisioner.Script 30, // 13: provisioner.Agent.extra_envs:type_name -> provisioner.Env @@ -4955,52 +5123,55 @@ var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 2, // 19: provisioner.App.sharing_level:type_name -> provisioner.AppSharingLevel 3, // 20: provisioner.App.open_in:type_name -> provisioner.AppOpenIn 25, // 21: provisioner.Resource.agents:type_name -> provisioner.Agent - 55, // 22: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata - 4, // 23: provisioner.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition - 37, // 24: provisioner.Metadata.workspace_owner_rbac_roles:type_name -> provisioner.Role - 5, // 25: provisioner.Metadata.prebuilt_workspace_build_stage:type_name -> provisioner.PrebuiltWorkspaceBuildStage - 38, // 26: provisioner.Metadata.running_agent_auth_tokens:type_name -> provisioner.RunningAgentAuthToken - 9, // 27: provisioner.ParseComplete.template_variables:type_name -> provisioner.TemplateVariable - 56, // 28: provisioner.ParseComplete.workspace_tags:type_name -> provisioner.ParseComplete.WorkspaceTagsEntry - 39, // 29: provisioner.PlanRequest.metadata:type_name -> provisioner.Metadata - 12, // 30: provisioner.PlanRequest.rich_parameter_values:type_name -> provisioner.RichParameterValue - 20, // 31: provisioner.PlanRequest.variable_values:type_name -> provisioner.VariableValue - 24, // 32: provisioner.PlanRequest.external_auth_providers:type_name -> provisioner.ExternalAuthProvider - 12, // 33: provisioner.PlanRequest.previous_parameter_values:type_name -> provisioner.RichParameterValue - 35, // 34: provisioner.PlanComplete.resources:type_name -> provisioner.Resource - 11, // 35: provisioner.PlanComplete.parameters:type_name -> provisioner.RichParameter - 23, // 36: provisioner.PlanComplete.external_auth_providers:type_name -> provisioner.ExternalAuthProviderResource - 47, // 37: provisioner.PlanComplete.timings:type_name -> provisioner.Timing - 36, // 38: provisioner.PlanComplete.modules:type_name -> provisioner.Module - 17, // 39: provisioner.PlanComplete.presets:type_name -> provisioner.Preset - 19, // 40: provisioner.PlanComplete.resource_replacements:type_name -> provisioner.ResourceReplacement - 39, // 41: provisioner.ApplyRequest.metadata:type_name -> provisioner.Metadata - 35, // 42: provisioner.ApplyComplete.resources:type_name -> provisioner.Resource - 11, // 43: provisioner.ApplyComplete.parameters:type_name -> provisioner.RichParameter - 23, // 44: provisioner.ApplyComplete.external_auth_providers:type_name -> provisioner.ExternalAuthProviderResource - 47, // 45: provisioner.ApplyComplete.timings:type_name -> provisioner.Timing - 57, // 46: provisioner.Timing.start:type_name -> google.protobuf.Timestamp - 57, // 47: provisioner.Timing.end:type_name -> google.protobuf.Timestamp - 6, // 48: provisioner.Timing.state:type_name -> provisioner.TimingState - 40, // 49: provisioner.Request.config:type_name -> provisioner.Config - 41, // 50: provisioner.Request.parse:type_name -> provisioner.ParseRequest - 43, // 51: provisioner.Request.plan:type_name -> provisioner.PlanRequest - 45, // 52: provisioner.Request.apply:type_name -> provisioner.ApplyRequest - 48, // 53: provisioner.Request.cancel:type_name -> provisioner.CancelRequest - 21, // 54: provisioner.Response.log:type_name -> provisioner.Log - 42, // 55: provisioner.Response.parse:type_name -> provisioner.ParseComplete - 44, // 56: provisioner.Response.plan:type_name -> provisioner.PlanComplete - 46, // 57: provisioner.Response.apply:type_name -> provisioner.ApplyComplete - 51, // 58: provisioner.Response.data_upload:type_name -> provisioner.DataUpload - 52, // 59: provisioner.Response.chunk_piece:type_name -> provisioner.ChunkPiece - 7, // 60: provisioner.DataUpload.upload_type:type_name -> provisioner.DataUploadType - 49, // 61: provisioner.Provisioner.Session:input_type -> provisioner.Request - 50, // 62: provisioner.Provisioner.Session:output_type -> provisioner.Response - 62, // [62:63] is the sub-list for method output_type - 61, // [61:62] is the sub-list for method input_type - 61, // [61:61] is the sub-list for extension type_name - 61, // [61:61] is the sub-list for extension extendee - 0, // [0:61] is the sub-list for field type_name + 57, // 22: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata + 39, // 23: provisioner.AITask.sidebar_app:type_name -> provisioner.AITaskSidebarApp + 4, // 24: provisioner.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition + 37, // 25: provisioner.Metadata.workspace_owner_rbac_roles:type_name -> provisioner.Role + 5, // 26: provisioner.Metadata.prebuilt_workspace_build_stage:type_name -> provisioner.PrebuiltWorkspaceBuildStage + 38, // 27: provisioner.Metadata.running_agent_auth_tokens:type_name -> provisioner.RunningAgentAuthToken + 9, // 28: provisioner.ParseComplete.template_variables:type_name -> provisioner.TemplateVariable + 58, // 29: provisioner.ParseComplete.workspace_tags:type_name -> provisioner.ParseComplete.WorkspaceTagsEntry + 41, // 30: provisioner.PlanRequest.metadata:type_name -> provisioner.Metadata + 12, // 31: provisioner.PlanRequest.rich_parameter_values:type_name -> provisioner.RichParameterValue + 20, // 32: provisioner.PlanRequest.variable_values:type_name -> provisioner.VariableValue + 24, // 33: provisioner.PlanRequest.external_auth_providers:type_name -> provisioner.ExternalAuthProvider + 12, // 34: provisioner.PlanRequest.previous_parameter_values:type_name -> provisioner.RichParameterValue + 35, // 35: provisioner.PlanComplete.resources:type_name -> provisioner.Resource + 11, // 36: provisioner.PlanComplete.parameters:type_name -> provisioner.RichParameter + 23, // 37: provisioner.PlanComplete.external_auth_providers:type_name -> provisioner.ExternalAuthProviderResource + 49, // 38: provisioner.PlanComplete.timings:type_name -> provisioner.Timing + 36, // 39: provisioner.PlanComplete.modules:type_name -> provisioner.Module + 17, // 40: provisioner.PlanComplete.presets:type_name -> provisioner.Preset + 19, // 41: provisioner.PlanComplete.resource_replacements:type_name -> provisioner.ResourceReplacement + 40, // 42: provisioner.PlanComplete.ai_tasks:type_name -> provisioner.AITask + 41, // 43: provisioner.ApplyRequest.metadata:type_name -> provisioner.Metadata + 35, // 44: provisioner.ApplyComplete.resources:type_name -> provisioner.Resource + 11, // 45: provisioner.ApplyComplete.parameters:type_name -> provisioner.RichParameter + 23, // 46: provisioner.ApplyComplete.external_auth_providers:type_name -> provisioner.ExternalAuthProviderResource + 49, // 47: provisioner.ApplyComplete.timings:type_name -> provisioner.Timing + 40, // 48: provisioner.ApplyComplete.ai_tasks:type_name -> provisioner.AITask + 59, // 49: provisioner.Timing.start:type_name -> google.protobuf.Timestamp + 59, // 50: provisioner.Timing.end:type_name -> google.protobuf.Timestamp + 6, // 51: provisioner.Timing.state:type_name -> provisioner.TimingState + 42, // 52: provisioner.Request.config:type_name -> provisioner.Config + 43, // 53: provisioner.Request.parse:type_name -> provisioner.ParseRequest + 45, // 54: provisioner.Request.plan:type_name -> provisioner.PlanRequest + 47, // 55: provisioner.Request.apply:type_name -> provisioner.ApplyRequest + 50, // 56: provisioner.Request.cancel:type_name -> provisioner.CancelRequest + 21, // 57: provisioner.Response.log:type_name -> provisioner.Log + 44, // 58: provisioner.Response.parse:type_name -> provisioner.ParseComplete + 46, // 59: provisioner.Response.plan:type_name -> provisioner.PlanComplete + 48, // 60: provisioner.Response.apply:type_name -> provisioner.ApplyComplete + 53, // 61: provisioner.Response.data_upload:type_name -> provisioner.DataUpload + 54, // 62: provisioner.Response.chunk_piece:type_name -> provisioner.ChunkPiece + 7, // 63: provisioner.DataUpload.upload_type:type_name -> provisioner.DataUploadType + 51, // 64: provisioner.Provisioner.Session:input_type -> provisioner.Request + 52, // 65: provisioner.Provisioner.Session:output_type -> provisioner.Response + 65, // [65:66] is the sub-list for method output_type + 64, // [64:65] is the sub-list for method input_type + 64, // [64:64] is the sub-list for extension type_name + 64, // [64:64] is the sub-list for extension extendee + 0, // [0:64] is the sub-list for field type_name } func init() { file_provisionersdk_proto_provisioner_proto_init() } @@ -5382,7 +5553,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Metadata); i { + switch v := v.(*AITaskSidebarApp); i { case 0: return &v.state case 1: @@ -5394,7 +5565,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Config); i { + switch v := v.(*AITask); i { case 0: return &v.state case 1: @@ -5406,7 +5577,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ParseRequest); i { + switch v := v.(*Metadata); i { case 0: return &v.state case 1: @@ -5418,7 +5589,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ParseComplete); i { + switch v := v.(*Config); i { case 0: return &v.state case 1: @@ -5430,7 +5601,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PlanRequest); i { + switch v := v.(*ParseRequest); i { case 0: return &v.state case 1: @@ -5442,7 +5613,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PlanComplete); i { + switch v := v.(*ParseComplete); i { case 0: return &v.state case 1: @@ -5454,7 +5625,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ApplyRequest); i { + switch v := v.(*PlanRequest); i { case 0: return &v.state case 1: @@ -5466,7 +5637,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ApplyComplete); i { + switch v := v.(*PlanComplete); i { case 0: return &v.state case 1: @@ -5478,7 +5649,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Timing); i { + switch v := v.(*ApplyRequest); i { case 0: return &v.state case 1: @@ -5490,7 +5661,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CancelRequest); i { + switch v := v.(*ApplyComplete); i { case 0: return &v.state case 1: @@ -5502,7 +5673,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Request); i { + switch v := v.(*Timing); i { case 0: return &v.state case 1: @@ -5514,7 +5685,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Response); i { + switch v := v.(*CancelRequest); i { case 0: return &v.state case 1: @@ -5526,7 +5697,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DataUpload); i { + switch v := v.(*Request); i { case 0: return &v.state case 1: @@ -5538,7 +5709,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ChunkPiece); i { + switch v := v.(*Response); i { case 0: return &v.state case 1: @@ -5550,7 +5721,19 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Agent_Metadata); i { + switch v := v.(*DataUpload); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_provisionersdk_proto_provisioner_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ChunkPiece); i { case 0: return &v.state case 1: @@ -5562,6 +5745,18 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Agent_Metadata); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_provisionersdk_proto_provisioner_proto_msgTypes[49].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Resource_Metadata); i { case 0: return &v.state @@ -5579,14 +5774,14 @@ func file_provisionersdk_proto_provisioner_proto_init() { (*Agent_Token)(nil), (*Agent_InstanceId)(nil), } - file_provisionersdk_proto_provisioner_proto_msgTypes[41].OneofWrappers = []interface{}{ + file_provisionersdk_proto_provisioner_proto_msgTypes[43].OneofWrappers = []interface{}{ (*Request_Config)(nil), (*Request_Parse)(nil), (*Request_Plan)(nil), (*Request_Apply)(nil), (*Request_Cancel)(nil), } - file_provisionersdk_proto_provisioner_proto_msgTypes[42].OneofWrappers = []interface{}{ + file_provisionersdk_proto_provisioner_proto_msgTypes[44].OneofWrappers = []interface{}{ (*Response_Log)(nil), (*Response_Parse)(nil), (*Response_Plan)(nil), @@ -5600,7 +5795,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_provisionersdk_proto_provisioner_proto_rawDesc, NumEnums: 8, - NumMessages: 49, + NumMessages: 51, NumExtensions: 0, NumServices: 1, }, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index cd4eb4960eb11..a57983c21ad9b 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -100,6 +100,7 @@ message Preset { string name = 1; repeated PresetParameter parameters = 2; Prebuild prebuild = 3; + bool default = 4; } message PresetParameter { @@ -266,6 +267,7 @@ message App { bool hidden = 11; AppOpenIn open_in = 12; string group = 13; + string id = 14; // If nil, new UUID will be generated. } // Healthcheck represents configuration for checking for app readiness. @@ -324,6 +326,15 @@ enum PrebuiltWorkspaceBuildStage { CLAIM = 2; // A prebuilt workspace is being claimed. } +message AITaskSidebarApp { + string id = 1; +} + +message AITask { + string id = 1; + AITaskSidebarApp sidebar_app = 2; +} + // Metadata is information about a workspace used in the execution of a build message Metadata { string coder_url = 1; @@ -399,6 +410,13 @@ message PlanComplete { repeated ResourceReplacement resource_replacements = 10; bytes module_files = 11; bytes module_files_hash = 12; + // Whether a template has any `coder_ai_task` resources defined, even if not planned for creation. + // During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we + // still need to know that such resources are defined. + // + // See `hasAITaskResources` in provisioner/terraform/resources.go for more details. + bool has_ai_tasks = 13; + repeated provisioner.AITask ai_tasks = 14; } // ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response @@ -415,6 +433,7 @@ message ApplyComplete { repeated RichParameter parameters = 4; repeated ExternalAuthProviderResource external_auth_providers = 5; repeated Timing timings = 6; + repeated provisioner.AITask ai_tasks = 7; } message Timing { diff --git a/provisionersdk/provisionertags_test.go b/provisionersdk/provisionertags_test.go index 70e05473eecc0..070285aea6c50 100644 --- a/provisionersdk/provisionertags_test.go +++ b/provisionersdk/provisionertags_test.go @@ -185,7 +185,6 @@ func TestMutateTags(t *testing.T) { }, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got := provisionersdk.MutateTags(tt.userID, tt.tags...) diff --git a/pty/ptytest/ptytest_test.go b/pty/ptytest/ptytest_test.go index 2b1b5570b18ea..29011ba9e7e61 100644 --- a/pty/ptytest/ptytest_test.go +++ b/pty/ptytest/ptytest_test.go @@ -56,7 +56,6 @@ func TestPtytest(t *testing.T) { {name: "10241 large output", output: strings.Repeat(".", 10241)}, // 1024 * 10 + 1 } for _, tt := range tests { - tt := tt // nolint:paralleltest // Avoid parallel test to more easily identify the issue. t.Run(tt.name, func(t *testing.T) { cmd := &serpent.Command{ diff --git a/scaletest/agentconn/config_test.go b/scaletest/agentconn/config_test.go index 5f5cdf7c53da7..412d7f6926119 100644 --- a/scaletest/agentconn/config_test.go +++ b/scaletest/agentconn/config_test.go @@ -167,8 +167,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scaletest/createworkspaces/config_test.go b/scaletest/createworkspaces/config_test.go index 6a3d9e8104624..3965e9f9dfb69 100644 --- a/scaletest/createworkspaces/config_test.go +++ b/scaletest/createworkspaces/config_test.go @@ -63,8 +63,6 @@ func Test_UserConfig(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -177,8 +175,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scaletest/harness/strategies.go b/scaletest/harness/strategies.go index 24bb04e871880..7d5067a4e1eb3 100644 --- a/scaletest/harness/strategies.go +++ b/scaletest/harness/strategies.go @@ -122,7 +122,6 @@ var _ ExecutionStrategy = TimeoutExecutionStrategyWrapper{} func (t TimeoutExecutionStrategyWrapper) Run(ctx context.Context, fns []TestFn) ([]error, error) { newFns := make([]TestFn, len(fns)) for i, fn := range fns { - fn := fn newFns[i] = func(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, t.Timeout) defer cancel() diff --git a/scaletest/harness/strategies_test.go b/scaletest/harness/strategies_test.go index 0858b5bf71da1..b18036a7931d3 100644 --- a/scaletest/harness/strategies_test.go +++ b/scaletest/harness/strategies_test.go @@ -186,8 +186,6 @@ func strategyTestData(count int, runFn func(ctx context.Context, i int, logs io. fns = make([]harness.TestFn, count) ) for i := 0; i < count; i++ { - i := i - runs[i] = harness.NewTestRun("test", strconv.Itoa(i), testFns{ RunFn: func(ctx context.Context, id string, logs io.Writer) error { if runFn != nil { diff --git a/scaletest/placebo/config_test.go b/scaletest/placebo/config_test.go index 8e3a40000a02e..84458c28a8d8e 100644 --- a/scaletest/placebo/config_test.go +++ b/scaletest/placebo/config_test.go @@ -98,8 +98,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scaletest/reconnectingpty/config_test.go b/scaletest/reconnectingpty/config_test.go index e0712b4631097..1b7646ad744d9 100644 --- a/scaletest/reconnectingpty/config_test.go +++ b/scaletest/reconnectingpty/config_test.go @@ -61,8 +61,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scaletest/workspacebuild/config_test.go b/scaletest/workspacebuild/config_test.go index b9c427f104f3d..c80b48df9055c 100644 --- a/scaletest/workspacebuild/config_test.go +++ b/scaletest/workspacebuild/config_test.go @@ -78,8 +78,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scripts/apitypings/main_test.go b/scripts/apitypings/main_test.go index 0ac20363327c3..1bb89c7ba5423 100644 --- a/scripts/apitypings/main_test.go +++ b/scripts/apitypings/main_test.go @@ -31,7 +31,6 @@ func TestGeneration(t *testing.T) { // Only test directories continue } - f := f t.Run(f.Name(), func(t *testing.T) { t.Parallel() dir := filepath.Join(".", "testdata", f.Name()) diff --git a/scripts/embedded-pg/main.go b/scripts/embedded-pg/main.go index aa6de1027f54d..705fec712693f 100644 --- a/scripts/embedded-pg/main.go +++ b/scripts/embedded-pg/main.go @@ -4,31 +4,43 @@ package main import ( "database/sql" "flag" + "log" "os" "path/filepath" + "time" embeddedpostgres "github.com/fergusstrange/embedded-postgres" ) func main() { var customPath string + var cachePath string flag.StringVar(&customPath, "path", "", "Optional custom path for postgres data directory") + flag.StringVar(&cachePath, "cache", "", "Optional custom path for embedded postgres binaries") flag.Parse() postgresPath := filepath.Join(os.TempDir(), "coder-test-postgres") if customPath != "" { postgresPath = customPath } + if err := os.MkdirAll(postgresPath, os.ModePerm); err != nil { + log.Fatalf("Failed to create directory %s: %v", postgresPath, err) + } + if cachePath == "" { + cachePath = filepath.Join(postgresPath, "cache") + } + if err := os.MkdirAll(cachePath, os.ModePerm); err != nil { + log.Fatalf("Failed to create directory %s: %v", cachePath, err) + } ep := embeddedpostgres.NewDatabase( embeddedpostgres.DefaultConfig(). Version(embeddedpostgres.V16). BinariesPath(filepath.Join(postgresPath, "bin")). - // Default BinaryRepositoryURL repo1.maven.org is flaky. BinaryRepositoryURL("https://repo.maven.apache.org/maven2"). DataPath(filepath.Join(postgresPath, "data")). RuntimePath(filepath.Join(postgresPath, "runtime")). - CachePath(filepath.Join(postgresPath, "cache")). + CachePath(cachePath). Username("postgres"). Password("postgres"). Database("postgres"). @@ -38,8 +50,27 @@ func main() { ) err := ep.Start() if err != nil { - panic(err) + log.Fatalf("Failed to start embedded postgres: %v", err) + } + + // Troubleshooting: list files in cachePath + if err := filepath.Walk(cachePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + switch { + case info.IsDir(): + log.Printf("D: %s", path) + case info.Mode().IsRegular(): + log.Printf("F: %s [%s] (%d bytes) %s", path, info.Mode().String(), info.Size(), info.ModTime().Format(time.RFC3339)) + default: + log.Printf("Other: %s [%s] %s", path, info.Mode(), info.ModTime().Format(time.RFC3339)) + } + return nil + }); err != nil { + log.Printf("Failed to list files in cachePath %s: %v", cachePath, err) } + // We execute these queries instead of using the embeddedpostgres // StartParams because it doesn't work on Windows. The library // seems to have a bug where it sends malformed parameters to @@ -58,21 +89,21 @@ func main() { } db, err := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable") if err != nil { - panic(err) + log.Fatalf("Failed to connect to embedded postgres: %v", err) } for _, query := range paramQueries { if _, err := db.Exec(query); err != nil { - panic(err) + log.Fatalf("Failed to execute setup query %q: %v", query, err) } } if err := db.Close(); err != nil { - panic(err) + log.Fatalf("Failed to close database connection: %v", err) } // We restart the database to apply all the parameters. if err := ep.Stop(); err != nil { - panic(err) + log.Fatalf("Failed to stop embedded postgres after applying parameters: %v", err) } if err := ep.Start(); err != nil { - panic(err) + log.Fatalf("Failed to start embedded postgres after applying parameters: %v", err) } } diff --git a/scripts/rbac-authz/benchmark_authz.sh b/scripts/rbac-authz/benchmark_authz.sh new file mode 100755 index 0000000000000..3c96dbfae8512 --- /dev/null +++ b/scripts/rbac-authz/benchmark_authz.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +# Run rbac authz benchmark tests on the current Git branch or compare benchmark results +# between two branches using `benchstat`. +# +# The script supports: +# 1) Running benchmarks and saving output to a file. +# 2) Checking out two branches, running benchmarks on each, and saving the `benchstat` +# comparison results to a file. +# Benchmark results are saved with filenames based on the branch name. +# +# Usage: +# benchmark_authz.sh --single # Run benchmarks on current branch +# benchmark_authz.sh --compare # Compare benchmarks between two branches + +set -euo pipefail + +# Go benchmark parameters +GOMAXPROCS=16 +TIMEOUT=30m +BENCHTIME=5s +COUNT=5 + +# Script configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DIR="${SCRIPT_DIR}/benchmark_outputs" + +# List of benchmark tests +BENCHMARKS=( + BenchmarkRBACAuthorize + BenchmarkRBACAuthorizeGroups + BenchmarkRBACFilter +) + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +function run_benchmarks() { + local branch=$1 + # Replace '/' with '-' for branch names with format user/branchName + local filename_branch=${branch//\//-} + local output_file_prefix="$OUTPUT_DIR/${filename_branch}" + + echo "Checking out $branch..." + git checkout "$branch" + + # Move into the rbac directory to run the benchmark tests + pushd ../../coderd/rbac/ >/dev/null + + for bench in "${BENCHMARKS[@]}"; do + local output_file="${output_file_prefix}_${bench}.txt" + echo "Running benchmark $bench on $branch..." + GOMAXPROCS=$GOMAXPROCS go test -timeout $TIMEOUT -bench="^${bench}$" -run=^$ -benchtime=$BENCHTIME -count=$COUNT | tee "$output_file" + done + + # Return to original directory + popd >/dev/null +} + +if [[ $# -eq 0 || "${1:-}" == "--single" ]]; then + current_branch=$(git rev-parse --abbrev-ref HEAD) + run_benchmarks "$current_branch" +elif [[ "${1:-}" == "--compare" ]]; then + base_branch=$2 + test_branch=$3 + + # Run all benchmarks on both branches + run_benchmarks "$base_branch" + run_benchmarks "$test_branch" + + # Compare results benchmark by benchmark + for bench in "${BENCHMARKS[@]}"; do + # Replace / with - for branch names with format user/branchName + filename_base_branch=${base_branch//\//-} + filename_test_branch=${test_branch//\//-} + + echo -e "\nGenerating benchmark diff for $bench using benchstat..." + benchstat "$OUTPUT_DIR/${filename_base_branch}_${bench}.txt" "$OUTPUT_DIR/${filename_test_branch}_${bench}.txt" | tee "$OUTPUT_DIR/${bench}_diff.txt" + done +else + echo "Usage:" + echo " $0 --single # run benchmarks on current branch" + echo " $0 --compare branchA branchB # compare benchmarks between two branches" + exit 1 +fi diff --git a/scripts/rbac-authz/gen_input.go b/scripts/rbac-authz/gen_input.go new file mode 100644 index 0000000000000..3028b402437b3 --- /dev/null +++ b/scripts/rbac-authz/gen_input.go @@ -0,0 +1,100 @@ +// This program generates an input.json file containing action, object, and subject fields +// to be used as input for `opa eval`, e.g.: +// > opa eval --format=pretty "data.authz.allow" -d policy.rego -i input.json +// This helps verify that the policy returns the expected authorization decision. +package main + +import ( + "encoding/json" + "log" + "os" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +type SubjectJSON struct { + ID string `json:"id"` + Roles []rbac.Role `json:"roles"` + Groups []string `json:"groups"` + Scope rbac.Scope `json:"scope"` +} +type OutputData struct { + Action policy.Action `json:"action"` + Object rbac.Object `json:"object"` + Subject *SubjectJSON `json:"subject"` +} + +func newSubjectJSON(s rbac.Subject) (*SubjectJSON, error) { + roles, err := s.Roles.Expand() + if err != nil { + return nil, xerrors.Errorf("failed to expand subject roles: %w", err) + } + scopes, err := s.Scope.Expand() + if err != nil { + return nil, xerrors.Errorf("failed to expand subject scopes: %w", err) + } + return &SubjectJSON{ + ID: s.ID, + Roles: roles, + Groups: s.Groups, + Scope: scopes, + }, nil +} + +// TODO: Support optional CLI flags to customize the input: +// --action=[one of the supported actions] +// --subject=[one of the built-in roles] +// --object=[one of the supported resources] +func main() { + // Template Admin user + subject := rbac.Subject{ + FriendlyName: "Test Name", + Email: "test@coder.com", + Type: "user", + ID: uuid.New().String(), + Roles: rbac.RoleIdentifiers{ + rbac.RoleTemplateAdmin(), + }, + Scope: rbac.ScopeAll, + } + + subjectJSON, err := newSubjectJSON(subject) + if err != nil { + log.Fatalf("Failed to convert to subject to JSON: %v", err) + } + + // Delete action + action := policy.ActionDelete + + // Prebuilt Workspace object + object := rbac.Object{ + ID: uuid.New().String(), + Owner: "c42fdf75-3097-471c-8c33-fb52454d81c0", + OrgID: "663f8241-23e0-41c4-a621-cec3a347318e", + Type: "prebuilt_workspace", + } + + // Output file path + outputPath := "input.json" + + output := OutputData{ + Action: action, + Object: object, + Subject: subjectJSON, + } + + outputBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + log.Fatalf("Failed to marshal output to json: %v", err) + } + + if err := os.WriteFile(outputPath, outputBytes, 0o600); err != nil { + log.Fatalf("Failed to generate input file: %v", err) + } + + log.Println("Input JSON written to", outputPath) +} diff --git a/scripts/release/main_internal_test.go b/scripts/release/main_internal_test.go index ce83e7169a35b..587d327272af5 100644 --- a/scripts/release/main_internal_test.go +++ b/scripts/release/main_internal_test.go @@ -115,7 +115,6 @@ Enjoy. } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if diff := cmp.Diff(removeMainlineBlurb(tt.body), tt.want); diff != "" { @@ -167,7 +166,6 @@ func Test_release_autoversion(t *testing.T) { require.NoError(t, err) for _, file := range files { - file := file t.Run(file, func(t *testing.T) { t.Parallel() diff --git a/scripts/releasemigrations/main.go b/scripts/releasemigrations/main.go index a06be904b004d..249d1891f9c29 100644 --- a/scripts/releasemigrations/main.go +++ b/scripts/releasemigrations/main.go @@ -230,7 +230,6 @@ func hasMigrationDiff(dir string, a, b string) ([]string, error) { migrations := strings.Split(strings.TrimSpace(string(output)), "\n") filtered := make([]string, 0, len(migrations)) for _, migration := range migrations { - migration := migration if strings.Contains(migration, "fixtures") { continue } diff --git a/scripts/typegen/main.go b/scripts/typegen/main.go index 0e7457406102e..acbaa9c2c35fe 100644 --- a/scripts/typegen/main.go +++ b/scripts/typegen/main.go @@ -243,7 +243,6 @@ func generateRbacObjects(templateSource string) ([]byte, error) { var out bytes.Buffer list := make([]Definition, 0) for t, v := range policy.RBACPermissions { - v := v list = append(list, Definition{ PermissionDefinition: v, Type: t, diff --git a/site/.storybook/main.js b/site/.storybook/main.js index 253733f9ee053..0f3bf46e3a0b7 100644 --- a/site/.storybook/main.js +++ b/site/.storybook/main.js @@ -35,6 +35,7 @@ module.exports = { }), ); } + config.server.allowedHosts = [".coder"]; return config; }, }; diff --git a/site/CLAUDE.md b/site/CLAUDE.md new file mode 100644 index 0000000000000..aded8db19c419 --- /dev/null +++ b/site/CLAUDE.md @@ -0,0 +1,115 @@ +# Frontend Development Guidelines + +## Bash commands + +- `pnpm dev` - Start Vite development server +- `pnpm storybook --no-open` - Run storybook tests +- `pnpm test` - Run jest unit tests +- `pnpm test -- path/to/specific.test.ts` - Run a single test file +- `pnpm lint` - Run complete linting suite (Biome + TypeScript + circular deps + knip) +- `pnpm lint:fix` - Auto-fix linting issues where possible +- `pnpm playwright:test` - Run playwright e2e tests. When running e2e tests, remind the user that a license is required to run all the tests +- `pnpm format` - Format frontend code. Always run before creating a PR + +## Components + +- MUI components are deprecated - migrate away from these when encountered +- Use shadcn/ui components first - check `site/src/components` for existing implementations. +- Do not use shadcn CLI - manually add components to maintain consistency +- The modules folder should contain components with business logic specific to the codebase. +- Create custom components only when shadcn alternatives don't exist + +## Styling + +- Emotion CSS is deprecated. Use Tailwind CSS instead. +- Use custom Tailwind classes in tailwind.config.js. +- Tailwind CSS reset is currently not used to maintain compatibility with MUI +- Responsive design - use Tailwind's responsive prefixes (sm:, md:, lg:, xl:) +- Do not use `dark:` prefix for dark mode + +## Tailwind Best Practices + +- Group related classes +- Use semantic color names from the theme inside `tailwind.config.js` including `content`, `surface`, `border`, `highlight` semantic tokens +- Prefer Tailwind utilities over custom CSS when possible + +## General Code style + +- Use ES modules (import/export) syntax, not CommonJS (require) +- Destructure imports when possible (eg. import { foo } from 'bar') +- Prefer `for...of` over `forEach` for iteration +- **Biome** handles both linting and formatting (not ESLint/Prettier) + +## Workflow + +- Be sure to typecheck when you’re done making a series of code changes +- Prefer running single tests, and not the whole test suite, for performance +- Some e2e tests require a license from the user to execute +- Use pnpm format before creating a PR + +## Pre-PR Checklist + +1. `pnpm check` - Ensure no TypeScript errors +2. `pnpm lint` - Fix linting issues +3. `pnpm format` - Format code consistently +4. `pnpm test` - Run affected unit tests +5. Visual check in Storybook if component changes + +## Migration (MUI β†’ shadcn) (Emotion β†’ Tailwind) + +### Migration Strategy + +- Identify MUI components in current feature +- Find shadcn equivalent in existing components +- Create wrapper if needed for missing functionality +- Update tests to reflect new component structure +- Remove MUI imports once migration complete + +### Migration Guidelines + +- Use Tailwind classes for all new styling +- Replace Emotion `css` prop with Tailwind classes +- Leverage custom color tokens: `content-primary`, `surface-secondary`, etc. +- Use `className` with `clsx` for conditional styling + +## React Rules + +### 1. Purity & Immutability + +- **Components and custom Hooks must be pure and idempotent**β€”same inputs β†’ same output; move side-effects to event handlers or Effects. +- **Never mutate props, state, or values returned by Hooks.** Always create new objects or use the setter from useState. + +### 2. Rules of Hooks + +- **Only call Hooks at the top level** of a function component or another custom Hookβ€”never in loops, conditions, nested functions, or try / catch. +- **Only call Hooks from React functions.** Regular JS functions, classes, event handlers, useMemo, etc. are off-limits. + +### 3. React orchestrates execution + +- **Don’t call component functions directly; render them via JSX.** This keeps Hook rules intact and lets React optimize reconciliation. +- **Never pass Hooks around as values or mutate them dynamically.** Keep Hook usage static and local to each component. + +### 4. State Management + +- After calling a setter you’ll still read the **previous** state during the same event; updates are queued and batched. +- Use **functional updates** (setX(prev β‡’ …)) whenever next state depends on previous state. +- Pass a function to useState(initialFn) for **lazy initialization**β€”it runs only on the first render. +- If the next state is Object.is-equal to the current one, React skips the re-render. + +### 5. Effects + +- An Effect takes a **setup** function and optional **cleanup**; React runs setup after commit, cleanup before the next setup or on unmount. +- The **dependency array must list every reactive value** referenced inside the Effect, and its length must stay constant. +- Effects run **only on the client**, never during server rendering. +- Use Effects solely to **synchronize with external systems**; if you’re not β€œescaping React,” you probably don’t need one. + +### 6. Lists & Keys + +- Every sibling element in a list **needs a stable, unique key prop**. Never use array indexes or Math.random(); prefer data-driven IDs. +- Keys aren’t passed to children and **must not change between renders**; if you return multiple nodes per item, use `` + +### 7. Refs & DOM Access + +- useRef stores a mutable .current **without causing re-renders**. +- **Don’t call Hooks (including useRef) inside loops, conditions, or map().** Extract a child component instead. +- **Avoid reading or mutating refs during render;** access them in event handlers or Effects after commit. diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index cc91984ae592f..a738899b25f2c 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -691,6 +691,7 @@ const createTemplateVersionTar = async ( parameters: [], externalAuthProviders: [], timings: [], + aiTasks: [], ...response.apply, } as ApplyComplete; response.apply.resources = response.apply.resources?.map(fillResource); @@ -713,6 +714,7 @@ const createTemplateVersionTar = async ( plan: emptyPlan, moduleFiles: new Uint8Array(), moduleFilesHash: new Uint8Array(), + aiTasks: [], ...response.plan, } as PlanComplete; response.plan.resources = response.plan.resources?.map(fillResource); @@ -1018,9 +1020,22 @@ export const updateWorkspace = async ( await fillParameters(page, richParameters, buildParameters); await page.getByRole("button", { name: /update parameters/i }).click(); + // Wait for the update button to detach. + await page.waitForSelector( + "button[data-testid='workspace-update-button']:enabled", + { state: "detached" }, + ); + // Wait for the workspace to be running again. await page.waitForSelector("text=Workspace status: Running", { state: "visible", }); + // Wait for the stop button to be enabled again + await page.waitForSelector( + "button[data-testid='workspace-stop-button']:enabled", + { + state: "visible", + }, + ); }; export const updateWorkspaceParameters = async ( diff --git a/site/e2e/provisionerGenerated.ts b/site/e2e/provisionerGenerated.ts index ee53f18d66d58..686dfb7031945 100644 --- a/site/e2e/provisionerGenerated.ts +++ b/site/e2e/provisionerGenerated.ts @@ -161,6 +161,7 @@ export interface Preset { name: string; parameters: PresetParameter[]; prebuild: Prebuild | undefined; + default: boolean; } export interface PresetParameter { @@ -311,6 +312,8 @@ export interface App { hidden: boolean; openIn: AppOpenIn; group: string; + /** If nil, new UUID will be generated. */ + id: string; } /** Healthcheck represents configuration for checking for app readiness. */ @@ -357,6 +360,15 @@ export interface RunningAgentAuthToken { token: string; } +export interface AITaskSidebarApp { + id: string; +} + +export interface AITask { + id: string; + sidebarApp: AITaskSidebarApp | undefined; +} + /** Metadata is information about a workspace used in the execution of a build */ export interface Metadata { coderUrl: string; @@ -439,6 +451,15 @@ export interface PlanComplete { resourceReplacements: ResourceReplacement[]; moduleFiles: Uint8Array; moduleFilesHash: Uint8Array; + /** + * Whether a template has any `coder_ai_task` resources defined, even if not planned for creation. + * During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we + * still need to know that such resources are defined. + * + * See `hasAITaskResources` in provisioner/terraform/resources.go for more details. + */ + hasAiTasks: boolean; + aiTasks: AITask[]; } /** @@ -457,6 +478,7 @@ export interface ApplyComplete { parameters: RichParameter[]; externalAuthProviders: ExternalAuthProviderResource[]; timings: Timing[]; + aiTasks: AITask[]; } export interface Timing { @@ -690,6 +712,9 @@ export const Preset = { if (message.prebuild !== undefined) { Prebuild.encode(message.prebuild, writer.uint32(26).fork()).ldelim(); } + if (message.default === true) { + writer.uint32(32).bool(message.default); + } return writer; }, }; @@ -1041,6 +1066,9 @@ export const App = { if (message.group !== "") { writer.uint32(106).string(message.group); } + if (message.id !== "") { + writer.uint32(114).string(message.id); + } return writer; }, }; @@ -1153,6 +1181,27 @@ export const RunningAgentAuthToken = { }, }; +export const AITaskSidebarApp = { + encode(message: AITaskSidebarApp, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== "") { + writer.uint32(10).string(message.id); + } + return writer; + }, +}; + +export const AITask = { + encode(message: AITask, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== "") { + writer.uint32(10).string(message.id); + } + if (message.sidebarApp !== undefined) { + AITaskSidebarApp.encode(message.sidebarApp, writer.uint32(18).fork()).ldelim(); + } + return writer; + }, +}; + export const Metadata = { encode(message: Metadata, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { if (message.coderUrl !== "") { @@ -1332,6 +1381,12 @@ export const PlanComplete = { if (message.moduleFilesHash.length !== 0) { writer.uint32(98).bytes(message.moduleFilesHash); } + if (message.hasAiTasks === true) { + writer.uint32(104).bool(message.hasAiTasks); + } + for (const v of message.aiTasks) { + AITask.encode(v!, writer.uint32(114).fork()).ldelim(); + } return writer; }, }; @@ -1365,6 +1420,9 @@ export const ApplyComplete = { for (const v of message.timings) { Timing.encode(v!, writer.uint32(50).fork()).ldelim(); } + for (const v of message.aiTasks) { + AITask.encode(v!, writer.uint32(58).fork()).ldelim(); + } return writer; }, }; diff --git a/site/e2e/tests/app.spec.ts b/site/e2e/tests/app.spec.ts index a12c1baccd735..587775b4dc3f8 100644 --- a/site/e2e/tests/app.spec.ts +++ b/site/e2e/tests/app.spec.ts @@ -42,6 +42,7 @@ test("app", async ({ context, page }) => { token, apps: [ { + id: randomUUID(), url: `http://localhost:${addr.port}`, displayName: appName, order: 0, diff --git a/site/package.json b/site/package.json index b099706bd57a3..1512a803b0a96 100644 --- a/site/package.json +++ b/site/package.json @@ -34,8 +34,6 @@ "update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis" }, "dependencies": { - "@ai-sdk/provider-utils": "2.2.6", - "@ai-sdk/react": "1.2.6", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@emotion/cache": "11.14.0", @@ -64,6 +62,7 @@ "@radix-ui/react-radio-group": "1.2.3", "@radix-ui/react-scroll-area": "1.2.3", "@radix-ui/react-select": "2.1.4", + "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.2.2", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-switch": "1.1.1", @@ -103,13 +102,13 @@ "react-helmet-async": "2.0.5", "react-markdown": "9.0.3", "react-query": "npm:@tanstack/react-query@5.77.0", + "react-resizable-panels": "3.0.3", "react-router-dom": "6.26.2", "react-syntax-highlighter": "15.6.1", "react-textarea-autosize": "8.5.9", "react-virtualized-auto-sizer": "1.0.24", "react-window": "1.8.11", "recharts": "2.15.0", - "rehype-raw": "7.0.0", "remark-gfm": "4.0.0", "resize-observer-polyfill": "1.5.1", "semver": "7.6.2", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 7b332074b32fc..62cdc6176092a 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -16,12 +16,6 @@ importers: .: dependencies: - '@ai-sdk/provider-utils': - specifier: 2.2.6 - version: 2.2.6(zod@3.24.3) - '@ai-sdk/react': - specifier: 1.2.6 - version: 1.2.6(react@18.3.1)(zod@3.24.3) '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 @@ -106,6 +100,9 @@ importers: '@radix-ui/react-select': specifier: 2.1.4 version: 2.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': + specifier: 1.1.7 + version: 1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slider': specifier: 1.2.2 version: 1.2.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -223,6 +220,9 @@ importers: react-query: specifier: npm:@tanstack/react-query@5.77.0 version: '@tanstack/react-query@5.77.0(react@18.3.1)' + react-resizable-panels: + specifier: 3.0.3 + version: 3.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: 6.26.2 version: 6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -241,9 +241,6 @@ importers: recharts: specifier: 2.15.0 version: 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - rehype-raw: - specifier: 7.0.0 - version: 7.0.0 remark-gfm: specifier: 4.0.0 version: 4.0.0 @@ -489,42 +486,6 @@ packages: '@adobe/css-tools@4.4.1': resolution: {integrity: sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==, tarball: https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz} - '@ai-sdk/provider-utils@2.2.4': - resolution: {integrity: sha512-13sEGBxB6kgaMPGOgCLYibF6r8iv8mgjhuToFrOTU09bBxbFQd8ZoARarCfJN6VomCUbUvMKwjTBLb1vQnN+WA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.4.tgz} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.23.8 - - '@ai-sdk/provider-utils@2.2.6': - resolution: {integrity: sha512-sUlZ7Gnq84DCGWMQRIK8XVbkzIBnvPR1diV4v6JwPgpn5armnLI/j+rqn62MpLrU5ZCQZlDKl/Lw6ed3ulYqaA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.6.tgz} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.23.8 - - '@ai-sdk/provider@1.1.0': - resolution: {integrity: sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.0.tgz} - engines: {node: '>=18'} - - '@ai-sdk/provider@1.1.2': - resolution: {integrity: sha512-ITdgNilJZwLKR7X5TnUr1BsQW6UTX5yFp0h66Nfx8XjBYkWD9W3yugr50GOz3CnE9m/U/Cd5OyEbTMI0rgi6ZQ==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.2.tgz} - engines: {node: '>=18'} - - '@ai-sdk/react@1.2.6': - resolution: {integrity: sha512-5BFChNbcYtcY9MBStcDev7WZRHf0NpTrk8yfSoedWctB3jfWkFd1HECBvdc8w3mUQshF2MumLHtAhRO7IFtGGQ==, tarball: https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.6.tgz} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - zod: - optional: true - - '@ai-sdk/ui-utils@1.2.5': - resolution: {integrity: sha512-XDgqnJcaCkDez7qolvk+PDbs/ceJvgkNkxkOlc9uDWqxfDJxtvCZ+14MP/1qr4IBwGIgKVHzMDYDXvqVhSWLzg==, tarball: https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.5.tgz} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.23.8 - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==, tarball: https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz} engines: {node: '>=10'} @@ -1615,6 +1576,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==, tarball: https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.1': resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==, tarball: https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz} peerDependencies: @@ -1833,6 +1803,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==, tarball: https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.2.3': resolution: {integrity: sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==, tarball: https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz} peerDependencies: @@ -1898,6 +1881,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==, tarball: https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slider@1.2.2': resolution: {integrity: sha512-sNlU06ii1/ZcbHf8I9En54ZPW0Vil/yPVg4vQMcFNjrIx51jsHbFl1HYHQvCIWJSr1q0ZmA+iIs/ZTv8h7HHSA==, tarball: https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.2.tgz} peerDependencies: @@ -1938,6 +1934,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==, tarball: https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.1.1': resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==, tarball: https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz} peerDependencies: @@ -3983,33 +3988,18 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz} engines: {node: '>= 0.4'} - hast-util-from-parse5@8.0.3: - resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==, tarball: https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz} - hast-util-parse-selector@2.2.5: resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz} - hast-util-parse-selector@4.0.0: - resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz} - - hast-util-raw@9.1.0: - resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==, tarball: https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz} - hast-util-to-jsx-runtime@2.3.2: resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==, tarball: https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz} - hast-util-to-parse5@8.0.0: - resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==, tarball: https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz} - hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==, tarball: https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz} hastscript@6.0.0: resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz} - hastscript@9.0.1: - resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz} - headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==, tarball: https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz} @@ -4032,9 +4022,6 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==, tarball: https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz} - html-void-elements@3.0.0: - resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==, tarball: https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz} - http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz} engines: {node: '>= 0.8'} @@ -4538,9 +4525,6 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz} - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, tarball: https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, tarball: https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz} @@ -5301,9 +5285,6 @@ packages: property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==, tarball: https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz} - property-information@7.0.0: - resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==, tarball: https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz} - protobufjs@7.4.0: resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==, tarball: https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz} engines: {node: '>=12.0.0'} @@ -5450,6 +5431,12 @@ packages: '@types/react': optional: true + react-resizable-panels@3.0.3: + resolution: {integrity: sha512-7HA8THVBHTzhDK4ON0tvlGXyMAJN1zBeRpuyyremSikgYh2ku6ltD7tsGQOcXx4NKPrZtYCm/5CBr+dkruTGQw==, tarball: https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.3.tgz} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-router-dom@6.26.2: resolution: {integrity: sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==, tarball: https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz} engines: {node: '>=14.0.0'} @@ -5564,9 +5551,6 @@ packages: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==, tarball: https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz} engines: {node: '>= 0.4'} - rehype-raw@7.0.0: - resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==, tarball: https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz} - remark-gfm@4.0.0: resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==, tarball: https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz} @@ -5671,9 +5655,6 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz} - secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==, tarball: https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz} - semver@7.6.2: resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==, tarball: https://registry.npmjs.org/semver/-/semver-7.6.2.tgz} engines: {node: '>=10'} @@ -5911,11 +5892,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, tarball: https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz} engines: {node: '>= 0.4'} - swr@2.3.3: - resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==, tarball: https://registry.npmjs.org/swr/-/swr-2.3.3.tgz} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, tarball: https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz} @@ -5953,10 +5929,6 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, tarball: https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz} - throttleit@2.1.0: - resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==, tarball: https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz} - engines: {node: '>=18'} - tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==, tarball: https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz} @@ -6262,9 +6234,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmjs.org/vary/-/vary-1.1.2.tgz} engines: {node: '>= 0.8'} - vfile-location@5.0.3: - resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==, tarball: https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz} - vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==, tarball: https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz} @@ -6364,9 +6333,6 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==, tarball: https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz} - web-namespaces@2.0.1: - resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==, tarball: https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz} engines: {node: '>=12'} @@ -6498,11 +6464,6 @@ packages: yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz} - zod-to-json-schema@3.24.5: - resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==, tarball: https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz} - peerDependencies: - zod: ^3.24.1 - zod-validation-error@3.4.0: resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==, tarball: https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz} engines: {node: '>=18.0.0'} @@ -6522,45 +6483,6 @@ snapshots: '@adobe/css-tools@4.4.1': {} - '@ai-sdk/provider-utils@2.2.4(zod@3.24.3)': - dependencies: - '@ai-sdk/provider': 1.1.0 - nanoid: 3.3.8 - secure-json-parse: 2.7.0 - zod: 3.24.3 - - '@ai-sdk/provider-utils@2.2.6(zod@3.24.3)': - dependencies: - '@ai-sdk/provider': 1.1.2 - nanoid: 3.3.8 - secure-json-parse: 2.7.0 - zod: 3.24.3 - - '@ai-sdk/provider@1.1.0': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/provider@1.1.2': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/react@1.2.6(react@18.3.1)(zod@3.24.3)': - dependencies: - '@ai-sdk/provider-utils': 2.2.4(zod@3.24.3) - '@ai-sdk/ui-utils': 1.2.5(zod@3.24.3) - react: 18.3.1 - swr: 2.3.3(react@18.3.1) - throttleit: 2.1.0 - optionalDependencies: - zod: 3.24.3 - - '@ai-sdk/ui-utils@1.2.5(zod@3.24.3)': - dependencies: - '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.4(zod@3.24.3) - zod: 3.24.3 - zod-to-json-schema: 3.24.5(zod@3.24.3) - '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -7792,6 +7714,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-context@1.1.1(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -8014,6 +7942,15 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-radio-group@1.2.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -8112,6 +8049,15 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-separator@1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-slider@1.2.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 @@ -8152,6 +8098,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-slot@1.2.3(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-switch@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -10352,39 +10305,8 @@ snapshots: dependencies: function-bind: 1.1.2 - hast-util-from-parse5@8.0.3: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - devlop: 1.1.0 - hastscript: 9.0.1 - property-information: 7.0.0 - vfile: 6.0.3 - vfile-location: 5.0.3 - web-namespaces: 2.0.1 - hast-util-parse-selector@2.2.5: {} - hast-util-parse-selector@4.0.0: - dependencies: - '@types/hast': 3.0.4 - - hast-util-raw@9.1.0: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - '@ungap/structured-clone': 1.3.0 - hast-util-from-parse5: 8.0.3 - hast-util-to-parse5: 8.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.0 - parse5: 7.1.2 - unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.2: dependencies: '@types/estree': 1.0.6 @@ -10405,16 +10327,6 @@ snapshots: transitivePeerDependencies: - supports-color - hast-util-to-parse5@8.0.0: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - property-information: 6.5.0 - space-separated-tokens: 2.0.2 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -10427,14 +10339,6 @@ snapshots: property-information: 5.6.0 space-separated-tokens: 1.1.5 - hastscript@9.0.1: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - hast-util-parse-selector: 4.0.0 - property-information: 7.0.0 - space-separated-tokens: 2.0.2 - headers-polyfill@4.0.3: {} highlight.js@10.7.3: {} @@ -10453,8 +10357,6 @@ snapshots: html-url-attributes@3.0.1: {} - html-void-elements@3.0.0: {} - http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -11182,8 +11084,6 @@ snapshots: json-schema-traverse@0.4.1: optional: true - json-schema@0.4.0: {} - json-stable-stringify-without-jsonify@1.0.1: optional: true @@ -12217,8 +12117,6 @@ snapshots: property-information@6.5.0: {} - property-information@7.0.0: {} - protobufjs@7.4.0: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -12395,6 +12293,11 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + react-resizable-panels@3.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@remix-run/router': 1.19.2 @@ -12542,12 +12445,6 @@ snapshots: define-properties: 1.2.1 set-function-name: 2.0.1 - rehype-raw@7.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-raw: 9.1.0 - vfile: 6.0.3 - remark-gfm@4.0.0: dependencies: '@types/mdast': 4.0.3 @@ -12685,8 +12582,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - secure-json-parse@2.7.0: {} - semver@7.6.2: {} send@0.19.0: @@ -12936,12 +12831,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swr@2.3.3(react@18.3.1): - dependencies: - dequal: 2.0.3 - react: 18.3.1 - use-sync-external-store: 1.4.0(react@18.3.1) - symbol-tree@3.2.4: {} tailwind-merge@2.6.0: {} @@ -13000,8 +12889,6 @@ snapshots: dependencies: any-promise: 1.3.0 - throttleit@2.1.0: {} - tiny-case@1.0.3: {} tiny-invariant@1.3.3: {} @@ -13298,11 +13185,6 @@ snapshots: vary@1.1.2: {} - vfile-location@5.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile: 6.0.3 - vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 @@ -13378,8 +13260,6 @@ snapshots: dependencies: defaults: 1.0.4 - web-namespaces@2.0.1: {} - webidl-conversions@7.0.0: {} webpack-sources@3.2.3: {} @@ -13494,10 +13374,6 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 - zod-to-json-schema@3.24.5(zod@3.24.3): - dependencies: - zod: 3.24.3 - zod-validation-error@3.4.0(zod@3.24.3): dependencies: zod: 3.24.3 diff --git a/site/site.go b/site/site.go index 0a97b4a78d2cb..682d21c695a88 100644 --- a/site/site.go +++ b/site/site.go @@ -849,8 +849,6 @@ func verifyBinSha1IsCurrent(dest string, siteFS fs.FS, shaFiles map[string]strin // Verify the hash of each on-disk binary. for file, hash1 := range shaFiles { - file := file - hash1 := hash1 eg.Go(func() error { hash2, err := sha1HashFile(filepath.Join(dest, file)) if err != nil { diff --git a/site/site_test.go b/site/site_test.go index d257bd9519b3d..f7301debba2be 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -498,7 +498,6 @@ func TestServingBin(t *testing.T) { } //nolint // Parallel test detection issue. for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index e72dd5f8d0bad..04536675f8943 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,5 +1,7 @@ import { + MockStoppedWorkspace, MockTemplate, + MockTemplateVersion2, MockTemplateVersionParameter1, MockTemplateVersionParameter2, MockWorkspace, @@ -171,65 +173,112 @@ describe("api.ts", () => { }); describe("update", () => { - it("creates a build with start and the latest template", async () => { - jest - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValueOnce(MockWorkspaceBuild); - jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); - await API.updateWorkspace(MockWorkspace); - expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { - transition: "start", - template_version_id: MockTemplate.active_version_id, - rich_parameter_values: [], + describe("given a running workspace", () => { + it("stops with current version before starting with the latest version", async () => { + jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }); + jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + template_version_id: MockTemplateVersion2.id, + transition: "start", + }); + jest.spyOn(API, "getTemplate").mockResolvedValueOnce({ + ...MockTemplate, + active_version_id: MockTemplateVersion2.id, + }); + await API.updateWorkspace(MockWorkspace); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "stop", + log_level: undefined, + }); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "start", + template_version_id: MockTemplateVersion2.id, + rich_parameter_values: [], + }); }); - }); - it("fails when having missing parameters", async () => { - jest - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValue(MockWorkspaceBuild); - jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); - jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]); - jest - .spyOn(API, "getTemplateVersionRichParameters") - .mockResolvedValue([ + it("fails when having missing parameters", async () => { + jest + .spyOn(API, "postWorkspaceBuild") + .mockResolvedValue(MockWorkspaceBuild); + jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); + jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]); + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValue([ + MockTemplateVersionParameter1, + { ...MockTemplateVersionParameter2, mutable: false }, + ]); + + let error = new Error(); + try { + await API.updateWorkspace(MockWorkspace); + } catch (e) { + error = e as Error; + } + + expect(error).toBeInstanceOf(MissingBuildParameters); + // Verify if the correct missing parameters are being passed + expect((error as MissingBuildParameters).parameters).toEqual([ MockTemplateVersionParameter1, { ...MockTemplateVersionParameter2, mutable: false }, ]); + }); - let error = new Error(); - try { + it("creates a build with no parameters if it is already filled", async () => { + jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }); + jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + template_version_id: MockTemplateVersion2.id, + transition: "start", + }); + jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); + jest + .spyOn(API, "getWorkspaceBuildParameters") + .mockResolvedValue([MockWorkspaceBuildParameter1]); + jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([ + { + ...MockTemplateVersionParameter1, + required: true, + mutable: false, + }, + ]); await API.updateWorkspace(MockWorkspace); - } catch (e) { - error = e as Error; - } - - expect(error).toBeInstanceOf(MissingBuildParameters); - // Verify if the correct missing parameters are being passed - expect((error as MissingBuildParameters).parameters).toEqual([ - MockTemplateVersionParameter1, - { ...MockTemplateVersionParameter2, mutable: false }, - ]); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "stop", + log_level: undefined, + }); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "start", + template_version_id: MockTemplate.active_version_id, + rich_parameter_values: [], + }); + }); }); - - it("creates a build with the no parameters if it is already filled", async () => { - jest - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValueOnce(MockWorkspaceBuild); - jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); - jest - .spyOn(API, "getWorkspaceBuildParameters") - .mockResolvedValue([MockWorkspaceBuildParameter1]); - jest - .spyOn(API, "getTemplateVersionRichParameters") - .mockResolvedValue([ - { ...MockTemplateVersionParameter1, required: true, mutable: false }, - ]); - await API.updateWorkspace(MockWorkspace); - expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { - transition: "start", - template_version_id: MockTemplate.active_version_id, - rich_parameter_values: [], + describe("given a stopped workspace", () => { + it("creates a build with start and the latest template", async () => { + jest + .spyOn(API, "postWorkspaceBuild") + .mockResolvedValueOnce(MockWorkspaceBuild); + jest.spyOn(API, "getTemplate").mockResolvedValueOnce({ + ...MockTemplate, + active_version_id: MockTemplateVersion2.id, + }); + await API.updateWorkspace(MockStoppedWorkspace); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith( + MockStoppedWorkspace.id, + { + transition: "start", + template_version_id: MockTemplateVersion2.id, + rich_parameter_values: [], + }, + ); }); }); }); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5b7cde65fb2ce..458e93b32cdbe 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -411,7 +411,11 @@ export type GetProvisionerDaemonsParams = { * lexical scope. */ class ApiMethods { - constructor(protected readonly axios: AxiosInstance) {} + experimental: ExperimentalApiMethods; + + constructor(protected readonly axios: AxiosInstance) { + this.experimental = new ExperimentalApiMethods(this.axios); + } login = async ( email: string, @@ -814,13 +818,6 @@ class ApiMethods { return response.data; }; - getDeploymentLLMs = async (): Promise => { - const response = await this.axios.get( - "/api/v2/deployment/llms", - ); - return response.data; - }; - getOrganizationIdpSyncClaimFieldValues = async ( organization: string, field: string, @@ -2237,6 +2234,7 @@ class ApiMethods { * - Update the build parameters and check if there are missed parameters for * the newest version * - If there are missing parameters raise an error + * - Stop the workspace with the current template version if it is already running * - Create a build with the latest version and updated build parameters */ updateWorkspace = async ( @@ -2274,6 +2272,19 @@ class ApiMethods { throw new MissingBuildParameters(missingParameters, activeVersionId); } + // Stop the workspace if it is already running. + if (workspace.latest_build.status === "running") { + const stopBuild = await this.stopWorkspace(workspace.id); + const awaitedStopBuild = await this.waitForBuild(stopBuild); + // If the stop is canceled halfway through, we bail. + // This is the same behaviour as restartWorkspace. + if (awaitedStopBuild?.status === "canceled") { + return Promise.reject( + new Error("Workspace stop was canceled, not proceeding with update."), + ); + } + } + return this.postWorkspaceBuild(workspace.id, { transition: "start", template_version_id: activeVersionId, @@ -2566,22 +2577,35 @@ class ApiMethods { markAllInboxNotificationsAsRead = async () => { await this.axios.put("/api/v2/notifications/inbox/mark-all-as-read"); }; +} - createChat = async () => { - const res = await this.axios.post("/api/v2/chats"); - return res.data; - }; +// Experimental API methods call endpoints under the /api/experimental/ prefix. +// These endpoints are not stable and may change or be removed at any time. +// +// All methods must be defined with arrow function syntax. See the docstring +// above the ApiMethods class for a full explanation. +class ExperimentalApiMethods { + constructor(protected readonly axios: AxiosInstance) {} - getChats = async () => { - const res = await this.axios.get("/api/v2/chats"); - return res.data; - }; + getAITasksPrompts = async ( + buildIds: TypesGen.WorkspaceBuild["id"][], + ): Promise => { + if (buildIds.length === 0) { + return { + prompts: {}, + }; + } - getChatMessages = async (chatId: string) => { - const res = await this.axios.get( - `/api/v2/chats/${chatId}/messages`, + const response = await this.axios.get( + "/api/experimental/aitasks/prompts", + { + params: { + build_ids: buildIds.join(","), + }, + }, ); - return res.data; + + return response.data; }; } diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index c9a328803bf15..9705a08ff057c 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -19,7 +19,7 @@ export interface ApiErrorResponse { validations?: FieldError[]; } -type ApiError = AxiosError & { +export type ApiError = AxiosError & { response: AxiosResponse; }; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts deleted file mode 100644 index d23f672f9cfaf..0000000000000 --- a/site/src/api/queries/chats.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { API } from "api/api"; -import type { QueryClient } from "react-query"; - -export const createChat = (queryClient: QueryClient) => { - return { - mutationFn: API.createChat, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["chats"] }); - }, - }; -}; - -export const getChats = () => { - return { - queryKey: ["chats"], - queryFn: API.getChats, - }; -}; - -export const getChatMessages = (chatID: string) => { - return { - queryKey: ["chatMessages", chatID], - queryFn: () => API.getChatMessages(chatID), - }; -}; diff --git a/site/src/api/queries/deployment.ts b/site/src/api/queries/deployment.ts index 4d1610bd57b46..17777bf09c4ec 100644 --- a/site/src/api/queries/deployment.ts +++ b/site/src/api/queries/deployment.ts @@ -39,10 +39,3 @@ export const deploymentIdpSyncFieldValues = (field: string) => { queryFn: () => API.getDeploymentIdpSyncFieldValues(field), }; }; - -export const deploymentLanguageModels = () => { - return { - queryKey: ["deployment", "llms"], - queryFn: API.getDeploymentLLMs, - }; -}; diff --git a/site/src/api/queries/experiments.ts b/site/src/api/queries/experiments.ts index 546a85ab5f083..fe7e3419a7065 100644 --- a/site/src/api/queries/experiments.ts +++ b/site/src/api/queries/experiments.ts @@ -1,11 +1,11 @@ import { API } from "api/api"; -import type { Experiments } from "api/typesGenerated"; +import { type Experiment, Experiments } from "api/typesGenerated"; import type { MetadataState } from "hooks/useEmbeddedMetadata"; import { cachedQuery } from "./util"; const experimentsKey = ["experiments"] as const; -export const experiments = (metadata: MetadataState) => { +export const experiments = (metadata: MetadataState) => { return cachedQuery({ metadata, queryKey: experimentsKey, @@ -19,3 +19,7 @@ export const availableExperiments = () => { queryFn: async () => API.getAvailableExperiments(), }; }; + +export const isKnownExperiment = (experiment: string): boolean => { + return Experiments.includes(experiment as Experiment); +}; diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 885f603c1eb82..de09b245ff049 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -31,12 +31,6 @@ export const RBACResourceActions: Partial< create: "create new audit log entries", read: "read audit logs", }, - chat: { - create: "create a chat", - delete: "delete a chat", - read: "read a chat", - update: "update a chat", - }, crypto_key: { create: "create crypto keys", delete: "delete crypto keys", @@ -123,6 +117,10 @@ export const RBACResourceActions: Partial< read: "read member", update: "update an organization member", }, + prebuilt_workspace: { + delete: "delete prebuilt workspace", + update: "update prebuilt workspace settings", + }, provisioner_daemon: { create: "create a provisioner daemon/key", delete: "delete a provisioner daemon/key", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c199729af989f..0e6a481406d8b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6,16 +6,12 @@ export interface ACLAvailable { readonly groups: readonly Group[]; } -// From codersdk/deployment.go -export interface AIConfig { - readonly providers?: readonly AIProviderConfig[]; -} +// From codersdk/aitasks.go +export const AITaskPromptParameterName = "AI Prompt"; -// From codersdk/deployment.go -export interface AIProviderConfig { - readonly type: string; - readonly models: readonly string[]; - readonly base_url: string; +// From codersdk/aitasks.go +export interface AITasksPromptsResponse { + readonly prompts: Record; } // From codersdk/apikey.go @@ -303,28 +299,6 @@ export interface ChangePasswordWithOneTimePasscodeRequest { readonly one_time_passcode: string; } -// From codersdk/chat.go -export interface Chat { - readonly id: string; - readonly created_at: string; - readonly updated_at: string; - readonly title: string; -} - -// From codersdk/chat.go -export interface ChatMessage { - readonly id: string; - readonly createdAt?: Record; - readonly content: string; - readonly role: string; - // external type "github.com/kylecarbs/aisdk-go.Part", to include this type the package must be explicitly included in the parsing - readonly parts?: readonly unknown[]; - // empty interface{} type, falling back to unknown - readonly annotations?: readonly unknown[]; - // external type "github.com/kylecarbs/aisdk-go.Attachment", to include this type the package must be explicitly included in the parsing - readonly experimental_attachments?: readonly unknown[]; -} - // From codersdk/client.go export const CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry"; @@ -346,14 +320,6 @@ export interface ConvertLoginRequest { readonly password: string; } -// From codersdk/chat.go -export interface CreateChatMessageRequest { - readonly model: string; - // external type "github.com/kylecarbs/aisdk-go.Message", to include this type the package must be explicitly included in the parsing - readonly message: unknown; - readonly thinking: boolean; -} - // From codersdk/users.go export interface CreateFirstUserRequest { readonly email: string; @@ -490,7 +456,6 @@ export interface CreateWorkspaceBuildRequest { readonly rich_parameter_values?: readonly WorkspaceBuildParameter[]; readonly log_level?: ProvisionerLogLevel; readonly template_version_preset_id?: string; - readonly enable_dynamic_parameters?: boolean; } // From codersdk/workspaceproxy.go @@ -510,7 +475,6 @@ export interface CreateWorkspaceRequest { readonly rich_parameter_values?: readonly WorkspaceBuildParameter[]; readonly automatic_updates?: AutomaticUpdates; readonly template_version_preset_id?: string; - readonly enable_dynamic_parameters?: boolean; } // From codersdk/deployment.go @@ -720,7 +684,6 @@ export interface DeploymentValues { readonly disable_password_auth?: boolean; readonly support?: SupportConfig; readonly external_auth?: SerpentStruct; - readonly ai?: SerpentStruct; readonly config_ssh?: SSHConfig; readonly wgtunnel_host?: string; readonly disable_owner_workspace_exec?: boolean; @@ -828,17 +791,19 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = - | "ai-tasks" - | "agentic-chat" | "auto-fill-parameters" | "example" | "notifications" | "web-push" - | "workspace-prebuilds" | "workspace-usage"; -// From codersdk/deployment.go -export type Experiments = readonly Experiment[]; +export const Experiments: Experiment[] = [ + "auto-fill-parameters", + "example", + "notifications", + "web-push", + "workspace-usage", +]; // From codersdk/externalauth.go export interface ExternalAuth { @@ -1247,18 +1212,6 @@ export type JobErrorCode = "REQUIRED_TEMPLATE_VARIABLES"; export const JobErrorCodes: JobErrorCode[] = ["REQUIRED_TEMPLATE_VARIABLES"]; -// From codersdk/deployment.go -export interface LanguageModel { - readonly id: string; - readonly display_name: string; - readonly provider: string; -} - -// From codersdk/deployment.go -export interface LanguageModelConfig { - readonly models: readonly LanguageModel[]; -} - // From codersdk/licenses.go export interface License { readonly id: number; @@ -1824,6 +1777,7 @@ export interface Preset { readonly ID: string; readonly Name: string; readonly Parameters: readonly PresetParameter[]; + readonly Default: boolean; } // From codersdk/presets.go @@ -2173,7 +2127,6 @@ export type RBACResource = | "assign_org_role" | "assign_role" | "audit_log" - | "chat" | "crypto_key" | "debug_info" | "deployment_config" @@ -2192,6 +2145,7 @@ export type RBACResource = | "oauth2_app_secret" | "organization" | "organization_member" + | "prebuilt_workspace" | "provisioner_daemon" | "provisioner_jobs" | "replicas" @@ -2212,7 +2166,6 @@ export const RBACResources: RBACResource[] = [ "assign_org_role", "assign_role", "audit_log", - "chat", "crypto_key", "debug_info", "deployment_config", @@ -2231,6 +2184,7 @@ export const RBACResources: RBACResource[] = [ "oauth2_app_secret", "organization", "organization_member", + "prebuilt_workspace", "provisioner_daemon", "provisioner_jobs", "replicas", @@ -3620,11 +3574,16 @@ export interface WorkspaceAppStatus { } // From codersdk/workspaceapps.go -export type WorkspaceAppStatusState = "complete" | "failure" | "working"; +export type WorkspaceAppStatusState = + | "complete" + | "failure" + | "idle" + | "working"; export const WorkspaceAppStatusStates: WorkspaceAppStatusState[] = [ "complete", "failure", + "idle", "working", ]; @@ -3653,6 +3612,8 @@ export interface WorkspaceBuild { readonly daily_cost: number; readonly matched_provisioners?: MatchedProvisioners; readonly template_version_preset_id: string | null; + readonly has_ai_task?: boolean; + readonly ai_task_sidebar_app_id?: string; } // From codersdk/workspacebuilds.go diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index 3b2a5d5897eb3..0d11c96d30433 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -22,6 +22,8 @@ const badgeVariants = cva( "border border-solid border-border-warning bg-surface-orange text-content-warning shadow", destructive: "border border-solid border-border-destructive bg-surface-red text-highlight-red shadow", + green: + "border border-solid border-surface-green bg-surface-green text-highlight-green shadow", }, size: { xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5", diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx index 859aa10d0cf68..1f2c6b3b3416b 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -7,6 +7,8 @@ import { type VariantProps, cva } from "class-variance-authority"; import { forwardRef } from "react"; import { cn } from "utils/cn"; +// Be careful when changing the child styles from the button such as images +// because they can override the styles from other components like Avatar. const buttonVariants = cva( ` inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans @@ -15,8 +17,8 @@ const buttonVariants = cva( focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link disabled:pointer-events-none disabled:text-content-disabled [&:is(a):not([href])]:pointer-events-none [&:is(a):not([href])]:text-content-disabled - [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5 - [&_img]:pointer-events-none [&_img]:shrink-0 [&_img]:p-0.5 + [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:p-0.5 + [&>img]:pointer-events-none [&>img]:shrink-0 [&>img]:p-0.5 `, { variants: { @@ -42,11 +44,11 @@ const buttonVariants = cva( }, size: { - lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg", - sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&_img]:size-icon-sm", + lg: "min-w-20 h-10 px-3 py-2 [&>svg]:size-icon-lg [&>img]:size-icon-lg", + sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&>svg]:size-icon-sm [&>img]:size-icon-sm", xs: "min-w-8 py-1 px-2 text-2xs rounded-md", - icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&_img]:size-icon-sm", - "icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg", + icon: "size-8 px-1.5 [&>svg]:size-icon-sm [&>img]:size-icon-sm", + "icon-lg": "size-10 px-2 [&>svg]:size-icon-lg [&>img]:size-icon-lg", }, }, defaultVariants: { diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index 2ec8ab40781c7..2ec5fa4dae212 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -3,6 +3,7 @@ * @see {@link https://ui.shadcn.com/docs/components/dialog} */ import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { type VariantProps, cva } from "class-variance-authority"; import { type ComponentPropsWithoutRef, type ElementRef, @@ -36,25 +37,41 @@ const DialogOverlay = forwardRef< /> )); +const dialogVariants = cva( + `fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-6 + border border-solid bg-surface-primary p-8 shadow-lg duration-200 sm:rounded-lg + translate-x-[-50%] translate-y-[-50%] + data-[state=open]:animate-in data-[state=closed]:animate-out + data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 + data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 + data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] + data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]`, + { + variants: { + variant: { + default: "border-border-primary", + destructive: "border-border-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +interface DialogContentProps + extends ComponentPropsWithoutRef, + VariantProps {} + export const DialogContent = forwardRef< ElementRef, - ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, variant, children, ...props }, ref) => ( {children} diff --git a/site/src/components/Icons/CoderIcon.tsx b/site/src/components/Icons/CoderIcon.tsx index 7dd2a7625734d..7053ffe8d255d 100644 --- a/site/src/components/Icons/CoderIcon.tsx +++ b/site/src/components/Icons/CoderIcon.tsx @@ -1,4 +1,4 @@ -import SvgIcon, { type SvgIconProps } from "@mui/material/SvgIcon"; +import type { SvgIconProps } from "@mui/material/SvgIcon"; import type { FC } from "react"; import { cn } from "utils/cn"; @@ -7,23 +7,16 @@ import { cn } from "utils/cn"; * contain additional aspects, like the word 'Coder'. */ export const CoderIcon: FC = ({ className, ...props }) => ( - Codestin Search App - - - - - - - - - + + ); diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx index 109a60e60448d..7c2a4e4d60057 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx @@ -39,6 +39,23 @@ export const OpenCombobox: Story = { }, }; +export const WithIcons: Story = { + args: { + options: organizations.map((org) => ({ + label: org.display_name, + value: org.id, + icon: org.icon, + })), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await waitFor(() => + expect(canvas.getByText("My Organization")).toBeInTheDocument(), + ); + }, +}; + export const SelectComboboxItem: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -98,3 +115,49 @@ export const ClearAllComboboxItems: Story = { ); }, }; + +export const WithGroups: Story = { + args: { + placeholder: "Make a playlist", + groupBy: "album", + options: [ + { + label: "Photo Facing Water", + value: "photo-facing-water", + album: "Papillon", + icon: "/emojis/1f301.png", + }, + { + label: "Mercurial", + value: "mercurial", + album: "Papillon", + icon: "/emojis/1fa90.png", + }, + { + label: "Merging", + value: "merging", + album: "Papillon", + icon: "/lol-not-a-real-image.png", + }, + { + label: "Flacks", + value: "flacks", + album: "aBliss", + // intentionally omitted icon + }, + { + label: "aBliss", + value: "abliss", + album: "aBliss", + // intentionally omitted icon + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Make a playlist")); + await waitFor(() => + expect(canvas.getByText("Papillon")).toBeInTheDocument(), + ); + }, +}; diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 2cfe69a25df52..2d25ae983c3d9 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -3,6 +3,7 @@ * @see {@link https://shadcnui-expansions.typeart.cc/docs/multiple-selector} */ import { Command as CommandPrimitive, useCommandState } from "cmdk"; +import { Avatar } from "components/Avatar/Avatar"; import { Badge } from "components/Badge/Badge"; import { Command, @@ -30,6 +31,7 @@ import { cn } from "utils/cn"; export interface Option { value: string; label: string; + icon?: string; disable?: boolean; /** fixed option that can't be removed. */ fixed?: boolean; @@ -353,7 +355,9 @@ export const MultiSelectCombobox = forwardRef< }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]); const CreatableItem = () => { - if (!creatable) return undefined; + if (!creatable) { + return undefined; + } if ( isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || selected.find((s) => s.value === inputValue) @@ -437,6 +441,7 @@ export const MultiSelectCombobox = forwardRef< } const fixedOptions = selected.filter((s) => s.fixed); + const showIcons = arrayOptions?.some((it) => it.icon); return ( - {option.label} +
+ {option.icon && ( + + )} + {option.label} +
+ + + + + ); +}; diff --git a/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx b/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx new file mode 100644 index 0000000000000..cc2512692c540 --- /dev/null +++ b/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx @@ -0,0 +1,83 @@ +import { getErrorDetail, getErrorMessage, isApiError } from "api/errors"; +import { Button } from "components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/Dialog/Dialog"; +import type { FC } from "react"; +import { useNavigate } from "react-router-dom"; + +interface WorkspaceErrorDialogProps { + open: boolean; + error?: unknown; + onClose: () => void; + showDetail: boolean; + workspaceOwner: string; + workspaceName: string; + templateVersionId: string; +} + +export const WorkspaceErrorDialog: FC = ({ + open, + error, + onClose, + showDetail, + workspaceOwner, + workspaceName, + templateVersionId, +}) => { + const navigate = useNavigate(); + + if (!error) { + return null; + } + + const handleGoToParameters = () => { + onClose(); + navigate( + `/@${workspaceOwner}/${workspaceName}/settings/parameters?templateVersionId=${templateVersionId}`, + ); + }; + + const errorDetail = getErrorDetail(error); + const validations = isApiError(error) + ? error.response.data.validations + : undefined; + + return ( + !isOpen && onClose()}> + + + Error building workspace + + Message{" "} + {getErrorMessage(error, "Failed to build workspace.")} + + {errorDetail && showDetail && ( + + Detail{" "} + {errorDetail} + + )} + {validations && ( + + Validations{" "} + + {validations.map((validation) => validation.detail).join(", ")} + + + )} + + + + + + + ); +}; diff --git a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.stories.tsx b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.stories.tsx index d95444e658d64..9327ff6b46e98 100644 --- a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.stories.tsx @@ -39,6 +39,26 @@ export const Working: Story = { }, }; +export const Idle: Story = { + args: { + status: { + ...MockWorkspaceAppStatus, + state: "idle", + message: "Done for now", + }, + }, +}; + +export const NoMessage: Story = { + args: { + status: { + ...MockWorkspaceAppStatus, + state: "idle", + message: "", + }, + }, +}; + export const LongMessage: Story = { args: { status: { @@ -49,9 +69,43 @@ export const LongMessage: Story = { }, }; -export const Disabled: Story = { +export const DisabledComplete: Story = { args: { status: MockWorkspaceAppStatus, disabled: true, }, }; + +export const DisabledFailure: Story = { + args: { + status: { + ...MockWorkspaceAppStatus, + state: "failure", + message: "Couldn't figure out how to start the dev server", + }, + disabled: true, + }, +}; + +export const DisabledWorking: Story = { + args: { + status: { + ...MockWorkspaceAppStatus, + state: "working", + message: "Starting dev server...", + uri: "", + }, + disabled: true, + }, +}; + +export const DisabledIdle: Story = { + args: { + status: { + ...MockWorkspaceAppStatus, + state: "idle", + message: "Done for now", + }, + disabled: true, + }, +}; diff --git a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx index 0b999f54402a8..633a9fcbc1ad8 100644 --- a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx +++ b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx @@ -5,8 +5,8 @@ import { TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; +import capitalize from "lodash/capitalize"; import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon"; -import { cn } from "utils/cn"; type WorkspaceAppStatusProps = { status: APIWorkspaceAppStatus | null; @@ -25,6 +25,7 @@ export const WorkspaceAppStatus = ({ ); } + const message = status.message || capitalize(status.state); return (
@@ -35,16 +36,13 @@ export const WorkspaceAppStatus = ({ latest disabled={disabled} state={status.state} - className={cn({ - "text-content-disabled": disabled, - })} /> - {status.message} + {message}
- {status.message} + {message} diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx index 8f5179b0b64da..2cfb74f2765c3 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx @@ -43,6 +43,18 @@ export const WorkspaceDeleteDialog: FC = ({ const hasError = !deletionConfirmed && userConfirmationText.length > 0; const displayErrorMessage = hasError && !isFocused; const inputColor = hasError ? "error" : "primary"; + // Orphaning is sort of a "last resort" that should really only + // be used under the following circumstances: + // a) Terraform is failing to apply while deleting, which + // usually means that builds are failing as well. + // b) No provisioner is available to delete the workspace, which will + // cause the job to remain in the "pending" state indefinitely. + // The assumption here is that an admin will cancel the job, in which + // case we want to allow them to perform an orphan-delete. + const canOrphan = + canDeleteFailedWorkspace && + (workspace.latest_build.status === "failed" || + workspace.latest_build.status === "canceled"); return ( = ({ "data-testid": "delete-dialog-name-confirmation", }} /> - { - // Orphaning is sort of a "last resort" that should really only - // be used if Terraform is failing to apply while deleting, which - // usually means that builds are failing as well. - canDeleteFailedWorkspace && - workspace.latest_build.status === "failed" && ( -
-
- { - setOrphanWorkspace(!orphanWorkspace); - }} - className="option" - name="orphan_resources" - checked={orphanWorkspace} - data-testid="orphan-checkbox" - /> -
-
-

Orphan Resources

- - As a Template Admin, you may skip resource cleanup to - delete a failed workspace. Resources such as volumes and - virtual machines will not be destroyed.  - - Learn more... - - -
-
- ) - } + {canOrphan && ( +
+
+ { + setOrphanWorkspace(!orphanWorkspace); + }} + className="option" + name="orphan_resources" + checked={orphanWorkspace} + data-testid="orphan-checkbox" + /> +
+
+

Orphan Resources

+ + As a Template Admin, you may skip resource cleanup to delete + a failed workspace. Resources such as volumes and virtual + machines will not be destroyed.  + + Learn more... + + +
+
+ )} } diff --git a/site/src/modules/workspaces/actions.ts b/site/src/modules/workspaces/actions.ts index 6a255e2cd2c88..f109c4d9ad1b9 100644 --- a/site/src/modules/workspaces/actions.ts +++ b/site/src/modules/workspaces/actions.ts @@ -6,16 +6,19 @@ import type { Workspace } from "api/typesGenerated"; const actionTypes = [ "start", "starting", - // Replaces start when an update is required. + // Appears beside start when an update is available. "updateAndStart", + // Replaces start when an update is required. + "updateAndStartRequireActiveVersion", "stop", "stopping", "restart", "restarting", - // Replaces restart when an update is required. + // Appears beside restart when an update is available. "updateAndRestart", + // Replaces restart when an update is required. + "updateAndRestartRequireActiveVersion", "deleting", - "update", "updating", "activate", "activating", @@ -74,10 +77,10 @@ export const abilitiesByWorkspaceStatus = ( const actions: ActionType[] = ["stop"]; if (workspace.template_require_active_version && workspace.outdated) { - actions.push("updateAndRestart"); + actions.push("updateAndRestartRequireActiveVersion"); } else { if (workspace.outdated) { - actions.unshift("update"); + actions.unshift("updateAndRestart"); } actions.push("restart"); } @@ -99,10 +102,10 @@ export const abilitiesByWorkspaceStatus = ( const actions: ActionType[] = []; if (workspace.template_require_active_version && workspace.outdated) { - actions.push("updateAndStart"); + actions.push("updateAndStartRequireActiveVersion"); } else { if (workspace.outdated) { - actions.unshift("update"); + actions.unshift("updateAndStart"); } actions.push("start"); } @@ -128,7 +131,7 @@ export const abilitiesByWorkspaceStatus = ( } if (workspace.outdated) { - actions.unshift("update"); + actions.unshift("updateAndStart"); } return { diff --git a/site/src/pages/ChatPage/ChatLanding.tsx b/site/src/pages/ChatPage/ChatLanding.tsx deleted file mode 100644 index 2902ae8663da5..0000000000000 --- a/site/src/pages/ChatPage/ChatLanding.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useTheme } from "@emotion/react"; -import IconButton from "@mui/material/IconButton"; -import Paper from "@mui/material/Paper"; -import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; -import { createChat } from "api/queries/chats"; -import type { Chat } from "api/typesGenerated"; -import { Button } from "components/Button/Button"; -import { Margins } from "components/Margins/Margins"; -import { useAuthenticated } from "hooks"; -import { SendIcon } from "lucide-react"; -import { type FC, type FormEvent, useState } from "react"; -import { useMutation, useQueryClient } from "react-query"; -import { useNavigate } from "react-router-dom"; -import { LanguageModelSelector } from "./LanguageModelSelector"; - -export interface ChatLandingLocationState { - chat: Chat; - message: string; -} - -const ChatLanding: FC = () => { - const { user } = useAuthenticated(); - const theme = useTheme(); - const [input, setInput] = useState(""); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const createChatMutation = useMutation(createChat(queryClient)); - - return ( - -
- {/* Initial Welcome Message Area */} -
-

- Good evening, {(user.name ?? user.username).split(" ")[0]} -

-

- How can I help you today? -

-
- - {/* Input Form and Suggestions - Always Visible */} -
- - - - - - - ) => { - e.preventDefault(); - setInput(""); - const chat = await createChatMutation.mutateAsync(); - navigate(`/chat/${chat.id}`, { - state: { - chat, - message: input, - }, - }); - }} - elevation={2} - css={{ - padding: "16px", - display: "flex", - alignItems: "center", - width: "100%", - borderRadius: "12px", - border: `1px solid ${theme.palette.divider}`, - }} - > - ) => { - setInput(event.target.value); - }} - placeholder="Ask Coder..." - required - fullWidth - variant="outlined" - multiline - maxRows={5} - css={{ - marginRight: theme.spacing(1), - "& .MuiOutlinedInput-root": { - borderRadius: "8px", - padding: "10px 14px", - }, - }} - autoFocus - /> - - - - -
-
-
- ); -}; - -export default ChatLanding; diff --git a/site/src/pages/ChatPage/ChatLayout.tsx b/site/src/pages/ChatPage/ChatLayout.tsx deleted file mode 100644 index 9e252764ea234..0000000000000 --- a/site/src/pages/ChatPage/ChatLayout.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { useTheme } from "@emotion/react"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemButton from "@mui/material/ListItemButton"; -import ListItemText from "@mui/material/ListItemText"; -import Paper from "@mui/material/Paper"; -import { createChat, getChats } from "api/queries/chats"; -import { deploymentLanguageModels } from "api/queries/deployment"; -import type { LanguageModelConfig } from "api/typesGenerated"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Button } from "components/Button/Button"; -import { Loader } from "components/Loader/Loader"; -import { Margins } from "components/Margins/Margins"; -import { useAgenticChat } from "contexts/useAgenticChat"; -import { PlusIcon } from "lucide-react"; -import { - type FC, - type PropsWithChildren, - createContext, - useContext, - useEffect, - useState, -} from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { Link, Outlet, useNavigate, useParams } from "react-router-dom"; - -interface ChatContext { - selectedModel: string; - modelConfig: LanguageModelConfig; - - setSelectedModel: (model: string) => void; -} -export const useChatContext = (): ChatContext => { - const context = useContext(ChatContext); - if (!context) { - throw new Error("useChatContext must be used within a ChatProvider"); - } - return context; -}; - -const ChatContext = createContext(undefined); - -const SELECTED_MODEL_KEY = "coder_chat_selected_model"; - -const ChatProvider: FC = ({ children }) => { - const [selectedModel, setSelectedModel] = useState(() => { - const savedModel = localStorage.getItem(SELECTED_MODEL_KEY); - return savedModel || ""; - }); - const modelConfigQuery = useQuery(deploymentLanguageModels()); - useEffect(() => { - if (!modelConfigQuery.data) { - return; - } - if (selectedModel === "") { - const firstModel = modelConfigQuery.data.models[0]?.id; // Handle empty models array - if (firstModel) { - setSelectedModel(firstModel); - localStorage.setItem(SELECTED_MODEL_KEY, firstModel); - } - } - }, [modelConfigQuery.data, selectedModel]); - - if (modelConfigQuery.error) { - return ; - } - - if (!modelConfigQuery.data) { - return ; - } - - const handleSetSelectedModel = (model: string) => { - setSelectedModel(model); - localStorage.setItem(SELECTED_MODEL_KEY, model); - }; - - return ( - - {children} - - ); -}; - -export const ChatLayout: FC = () => { - const agenticChat = useAgenticChat(); - const queryClient = useQueryClient(); - const { data: chats, isLoading: chatsLoading } = useQuery(getChats()); - const createChatMutation = useMutation(createChat(queryClient)); - const theme = useTheme(); - const navigate = useNavigate(); - const { chatID } = useParams<{ chatID?: string }>(); - - const handleNewChat = () => { - navigate("/chat"); - }; - - if (!agenticChat.enabled) { - return ( - -
-

Agentic Chat is not enabled

-

- Agentic Chat is an experimental feature and is not enabled by - default. Please contact your administrator for more information. -

-
-
- ); - } - - return ( - // Outermost container: controls height and prevents page scroll -
- {/* Sidebar Container (using Paper for background/border) */} - - {/* Sidebar Header */} -
- {/* Replaced Typography with div + styling */} -
- Chats -
- -
- {/* Sidebar Scrollable List Area */} -
- {chatsLoading ? ( - - ) : chats && chats.length > 0 ? ( - - {chats.map((chat) => ( - - - - - - ))} - - ) : ( - // Replaced Typography with div + styling -
- No chats yet. Start a new one! -
- )} -
-
- - {/* Main Content Area Container */} -
- - {/* Outlet renders ChatMessages, which should have its own internal scroll */} - - -
-
- ); -}; diff --git a/site/src/pages/ChatPage/ChatMessages.tsx b/site/src/pages/ChatPage/ChatMessages.tsx deleted file mode 100644 index 1ee75948d0976..0000000000000 --- a/site/src/pages/ChatPage/ChatMessages.tsx +++ /dev/null @@ -1,491 +0,0 @@ -import { type Message, useChat } from "@ai-sdk/react"; -import { type Theme, keyframes, useTheme } from "@emotion/react"; -import IconButton from "@mui/material/IconButton"; -import Paper from "@mui/material/Paper"; -import TextField from "@mui/material/TextField"; -import { getChatMessages } from "api/queries/chats"; -import type { ChatMessage, CreateChatMessageRequest } from "api/typesGenerated"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Loader } from "components/Loader/Loader"; -import { SendIcon } from "lucide-react"; -import { - type FC, - type KeyboardEvent, - memo, - useCallback, - useEffect, - useRef, -} from "react"; -import ReactMarkdown from "react-markdown"; -import { useQuery } from "react-query"; -import { useLocation, useParams } from "react-router-dom"; -import rehypeRaw from "rehype-raw"; -import remarkGfm from "remark-gfm"; -import type { ChatLandingLocationState } from "./ChatLanding"; -import { useChatContext } from "./ChatLayout"; -import { ChatToolInvocation } from "./ChatToolInvocation"; -import { LanguageModelSelector } from "./LanguageModelSelector"; - -const fadeIn = keyframes` - from { - opacity: 0; - transform: translateY(5px); - } - to { - opacity: 1; - transform: translateY(0); - } -`; - -const renderReasoning = (reasoning: string, theme: Theme) => ( -
-
- πŸ’­ Reasoning: -
-
- {reasoning} -
-
-); - -interface MessageBubbleProps { - message: Message; -} - -const MessageBubble: FC = memo(({ message }) => { - const theme = useTheme(); - const isUser = message.role === "user"; - - return ( -
- code)": { - backgroundColor: isUser - ? theme.palette.grey[700] - : theme.palette.action.hover, - color: isUser ? theme.palette.grey[50] : theme.palette.text.primary, - padding: theme.spacing(0.25, 0.75), - borderRadius: "4px", - fontSize: "0.875em", - fontFamily: "monospace", - }, - "& pre": { - backgroundColor: isUser - ? theme.palette.common.black - : theme.palette.grey[100], - color: isUser - ? theme.palette.grey[100] - : theme.palette.text.primary, - padding: theme.spacing(1.5), - borderRadius: "8px", - overflowX: "auto", - margin: theme.spacing(1.5, 0), - width: "100%", - "& code": { - backgroundColor: "transparent", - padding: 0, - fontSize: "0.875em", - fontFamily: "monospace", - color: "inherit", - }, - }, - "& a": { - color: isUser - ? theme.palette.grey[100] - : theme.palette.primary.main, - textDecoration: "underline", - fontWeight: 500, - "&:hover": { - textDecoration: "none", - color: isUser - ? theme.palette.grey[300] - : theme.palette.primary.dark, - }, - }, - }} - > - {message.role === "assistant" && message.parts ? ( -
- {message.parts.map((part) => { - switch (part.type) { - case "text": - return ( - - {part.text} - - ); - case "tool-invocation": - return ( -
- -
- ); - case "reasoning": - return ( -
- {renderReasoning(part.reasoning, theme)} -
- ); - default: - return null; - } - })} -
- ) : ( - - {message.content} - - )} -
-
- ); -}); - -interface ChatViewProps { - messages: Message[]; - input: string; - handleInputChange: React.ChangeEventHandler< - HTMLInputElement | HTMLTextAreaElement - >; - handleSubmit: (e?: React.FormEvent) => void; - isLoading: boolean; - chatID: string; -} - -const ChatView: FC = ({ - messages, - input, - handleInputChange, - handleSubmit, - isLoading, -}) => { - const theme = useTheme(); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const chatContext = useChatContext(); - - useEffect(() => { - const timer = setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ - behavior: "smooth", - block: "end", - }); - }, 50); - return () => clearTimeout(timer); - }, []); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleSubmit(); - } - }; - - return ( -
-
-
- {messages.map((message) => ( - - ))} -
-
-
- -
- -
- -
- - - - -
-
-
- ); -}; - -export const ChatMessages: FC = () => { - const { chatID } = useParams(); - if (!chatID) { - throw new Error("Chat ID is required in URL path /chat/:chatID"); - } - - const { state } = useLocation(); - const transferredState = state as ChatLandingLocationState | undefined; - - const messagesQuery = useQuery(getChatMessages(chatID)); - - const chatContext = useChatContext(); - - const { - messages, - input, - handleInputChange, - handleSubmit: originalHandleSubmit, - isLoading, - setInput, - setMessages, - } = useChat({ - id: chatID, - api: `/api/v2/chats/${chatID}/messages`, - experimental_prepareRequestBody: (options): CreateChatMessageRequest => { - const userMessages = options.messages.filter( - (message) => message.role === "user", - ); - const mostRecentUserMessage = userMessages.at(-1); - return { - model: chatContext.selectedModel, - message: mostRecentUserMessage, - thinking: false, - }; - }, - initialInput: transferredState?.message, - initialMessages: messagesQuery.data as Message[] | undefined, - }); - - // Update messages from query data when it loads - useEffect(() => { - if (messagesQuery.data && messages.length === 0) { - setMessages(messagesQuery.data as Message[]); - } - }, [messagesQuery.data, messages.length, setMessages]); - - const handleSubmitCallback = useCallback( - (e?: React.FormEvent) => { - if (e) e.preventDefault(); - if (!input.trim()) return; - originalHandleSubmit(); - setInput(""); // Clear input after submit - }, - [input, originalHandleSubmit, setInput], - ); - - // Clear input and potentially submit on initial load with message - useEffect(() => { - if (transferredState?.message && input === transferredState.message) { - // Prevent submitting if messages already exist (e.g., browser back/forward) - if (messages.length === (messagesQuery.data?.length ?? 0)) { - handleSubmitCallback(); // Use the correct callback name - } - // Clear the state to prevent re-submission on subsequent renders/navigation - window.history.replaceState({}, document.title); - } - }, [ - transferredState?.message, - input, - handleSubmitCallback, - messages.length, - messagesQuery.data?.length, - ]); // Use the correct callback name - - useEffect(() => { - if (transferredState?.message) { - // Logic potentially related to transferredState can go here if needed, - } - }, [transferredState?.message]); - - if (messagesQuery.error) { - return ; - } - - if (messagesQuery.isLoading && messages.length === 0) { - return ; - } - - return ( - - ); -}; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx deleted file mode 100644 index a05cdd1843354..0000000000000 --- a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx +++ /dev/null @@ -1,1213 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { - MockStartingWorkspace, - MockStoppedWorkspace, - MockStoppingWorkspace, - MockTemplate, - MockTemplateVersion, - MockUserMember, - MockWorkspace, - MockWorkspaceBuild, -} from "testHelpers/entities"; -import { ChatToolInvocation } from "./ChatToolInvocation"; - -const meta: Meta = { - title: "pages/ChatPage/ChatToolInvocation", - component: ChatToolInvocation, -}; - -export default meta; -type Story = StoryObj; - -export const GetWorkspace: Story = { - render: () => - renderInvocations( - "coder_get_workspace", - { - workspace_id: MockWorkspace.id, - }, - MockWorkspace, - ), -}; - -export const CreateWorkspace: Story = { - render: () => - renderInvocations( - "coder_create_workspace", - { - name: MockWorkspace.name, - rich_parameters: {}, - template_version_id: MockWorkspace.template_active_version_id, - user: MockWorkspace.owner_name, - }, - MockWorkspace, - ), -}; - -export const ListWorkspaces: Story = { - render: () => - renderInvocations( - "coder_list_workspaces", - { - owner: "me", - }, - [ - MockWorkspace, - MockStoppedWorkspace, - MockStoppingWorkspace, - MockStartingWorkspace, - ], - ), -}; - -export const ListTemplates: Story = { - render: () => - renderInvocations("coder_list_templates", {}, [ - { - id: MockTemplate.id, - name: MockTemplate.name, - description: MockTemplate.description, - active_version_id: MockTemplate.active_version_id, - active_user_count: MockTemplate.active_user_count, - }, - { - id: "another-template", - name: "Another Template", - description: "A different template for testing purposes.", - active_version_id: "v2.0", - active_user_count: 5, - }, - ]), -}; - -export const TemplateVersionParameters: Story = { - render: () => - renderInvocations( - "coder_template_version_parameters", - { - template_version_id: MockTemplateVersion.id, - }, - [ - { - name: "region", - display_name: "Region", - description: "Select the deployment region.", - description_plaintext: "Select the deployment region.", - type: "string", - form_type: "radio", - mutable: false, - default_value: "us-west-1", - icon: "", - options: [ - { name: "US West", description: "", value: "us-west-1", icon: "" }, - { name: "US East", description: "", value: "us-east-1", icon: "" }, - ], - required: true, - ephemeral: false, - }, - { - name: "cpu_cores", - display_name: "CPU Cores", - description: "Number of CPU cores.", - description_plaintext: "Number of CPU cores.", - type: "number", - form_type: "input", - mutable: true, - default_value: "4", - icon: "", - options: [], - required: false, - ephemeral: false, - }, - ], - ), -}; - -export const GetAuthenticatedUser: Story = { - render: () => - renderInvocations("coder_get_authenticated_user", {}, MockUserMember), -}; - -export const CreateWorkspaceBuild: Story = { - render: () => - renderInvocations( - "coder_create_workspace_build", - { - workspace_id: MockWorkspace.id, - transition: "start", - }, - MockWorkspaceBuild, - ), -}; - -export const CreateTemplateVersion: Story = { - render: () => - renderInvocations( - "coder_create_template_version", - { - template_id: MockTemplate.id, - file_id: "file-123", - }, - MockTemplateVersion, - ), -}; - -const mockLogs = [ - "[INFO] Starting build process...", - "[DEBUG] Reading configuration file.", - "[WARN] Deprecated setting detected.", - "[INFO] Applying changes...", - "[ERROR] Failed to connect to database.", -]; - -export const GetWorkspaceAgentLogs: Story = { - render: () => - renderInvocations( - "coder_get_workspace_agent_logs", - { - workspace_agent_id: "agent-456", - }, - mockLogs, - ), -}; - -export const GetWorkspaceBuildLogs: Story = { - render: () => - renderInvocations( - "coder_get_workspace_build_logs", - { - workspace_build_id: MockWorkspaceBuild.id, - }, - mockLogs, - ), -}; - -export const GetTemplateVersionLogs: Story = { - render: () => - renderInvocations( - "coder_get_template_version_logs", - { - template_version_id: MockTemplateVersion.id, - }, - mockLogs, - ), -}; - -export const UpdateTemplateActiveVersion: Story = { - render: () => - renderInvocations( - "coder_update_template_active_version", - { - template_id: MockTemplate.id, - template_version_id: MockTemplateVersion.id, - }, - `Successfully updated active version for template ${MockTemplate.name}.`, - ), -}; - -export const UploadTarFile: Story = { - render: () => - renderInvocations( - "coder_upload_tar_file", - { - files: { "main.tf": templateTerraform, Dockerfile: templateDockerfile }, - }, - { - hash: "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", - }, - ), -}; - -export const CreateTemplate: Story = { - render: () => - renderInvocations( - "coder_create_template", - { - name: "new-template", - }, - MockTemplate, - ), -}; - -export const DeleteTemplate: Story = { - render: () => - renderInvocations( - "coder_delete_template", - { - template_id: MockTemplate.id, - }, - `Successfully deleted template ${MockTemplate.name}.`, - ), -}; - -export const GetTemplateVersion: Story = { - render: () => - renderInvocations( - "coder_get_template_version", - { - template_version_id: MockTemplateVersion.id, - }, - MockTemplateVersion, - ), -}; - -export const DownloadTarFile: Story = { - render: () => - renderInvocations( - "coder_download_tar_file", - { - file_id: "file-789", - }, - { "main.tf": templateTerraform, "README.md": "# My Template\n" }, - ), -}; - -const renderInvocations = ( - toolName: T, - args: Extract["args"], - result: Extract< - ChatToolInvocation, - { toolName: T; state: "result" } - >["result"], - error?: string, -) => { - return ( - <> - - - - - - ); -}; - -const templateDockerfile = `FROM rust:slim@sha256:9abf10cc84dfad6ace1b0aae3951dc5200f467c593394288c11db1e17bb4d349 AS rust-utils -# Install rust helper programs -# ENV CARGO_NET_GIT_FETCH_WITH_CLI=true -ENV CARGO_INSTALL_ROOT=/tmp/ -RUN cargo install typos-cli watchexec-cli && \ - # Reduce image size. - rm -rf /usr/local/cargo/registry - -FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 AS go - -# Install Go manually, so that we can control the version -ARG GO_VERSION=1.24.1 - -# Boring Go is needed to build FIPS-compliant binaries. -RUN apt-get update && \ - apt-get install --yes curl && \ - curl --silent --show-error --location \ - "https://go.dev/dl/go\${GO_VERSION}.linux-amd64.tar.gz" \ - -o /usr/local/go.tar.gz && \ - rm -rf /var/lib/apt/lists/* - -ENV PATH=$PATH:/usr/local/go/bin -ARG GOPATH="/tmp/" -# Install Go utilities. -RUN apt-get update && \ - apt-get install --yes gcc && \ - mkdir --parents /usr/local/go && \ - tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 && \ - mkdir --parents "$GOPATH" && \ - # moq for Go tests. - go install github.com/matryer/moq@v0.2.3 && \ - # swag for Swagger doc generation - go install github.com/swaggo/swag/cmd/swag@v1.7.4 && \ - # go-swagger tool to generate the go coder api client - go install github.com/go-swagger/go-swagger/cmd/swagger@v0.28.0 && \ - # goimports for updating imports - go install golang.org/x/tools/cmd/goimports@v0.31.0 && \ - # protoc-gen-go is needed to build sysbox from source - go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 && \ - # drpc support for v2 - go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 && \ - # migrate for migration support for v2 - go install github.com/golang-migrate/migrate/v4/cmd/migrate@v4.15.1 && \ - # goreleaser for compiling v2 binaries - go install github.com/goreleaser/goreleaser@v1.6.1 && \ - # Install the latest version of gopls for editors that support - # the language server protocol - go install golang.org/x/tools/gopls@v0.18.1 && \ - # gotestsum makes test output more readable - go install gotest.tools/gotestsum@v1.9.0 && \ - # goveralls collects code coverage metrics from tests - # and sends to Coveralls - go install github.com/mattn/goveralls@v0.0.11 && \ - # kind for running Kubernetes-in-Docker, needed for tests - go install sigs.k8s.io/kind@v0.10.0 && \ - # helm-docs generates our Helm README based on a template and the - # charts and values files - go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.5.0 && \ - # sqlc for Go code generation - (CGO_ENABLED=1 go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.27.0) && \ - # gcr-cleaner-cli used by CI to prune unused images - go install github.com/sethvargo/gcr-cleaner/cmd/gcr-cleaner-cli@v0.5.1 && \ - # ruleguard for checking custom rules, without needing to run all of - # golangci-lint. Check the go.mod in the release of golangci-lint that - # we're using for the version of go-critic that it embeds, then check - # the version of ruleguard in go-critic for that tag. - go install github.com/quasilyte/go-ruleguard/cmd/ruleguard@v0.3.13 && \ - # go-releaser for building 'fat binaries' that work cross-platform - go install github.com/goreleaser/goreleaser@v1.6.1 && \ - go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0 && \ - # nfpm is used with \`make build\` to make release packages - go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1 && \ - # yq v4 is used to process yaml files in coder v2. Conflicts with - # yq v3 used in v1. - go install github.com/mikefarah/yq/v4@v4.44.3 && \ - mv /tmp/bin/yq /tmp/bin/yq4 && \ - go install go.uber.org/mock/mockgen@v0.5.0 && \ - # Reduce image size. - apt-get remove --yes gcc && \ - apt-get autoremove --yes && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* && \ - rm -rf /usr/local/go && \ - rm -rf /tmp/go/pkg && \ - rm -rf /tmp/go/src - -# alpine:3.18 -FROM us-docker.pkg.dev/coder-v2-images-public/public/alpine@sha256:fd032399cd767f310a1d1274e81cab9f0fd8a49b3589eba2c3420228cd45b6a7 AS proto -WORKDIR /tmp -RUN apk add curl unzip -RUN curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip && \ - unzip protoc.zip && \ - rm protoc.zip - -FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 - -SHELL ["/bin/bash", "-c"] - -# Install packages from apt repositories -ARG DEBIAN_FRONTEND="noninteractive" - -# Updated certificates are necessary to use the teraswitch mirror. -# This must be ran before copying in configuration since the config replaces -# the default mirror with teraswitch. -# Also enable the en_US.UTF-8 locale so that we don't generate multiple locales -# and unminimize to include man pages. -RUN apt-get update && \ - apt-get install --yes ca-certificates locales && \ - echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ - locale-gen && \ - yes | unminimize - -COPY files / - -# We used to copy /etc/sudoers.d/* in from files/ but this causes issues with -# permissions and layer caching. Instead, create the file directly. -RUN mkdir -p /etc/sudoers.d && \ - echo 'coder ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/nopasswd && \ - chmod 750 /etc/sudoers.d/ && \ - chmod 640 /etc/sudoers.d/nopasswd - -RUN apt-get update --quiet && apt-get install --yes \ - ansible \ - apt-transport-https \ - apt-utils \ - asciinema \ - bash \ - bash-completion \ - bat \ - bats \ - bind9-dnsutils \ - build-essential \ - ca-certificates \ - cargo \ - cmake \ - containerd.io \ - crypto-policies \ - curl \ - docker-ce \ - docker-ce-cli \ - docker-compose-plugin \ - exa \ - fd-find \ - file \ - fish \ - gettext-base \ - git \ - gnupg \ - google-cloud-sdk \ - google-cloud-sdk-datastore-emulator \ - graphviz \ - helix \ - htop \ - httpie \ - inetutils-tools \ - iproute2 \ - iputils-ping \ - iputils-tracepath \ - jq \ - kubectl \ - language-pack-en \ - less \ - libgbm-dev \ - libssl-dev \ - lsb-release \ - lsof \ - man \ - meld \ - ncdu \ - neovim \ - net-tools \ - openjdk-11-jdk-headless \ - openssh-server \ - openssl \ - packer \ - pkg-config \ - postgresql-16 \ - python3 \ - python3-pip \ - ripgrep \ - rsync \ - screen \ - shellcheck \ - strace \ - sudo \ - tcptraceroute \ - termshark \ - traceroute \ - unzip \ - vim \ - wget \ - xauth \ - zip \ - zsh \ - zstd && \ - # Delete package cache to avoid consuming space in layer - apt-get clean && \ - # Configure FIPS-compliant policies - update-crypto-policies --set FIPS - -# NOTE: In scripts/Dockerfile.base we specifically install Terraform version 1.11.3. -# Installing the same version here to match. -RUN wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.11.3/terraform_1.11.3_linux_amd64.zip" && \ - unzip /tmp/terraform.zip -d /usr/local/bin && \ - rm -f /tmp/terraform.zip && \ - chmod +x /usr/local/bin/terraform && \ - terraform --version - -# Install the docker buildx component. -RUN DOCKER_BUILDX_VERSION=$(curl -s "https://api.github.com/repos/docker/buildx/releases/latest" | grep '"tag_name":' | sed -E 's/.*"(v[^"]+)".*/\\1/') && \ - mkdir -p /usr/local/lib/docker/cli-plugins && \ - curl -Lo /usr/local/lib/docker/cli-plugins/docker-buildx "https://github.com/docker/buildx/releases/download/\${DOCKER_BUILDX_VERSION}/buildx-\${DOCKER_BUILDX_VERSION}.linux-amd64" && \ - chmod a+x /usr/local/lib/docker/cli-plugins/docker-buildx - -# See https://github.com/cli/cli/issues/6175#issuecomment-1235984381 for proof -# the apt repository is unreliable -RUN GH_CLI_VERSION=$(curl -s "https://api.github.com/repos/cli/cli/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\\1/') && \ - curl -L https://github.com/cli/cli/releases/download/v\${GH_CLI_VERSION}/gh_\${GH_CLI_VERSION}_linux_amd64.deb -o gh.deb && \ - dpkg -i gh.deb && \ - rm gh.deb - -# Install Lazygit -# See https://github.com/jesseduffield/lazygit#ubuntu -RUN LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v*([^"]+)".*/\\1/') && \ - curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_\${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" && \ - tar xf lazygit.tar.gz -C /usr/local/bin lazygit && \ - rm lazygit.tar.gz - -# Install doctl -# See https://docs.digitalocean.com/reference/doctl/how-to/install -RUN DOCTL_VERSION=$(curl -s "https://api.github.com/repos/digitalocean/doctl/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\\1/') && \ - curl -L https://github.com/digitalocean/doctl/releases/download/v\${DOCTL_VERSION}/doctl-\${DOCTL_VERSION}-linux-amd64.tar.gz -o doctl.tar.gz && \ - tar xf doctl.tar.gz -C /usr/local/bin doctl && \ - rm doctl.tar.gz - -ARG NVM_INSTALL_SHA=bdea8c52186c4dd12657e77e7515509cda5bf9fa5a2f0046bce749e62645076d -# Install frontend utilities -ENV NVM_DIR=/usr/local/nvm -ENV NODE_VERSION=20.16.0 -RUN mkdir -p $NVM_DIR -RUN curl -o nvm_install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh && \ - echo "\${NVM_INSTALL_SHA} nvm_install.sh" | sha256sum -c && \ - bash nvm_install.sh && \ - rm nvm_install.sh -RUN source $NVM_DIR/nvm.sh && \ - nvm install $NODE_VERSION && \ - nvm use $NODE_VERSION -ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH -# Allow patch updates for npm and pnpm -RUN npm install -g npm@10.8.1 --integrity=sha512-Dp1C6SvSMYQI7YHq/y2l94uvI+59Eqbu1EpuKQHQ8p16txXRuRit5gH3Lnaagk2aXDIjg/Iru9pd05bnneKgdw== -RUN npm install -g pnpm@9.15.1 --integrity=sha512-GstWXmGT7769p3JwKVBGkVDPErzHZCYudYfnHRncmKQj3/lTblfqRMSb33kP9pToPCe+X6oj1n4MAztYO+S/zw== - -RUN pnpx playwright@1.47.0 install --with-deps chromium - -# Ensure PostgreSQL binaries are in the users $PATH. -RUN update-alternatives --install /usr/local/bin/initdb initdb /usr/lib/postgresql/16/bin/initdb 100 && \ - update-alternatives --install /usr/local/bin/postgres postgres /usr/lib/postgresql/16/bin/postgres 100 - -# Create links for injected dependencies -RUN ln --symbolic /var/tmp/coder/coder-cli/coder /usr/local/bin/coder && \ - ln --symbolic /var/tmp/coder/code-server/bin/code-server /usr/local/bin/code-server - -# Disable the PostgreSQL systemd service. -# Coder uses a custom timescale container to test the database instead. -RUN systemctl disable \ - postgresql - -# Configure systemd services for CVMs -RUN systemctl enable \ - docker \ - ssh && \ - # Workaround for envbuilder cache probing not working unless the filesystem is modified. - touch /tmp/.envbuilder-systemctl-enable-docker-ssh-workaround - -# Install tools with published releases, where that is the -# preferred/recommended installation method. -ARG CLOUD_SQL_PROXY_VERSION=2.2.0 \ - DIVE_VERSION=0.10.0 \ - DOCKER_GCR_VERSION=2.1.8 \ - GOLANGCI_LINT_VERSION=1.64.8 \ - GRYPE_VERSION=0.61.1 \ - HELM_VERSION=3.12.0 \ - KUBE_LINTER_VERSION=0.6.3 \ - KUBECTX_VERSION=0.9.4 \ - STRIPE_VERSION=1.14.5 \ - TERRAGRUNT_VERSION=0.45.11 \ - TRIVY_VERSION=0.41.0 \ - SYFT_VERSION=1.20.0 \ - COSIGN_VERSION=2.4.3 - -# cloud_sql_proxy, for connecting to cloudsql instances -# the upstream go.mod prevents this from being installed with go install -RUN curl --silent --show-error --location --output /usr/local/bin/cloud_sql_proxy "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v\${CLOUD_SQL_PROXY_VERSION}/cloud-sql-proxy.linux.amd64" && \ - chmod a=rx /usr/local/bin/cloud_sql_proxy && \ - # dive for scanning image layer utilization metrics in CI - curl --silent --show-error --location "https://github.com/wagoodman/dive/releases/download/v\${DIVE_VERSION}/dive_\${DIVE_VERSION}_linux_amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- dive && \ - # docker-credential-gcr is a Docker credential helper for pushing/pulling - # images from Google Container Registry and Artifact Registry - curl --silent --show-error --location "https://github.com/GoogleCloudPlatform/docker-credential-gcr/releases/download/v\${DOCKER_GCR_VERSION}/docker-credential-gcr_linux_amd64-\${DOCKER_GCR_VERSION}.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- docker-credential-gcr && \ - # golangci-lint performs static code analysis for our Go code - curl --silent --show-error --location "https://github.com/golangci/golangci-lint/releases/download/v\${GOLANGCI_LINT_VERSION}/golangci-lint-\${GOLANGCI_LINT_VERSION}-linux-amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- --strip-components=1 "golangci-lint-\${GOLANGCI_LINT_VERSION}-linux-amd64/golangci-lint" && \ - # Anchore Grype for scanning container images for security issues - curl --silent --show-error --location "https://github.com/anchore/grype/releases/download/v\${GRYPE_VERSION}/grype_\${GRYPE_VERSION}_linux_amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- grype && \ - # Helm is necessary for deploying Coder - curl --silent --show-error --location "https://get.helm.sh/helm-v\${HELM_VERSION}-linux-amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- --strip-components=1 linux-amd64/helm && \ - # kube-linter for linting Kubernetes objects, including those - # that Helm generates from our charts - curl --silent --show-error --location "https://github.com/stackrox/kube-linter/releases/download/\${KUBE_LINTER_VERSION}/kube-linter-linux" --output /usr/local/bin/kube-linter && \ - # kubens and kubectx for managing Kubernetes namespaces and contexts - curl --silent --show-error --location "https://github.com/ahmetb/kubectx/releases/download/v\${KUBECTX_VERSION}/kubectx_v\${KUBECTX_VERSION}_linux_x86_64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- kubectx && \ - curl --silent --show-error --location "https://github.com/ahmetb/kubectx/releases/download/v\${KUBECTX_VERSION}/kubens_v\${KUBECTX_VERSION}_linux_x86_64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- kubens && \ - # stripe for coder.com billing API - curl --silent --show-error --location "https://github.com/stripe/stripe-cli/releases/download/v\${STRIPE_VERSION}/stripe_\${STRIPE_VERSION}_linux_x86_64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- stripe && \ - # terragrunt for running Terraform and Terragrunt files - curl --silent --show-error --location --output /usr/local/bin/terragrunt "https://github.com/gruntwork-io/terragrunt/releases/download/v\${TERRAGRUNT_VERSION}/terragrunt_linux_amd64" && \ - chmod a=rx /usr/local/bin/terragrunt && \ - # AquaSec Trivy for scanning container images for security issues - curl --silent --show-error --location "https://github.com/aquasecurity/trivy/releases/download/v\${TRIVY_VERSION}/trivy_\${TRIVY_VERSION}_Linux-64bit.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- trivy && \ - # Anchore Syft for SBOM generation - curl --silent --show-error --location "https://github.com/anchore/syft/releases/download/v\${SYFT_VERSION}/syft_\${SYFT_VERSION}_linux_amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- syft && \ - # Sigstore Cosign for artifact signing and attestation - curl --silent --show-error --location --output /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/v\${COSIGN_VERSION}/cosign-linux-amd64" && \ - chmod a=rx /usr/local/bin/cosign - -# We use yq during "make deploy" to manually substitute out fields in -# our helm values.yaml file. See https://github.com/helm/helm/issues/3141 -# -# TODO: update to 4.x, we can't do this now because it included breaking -# changes (yq w doesn't work anymore) -# RUN curl --silent --show-error --location "https://github.com/mikefarah/yq/releases/download/v4.9.0/yq_linux_amd64.tar.gz" | \ -# tar --extract --gzip --directory=/usr/local/bin --file=- ./yq_linux_amd64 && \ -# mv /usr/local/bin/yq_linux_amd64 /usr/local/bin/yq - -RUN curl --silent --show-error --location --output /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/3.3.0/yq_linux_amd64" && \ - chmod a=rx /usr/local/bin/yq - -# Install GoLand. -RUN mkdir --parents /usr/local/goland && \ - curl --silent --show-error --location "https://download.jetbrains.com/go/goland-2021.2.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/goland --file=- --strip-components=1 && \ - ln --symbolic /usr/local/goland/bin/goland.sh /usr/local/bin/goland - -# Install Antlrv4, needed to generate paramlang lexer/parser -RUN curl --silent --show-error --location --output /usr/local/lib/antlr-4.9.2-complete.jar "https://www.antlr.org/download/antlr-4.9.2-complete.jar" -ENV CLASSPATH="/usr/local/lib/antlr-4.9.2-complete.jar:\${PATH}" - -# Add coder user and allow use of docker/sudo -RUN useradd coder \ - --create-home \ - --shell=/bin/bash \ - --groups=docker \ - --uid=1000 \ - --user-group - -# Adjust OpenSSH config -RUN echo "PermitUserEnvironment yes" >>/etc/ssh/sshd_config && \ - echo "X11Forwarding yes" >>/etc/ssh/sshd_config && \ - echo "X11UseLocalhost no" >>/etc/ssh/sshd_config - -# We avoid copying the extracted directory since COPY slows to minutes when there -# are a lot of small files. -COPY --from=go /usr/local/go.tar.gz /usr/local/go.tar.gz -RUN mkdir /usr/local/go && \ - tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 - -ENV PATH=$PATH:/usr/local/go/bin - -RUN update-alternatives --install /usr/local/bin/gofmt gofmt /usr/local/go/bin/gofmt 100 - -COPY --from=go /tmp/bin /usr/local/bin -COPY --from=rust-utils /tmp/bin /usr/local/bin -COPY --from=proto /tmp/bin /usr/local/bin -COPY --from=proto /tmp/include /usr/local/bin/include - -USER coder - -# Ensure go bins are in the 'coder' user's path. Note that no go bins are -# installed in this docker file, as they'd be mounted over by the persistent -# home volume. -ENV PATH="/home/coder/go/bin:\${PATH}" - -# This setting prevents Go from using the public checksum database for -# our module path prefixes. It is required because these are in private -# repositories that require authentication. -# -# For details, see: https://golang.org/ref/mod#private-modules -ENV GOPRIVATE="coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder" - -# Increase memory allocation to NodeJS -ENV NODE_OPTIONS="--max-old-space-size=8192" -`; - -const templateTerraform = `terraform { - required_providers { - coder = { - source = "coder/coder" - version = "2.2.0-pre0" - } - docker = { - source = "kreuzwerker/docker" - version = "~> 3.0.0" - } - } -} - -locals { - // These are cluster service addresses mapped to Tailscale nodes. Ask Dean or - // Kyle for help. - docker_host = { - "" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" - "us-pittsburgh" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" - // For legacy reasons, this host is labelled \`eu-helsinki\` but it's - // actually in Germany now. - "eu-helsinki" = "tcp://katerose-fsn-cdr-dev.tailscale.svc.cluster.local:2375" - "ap-sydney" = "tcp://wolfgang-syd-cdr-dev.tailscale.svc.cluster.local:2375" - "sa-saopaulo" = "tcp://oberstein-sao-cdr-dev.tailscale.svc.cluster.local:2375" - "za-cpt" = "tcp://schonkopf-cpt-cdr-dev.tailscale.svc.cluster.local:2375" - } - - repo_base_dir = data.coder_parameter.repo_base_dir.value == "~" ? "/home/coder" : replace(data.coder_parameter.repo_base_dir.value, "/^~\\//", "/home/coder/") - repo_dir = replace(try(module.git-clone[0].repo_dir, ""), "/^~\\//", "/home/coder/") - container_name = "coder-\${data.coder_workspace_owner.me.name}-\${lower(data.coder_workspace.me.name)}" -} - -data "coder_parameter" "repo_base_dir" { - type = "string" - name = "Coder Repository Base Directory" - default = "~" - description = "The directory specified will be created (if missing) and [coder/coder](https://github.com/coder/coder) will be automatically cloned into [base directory]/coder πŸͺ„." - mutable = true -} - -data "coder_parameter" "image_type" { - type = "string" - name = "Coder Image" - default = "codercom/oss-dogfood:latest" - description = "The Docker image used to run your workspace. Choose between nix and non-nix images." - option { - icon = "/icon/coder.svg" - name = "Dogfood (Default)" - value = "codercom/oss-dogfood:latest" - } - option { - icon = "/icon/nix.svg" - name = "Dogfood Nix (Experimental)" - value = "codercom/oss-dogfood-nix:latest" - } -} - -data "coder_parameter" "region" { - type = "string" - name = "Region" - icon = "/emojis/1f30e.png" - default = "us-pittsburgh" - option { - icon = "/emojis/1f1fa-1f1f8.png" - name = "Pittsburgh" - value = "us-pittsburgh" - } - option { - icon = "/emojis/1f1e9-1f1ea.png" - name = "Falkenstein" - // For legacy reasons, this host is labelled \`eu-helsinki\` but it's - // actually in Germany now. - value = "eu-helsinki" - } - option { - icon = "/emojis/1f1e6-1f1fa.png" - name = "Sydney" - value = "ap-sydney" - } - option { - icon = "/emojis/1f1e7-1f1f7.png" - name = "SΓ£o Paulo" - value = "sa-saopaulo" - } - option { - icon = "/emojis/1f1ff-1f1e6.png" - name = "Cape Town" - value = "za-cpt" - } -} - -data "coder_parameter" "res_mon_memory_threshold" { - type = "number" - name = "Memory usage threshold" - default = 80 - description = "The memory usage threshold used in resources monitoring to trigger notifications." - mutable = true - validation { - min = 0 - max = 100 - } -} - -data "coder_parameter" "res_mon_volume_threshold" { - type = "number" - name = "Volume usage threshold" - default = 90 - description = "The volume usage threshold used in resources monitoring to trigger notifications." - mutable = true - validation { - min = 0 - max = 100 - } -} - -data "coder_parameter" "res_mon_volume_path" { - type = "string" - name = "Volume path" - default = "/home/coder" - description = "The path monitored in resources monitoring to trigger notifications." - mutable = true -} - -provider "docker" { - host = lookup(local.docker_host, data.coder_parameter.region.value) -} - -provider "coder" {} - -data "coder_external_auth" "github" { - id = "github" -} - -data "coder_workspace" "me" {} -data "coder_workspace_owner" "me" {} -data "coder_workspace_tags" "tags" { - tags = { - "cluster" : "dogfood-v2" - "env" : "gke" - } -} - -module "slackme" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/slackme/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - auth_provider_id = "slack" -} - -module "dotfiles" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/dotfiles/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id -} - -module "git-clone" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/git-clone/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - url = "https://github.com/coder/coder" - base_dir = local.repo_base_dir -} - -module "personalize" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/personalize/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id -} - -module "code-server" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/code-server/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - folder = local.repo_dir - auto_install_extensions = true -} - -module "vscode-web" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/modules/vscode-web/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - folder = local.repo_dir - extensions = ["github.copilot"] - auto_install_extensions = true # will install extensions from the repos .vscode/extensions.json file - accept_license = true -} - -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/jetbrains-gateway/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - agent_name = "dev" - folder = local.repo_dir - jetbrains_ides = ["GO", "WS"] - default = "GO" - latest = true -} - -module "filebrowser" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/filebrowser/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - agent_name = "dev" -} - -module "coder-login" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/coder-login/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id -} - -module "cursor" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/cursor/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - folder = local.repo_dir -} - -module "zed" { - count = data.coder_workspace.me.start_count - source = "./zed" - agent_id = coder_agent.dev.id - folder = local.repo_dir -} - -resource "coder_agent" "dev" { - arch = "amd64" - os = "linux" - dir = local.repo_dir - env = { - OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token, - } - startup_script_behavior = "blocking" - - # The following metadata blocks are optional. They are used to display - # information about your workspace in the dashboard. You can remove them - # if you don't want to display any information. - metadata { - display_name = "CPU Usage" - key = "cpu_usage" - order = 0 - script = "coder stat cpu" - interval = 10 - timeout = 1 - } - - metadata { - display_name = "RAM Usage" - key = "ram_usage" - order = 1 - script = "coder stat mem" - interval = 10 - timeout = 1 - } - - metadata { - display_name = "CPU Usage (Host)" - key = "cpu_usage_host" - order = 2 - script = "coder stat cpu --host" - interval = 10 - timeout = 1 - } - - metadata { - display_name = "RAM Usage (Host)" - key = "ram_usage_host" - order = 3 - script = "coder stat mem --host" - interval = 10 - timeout = 1 - } - - metadata { - display_name = "Swap Usage (Host)" - key = "swap_usage_host" - order = 4 - script = <&1 | awk ' $0 ~ "Word of the Day: [A-z]+" { print $5; exit }' - EOT - interval = 86400 - timeout = 5 - } - - resources_monitoring { - memory { - enabled = true - threshold = data.coder_parameter.res_mon_memory_threshold.value - } - volume { - enabled = true - threshold = data.coder_parameter.res_mon_volume_threshold.value - path = data.coder_parameter.res_mon_volume_path.value - } - } - - startup_script = <<-EOT - #!/usr/bin/env bash - set -eux -o pipefail - - # Allow synchronization between scripts. - trap 'touch /tmp/.coder-startup-script.done' EXIT - - # Start Docker service - sudo service docker start - # Install playwright dependencies - # We want to use the playwright version from site/package.json - # Check if the directory exists At workspace creation as the coder_script runs in parallel so clone might not exist yet. - while ! [[ -f "\${local.repo_dir}/site/package.json" ]]; do - sleep 1 - done - cd "\${local.repo_dir}" && make clean - cd "\${local.repo_dir}/site" && pnpm install - EOT - - shutdown_script = <<-EOT - #!/usr/bin/env bash - set -eux -o pipefail - - # Stop the Docker service to prevent errors during workspace destroy. - sudo service docker stop - EOT -} - -# Add a cost so we get some quota usage in dev.coder.com -resource "coder_metadata" "home_volume" { - resource_id = docker_volume.home_volume.id - daily_cost = 1 -} - -resource "docker_volume" "home_volume" { - name = "coder-\${data.coder_workspace.me.id}-home" - # Protect the volume from being deleted due to changes in attributes. - lifecycle { - ignore_changes = all - } - # Add labels in Docker to keep track of orphan resources. - labels { - label = "coder.owner" - value = data.coder_workspace_owner.me.name - } - labels { - label = "coder.owner_id" - value = data.coder_workspace_owner.me.id - } - labels { - label = "coder.workspace_id" - value = data.coder_workspace.me.id - } - # This field becomes outdated if the workspace is renamed but can - # be useful for debugging or cleaning out dangling volumes. - labels { - label = "coder.workspace_name_at_creation" - value = data.coder_workspace.me.name - } -} - -data "docker_registry_image" "dogfood" { - name = data.coder_parameter.image_type.value -} - -resource "docker_image" "dogfood" { - name = "\${data.coder_parameter.image_type.value}@\${data.docker_registry_image.dogfood.sha256_digest}" - pull_triggers = [ - data.docker_registry_image.dogfood.sha256_digest, - sha1(join("", [for f in fileset(path.module, "files/*") : filesha1(f)])), - filesha1("Dockerfile"), - filesha1("nix.hash"), - ] - keep_locally = true -} - -resource "docker_container" "workspace" { - count = data.coder_workspace.me.start_count - image = docker_image.dogfood.name - name = local.container_name - # Hostname makes the shell more user friendly: coder@my-workspace:~$ - hostname = data.coder_workspace.me.name - # Use the docker gateway if the access URL is 127.0.0.1 - entrypoint = ["sh", "-c", coder_agent.dev.init_script] - # CPU limits are unnecessary since Docker will load balance automatically - memory = data.coder_workspace_owner.me.name == "code-asher" ? 65536 : 32768 - runtime = "sysbox-runc" - # Ensure the workspace is given time to execute shutdown scripts. - destroy_grace_seconds = 60 - stop_timeout = 60 - stop_signal = "SIGINT" - env = [ - "CODER_AGENT_TOKEN=\${coder_agent.dev.token}", - "USE_CAP_NET_ADMIN=true", - "CODER_PROC_PRIO_MGMT=1", - "CODER_PROC_OOM_SCORE=10", - "CODER_PROC_NICE_SCORE=1", - "CODER_AGENT_DEVCONTAINERS_ENABLE=1", - ] - host { - host = "host.docker.internal" - ip = "host-gateway" - } - volumes { - container_path = "/home/coder/" - volume_name = docker_volume.home_volume.name - read_only = false - } - capabilities { - add = ["CAP_NET_ADMIN", "CAP_SYS_NICE"] - } - # Add labels in Docker to keep track of orphan resources. - labels { - label = "coder.owner" - value = data.coder_workspace_owner.me.name - } - labels { - label = "coder.owner_id" - value = data.coder_workspace_owner.me.id - } - labels { - label = "coder.workspace_id" - value = data.coder_workspace.me.id - } - labels { - label = "coder.workspace_name" - value = data.coder_workspace.me.name - } -} - -resource "coder_metadata" "container_info" { - count = data.coder_workspace.me.start_count - resource_id = docker_container.workspace[0].id - item { - key = "memory" - value = docker_container.workspace[0].memory - } - item { - key = "runtime" - value = docker_container.workspace[0].runtime - } - item { - key = "region" - value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name - } -} -`; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.tsx b/site/src/pages/ChatPage/ChatToolInvocation.tsx deleted file mode 100644 index 1f6b5556cb30c..0000000000000 --- a/site/src/pages/ChatPage/ChatToolInvocation.tsx +++ /dev/null @@ -1,880 +0,0 @@ -import type { ToolCall, ToolResult } from "@ai-sdk/provider-utils"; -import { useTheme } from "@emotion/react"; -import ArticleIcon from "@mui/icons-material/Article"; -import BuildIcon from "@mui/icons-material/Build"; -import CodeIcon from "@mui/icons-material/Code"; -import FileUploadIcon from "@mui/icons-material/FileUpload"; -import PersonIcon from "@mui/icons-material/Person"; -import SettingsIcon from "@mui/icons-material/Settings"; -import CircularProgress from "@mui/material/CircularProgress"; -import Tooltip from "@mui/material/Tooltip"; -import type * as TypesGen from "api/typesGenerated"; -import { Avatar } from "components/Avatar/Avatar"; -import { - CircleAlertIcon, - CircleCheckIcon, - InfoIcon, - TrashIcon, -} from "lucide-react"; -import type React from "react"; -import { type FC, memo, useMemo, useState } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism"; -import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; -import { TabLink, Tabs, TabsList } from "../../components/Tabs/Tabs"; - -interface ChatToolInvocationProps { - toolInvocation: ChatToolInvocation; -} - -export const ChatToolInvocation: FC = ({ - toolInvocation, -}) => { - const theme = useTheme(); - const friendlyName = useMemo(() => { - return toolInvocation.toolName - .replace("coder_", "") - .replace(/_/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()); - }, [toolInvocation.toolName]); - - const hasError = useMemo(() => { - if (toolInvocation.state !== "result") { - return false; - } - return ( - typeof toolInvocation.result === "object" && - toolInvocation.result !== null && - "error" in toolInvocation.result - ); - }, [toolInvocation]); - const statusColor = useMemo(() => { - if (toolInvocation.state !== "result") { - return theme.palette.info.main; - } - return hasError ? theme.palette.error.main : theme.palette.success.main; - }, [toolInvocation, hasError, theme]); - const tooltipContent = useMemo(() => { - return ( - - {JSON.stringify(toolInvocation, null, 2)} - - ); - }, [toolInvocation, theme.shape.borderRadius, theme.spacing]); - - return ( -
-
- {toolInvocation.state !== "result" && ( - - )} - {toolInvocation.state === "result" ? ( - hasError ? ( - - ) : ( - - ) - ) : null} -
- {friendlyName} -
- - - -
- {toolInvocation.state === "result" ? ( - - ) : ( - - )} -
- ); -}; - -const ChatToolInvocationCallPreview: FC<{ - toolInvocation: Extract< - ChatToolInvocation, - { state: "call" | "partial-call" } - >; -}> = memo(({ toolInvocation }) => { - const theme = useTheme(); - - let content: React.ReactNode; - switch (toolInvocation.toolName) { - case "coder_upload_tar_file": - content = ( - - ); - break; - } - - if (!content) { - return null; - } - - return
{content}
; -}); - -const ChatToolInvocationResultPreview: FC<{ - toolInvocation: Extract; -}> = memo(({ toolInvocation }) => { - const theme = useTheme(); - - if (!toolInvocation.result) { - return null; - } - - if ( - typeof toolInvocation.result === "object" && - "error" in toolInvocation.result - ) { - return null; - } - - let content: React.ReactNode; - switch (toolInvocation.toolName) { - case "coder_get_workspace": - case "coder_create_workspace": - content = ( -
- {toolInvocation.result.template_icon && ( - {toolInvocation.result.template_display_name - )} -
-
- {toolInvocation.result.name} -
-
- {toolInvocation.result.template_display_name} -
-
-
- ); - break; - case "coder_list_workspaces": - content = ( -
- {toolInvocation.result.map((workspace) => ( -
- {workspace.template_icon && ( - {workspace.template_display_name - )} -
-
- {workspace.name} -
-
- {workspace.template_display_name} -
-
-
- ))} -
- ); - break; - case "coder_list_templates": { - const templates = toolInvocation.result; - content = ( -
- {templates.map((template) => ( -
- -
-
- {template.name} -
-
- {template.description} -
-
-
- ))} - {templates.length === 0 &&
No templates found.
} -
- ); - break; - } - case "coder_template_version_parameters": { - const params = toolInvocation.result; - content = ( -
- - {params.length > 0 - ? `${params.length} parameter(s)` - : "No parameters"} -
- ); - break; - } - case "coder_get_authenticated_user": { - const user = toolInvocation.result; - content = ( -
- - - -
-
- {user.username} -
-
- {user.email} -
-
-
- ); - break; - } - case "coder_create_workspace_build": { - const build = toolInvocation.result; - content = ( -
- - Build #{build.build_number} ({build.transition}) status:{" "} - {build.status} -
- ); - break; - } - case "coder_create_template_version": { - const version = toolInvocation.result; - content = ( -
- -
-
{version.name}
- {version.message && ( -
- {version.message} -
- )} -
-
- ); - break; - } - case "coder_get_workspace_agent_logs": - case "coder_get_workspace_build_logs": - case "coder_get_template_version_logs": { - const logs = toolInvocation.result; - const totalLines = logs.length; - const maxLinesToShow = 5; - const lastLogs = logs.slice(-maxLinesToShow); - const hiddenLines = totalLines - lastLogs.length; - - const totalLinesText = `${totalLines} log line${totalLines !== 1 ? "s" : ""}`; - const hiddenLinesText = - hiddenLines > 0 - ? `... hiding ${hiddenLines} more line${hiddenLines !== 1 ? "s" : ""} ...` - : null; - - const logsToShow = hiddenLinesText - ? [hiddenLinesText, ...lastLogs] - : lastLogs; - - content = ( -
-
- - Retrieved {totalLinesText}. -
- {logsToShow.length > 0 && ( - - {logsToShow.join("\n")} - - )} -
- ); - break; - } - case "coder_update_template_active_version": - content = ( -
- - {toolInvocation.result} -
- ); - break; - case "coder_upload_tar_file": - content = ( - - ); - break; - case "coder_create_template": { - const template = toolInvocation.result; - content = ( -
- {template.display_name -
-
- {template.name} -
-
- {template.display_name} -
-
-
- ); - break; - } - case "coder_delete_template": - content = ( -
- - {toolInvocation.result} -
- ); - break; - case "coder_get_template_version": { - const version = toolInvocation.result; - content = ( -
- -
-
{version.name}
- {version.message && ( -
- {version.message} -
- )} -
-
- ); - break; - } - case "coder_download_tar_file": { - const files = toolInvocation.result; - content = ; - break; - } - // Add default case or handle other tools if necessary - } - return ( -
- {content} -
- ); -}); - -// New component to preview files with tabs -const FilePreview: FC<{ files: Record; prefix?: string }> = - memo(({ files, prefix }) => { - const theme = useTheme(); - const [selectedTab, setSelectedTab] = useState(0); - const fileEntries = useMemo(() => Object.entries(files), [files]); - - if (fileEntries.length === 0) { - return null; - } - - const handleTabChange = (index: number) => { - setSelectedTab(index); - }; - - const getLanguage = (filename: string): string => { - if (filename.includes("Dockerfile")) { - return "dockerfile"; - } - const extension = filename.split(".").pop()?.toLowerCase(); - switch (extension) { - case "tf": - return "hcl"; - case "json": - return "json"; - case "yaml": - case "yml": - return "yaml"; - case "js": - case "jsx": - return "javascript"; - case "ts": - case "tsx": - return "typescript"; - case "py": - return "python"; - case "go": - return "go"; - case "rb": - return "ruby"; - case "java": - return "java"; - case "sh": - return "bash"; - case "md": - return "markdown"; - default: - return "plaintext"; - } - }; - - // Get filename and content based on the selectedTab index - const [selectedFilename, selectedContent] = fileEntries[selectedTab] ?? [ - "", - "", - ]; - - return ( -
- {prefix && ( -
- - {prefix} -
- )} - {/* Use custom Tabs component with active prop */} - - - {fileEntries.map(([filename], index) => ( - { - e.preventDefault(); // Prevent any potential default link behavior - handleTabChange(index); - }} - > - {filename} - - ))} - - - - {selectedContent} - -
- ); - }); - -// TODO: generate these from codersdk/toolsdk.go. -export type ChatToolInvocation = - | ToolInvocation< - "coder_get_workspace", - { - workspace_id: string; - }, - TypesGen.Workspace - > - | ToolInvocation< - "coder_create_workspace", - { - user: string; - template_version_id: string; - name: string; - rich_parameters: Record; - }, - TypesGen.Workspace - > - | ToolInvocation< - "coder_list_workspaces", - { - owner: string; - }, - Pick< - TypesGen.Workspace, - | "id" - | "name" - | "template_id" - | "template_name" - | "template_display_name" - | "template_icon" - | "template_active_version_id" - | "outdated" - >[] - > - | ToolInvocation< - "coder_list_templates", - Record, - Pick< - TypesGen.Template, - | "id" - | "name" - | "description" - | "active_version_id" - | "active_user_count" - >[] - > - | ToolInvocation< - "coder_template_version_parameters", - { - template_version_id: string; - }, - TypesGen.TemplateVersionParameter[] - > - | ToolInvocation< - "coder_get_authenticated_user", - Record, - TypesGen.User - > - | ToolInvocation< - "coder_create_workspace_build", - { - workspace_id: string; - template_version_id?: string; - transition: "start" | "stop" | "delete"; - }, - TypesGen.WorkspaceBuild - > - | ToolInvocation< - "coder_create_template_version", - { - template_id?: string; - file_id: string; - }, - TypesGen.TemplateVersion - > - | ToolInvocation< - "coder_get_workspace_agent_logs", - { - workspace_agent_id: string; - }, - string[] - > - | ToolInvocation< - "coder_get_workspace_build_logs", - { - workspace_build_id: string; - }, - string[] - > - | ToolInvocation< - "coder_get_template_version_logs", - { - template_version_id: string; - }, - string[] - > - | ToolInvocation< - "coder_get_template_version", - { - template_version_id: string; - }, - TypesGen.TemplateVersion - > - | ToolInvocation< - "coder_download_tar_file", - { - file_id: string; - }, - Record - > - | ToolInvocation< - "coder_update_template_active_version", - { - template_id: string; - template_version_id: string; - }, - string - > - | ToolInvocation< - "coder_upload_tar_file", - { - files: Record; - }, - TypesGen.UploadResponse - > - | ToolInvocation< - "coder_create_template", - { - name: string; - }, - TypesGen.Template - > - | ToolInvocation< - "coder_delete_template", - { - template_id: string; - }, - string - >; - -type ToolInvocation = - | ({ - state: "partial-call"; - step?: number; - } & ToolCall) - | ({ - state: "call"; - step?: number; - } & ToolCall) - | ({ - state: "result"; - step?: number; - } & ToolResult< - N, - A, - | R - | { - error: string; - } - >); diff --git a/site/src/pages/ChatPage/LanguageModelSelector.tsx b/site/src/pages/ChatPage/LanguageModelSelector.tsx deleted file mode 100644 index da56ad6839491..0000000000000 --- a/site/src/pages/ChatPage/LanguageModelSelector.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useTheme } from "@emotion/react"; -import FormControl from "@mui/material/FormControl"; -import InputLabel from "@mui/material/InputLabel"; -import MenuItem from "@mui/material/MenuItem"; -import Select from "@mui/material/Select"; -import { deploymentLanguageModels } from "api/queries/deployment"; -import type { LanguageModel } from "api/typesGenerated"; // Assuming types live here based on project structure -import { Loader } from "components/Loader/Loader"; -import type { FC } from "react"; -import { useQuery } from "react-query"; -import { useChatContext } from "./ChatLayout"; - -export const LanguageModelSelector: FC = () => { - const theme = useTheme(); - const { setSelectedModel, modelConfig, selectedModel } = useChatContext(); - const { - data: languageModelConfig, - isLoading, - error, - } = useQuery(deploymentLanguageModels()); - - if (isLoading) { - return ; - } - - if (error || !languageModelConfig) { - console.error("Failed to load language models:", error); - return ( -
Error loading models.
- ); - } - - const models = Array.from(languageModelConfig.models).toSorted((a, b) => { - // Sort by provider first, then by display name - const compareProvider = a.provider.localeCompare(b.provider); - if (compareProvider !== 0) { - return compareProvider; - } - return a.display_name.localeCompare(b.display_name); - }); - - if (models.length === 0) { - return ( -
- No language models available. -
- ); - } - - return ( - - Model - - - ); -}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 070576a5e9a99..2e39c5625a6cb 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -326,7 +326,6 @@ const CreateWorkspacePageExperimental: FC = () => { const workspace = await createWorkspaceMutation.mutateAsync({ ...workspaceRequest, - enable_dynamic_parameters: true, userId: owner.id, }); onCreateWorkspace(workspace); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index deab54d531ec4..f085c74c57073 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -125,6 +125,7 @@ export const PresetsButNoneSelected: Story = { { ID: "preset-1", Name: "Preset 1", + Default: false, Parameters: [ { Name: MockTemplateVersionParameter1.name, @@ -135,6 +136,7 @@ export const PresetsButNoneSelected: Story = { { ID: "preset-2", Name: "Preset 2", + Default: false, Parameters: [ { Name: MockTemplateVersionParameter2.name, @@ -201,6 +203,87 @@ export const PresetReselected: Story = { }, }; +export const PresetNoneSelected: Story = { + args: { + ...PresetsButNoneSelected.args, + onSubmit: (request, owner) => { + // Assert that template_version_preset_id is not present in the request + console.assert( + !("template_version_preset_id" in request), + 'template_version_preset_id should not be present when "None" is selected', + ); + action("onSubmit")(request, owner); + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // First select a preset to set the field value + await userEvent.click(canvas.getByLabelText("Preset")); + await userEvent.click(canvas.getByText("Preset 1")); + + // Then select "None" to unset the field value + await userEvent.click(canvas.getByLabelText("Preset")); + await userEvent.click(canvas.getByText("None")); + + // Fill in required fields and submit to test the API call + await userEvent.type( + canvas.getByLabelText("Workspace Name"), + "test-workspace", + ); + await userEvent.click(canvas.getByText("Create workspace")); + }, + parameters: { + docs: { + description: { + story: + "This story tests that when 'None' preset is selected, the template_version_preset_id field is not included in the form submission. The story first selects a preset to set the field value, then selects 'None' to unset it, and finally submits the form to verify the API call behavior.", + }, + }, + }, +}; + +export const PresetsWithDefault: Story = { + args: { + presets: [ + { + ID: "preset-1", + Name: "Preset 1", + Default: false, + Parameters: [ + { + Name: MockTemplateVersionParameter1.name, + Value: "preset 1 override", + }, + ], + }, + { + ID: "preset-2", + Name: "Preset 2", + Default: true, + Parameters: [ + { + Name: MockTemplateVersionParameter2.name, + Value: "150189", + }, + ], + }, + ], + parameters: [ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Wait for the switch to be available since preset parameters are populated asynchronously + await canvas.findByLabelText("Show preset parameters"); + // Toggle off the show preset parameters switch + await userEvent.click(canvas.getByLabelText("Show preset parameters")); + }, +}; + export const ExternalAuth: Story = { args: { externalAuth: [ diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 7a880e8df26b6..75c382f807b1b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -6,7 +6,6 @@ import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { Button } from "components/Button/Button"; -import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { SelectFilter } from "components/Filter/SelectFilter"; import { FormFields, @@ -152,21 +151,31 @@ export const CreateWorkspacePageView: FC = ({ const [presetOptions, setPresetOptions] = useState([ { label: "None", value: "" }, ]); + const [selectedPresetIndex, setSelectedPresetIndex] = useState(0); + // Build options and keep default label/value in sync useEffect(() => { - setPresetOptions([ + const options = [ { label: "None", value: "" }, - ...presets.map((preset) => ({ - label: preset.Name, - value: preset.ID, + ...presets.map((p) => ({ + label: p.Default ? `${p.Name} (Default)` : p.Name, + value: p.ID, })), - ]); - }, [presets]); + ]; + setPresetOptions(options); + const defaultPreset = presets.find((p) => p.Default); + if (defaultPreset) { + const idx = presets.indexOf(defaultPreset) + 1; // +1 for "None" + setSelectedPresetIndex(idx); + form.setFieldValue("template_version_preset_id", defaultPreset.ID); + } else { + setSelectedPresetIndex(0); // Explicitly set to "None" + form.setFieldValue("template_version_preset_id", undefined); + } + }, [presets, form.setFieldValue]); - const [selectedPresetIndex, setSelectedPresetIndex] = useState(0); const [presetParameterNames, setPresetParameterNames] = useState( [], ); - useEffect(() => { const selectedPresetOption = presetOptions[selectedPresetIndex]; let selectedPreset: TypesGen.Preset | undefined; @@ -352,7 +361,6 @@ export const CreateWorkspacePageView: FC = ({ Select a preset to get started - @@ -369,32 +377,36 @@ export const CreateWorkspacePageView: FC = ({ setSelectedPresetIndex(index); form.setFieldValue( "template_version_preset_id", - option?.value, + // Empty string is equivalent to using None + option?.value === "" ? undefined : option?.value, ); }} placeholder="Select a preset" selectedOption={presetOptions[selectedPresetIndex]} /> -
- -
+ )}
)} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 3522d24012445..8cb6c4acb6e49 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -29,8 +29,8 @@ import { type FormikContextType, useFormik } from "formik"; import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { ArrowLeft, CircleHelp } from "lucide-react"; import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; -import { Diagnostics } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { + Diagnostics, DynamicParameter, getInitialParameterValues, useValidationSchemaForDynamicParameters, @@ -190,13 +190,25 @@ export const CreateWorkspacePageViewExperimental: FC< setPresetOptions([ { label: "None", value: "None" }, ...presets.map((preset) => ({ - label: preset.Name, + label: preset.Default ? `${preset.Name} (Default)` : preset.Name, value: preset.ID, })), ]); }, [presets]); const [selectedPresetIndex, setSelectedPresetIndex] = useState(0); + + // Set default preset when presets are loaded + useEffect(() => { + const defaultPreset = presets.find((preset) => preset.Default); + if (defaultPreset) { + // +1 because "None" is at index 0 + const defaultIndex = + presets.findIndex((preset) => preset.ID === defaultPreset.ID) + 1; + setSelectedPresetIndex(defaultIndex); + } + }, [presets]); + const [presetParameterNames, setPresetParameterNames] = useState( [], ); @@ -550,11 +562,11 @@ export const CreateWorkspacePageViewExperimental: FC<
-
- - - - + {/* Only show the preset parameter visibility toggle if preset parameters are actually being modified, otherwise it is ineffectual */} + {presetParameterNames.length > 0 && ( + + + + + )}
)} diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 998939686675a..4988f95ea7cc2 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -112,7 +112,7 @@ export const AppearanceSettingsPageView: FC< corner of the dashboard." validation={ isEntitled - ? "We recommend a transparent image with 3:1 aspect ratio." + ? "An image with transparency and an aspect ratio of 3:1 or less will look best." : "This is an Enterprise only feature." } onSubmit={logoForm.handleSubmit} diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.stories.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.stories.tsx index 052e855b284a9..bd3deeeee7c26 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.stories.tsx @@ -15,8 +15,10 @@ export default meta; type Story = StoryObj; export const TestNotification: Story = { - play: async ({ canvasElement }) => { + beforeEach() { spyOn(API, "postTestNotification").mockResolvedValue(); + }, + play: async ({ canvasElement }) => { const user = userEvent.setup(); const canvas = within(canvasElement); diff --git a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPage.tsx b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPage.tsx index fc15eca1ec4f1..c4f49e1dda31a 100644 --- a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPage.tsx @@ -1,5 +1,9 @@ import { deploymentDAUs } from "api/queries/deployment"; -import { availableExperiments, experiments } from "api/queries/experiments"; +import { + availableExperiments, + experiments, + isKnownExperiment, +} from "api/queries/experiments"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { useDeploymentConfig } from "modules/management/DeploymentConfigProvider"; import type { FC } from "react"; @@ -18,7 +22,7 @@ const OverviewPage: FC = () => { const safeExperiments = safeExperimentsQuery.data?.safe ?? []; const invalidExperiments = enabledExperimentsQuery.data?.filter((exp) => { - return !safeExperiments.includes(exp); + return !isKnownExperiment(exp); }) ?? []; const { data: dailyActiveUsers } = useQuery(deploymentDAUs()); diff --git a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.stories.tsx index 3535d4ffd1d47..24e121b9ff0f5 100644 --- a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.stories.tsx @@ -30,7 +30,7 @@ const meta: Meta = { description: "Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments.", flag: "experiments", - value: ["workspace_actions"], + value: ["example"], flag_shorthand: "", hidden: false, }, @@ -82,8 +82,8 @@ export const allExperimentsEnabled: Story = { hidden: false, }, ], - safeExperiments: ["shared-ports"], - invalidExperiments: ["invalid"], + safeExperiments: ["example"], + invalidExperiments: [], }, }; @@ -118,7 +118,7 @@ export const invalidExperimentsEnabled: Story = { hidden: false, }, ], - safeExperiments: ["shared-ports"], + safeExperiments: ["example"], invalidExperiments: ["invalid"], }, }; diff --git a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.tsx b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.tsx index 505036a9c821f..37da47f4b8a16 100644 --- a/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OverviewPage/OverviewPageView.tsx @@ -1,7 +1,7 @@ import AlertTitle from "@mui/material/AlertTitle"; import type { DAUsResponse, - Experiments, + Experiment, SerpentOption, } from "api/typesGenerated"; import { Link } from "components/Link/Link"; @@ -22,8 +22,8 @@ import { UserEngagementChart } from "./UserEngagementChart"; type OverviewPageViewProps = { deploymentOptions: SerpentOption[]; dailyActiveUsers: DAUsResponse | undefined; - readonly invalidExperiments: Experiments | string[]; - readonly safeExperiments: Experiments | string[]; + readonly invalidExperiments: readonly string[]; + readonly safeExperiments: readonly Experiment[]; }; export const OverviewPageView: FC = ({ diff --git a/site/src/pages/TaskPage/TaskAppIframe.tsx b/site/src/pages/TaskPage/TaskAppIframe.tsx index 860fc64cbbcec..b995dfec771b6 100644 --- a/site/src/pages/TaskPage/TaskAppIframe.tsx +++ b/site/src/pages/TaskPage/TaskAppIframe.tsx @@ -58,42 +58,44 @@ export const TaskAppIFrame: FC = ({ return (
-
- + {app.slug === "preview" && ( +
+ - {/* Possibly we will put a URL bar here, but for now we cannot due to - * cross-origin restrictions in iframes. */} -
+ {/* Possibly we will put a URL bar here, but for now we cannot due to + * cross-origin restrictions in iframes. */} +
- - - - - - - - - Open app in new tab - - - - -
+ + + + + + + + + Open app in new tab + + + + +
+ )}