diff --git a/.editorconfig b/.editorconfig index 6ca567c288220..9415469de3c00 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,10 @@ indent_style = tab indent_style = space indent_size = 2 +[*.proto] +indent_style = space +indent_size = 2 + [coderd/database/dump.sql] indent_style = space indent_size = 4 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f59add8754f8e..b0c73ff5b2097 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -187,7 +187,7 @@ jobs: # Check for any typos - name: Check for typos - uses: crate-ci/typos@0f0ccba9ed1df83948f0c15026e4f5ccfce46109 # v1.32.0 + uses: crate-ci/typos@b1ae8d918b6e85bd611117d3d9a3be4f903ee5e4 # v1.33.1 with: config: .github/workflows/typos.toml @@ -582,7 +582,7 @@ jobs: # NOTE: this could instead be defined as a matrix strategy, but we want to # only block merging if tests on postgres 13 fail. Using a matrix strategy # here makes the check in the above `required` job rather complicated. - test-go-pg-16: + test-go-pg-17: runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} needs: - changes @@ -613,11 +613,11 @@ jobs: id: download-cache uses: ./.github/actions/test-cache/download with: - key-prefix: test-go-pg-16-${{ runner.os }}-${{ runner.arch }} + key-prefix: test-go-pg-17-${{ runner.os }}-${{ runner.arch }} - name: Test with PostgreSQL Database env: - POSTGRES_VERSION: "16" + POSTGRES_VERSION: "17" TS_DEBUG_DISCO: "true" TEST_RETRIES: 2 run: | @@ -719,7 +719,7 @@ jobs: # c.f. discussion on https://github.com/coder/coder/pull/15106 - name: Run Tests env: - POSTGRES_VERSION: "16" + POSTGRES_VERSION: "17" run: | make test-postgres-docker DB=ci gotestsum --junitfile="gotests.xml" --packages="./..." --rerun-fails=2 --rerun-fails-abort-on-data-race -- -race -parallel 4 -p 4 @@ -902,7 +902,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@d7afd50124cf4f337bcd943e7f45cfa85a5e4476 # v12.0.0 + uses: chromaui/action@8536229ee904071f8edce292596f6dbe0da96b9b # v12.1.1 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true @@ -934,7 +934,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@d7afd50124cf4f337bcd943e7f45cfa85a5e4476 # v12.0.0 + uses: chromaui/action@8536229ee904071f8edce292596f6dbe0da96b9b # v12.1.1 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 60192e2b98919..0272db8573ff5 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -47,6 +47,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 with: sarif_file: results.sarif diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 721584b89e202..7aea12a1fd51c 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -38,7 +38,7 @@ jobs: uses: ./.github/actions/setup-go - name: Initialize CodeQL - uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 with: languages: go, javascript @@ -48,7 +48,7 @@ jobs: rm Makefile - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 - name: Send Slack notification on failure if: ${{ failure() }} @@ -142,7 +142,7 @@ jobs: echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 + uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 with: image-ref: ${{ steps.build.outputs.image }} format: sarif @@ -150,7 +150,7 @@ jobs: severity: "CRITICAL,HIGH" - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 with: sarif_file: trivy-results.sarif category: "Trivy" diff --git a/CLAUDE.md b/CLAUDE.md index 90d91c9966df7..e124df8e2d05e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,4 +101,4 @@ Read [cursor rules](.cursorrules). ## Frontend -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/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 37dadd19667d4..6482f8c8c99f1 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,2 +1,2 @@ -[https://coder.com/docs/contributing/CODE_OF_CONDUCT](https://coder.com/docs/contributing/CODE_OF_CONDUCT) +[https://coder.com/docs/about/contributing/CODE_OF_CONDUCT](https://coder.com/docs/about/contributing/CODE_OF_CONDUCT) diff --git a/Makefile b/Makefile index 0b8cefbab0663..b6e69ac28f223 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,9 @@ GOOS := $(shell go env GOOS) GOARCH := $(shell go env GOARCH) GOOS_BIN_EXT := $(if $(filter windows, $(GOOS)),.exe,) VERSION := $(shell ./scripts/version.sh) -POSTGRES_VERSION ?= 16 + +POSTGRES_VERSION ?= 17 +POSTGRES_IMAGE ?= us-docker.pkg.dev/coder-v2-images-public/public/postgres:$(POSTGRES_VERSION) # Use the highest ZSTD compression level in CI. ifdef CI @@ -949,12 +951,12 @@ test-postgres-docker: docker rm -f test-postgres-docker-${POSTGRES_VERSION} || true # Try pulling up to three times to avoid CI flakes. - docker pull gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} || { + docker pull ${POSTGRES_IMAGE} || { retries=2 for try in $(seq 1 ${retries}); do echo "Failed to pull image, retrying (${try}/${retries})..." sleep 1 - if docker pull gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION}; then + if docker pull ${POSTGRES_IMAGE}; then break fi done @@ -982,7 +984,7 @@ test-postgres-docker: --restart no \ --detach \ --memory 16GB \ - gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} \ + ${POSTGRES_IMAGE} \ -c shared_buffers=2GB \ -c effective_cache_size=1GB \ -c work_mem=8MB \ diff --git a/agent/agent.go b/agent/agent.go index a971c0e7987b6..9f105ee296f5c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -456,7 +456,7 @@ func (t *trySingleflight) Do(key string, fn func()) { fn() } -func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient26) error { tickerDone := make(chan struct{}) collectDone := make(chan struct{}) ctx, cancel := context.WithCancel(ctx) @@ -672,7 +672,7 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient24 // reportLifecycle reports the current lifecycle state once. All state // changes are reported in order. -func (a *agent) reportLifecycle(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) reportLifecycle(ctx context.Context, aAPI proto.DRPCAgentClient26) error { for { select { case <-a.lifecycleUpdate: @@ -752,7 +752,7 @@ func (a *agent) setLifecycle(state codersdk.WorkspaceAgentLifecycle) { } // reportConnectionsLoop reports connections to the agent for auditing. -func (a *agent) reportConnectionsLoop(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) reportConnectionsLoop(ctx context.Context, aAPI proto.DRPCAgentClient26) error { for { select { case <-a.reportConnectionsUpdate: @@ -872,7 +872,7 @@ func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_T // fetchServiceBannerLoop fetches the service banner on an interval. It will // not be fetched immediately; the expectation is that it is primed elsewhere // (and must be done before the session actually starts). -func (a *agent) fetchServiceBannerLoop(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) fetchServiceBannerLoop(ctx context.Context, aAPI proto.DRPCAgentClient26) error { ticker := time.NewTicker(a.announcementBannersRefreshInterval) defer ticker.Stop() for { @@ -925,7 +925,7 @@ func (a *agent) run() (retErr error) { connMan := newAPIConnRoutineManager(a.gracefulCtx, a.hardCtx, a.logger, aAPI, tAPI) connMan.startAgentAPI("init notification banners", gracefulShutdownBehaviorStop, - func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { bannersProto, err := aAPI.GetAnnouncementBanners(ctx, &proto.GetAnnouncementBannersRequest{}) if err != nil { return xerrors.Errorf("fetch service banner: %w", err) @@ -942,7 +942,7 @@ func (a *agent) run() (retErr error) { // sending logs gets gracefulShutdownBehaviorRemain because we want to send logs generated by // shutdown scripts. connMan.startAgentAPI("send logs", gracefulShutdownBehaviorRemain, - func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { err := a.logSender.SendLoop(ctx, aAPI) if xerrors.Is(err, agentsdk.ErrLogLimitExceeded) { // we don't want this error to tear down the API connection and propagate to the @@ -961,7 +961,7 @@ func (a *agent) run() (retErr error) { connMan.startAgentAPI("report metadata", gracefulShutdownBehaviorStop, a.reportMetadata) // resources monitor can cease as soon as we start gracefully shutting down. - connMan.startAgentAPI("resources monitor", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + connMan.startAgentAPI("resources monitor", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { logger := a.logger.Named("resources_monitor") clk := quartz.NewReal() config, err := aAPI.GetResourcesMonitoringConfiguration(ctx, &proto.GetResourcesMonitoringConfigurationRequest{}) @@ -1008,7 +1008,7 @@ func (a *agent) run() (retErr error) { connMan.startAgentAPI("handle manifest", gracefulShutdownBehaviorStop, a.handleManifest(manifestOK)) connMan.startAgentAPI("app health reporter", gracefulShutdownBehaviorStop, - func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { if err := manifestOK.wait(ctx); err != nil { return xerrors.Errorf("no manifest: %w", err) } @@ -1041,7 +1041,7 @@ func (a *agent) run() (retErr error) { connMan.startAgentAPI("fetch service banner loop", gracefulShutdownBehaviorStop, a.fetchServiceBannerLoop) - connMan.startAgentAPI("stats report loop", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + connMan.startAgentAPI("stats report loop", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { if err := networkOK.wait(ctx); err != nil { return xerrors.Errorf("no network: %w", err) } @@ -1056,8 +1056,8 @@ func (a *agent) run() (retErr error) { } // handleManifest returns a function that fetches and processes the manifest -func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { - return func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { + return func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { var ( sentResult = false err error @@ -1080,6 +1080,18 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, if manifest.AgentID == uuid.Nil { return xerrors.New("nil agentID returned by manifest") } + if manifest.ParentID != uuid.Nil { + // This is a sub agent, disable all the features that should not + // be used by sub agents. + a.logger.Debug(ctx, "sub agent detected, disabling features", + slog.F("parent_id", manifest.ParentID), + slog.F("agent_id", manifest.AgentID), + ) + if a.experimentalDevcontainersEnabled { + a.logger.Info(ctx, "devcontainers are not supported on sub agents, disabling feature") + a.experimentalDevcontainersEnabled = false + } + } a.client.RewriteDERPMap(manifest.DERPMap) // Expand the directory and send it back to coderd so external @@ -1187,8 +1199,8 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, // 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.DRPCAgentClient24) error { - return func(ctx context.Context, _ proto.DRPCAgentClient24) (retErr error) { +func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(context.Context, proto.DRPCAgentClient26) error { + return func(ctx context.Context, aAPI proto.DRPCAgentClient26) (retErr error) { if err := manifestOK.wait(ctx); err != nil { return xerrors.Errorf("no manifest: %w", err) } @@ -1208,6 +1220,7 @@ func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(co // agent API. network, err = a.createTailnet( a.gracefulCtx, + aAPI, manifest.AgentID, manifest.DERPMap, manifest.DERPForceWebSockets, @@ -1355,6 +1368,7 @@ 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, @@ -1487,7 +1501,7 @@ func (a *agent) createTailnet( }() if err = a.trackGoroutine(func() { defer apiListener.Close() - apiHandler, closeAPIHAndler := a.apiHandler() + apiHandler, closeAPIHAndler := a.apiHandler(aAPI) defer func() { _ = closeAPIHAndler() }() @@ -1960,7 +1974,7 @@ const ( type apiConnRoutineManager struct { logger slog.Logger - aAPI proto.DRPCAgentClient24 + aAPI proto.DRPCAgentClient26 tAPI tailnetproto.DRPCTailnetClient24 eg *errgroup.Group stopCtx context.Context @@ -1969,7 +1983,7 @@ type apiConnRoutineManager struct { func newAPIConnRoutineManager( gracefulCtx, hardCtx context.Context, logger slog.Logger, - aAPI proto.DRPCAgentClient24, tAPI tailnetproto.DRPCTailnetClient24, + aAPI proto.DRPCAgentClient26, tAPI tailnetproto.DRPCTailnetClient24, ) *apiConnRoutineManager { // routines that remain in operation during graceful shutdown use the remainCtx. They'll still // exit if the errgroup hits an error, which usually means a problem with the conn. @@ -2002,7 +2016,7 @@ func newAPIConnRoutineManager( // but for Tailnet. func (a *apiConnRoutineManager) startAgentAPI( name string, behavior gracefulShutdownBehavior, - f func(context.Context, proto.DRPCAgentClient24) error, + f func(context.Context, proto.DRPCAgentClient26) error, ) { logger := a.logger.With(slog.F("name", name)) var ctx context.Context diff --git a/agent/agent_test.go b/agent/agent_test.go index 3a2562237b603..9a8073a289b5f 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -48,6 +48,7 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentssh" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/agent/proto" @@ -60,9 +61,16 @@ import ( "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/tailnet/tailnettest" "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" ) func TestMain(m *testing.M) { + if os.Getenv("CODER_TEST_RUN_SUB_AGENT_MAIN") == "1" { + // If we're running as a subagent, we don't want to run the main tests. + // Instead, we just run the subagent tests. + exit := runSubAgentMain() + os.Exit(exit) + } goleak.VerifyTestMain(m, testutil.GoleakOptions...) } @@ -1930,6 +1938,9 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) { if os.Getenv("CODER_TEST_USE_DOCKER") != "1" { t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") } + if _, err := exec.LookPath("devcontainer"); err != nil { + t.Skip("This test requires the devcontainer CLI: npm install -g @devcontainers/cli") + } pool, err := dockertest.NewPool("") require.NoError(t, err, "Could not connect to docker") @@ -1955,6 +1966,9 @@ 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, + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + ) }) ctx := testutil.Context(t, testutil.WaitLong) ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "/bin/sh", func(arp *workspacesdk.AgentReconnectingPTYInit) { @@ -1986,6 +2000,60 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) { require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF) } +type subAgentRequestPayload struct { + Token string `json:"token"` + Directory string `json:"directory"` +} + +// runSubAgentMain is the main function for the sub-agent that connects +// to the control plane. It reads the CODER_AGENT_URL and +// CODER_AGENT_TOKEN environment variables, sends the token, and exits +// with a status code based on the response. +func runSubAgentMain() int { + url := os.Getenv("CODER_AGENT_URL") + token := os.Getenv("CODER_AGENT_TOKEN") + if url == "" || token == "" { + _, _ = fmt.Fprintln(os.Stderr, "CODER_AGENT_URL and CODER_AGENT_TOKEN must be set") + return 10 + } + + dir, err := os.Getwd() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to get current working directory: %v\n", err) + return 1 + } + payload := subAgentRequestPayload{ + Token: token, + Directory: dir, + } + b, err := json.Marshal(payload) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to marshal payload: %v\n", err) + return 1 + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(b)) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to create request: %v\n", err) + return 1 + } + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + req = req.WithContext(ctx) + resp, err := http.DefaultClient.Do(req) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "agent connection failed: %v\n", err) + return 11 + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _, _ = fmt.Fprintf(os.Stderr, "agent exiting with non-zero exit code %d\n", resp.StatusCode) + return 12 + } + _, _ = fmt.Println("sub-agent connected successfully") + return 0 +} + // This tests end-to-end functionality of auto-starting a devcontainer. // It runs "devcontainer up" which creates a real Docker container. As // such, it does not run by default in CI. @@ -1999,6 +2067,56 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { if os.Getenv("CODER_TEST_USE_DOCKER") != "1" { t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") } + if _, err := exec.LookPath("devcontainer"); err != nil { + t.Skip("This test requires the devcontainer CLI: npm install -g @devcontainers/cli") + } + + // This HTTP handler handles requests from runSubAgentMain which + // acts as a fake sub-agent. We want to verify that the sub-agent + // connects and sends its token. We use a channel to signal + // that the sub-agent has connected successfully and then we wait + // until we receive another signal to return from the handler. This + // keeps the agent "alive" for as long as we want. + subAgentConnected := make(chan subAgentRequestPayload, 1) + subAgentReady := make(chan struct{}, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Logf("Sub-agent request received: %s %s", r.Method, r.URL.Path) + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read the token from the request body. + var payload subAgentRequestPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Failed to read token", http.StatusBadRequest) + t.Logf("Failed to read token: %v", err) + return + } + defer r.Body.Close() + + t.Logf("Sub-agent request payload received: %+v", payload) + + // Signal that the sub-agent has connected successfully. + select { + case <-t.Context().Done(): + t.Logf("Test context done, not processing sub-agent request") + return + case subAgentConnected <- payload: + } + + // Wait for the signal to return from the handler. + select { + case <-t.Context().Done(): + t.Logf("Test context done, not waiting for sub-agent ready") + return + case <-subAgentReady: + } + + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() pool, err := dockertest.NewPool("") require.NoError(t, err, "Could not connect to docker") @@ -2016,9 +2134,10 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { require.NoError(t, err, "create devcontainer directory") devcontainerFile := filepath.Join(devcontainerPath, "devcontainer.json") err = os.WriteFile(devcontainerFile, []byte(`{ - "name": "mywork", - "image": "busybox:latest", - "cmd": ["sleep", "infinity"] + "name": "mywork", + "image": "ubuntu:latest", + "cmd": ["sleep", "infinity"], + "runArgs": ["--network=host"] }`), 0o600) require.NoError(t, err, "write devcontainer.json") @@ -2043,9 +2162,24 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { }, }, } + mClock := quartz.NewMock(t) + mClock.Set(time.Now()) + tickerFuncTrap := mClock.Trap().TickerFunc("agentcontainers") + //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { + _, agentClient, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { o.ExperimentalDevcontainersEnabled = true + o.ContainerAPIOptions = append( + o.ContainerAPIOptions, + // Only match this specific dev container. + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerLabelIncludeFilter("devcontainer.local_folder", tempWorkspaceFolder), + 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 + // to execute the sub-agent main function via this env. + agentcontainers.WithSubAgentEnv("CODER_TEST_RUN_SUB_AGENT_MAIN=1"), + ) }) t.Logf("Waiting for container with label: devcontainer.local_folder=%s", tempWorkspaceFolder) @@ -2089,32 +2223,34 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "", func(opts *workspacesdk.AgentReconnectingPTYInit) { - opts.Container = container.ID - }) - require.NoError(t, err, "failed to create ReconnectingPTY") - defer ac.Close() + // Ensure the container update routine runs. + tickerFuncTrap.MustWait(ctx).MustRelease(ctx) + tickerFuncTrap.Close() + _, next := mClock.AdvanceNext() + next.MustWait(ctx) - // Use terminal reader so we can see output in case somethin goes wrong. - tr := testutil.NewTerminalReader(t, ac) + // Verify that a subagent was created. + subAgents := agentClient.GetSubAgents() + require.Len(t, subAgents, 1, "expected one sub agent") - require.NoError(t, tr.ReadUntil(ctx, func(line string) bool { - return strings.Contains(line, "#") || strings.Contains(line, "$") - }), "find prompt") + subAgent := subAgents[0] + subAgentID, err := uuid.FromBytes(subAgent.GetId()) + require.NoError(t, err, "failed to parse sub-agent ID") + t.Logf("Connecting to sub-agent: %s (ID: %s)", subAgent.Name, subAgentID) - wantFileName := "file-from-devcontainer" - wantFile := filepath.Join(tempWorkspaceFolder, wantFileName) + gotDir, err := agentClient.GetSubAgentDirectory(subAgentID) + require.NoError(t, err, "failed to get sub-agent directory") + require.Equal(t, "/workspaces/mywork", gotDir, "sub-agent directory should match") - require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{ - // NOTE(mafredri): We must use absolute path here for some reason. - Data: fmt.Sprintf("touch /workspaces/mywork/%s; exit\r", wantFileName), - }), "create file inside devcontainer") + subAgentToken, err := uuid.FromBytes(subAgent.GetAuthToken()) + require.NoError(t, err, "failed to parse sub-agent token") - // Wait for the connection to close to ensure the touch was executed. - require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF) + payload := testutil.RequireReceive(ctx, t, subAgentConnected) + require.Equal(t, subAgentToken.String(), payload.Token, "sub-agent token should match") + require.Equal(t, "/workspaces/mywork", payload.Directory, "sub-agent directory should match") - _, err = os.Stat(wantFile) - require.NoError(t, err, "file should exist outside devcontainer") + // Allow the subagent to exit. + close(subAgentReady) } // TestAgent_DevcontainerRecreate tests that RecreateDevcontainer @@ -2173,6 +2309,9 @@ 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, + agentcontainers.WithContainerLabelIncludeFilter("devcontainer.local_folder", workspaceFolder), + ) }) ctx := testutil.Context(t, testutil.WaitLong) @@ -2284,6 +2423,34 @@ waitForOutcomeLoop: }(container) } +func TestAgent_DevcontainersDisabledForSubAgent(t *testing.T) { + t.Parallel() + + // Create a manifest with a ParentID to make this a sub agent. + manifest := agentsdk.Manifest{ + AgentID: uuid.New(), + ParentID: uuid.New(), + } + + // Setup the agent with devcontainers enabled initially. + //nolint:dogsled + conn, _, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { + o.ExperimentalDevcontainersEnabled = true + }) + + // Query the containers API endpoint. This should fail because + // devcontainers have been disabled for the sub agent. + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + _, err := conn.ListContainers(ctx) + require.Error(t, err) + + // Verify the error message contains the expected text. + require.Contains(t, err.Error(), "The agent dev containers feature is experimental and not enabled by default.") + require.Contains(t, err.Error(), "To enable this feature, set CODER_AGENT_DEVCONTAINERS_ENABLE=true in your template.") +} + func TestAgent_Dial(t *testing.T) { t.Parallel() diff --git a/agent/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go index 869d2f7d0923b..f9723e8a15758 100644 --- a/agent/agentcontainers/acmock/acmock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: .. (interfaces: Lister,DevcontainerCLI) +// Source: .. (interfaces: ContainerCLI,DevcontainerCLI) // // Generated by this command: // -// mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI +// mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI // // Package acmock is a generated GoMock package. @@ -18,32 +18,81 @@ import ( gomock "go.uber.org/mock/gomock" ) -// MockLister is a mock of Lister interface. -type MockLister struct { +// MockContainerCLI is a mock of ContainerCLI interface. +type MockContainerCLI struct { ctrl *gomock.Controller - recorder *MockListerMockRecorder + recorder *MockContainerCLIMockRecorder isgomock struct{} } -// MockListerMockRecorder is the mock recorder for MockLister. -type MockListerMockRecorder struct { - mock *MockLister +// MockContainerCLIMockRecorder is the mock recorder for MockContainerCLI. +type MockContainerCLIMockRecorder struct { + mock *MockContainerCLI } -// NewMockLister creates a new mock instance. -func NewMockLister(ctrl *gomock.Controller) *MockLister { - mock := &MockLister{ctrl: ctrl} - mock.recorder = &MockListerMockRecorder{mock} +// NewMockContainerCLI creates a new mock instance. +func NewMockContainerCLI(ctrl *gomock.Controller) *MockContainerCLI { + mock := &MockContainerCLI{ctrl: ctrl} + mock.recorder = &MockContainerCLIMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockLister) EXPECT() *MockListerMockRecorder { +func (m *MockContainerCLI) EXPECT() *MockContainerCLIMockRecorder { return m.recorder } +// Copy mocks base method. +func (m *MockContainerCLI) Copy(ctx context.Context, containerName, src, dst string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Copy", ctx, containerName, src, dst) + ret0, _ := ret[0].(error) + return ret0 +} + +// Copy indicates an expected call of Copy. +func (mr *MockContainerCLIMockRecorder) Copy(ctx, containerName, src, dst any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Copy", reflect.TypeOf((*MockContainerCLI)(nil).Copy), ctx, containerName, src, dst) +} + +// DetectArchitecture mocks base method. +func (m *MockContainerCLI) DetectArchitecture(ctx context.Context, containerName string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DetectArchitecture", ctx, containerName) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DetectArchitecture indicates an expected call of DetectArchitecture. +func (mr *MockContainerCLIMockRecorder) DetectArchitecture(ctx, containerName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectArchitecture", reflect.TypeOf((*MockContainerCLI)(nil).DetectArchitecture), ctx, containerName) +} + +// ExecAs mocks base method. +func (m *MockContainerCLI) ExecAs(ctx context.Context, containerName, user string, args ...string) ([]byte, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, containerName, user} + for _, a := range args { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ExecAs", varargs...) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExecAs indicates an expected call of ExecAs. +func (mr *MockContainerCLIMockRecorder) ExecAs(ctx, containerName, user any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, containerName, user}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecAs", reflect.TypeOf((*MockContainerCLI)(nil).ExecAs), varargs...) +} + // List mocks base method. -func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func (m *MockContainerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx) ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse) @@ -52,9 +101,9 @@ func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListConta } // List indicates an expected call of List. -func (mr *MockListerMockRecorder) List(ctx any) *gomock.Call { +func (mr *MockContainerCLIMockRecorder) List(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLister)(nil).List), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockContainerCLI)(nil).List), ctx) } // MockDevcontainerCLI is a mock of DevcontainerCLI interface. @@ -81,6 +130,25 @@ func (m *MockDevcontainerCLI) EXPECT() *MockDevcontainerCLIMockRecorder { return m.recorder } +// Exec mocks base method. +func (m *MockDevcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath, cmd string, cmdArgs []string, opts ...agentcontainers.DevcontainerCLIExecOptions) error { + m.ctrl.T.Helper() + varargs := []any{ctx, workspaceFolder, configPath, cmd, cmdArgs} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Exec", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Exec indicates an expected call of Exec. +func (mr *MockDevcontainerCLIMockRecorder) Exec(ctx, workspaceFolder, configPath, cmd, cmdArgs any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, workspaceFolder, configPath, cmd, cmdArgs}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockDevcontainerCLI)(nil).Exec), varargs...) +} + // Up mocks base method. func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { m.ctrl.T.Helper() diff --git a/agent/agentcontainers/acmock/doc.go b/agent/agentcontainers/acmock/doc.go index b807efa253b75..d0951fc848eb1 100644 --- a/agent/agentcontainers/acmock/doc.go +++ b/agent/agentcontainers/acmock/doc.go @@ -1,4 +1,4 @@ // Package acmock contains a mock implementation of agentcontainers.Lister for use in tests. package acmock -//go:generate mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI +//go:generate mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 349b85e3d269f..56c5df6710297 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -1,11 +1,16 @@ package agentcontainers import ( + "bytes" "context" "errors" "fmt" + "io" "net/http" + "os" "path" + "path/filepath" + "runtime" "slices" "strings" "sync" @@ -26,27 +31,37 @@ import ( ) const ( - defaultUpdateInterval = 10 * time.Second - listContainersTimeout = 15 * time.Second + defaultUpdateInterval = 10 * time.Second + defaultOperationTimeout = 15 * time.Second + + // Destination path inside the container, we store it in a fixed location + // under /.coder-agent/coder to avoid conflicts and avoid being shadowed + // by tmpfs or other mounts. This assumes the container root filesystem is + // read-write, which seems sensible for dev containers. + coderPathInsideContainer = "/.coder-agent/coder" ) // API is responsible for container-related operations in the agent. // It provides methods to list and manage containers. type API struct { - ctx context.Context - 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 - cl Lister - dccli DevcontainerCLI - clock quartz.Clock - scriptLogger func(logSourceID uuid.UUID) ScriptLogger + ctx context.Context + 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 + ccli ContainerCLI + containerLabelIncludeFilter map[string]string // Labels to filter containers by. + dccli DevcontainerCLI + clock quartz.Clock + scriptLogger func(logSourceID uuid.UUID) ScriptLogger + subAgentClient SubAgentClient + subAgentURL string + subAgentEnv []string mu sync.RWMutex closed bool @@ -57,11 +72,18 @@ type API struct { 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. - recreateWg sync.WaitGroup + injectedSubAgentProcs map[string]subAgentProcess // By container ID. + asyncWg sync.WaitGroup devcontainerLogSourceIDs map[string]uuid.UUID // By workspace folder. } +type subAgentProcess struct { + agent SubAgent + ctx context.Context + stop context.CancelFunc +} + // Option is a functional option for API. type Option func(*API) @@ -80,11 +102,21 @@ func WithExecer(execer agentexec.Execer) Option { } } -// WithLister sets the agentcontainers.Lister implementation to use. -// The default implementation uses the Docker CLI to list containers. -func WithLister(cl Lister) Option { +// WithContainerCLI sets the agentcontainers.ContainerCLI implementation +// to use. The default implementation uses the Docker CLI. +func WithContainerCLI(ccli ContainerCLI) Option { + return func(api *API) { + api.ccli = ccli + } +} + +// WithContainerLabelIncludeFilter sets a label filter for containers. +// This option can be given multiple times to filter by multiple labels. +// The behavior is such that only containers matching one or more of the +// provided labels will be included. +func WithContainerLabelIncludeFilter(label, value string) Option { return func(api *API) { - api.cl = cl + api.containerLabelIncludeFilter[label] = value } } @@ -96,6 +128,29 @@ func WithDevcontainerCLI(dccli DevcontainerCLI) Option { } } +// WithSubAgentClient sets the SubAgentClient implementation to use. +// This is used to list, create and delete Dev Container agents. +func WithSubAgentClient(client SubAgentClient) Option { + return func(api *API) { + api.subAgentClient = client + } +} + +// WithSubAgentURL sets the agent URL for the sub-agent for +// communicating with the control plane. +func WithSubAgentURL(url string) Option { + return func(api *API) { + api.subAgentURL = url + } +} + +// WithSubAgent sets the environment variables for the sub-agent. +func WithSubAgentEnv(env ...string) Option { + return func(api *API) { + api.subAgentEnv = env + } +} + // WithDevcontainers sets the known devcontainers for the API. This // allows the API to be aware of devcontainers defined in the workspace // agent manifest. @@ -164,30 +219,33 @@ func WithScriptLogger(scriptLogger func(logSourceID uuid.UUID) ScriptLogger) Opt func NewAPI(logger slog.Logger, options ...Option) *API { ctx, cancel := context.WithCancel(context.Background()) api := &API{ - ctx: ctx, - cancel: cancel, - watcherDone: make(chan struct{}), - updaterDone: make(chan struct{}), - initialUpdateDone: make(chan struct{}), - updateTrigger: make(chan chan error), - updateInterval: defaultUpdateInterval, - logger: logger, - clock: quartz.NewReal(), - execer: agentexec.DefaultExecer, - devcontainerNames: make(map[string]bool), - knownDevcontainers: make(map[string]codersdk.WorkspaceAgentDevcontainer), - configFileModifiedTimes: make(map[string]time.Time), - recreateSuccessTimes: make(map[string]time.Time), - recreateErrorTimes: make(map[string]time.Time), - scriptLogger: func(uuid.UUID) ScriptLogger { return noopScriptLogger{} }, + ctx: ctx, + cancel: cancel, + watcherDone: make(chan struct{}), + updaterDone: make(chan struct{}), + initialUpdateDone: make(chan struct{}), + updateTrigger: make(chan chan error), + updateInterval: defaultUpdateInterval, + logger: logger, + clock: quartz.NewReal(), + execer: agentexec.DefaultExecer, + subAgentClient: noopSubAgentClient{}, + containerLabelIncludeFilter: make(map[string]string), + devcontainerNames: make(map[string]bool), + knownDevcontainers: make(map[string]codersdk.WorkspaceAgentDevcontainer), + configFileModifiedTimes: make(map[string]time.Time), + recreateSuccessTimes: make(map[string]time.Time), + recreateErrorTimes: make(map[string]time.Time), + scriptLogger: func(uuid.UUID) ScriptLogger { return noopScriptLogger{} }, + injectedSubAgentProcs: make(map[string]subAgentProcess), } // The ctx and logger must be set before applying options to avoid // nil pointer dereference. for _, opt := range options { opt(api) } - if api.cl == nil { - api.cl = NewDocker(api.execer) + if api.ccli == nil { + api.ccli = NewDockerCLI(api.execer) } if api.dccli == nil { api.dccli = NewDevcontainerCLI(logger.Named("devcontainer-cli"), api.execer) @@ -230,7 +288,7 @@ func (api *API) watcherLoop() { continue } - now := api.clock.Now("watcherLoop") + now := api.clock.Now("agentcontainers", "watcherLoop") switch { case event.Has(fsnotify.Create | fsnotify.Write): api.logger.Debug(api.ctx, "devcontainer config file changed", slog.F("file", event.Name)) @@ -254,6 +312,15 @@ func (api *API) updaterLoop() { defer api.logger.Debug(api.ctx, "updater loop stopped") api.logger.Debug(api.ctx, "updater loop started") + // Make sure we clean up any subagents not tracked by this process + // before starting the update loop and creating new ones. + api.logger.Debug(api.ctx, "cleaning up subagents") + if err := api.cleanupSubAgents(api.ctx); err != nil { + api.logger.Error(api.ctx, "cleanup subagents failed", slog.Error(err)) + } else { + api.logger.Debug(api.ctx, "cleanup subagents complete") + } + // Perform an initial update to populate the container list, this // gives us a guarantee that the API has loaded the initial state // before returning any responses. This is useful for both tests @@ -288,9 +355,9 @@ func (api *API) updaterLoop() { } return nil // Always nil to keep the ticker going. - }, "updaterLoop") + }, "agentcontainers", "updaterLoop") defer func() { - if err := ticker.Wait("updaterLoop"); err != nil && !errors.Is(err, context.Canceled) { + if err := ticker.Wait("agentcontainers", "updaterLoop"); err != nil && !errors.Is(err, context.Canceled) { api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err)) } }() @@ -360,10 +427,10 @@ func (api *API) handleList(rw http.ResponseWriter, r *http.Request) { // updateContainers fetches the latest container list, processes it, and // updates the cache. It performs locking for updating shared API state. func (api *API) updateContainers(ctx context.Context) error { - listCtx, listCancel := context.WithTimeout(ctx, listContainersTimeout) + listCtx, listCancel := context.WithTimeout(ctx, defaultOperationTimeout) defer listCancel() - updated, err := api.cl.List(listCtx) + updated, err := api.ccli.List(listCtx) if err != nil { // If the context was canceled, we hold off on clearing the // containers cache. This is to avoid clearing the cache if @@ -378,6 +445,8 @@ func (api *API) updateContainers(ctx context.Context) error { return xerrors.Errorf("list containers failed: %w", err) } + // Clone to avoid test flakes due to data manipulation. + updated.Containers = slices.Clone(updated.Containers) api.mu.Lock() defer api.mu.Unlock() @@ -393,6 +462,20 @@ func (api *API) updateContainers(ctx context.Context) error { // on the latest list of containers. This method assumes that api.mu is // held. func (api *API) processUpdatedContainersLocked(ctx context.Context, updated codersdk.WorkspaceAgentListContainersResponse) { + dcFields := func(dc codersdk.WorkspaceAgentDevcontainer) []slog.Field { + f := []slog.Field{ + slog.F("devcontainer_id", dc.ID), + slog.F("devcontainer_name", dc.Name), + slog.F("workspace_folder", dc.WorkspaceFolder), + slog.F("config_path", dc.ConfigPath), + } + if dc.Container != nil { + f = append(f, slog.F("container_id", dc.Container.ID)) + f = append(f, slog.F("container_name", dc.Container.FriendlyName)) + } + return f + } + // Reset the container links in known devcontainers to detect if // they still exist. for _, dc := range api.knownDevcontainers { @@ -413,6 +496,29 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code continue } + logger := api.logger.With( + slog.F("container_id", updated.Containers[i].ID), + slog.F("container_name", updated.Containers[i].FriendlyName), + slog.F("workspace_folder", workspaceFolder), + slog.F("config_file", configFile), + ) + + if len(api.containerLabelIncludeFilter) > 0 { + var ok bool + for label, value := range api.containerLabelIncludeFilter { + if v, found := container.Labels[label]; found && v == value { + ok = true + } + } + // Verbose debug logging is fine here since typically filters + // are only used in development or testing environments. + if !ok { + logger.Debug(ctx, "container does not match include filter, ignoring dev container", slog.F("container_labels", container.Labels), slog.F("include_filter", api.containerLabelIncludeFilter)) + continue + } + logger.Debug(ctx, "container matches include filter, processing dev container", slog.F("container_labels", container.Labels), slog.F("include_filter", api.containerLabelIncludeFilter)) + } + if dc, ok := api.knownDevcontainers[workspaceFolder]; ok { // If no config path is set, this devcontainer was defined // in Terraform without the optional config file. Assume the @@ -421,7 +527,7 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code if dc.ConfigPath == "" && configFile != "" { dc.ConfigPath = configFile if err := api.watcher.Add(configFile); err != nil { - api.logger.Error(ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", configFile)) + logger.With(dcFields(dc)...).Error(ctx, "watch devcontainer config file failed", slog.Error(err)) } } @@ -430,53 +536,47 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code continue } - // NOTE(mafredri): This name impl. may change to accommodate devcontainer agents RFC. - // If not in our known list, add as a runtime detected entry. - name := path.Base(workspaceFolder) - if api.devcontainerNames[name] { - // Try to find a unique name by appending a number. - for i := 2; ; i++ { - newName := fmt.Sprintf("%s-%d", name, i) - if !api.devcontainerNames[newName] { - name = newName - break - } - } - } - api.devcontainerNames[name] = true - if configFile != "" { - if err := api.watcher.Add(configFile); err != nil { - api.logger.Error(ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", configFile)) - } - } - - api.knownDevcontainers[workspaceFolder] = codersdk.WorkspaceAgentDevcontainer{ + dc := codersdk.WorkspaceAgentDevcontainer{ ID: uuid.New(), - Name: name, + Name: "", // Updated later based on container state. WorkspaceFolder: workspaceFolder, ConfigPath: configFile, Status: "", // Updated later based on container state. Dirty: false, // Updated later based on config file changes. Container: container, } + + if configFile != "" { + if err := api.watcher.Add(configFile); err != nil { + logger.With(dcFields(dc)...).Error(ctx, "watch devcontainer config file failed", slog.Error(err)) + } + } + + api.knownDevcontainers[workspaceFolder] = dc } // Iterate through all known devcontainers and update their status // based on the current state of the containers. for _, dc := range api.knownDevcontainers { + logger := api.logger.With(dcFields(dc)...) + + 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 dev container is recreated. + // TODO(mafredri): Parse the container label (i.e. devcontainer.json) for customization. + dc.Name = safeFriendlyName(dc.Container.FriendlyName) + } + dc.Container.DevcontainerStatus = dc.Status + dc.Container.DevcontainerDirty = dc.Dirty + } + switch { case dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting: - if dc.Container != nil { - dc.Container.DevcontainerStatus = dc.Status - dc.Container.DevcontainerDirty = dc.Dirty - } continue // This state is handled by the recreation routine. case dc.Status == codersdk.WorkspaceAgentDevcontainerStatusError && (dc.Container == nil || dc.Container.CreatedAt.Before(api.recreateErrorTimes[dc.WorkspaceFolder])): - if dc.Container != nil { - dc.Container.DevcontainerStatus = dc.Status - dc.Container.DevcontainerDirty = dc.Dirty - } continue // The devcontainer needs to be recreated. case dc.Container != nil: @@ -492,7 +592,17 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code } dc.Container.DevcontainerDirty = dc.Dirty + if _, injected := api.injectedSubAgentProcs[dc.Container.ID]; !injected && dc.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning { + err := api.injectSubAgentIntoContainerLocked(ctx, dc) + if err != nil { + logger.Error(ctx, "inject subagent into container failed", slog.Error(err)) + } + } + case dc.Container == nil: + if !api.devcontainerNames[dc.Name] { + dc.Name = "" + } dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStopped dc.Dirty = false } @@ -505,6 +615,18 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code api.containersErr = nil } +// safeFriendlyName returns a API safe version of the container's +// friendly name. +// +// See provisioner/regexes.go for the regex used to validate +// the friendly name on the API side. +func safeFriendlyName(name string) string { + name = strings.ToLower(name) + name = strings.ReplaceAll(name, "_", "-") + + return name +} + // refreshContainers triggers an immediate update of the container list // and waits for it to complete. func (api *API) refreshContainers(ctx context.Context) (err error) { @@ -622,7 +744,7 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques dc.Container.DevcontainerStatus = dc.Status } api.knownDevcontainers[dc.WorkspaceFolder] = dc - api.recreateWg.Add(1) + api.asyncWg.Add(1) go api.recreateDevcontainer(dc, configPath) api.mu.Unlock() @@ -638,10 +760,10 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques // 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 recreateWg must be +// 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.recreateWg.Done() + defer api.asyncWg.Done() var ( err error @@ -682,7 +804,7 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con logger.Debug(ctx, "starting devcontainer recreation") - _, err = api.dccli.Up(ctx, dc.WorkspaceFolder, configPath, WithOutput(infoW, errW), WithRemoveExistingContainer()) + _, err = api.dccli.Up(ctx, dc.WorkspaceFolder, configPath, WithUpOutput(infoW, errW), WithRemoveExistingContainer()) 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. @@ -697,7 +819,7 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con dc.Container.DevcontainerStatus = dc.Status } api.knownDevcontainers[dc.WorkspaceFolder] = dc - api.recreateErrorTimes[dc.WorkspaceFolder] = api.clock.Now("recreate", "errorTimes") + api.recreateErrorTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "errorTimes") api.mu.Unlock() return } @@ -719,7 +841,7 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con dc.Container.DevcontainerStatus = dc.Status } dc.Dirty = false - api.recreateSuccessTimes[dc.WorkspaceFolder] = api.clock.Now("recreate", "successTimes") + api.recreateSuccessTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "successTimes") api.knownDevcontainers[dc.WorkspaceFolder] = dc api.mu.Unlock() @@ -801,6 +923,272 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) { } } +// cleanupSubAgents removes subagents that are no longer managed by +// this agent. This is usually only run at startup to ensure a clean +// slate. This method has an internal timeout to prevent blocking +// indefinitely if something goes wrong with the subagent deletion. +func (api *API) cleanupSubAgents(ctx context.Context) error { + agents, err := api.subAgentClient.List(ctx) + if err != nil { + return xerrors.Errorf("list agents: %w", err) + } + if len(agents) == 0 { + return nil + } + + api.mu.Lock() + defer api.mu.Unlock() + + injected := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs)) + for _, proc := range api.injectedSubAgentProcs { + injected[proc.agent.ID] = true + } + + ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout) + defer cancel() + + for _, agent := range agents { + if injected[agent.ID] { + continue + } + err := api.subAgentClient.Delete(ctx, agent.ID) + if err != nil { + api.logger.Error(ctx, "failed to delete agent", + slog.Error(err), + slog.F("agent_id", agent.ID), + slog.F("agent_name", agent.Name), + ) + } + } + + return nil +} + +// injectSubAgentIntoContainerLocked injects a subagent into a dev +// container and starts the subagent process. This method assumes that +// api.mu is held. +// +// This method uses an internal timeout to prevent blocking indefinitely +// if something goes wrong with the injection. +func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc codersdk.WorkspaceAgentDevcontainer) (err error) { + ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout) + defer cancel() + + container := dc.Container + if container == nil { + return xerrors.New("container is nil, cannot inject subagent") + } + + // Skip if subagent already exists for this container. + if _, injected := api.injectedSubAgentProcs[container.ID]; injected || api.closed { + return nil + } + + // Mark subagent as being injected immediately with a placeholder. + subAgent := subAgentProcess{ + ctx: context.Background(), + stop: func() {}, + } + api.injectedSubAgentProcs[container.ID] = subAgent + + // This is used to track the goroutine that will run the subagent + // process inside the container. It will be decremented when the + // subagent process completes or if an error occurs before we can + // start the subagent. + api.asyncWg.Add(1) + ranSubAgent := false + + // Clean up if injection fails. + defer func() { + if !ranSubAgent { + api.asyncWg.Done() + } + if err != nil { + // Mutex is held (defer re-lock). + delete(api.injectedSubAgentProcs, container.ID) + } + }() + + // Unlock the mutex to allow other operations while we + // inject the subagent into the container. + api.mu.Unlock() + defer api.mu.Lock() // Re-lock. + + 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", dc.ConfigPath), + ) + + arch, err := api.ccli.DetectArchitecture(ctx, container.ID) + if err != nil { + return xerrors.Errorf("detect architecture: %w", err) + } + + logger.Info(ctx, "detected container architecture", slog.F("architecture", arch)) + + // For now, only support injecting if the architecture matches the host. + hostArch := runtime.GOARCH + + // TODO(mafredri): Add support for downloading agents for supported architectures. + if arch != hostArch { + logger.Warn(ctx, "skipping subagent injection for unsupported architecture", + slog.F("container_arch", arch), + slog.F("host_arch", hostArch)) + return nil + } + 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)) + // } + + // 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 + } + + // The preparation of the subagent is done, now we can create the + // subagent record in the database to receive the auth token. + createdAgent, err := api.subAgentClient.Create(ctx, SubAgent{ + Name: dc.Name, + Directory: directory, + OperatingSystem: "linux", // Assuming Linux for dev containers. + Architecture: arch, + }) + if err != nil { + return xerrors.Errorf("create agent: %w", err) + } + + logger.Info(ctx, "created subagent record", slog.F("agent_id", createdAgent.ID)) + + // Start the subagent in the container in a new goroutine to avoid + // blocking. Note that we pass the api.ctx to the subagent process + // so that it isn't affected by the timeout. + go api.runSubAgentInContainer(api.ctx, dc, createdAgent, coderPathInsideContainer) + ranSubAgent = true + + return nil +} + +// runSubAgentInContainer runs the subagent process inside a dev +// container. The api.asyncWg must be incremented before calling this +// function, and it will be decremented when the subagent process +// completes or if an error occurs. +func (api *API) runSubAgentInContainer(ctx context.Context, dc codersdk.WorkspaceAgentDevcontainer, agent SubAgent, agentPath string) { + container := dc.Container // Must not be nil. + logger := api.logger.With( + slog.F("container_name", container.FriendlyName), + slog.F("agent_id", agent.ID), + ) + + agentCtx, agentStop := context.WithCancel(ctx) + defer func() { + agentStop() + + // Best effort cleanup of the agent record after the process + // completes. Note that we use the background context here + // because the api.ctx will be canceled when the API is closed. + // This may delay shutdown of the agent by the given timeout. + deleteCtx, cancel := context.WithTimeout(context.Background(), defaultOperationTimeout) + defer cancel() + err := api.subAgentClient.Delete(deleteCtx, agent.ID) + if err != nil { + logger.Error(deleteCtx, "failed to delete agent record after process completion", slog.Error(err)) + } + + api.mu.Lock() + delete(api.injectedSubAgentProcs, container.ID) + api.mu.Unlock() + + logger.Debug(ctx, "agent process cleanup complete") + api.asyncWg.Done() + }() + + api.mu.Lock() + if api.closed { + api.mu.Unlock() + // If the API is closed, we should not run the agent. + logger.Debug(ctx, "the API is closed, not running subagent in container") + return + } + // Update the placeholder with a valid subagent, context and stop. + api.injectedSubAgentProcs[container.ID] = subAgentProcess{ + agent: agent, + ctx: agentCtx, + stop: agentStop, + } + api.mu.Unlock() + + logger.Info(ctx, "starting subagent in dev container") + + env := []string{ + "CODER_AGENT_URL=" + api.subAgentURL, + "CODER_AGENT_TOKEN=" + agent.AuthToken.String(), + } + env = append(env, api.subAgentEnv...) + err := api.dccli.Exec(agentCtx, dc.WorkspaceFolder, dc.ConfigPath, agentPath, []string{"agent"}, + WithExecContainerID(container.ID), + WithRemoteEnv(env...), + ) + if err != nil && !errors.Is(err, context.Canceled) { + logger.Error(ctx, "subagent process failed", slog.Error(err)) + } else { + logger.Info(ctx, "subagent process finished") + } +} + func (api *API) Close() error { api.mu.Lock() if api.closed { @@ -809,6 +1197,12 @@ func (api *API) Close() error { } api.logger.Debug(api.ctx, "closing API") api.closed = true + + for _, proc := range api.injectedSubAgentProcs { + api.logger.Debug(api.ctx, "canceling subagent process", slog.F("agent_name", proc.agent.Name), slog.F("agent_id", proc.agent.ID)) + proc.stop() + } + api.cancel() // Interrupt all routines. api.mu.Unlock() // Release lock before waiting for goroutines. @@ -819,8 +1213,8 @@ func (api *API) Close() error { <-api.watcherDone <-api.updaterDone - // Wait for all devcontainer recreation tasks to complete. - api.recreateWg.Wait() + // Wait for all async tasks to complete. + api.asyncWg.Wait() api.logger.Debug(api.ctx, "closed API") return err diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index fb55825097190..91cebcf2e5d25 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -6,6 +6,8 @@ import ( "math/rand" "net/http" "net/http/httptest" + "os" + "runtime" "strings" "testing" "time" @@ -28,34 +30,69 @@ import ( "github.com/coder/quartz" ) -// fakeLister implements the agentcontainers.Lister interface for +// fakeContainerCLI implements the agentcontainers.ContainerCLI interface for // testing. -type fakeLister struct { +type fakeContainerCLI struct { containers codersdk.WorkspaceAgentListContainersResponse - err error + listErr error + arch string + archErr error + copyErr error + execErr error } -func (f *fakeLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { - return f.containers, f.err +func (f *fakeContainerCLI) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { + return f.containers, f.listErr +} + +func (f *fakeContainerCLI) DetectArchitecture(_ context.Context, _ string) (string, error) { + return f.arch, f.archErr +} + +func (f *fakeContainerCLI) Copy(ctx context.Context, name, src, dst string) error { + return f.copyErr +} + +func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args ...string) ([]byte, error) { + return nil, f.execErr } // fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI // interface for testing. type fakeDevcontainerCLI struct { - id string - err error - continueUp chan struct{} + upID string + upErr error + upErrC chan error // If set, send to return err, close to return upErr. + execErr error + execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr. } func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { - if f.continueUp != nil { + if f.upErrC != nil { select { case <-ctx.Done(): - return "", xerrors.New("test timeout") - case <-f.continueUp: + return "", ctx.Err() + case err, ok := <-f.upErrC: + if ok { + return f.upID, err + } } } - return f.id, f.err + return f.upID, f.upErr +} + +func (f *fakeDevcontainerCLI) Exec(ctx context.Context, _, _ string, cmd string, args []string, _ ...agentcontainers.DevcontainerCLIExecOptions) error { + if f.execErrC != nil { + select { + case <-ctx.Done(): + return ctx.Err() + case fn, ok := <-f.execErrC: + if ok && fn != nil { + return fn(cmd, args...) + } + } + } + return f.execErr } // fakeWatcher implements the watcher.Watcher interface for testing. @@ -155,6 +192,80 @@ func (w *fakeWatcher) sendEventWaitNextCalled(ctx context.Context, event fsnotif w.waitNext(ctx) } +// fakeSubAgentClient implements SubAgentClient for testing purposes. +type fakeSubAgentClient struct { + agents map[uuid.UUID]agentcontainers.SubAgent + nextID int + + listErrC chan error // If set, send to return error, close to return nil. + created []agentcontainers.SubAgent + createErrC chan error // If set, send to return error, close to return nil. + deleted []uuid.UUID + deleteErrC chan error // If set, send to return error, close to return nil. +} + +func (m *fakeSubAgentClient) List(ctx context.Context) ([]agentcontainers.SubAgent, error) { + var listErr error + if m.listErrC != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case err, ok := <-m.listErrC: + if ok { + listErr = err + } + } + } + var agents []agentcontainers.SubAgent + for _, agent := range m.agents { + agents = append(agents, agent) + } + return agents, listErr +} + +func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) { + var createErr error + if m.createErrC != nil { + select { + case <-ctx.Done(): + return agentcontainers.SubAgent{}, ctx.Err() + case err, ok := <-m.createErrC: + if ok { + createErr = err + } + } + } + m.nextID++ + agent.ID = uuid.New() + agent.AuthToken = uuid.New() + if m.agents == nil { + m.agents = make(map[uuid.UUID]agentcontainers.SubAgent) + } + m.agents[agent.ID] = agent + m.created = append(m.created, agent) + return agent, createErr +} + +func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error { + var deleteErr error + if m.deleteErrC != nil { + select { + case <-ctx.Done(): + return ctx.Err() + case err, ok := <-m.deleteErrC: + if ok { + deleteErr = err + } + } + } + if m.agents == nil { + m.agents = make(map[uuid.UUID]agentcontainers.SubAgent) + } + delete(m.agents, id) + m.deleted = append(m.deleted, id) + return deleteErr +} + func TestAPI(t *testing.T) { t.Parallel() @@ -180,7 +291,7 @@ func TestAPI(t *testing.T) { // initialData to be stored in the handler initialData initialDataPayload // function to set up expectations for the mock - setupMock func(mcl *acmock.MockLister, preReq *gomock.Call) + setupMock func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) // expected result expected codersdk.WorkspaceAgentListContainersResponse // expected error @@ -189,7 +300,7 @@ func TestAPI(t *testing.T) { { name: "no initial data", initialData: initialDataPayload{makeResponse(), nil}, - setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes() }, expected: makeResponse(fakeCt), @@ -207,7 +318,7 @@ func TestAPI(t *testing.T) { { name: "lister error only during initial data", initialData: initialDataPayload{makeResponse(), assert.AnError}, - setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes() }, expected: makeResponse(fakeCt), @@ -215,7 +326,7 @@ func TestAPI(t *testing.T) { { name: "lister error after initial data", initialData: initialDataPayload{makeResponse(fakeCt), nil}, - setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).After(preReq).AnyTimes() }, expectedErr: assert.AnError.Error(), @@ -223,7 +334,7 @@ func TestAPI(t *testing.T) { { name: "updated data", initialData: initialDataPayload{makeResponse(fakeCt), nil}, - setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).After(preReq).AnyTimes() }, expected: makeResponse(fakeCt2), @@ -236,7 +347,7 @@ func TestAPI(t *testing.T) { mClock = quartz.NewMock(t) tickerTrap = mClock.Trap().TickerFunc("updaterLoop") mCtrl = gomock.NewController(t) - mLister = acmock.NewMockLister(mCtrl) + mLister = acmock.NewMockContainerCLI(mCtrl) logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) r = chi.NewRouter() ) @@ -250,7 +361,8 @@ func TestAPI(t *testing.T) { api := agentcontainers.NewAPI(logger, agentcontainers.WithClock(mClock), - agentcontainers.WithLister(mLister), + agentcontainers.WithContainerCLI(mLister), + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) defer api.Close() r.Mount("/", api.Routes()) @@ -312,7 +424,7 @@ func TestAPI(t *testing.T) { FriendlyName: "container-name", Running: true, Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/workspace", + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces", agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", }, } @@ -326,7 +438,7 @@ func TestAPI(t *testing.T) { tests := []struct { name string containerID string - lister *fakeLister + lister *fakeContainerCLI devcontainerCLI *fakeDevcontainerCLI wantStatus []int wantBody []string @@ -334,7 +446,7 @@ func TestAPI(t *testing.T) { { name: "Missing container ID", containerID: "", - lister: &fakeLister{}, + lister: &fakeContainerCLI{}, devcontainerCLI: &fakeDevcontainerCLI{}, wantStatus: []int{http.StatusBadRequest}, wantBody: []string{"Missing container ID or name"}, @@ -342,8 +454,8 @@ func TestAPI(t *testing.T) { { name: "List error", containerID: "container-id", - lister: &fakeLister{ - err: xerrors.New("list error"), + lister: &fakeContainerCLI{ + listErr: xerrors.New("list error"), }, devcontainerCLI: &fakeDevcontainerCLI{}, wantStatus: []int{http.StatusInternalServerError}, @@ -352,7 +464,7 @@ func TestAPI(t *testing.T) { { name: "Container not found", containerID: "nonexistent-container", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{validContainer}, }, @@ -364,7 +476,7 @@ func TestAPI(t *testing.T) { { name: "Missing workspace folder label", containerID: "missing-folder-container", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{missingFolderContainer}, }, @@ -376,13 +488,14 @@ func TestAPI(t *testing.T) { { name: "Devcontainer CLI error", containerID: "container-id", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{validContainer}, }, + arch: "", // Unsupported architecture, don't inject subagent. }, devcontainerCLI: &fakeDevcontainerCLI{ - err: xerrors.New("devcontainer CLI error"), + upErr: xerrors.New("devcontainer CLI error"), }, wantStatus: []int{http.StatusAccepted, http.StatusConflict}, wantBody: []string{"Devcontainer recreation initiated", "Devcontainer recreation already in progress"}, @@ -390,10 +503,11 @@ func TestAPI(t *testing.T) { { name: "OK", containerID: "container-id", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{validContainer}, }, + arch: "", // Unsupported architecture, don't inject subagent. }, devcontainerCLI: &fakeDevcontainerCLI{}, wantStatus: []int{http.StatusAccepted, http.StatusConflict}, @@ -416,14 +530,14 @@ func TestAPI(t *testing.T) { nowRecreateErrorTrap := mClock.Trap().Now("recreate", "errorTimes") nowRecreateSuccessTrap := mClock.Trap().Now("recreate", "successTimes") - tt.devcontainerCLI.continueUp = make(chan struct{}) + tt.devcontainerCLI.upErrC = make(chan error) // Setup router with the handler under test. r := chi.NewRouter() api := agentcontainers.NewAPI( logger, agentcontainers.WithClock(mClock), - agentcontainers.WithLister(tt.lister), + agentcontainers.WithContainerCLI(tt.lister), agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI), agentcontainers.WithWatcher(watcher.NewNoop()), ) @@ -454,7 +568,7 @@ func TestAPI(t *testing.T) { // because we must check what state the devcontainer ends up in // after the recreation process is initiated and finished. if tt.wantStatus[0] != http.StatusAccepted { - close(tt.devcontainerCLI.continueUp) + close(tt.devcontainerCLI.upErrC) nowRecreateSuccessTrap.Close() nowRecreateErrorTrap.Close() return @@ -481,10 +595,10 @@ func TestAPI(t *testing.T) { assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStarting, resp.Devcontainers[0].Container.DevcontainerStatus, "container dc status is not starting") // Allow the devcontainer CLI to continue the up process. - close(tt.devcontainerCLI.continueUp) + close(tt.devcontainerCLI.upErrC) // Ensure the devcontainer ends up in error state if the up call fails. - if tt.devcontainerCLI.err != nil { + if tt.devcontainerCLI.upErr != nil { nowRecreateSuccessTrap.Close() // The timestamp for the error will be stored, which gives // us a good anchor point to know when to do our request. @@ -559,7 +673,7 @@ func TestAPI(t *testing.T) { tests := []struct { name string - lister *fakeLister + lister *fakeContainerCLI knownDevcontainers []codersdk.WorkspaceAgentDevcontainer wantStatus int wantCount int @@ -567,20 +681,20 @@ func TestAPI(t *testing.T) { }{ { name: "List error", - lister: &fakeLister{ - err: xerrors.New("list error"), + lister: &fakeContainerCLI{ + listErr: xerrors.New("list error"), }, wantStatus: http.StatusInternalServerError, }, { name: "Empty containers", - lister: &fakeLister{}, + lister: &fakeContainerCLI{}, wantStatus: http.StatusOK, wantCount: 0, }, { name: "Only known devcontainers, no containers", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{}, }, @@ -597,7 +711,7 @@ func TestAPI(t *testing.T) { }, { name: "Runtime-detected devcontainer", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -631,7 +745,7 @@ func TestAPI(t *testing.T) { }, { name: "Mixed known and runtime-detected devcontainers", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -679,7 +793,7 @@ func TestAPI(t *testing.T) { }, { name: "Both running and non-running containers have container references", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -723,7 +837,7 @@ func TestAPI(t *testing.T) { }, { name: "Config path update", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -759,7 +873,7 @@ func TestAPI(t *testing.T) { }, { name: "Name generation and uniqueness", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -831,7 +945,7 @@ func TestAPI(t *testing.T) { r := chi.NewRouter() apiOptions := []agentcontainers.Option{ agentcontainers.WithClock(mClock), - agentcontainers.WithLister(tt.lister), + agentcontainers.WithContainerCLI(tt.lister), agentcontainers.WithWatcher(watcher.NewNoop()), } @@ -914,7 +1028,7 @@ func TestAPI(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - fLister := &fakeLister{ + fLister := &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{container}, }, @@ -926,7 +1040,7 @@ func TestAPI(t *testing.T) { api := agentcontainers.NewAPI(logger, agentcontainers.WithClock(mClock), - agentcontainers.WithLister(fLister), + agentcontainers.WithContainerCLI(fLister), agentcontainers.WithWatcher(fWatcher), agentcontainers.WithDevcontainers( []codersdk.WorkspaceAgentDevcontainer{dc}, @@ -1013,7 +1127,7 @@ func TestAPI(t *testing.T) { mClock.Set(startTime) tickerTrap := mClock.Trap().TickerFunc("updaterLoop") fWatcher := newFakeWatcher(t) - fLister := &fakeLister{ + fLister := &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{container}, }, @@ -1022,7 +1136,7 @@ func TestAPI(t *testing.T) { logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) api := agentcontainers.NewAPI( logger, - agentcontainers.WithLister(fLister), + agentcontainers.WithContainerCLI(fLister), agentcontainers.WithWatcher(fWatcher), agentcontainers.WithClock(mClock), ) @@ -1116,6 +1230,197 @@ func TestAPI(t *testing.T) { assert.False(t, response.Devcontainers[0].Container.DevcontainerDirty, "dirty flag should be cleared on the container after container recreation") }) + + t.Run("SubAgentLifecycle", 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) + errTestTermination = xerrors.New("test termination") + logger = slogtest.Make(t, &slogtest.Options{IgnoredErrorIs: []error{errTestTermination}}).Leveled(slog.LevelDebug) + mClock = quartz.NewMock(t) + mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t)) + fakeSAC = &fakeSubAgentClient{ + createErrC: make(chan error, 1), + deleteErrC: make(chan error, 1), + } + fakeDCCLI = &fakeDevcontainerCLI{ + execErrC: make(chan func(cmd string, args ...string) error, 1), + } + + 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", + agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", + }, + } + ) + + coderBin, err := os.Executable() + require.NoError(t, err) + + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{testContainer}, + }, nil).AnyTimes() + gomock.InOrder( + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), "test-container-id").Return(runtime.GOARCH, nil), + 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), + ) + + mClock.Set(time.Now()).MustWait(ctx) + tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithSubAgentClient(fakeSAC), + agentcontainers.WithSubAgentURL("test-subagent-url"), + agentcontainers.WithDevcontainerCLI(fakeDCCLI), + ) + defer api.Close() + + // Close before api.Close() defer to avoid deadlock after test. + defer close(fakeSAC.createErrC) + defer close(fakeSAC.deleteErrC) + defer close(fakeDCCLI.execErrC) + + // 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. + + // Make sure the ticker function has been registered + // before advancing the clock. + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + // Ensure we only inject the agent once. + for i := range 3 { + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + t.Logf("Iteration %d: agents created: %d", i+1, len(fakeSAC.created)) + + // 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.Len(t, fakeSAC.deleted, 0) + } + + t.Log("Agent injected successfully, now testing cleanup and reinjection...") + + // Expect the agent to be reinjected. + gomock.InOrder( + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), "test-container-id").Return(runtime.GOARCH, nil), + 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), + ) + + // Terminate the agent and verify it is deleted. + testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(_ string, args ...string) error { + if len(args) > 0 { + assert.Equal(t, "agent", args[0]) + } else { + assert.Fail(t, `want "agent" command argument`) + } + return errTestTermination + }) + + // Allow cleanup to proceed. + testutil.RequireSend(ctx, t, fakeSAC.deleteErrC, nil) + + t.Log("Waiting for agent recreation...") + + // Allow agent recreation and reinjection 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. + + // Wait until the agent recreation is started. + for len(fakeSAC.createErrC) > 0 { + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + } + + t.Log("Agent recreated successfully.") + + // Verify agent was deleted. + require.Len(t, fakeSAC.deleted, 1) + assert.Equal(t, fakeSAC.created[0].ID, fakeSAC.deleted[0]) + + // Verify the agent recreated. + require.Len(t, fakeSAC.created, 2) + }) + + t.Run("SubAgentCleanup", func(t *testing.T) { + t.Parallel() + + var ( + existingAgentID = uuid.New() + existingAgentToken = uuid.New() + existingAgent = agentcontainers.SubAgent{ + ID: existingAgentID, + Name: "stopped-container", + Directory: "/tmp", + AuthToken: existingAgentToken, + } + + ctx = testutil.Context(t, testutil.WaitMedium) + logger = slog.Make() + mClock = quartz.NewMock(t) + mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t)) + fakeSAC = &fakeSubAgentClient{ + agents: map[uuid.UUID]agentcontainers.SubAgent{ + existingAgentID: existingAgent, + }, + } + ) + + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{}, + }, nil).AnyTimes() + + mClock.Set(time.Now()).MustWait(ctx) + tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithSubAgentClient(fakeSAC), + agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}), + ) + defer api.Close() + + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + // Verify agent was deleted. + assert.Contains(t, fakeSAC.deleted, existingAgentID) + assert.Empty(t, fakeSAC.agents) + }) } // mustFindDevcontainerByPath returns the devcontainer with the given workspace diff --git a/agent/agentcontainers/containers.go b/agent/agentcontainers/containers.go index 5be288781d480..e728507e8f394 100644 --- a/agent/agentcontainers/containers.go +++ b/agent/agentcontainers/containers.go @@ -6,19 +6,32 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// Lister is an interface for listing containers visible to the -// workspace agent. -type Lister interface { +// ContainerCLI is an interface for interacting with containers in a workspace. +type ContainerCLI interface { // List returns a list of containers visible to the workspace agent. // This should include running and stopped containers. List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) + // DetectArchitecture detects the architecture of a container. + DetectArchitecture(ctx context.Context, containerName string) (string, error) + // Copy copies a file from the host to a container. + Copy(ctx context.Context, containerName, src, dst string) error + // ExecAs executes a command in a container as a specific user. + ExecAs(ctx context.Context, containerName, user string, args ...string) ([]byte, error) } -// NoopLister is a Lister interface that never returns any containers. -type NoopLister struct{} +// noopContainerCLI is a ContainerCLI that does nothing. +type noopContainerCLI struct{} -var _ Lister = NoopLister{} +var _ ContainerCLI = noopContainerCLI{} -func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func (noopContainerCLI) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { return codersdk.WorkspaceAgentListContainersResponse{}, nil } + +func (noopContainerCLI) DetectArchitecture(_ context.Context, _ string) (string, error) { + return "", nil +} +func (noopContainerCLI) Copy(_ context.Context, _ string, _ string, _ string) error { return nil } +func (noopContainerCLI) ExecAs(_ context.Context, _ string, _ string, _ ...string) ([]byte, error) { + return nil, nil +} diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index d5499f6b1af2b..83463481c97f7 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -228,23 +228,23 @@ func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...strin return stdout, stderr, err } -// DockerCLILister is a ContainerLister that lists containers using the docker CLI -type DockerCLILister struct { +// dockerCLI is an implementation for Docker CLI that lists containers. +type dockerCLI struct { execer agentexec.Execer } -var _ Lister = &DockerCLILister{} +var _ ContainerCLI = (*dockerCLI)(nil) -func NewDocker(execer agentexec.Execer) Lister { - return &DockerCLILister{ - execer: agentexec.DefaultExecer, +func NewDockerCLI(execer agentexec.Execer) ContainerCLI { + return &dockerCLI{ + execer: execer, } } -func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func (dcli *dockerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { var stdoutBuf, stderrBuf bytes.Buffer // List all container IDs, one per line, with no truncation - cmd := dcl.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") + cmd := dcli.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf if err := cmd.Run(); err != nil { @@ -288,7 +288,7 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi // will still contain valid JSON. We will just end up missing // information about the removed container. We could potentially // log this error, but I'm not sure it's worth it. - dockerInspectStdout, dockerInspectStderr, err := runDockerInspect(ctx, dcl.execer, ids...) + dockerInspectStdout, dockerInspectStderr, err := runDockerInspect(ctx, dcli.execer, ids...) if err != nil { return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, dockerInspectStderr) } @@ -517,3 +517,71 @@ func isLoopbackOrUnspecified(ips string) bool { } return nip.IsLoopback() || nip.IsUnspecified() } + +// DetectArchitecture detects the architecture of a container by inspecting its +// image. +func (dcli *dockerCLI) DetectArchitecture(ctx context.Context, containerName string) (string, error) { + // Inspect the container to get the image name, which contains the architecture. + stdout, stderr, err := runCmd(ctx, dcli.execer, "docker", "inspect", "--format", "{{.Config.Image}}", containerName) + if err != nil { + return "", xerrors.Errorf("inspect container %s: %w: %s", containerName, err, stderr) + } + imageName := string(stdout) + if imageName == "" { + return "", xerrors.Errorf("no image found for container %s", containerName) + } + + stdout, stderr, err = runCmd(ctx, dcli.execer, "docker", "inspect", "--format", "{{.Architecture}}", imageName) + if err != nil { + return "", xerrors.Errorf("inspect image %s: %w: %s", imageName, err, stderr) + } + arch := string(stdout) + if arch == "" { + return "", xerrors.Errorf("no architecture found for image %s", imageName) + } + return arch, nil +} + +// Copy copies a file from the host to a container. +func (dcli *dockerCLI) Copy(ctx context.Context, containerName, src, dst string) error { + _, stderr, err := runCmd(ctx, dcli.execer, "docker", "cp", src, containerName+":"+dst) + if err != nil { + return xerrors.Errorf("copy %s to %s:%s: %w: %s", src, containerName, dst, err, stderr) + } + return nil +} + +// ExecAs executes a command in a container as a specific user. +func (dcli *dockerCLI) ExecAs(ctx context.Context, containerName, uid string, args ...string) ([]byte, error) { + execArgs := []string{"exec"} + if uid != "" { + altUID := uid + if uid == "root" { + // UID 0 is more portable than the name root, so we use that + // because some containers may not have a user named "root". + altUID = "0" + } + execArgs = append(execArgs, "--user", altUID) + } + execArgs = append(execArgs, containerName) + execArgs = append(execArgs, args...) + + stdout, stderr, err := runCmd(ctx, dcli.execer, "docker", execArgs...) + if err != nil { + return nil, xerrors.Errorf("exec in container %s as user %s: %w: %s", containerName, uid, err, stderr) + } + return stdout, nil +} + +// runCmd is a helper function that runs a command with the given +// arguments and returns the stdout and stderr output. +func runCmd(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr []byte, err error) { + var stdoutBuf, stderrBuf bytes.Buffer + c := execer.CommandContext(ctx, cmd, args...) + c.Stdout = &stdoutBuf + c.Stderr = &stderrBuf + err = c.Run() + stdout = bytes.TrimSpace(stdoutBuf.Bytes()) + stderr = bytes.TrimSpace(stderrBuf.Bytes()) + return stdout, stderr, err +} diff --git a/agent/agentcontainers/containers_dockercli_test.go b/agent/agentcontainers/containers_dockercli_test.go new file mode 100644 index 0000000000000..c69110a757bc7 --- /dev/null +++ b/agent/agentcontainers/containers_dockercli_test.go @@ -0,0 +1,126 @@ +package agentcontainers_test + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/agent/agentcontainers" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/testutil" +) + +// TestIntegrationDockerCLI tests the DetectArchitecture, Copy, and +// ExecAs methods using a real Docker container. All tests share a +// single container to avoid setup overhead. +// +// Run manually with: CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestIntegrationDockerCLI +// +//nolint:tparallel,paralleltest // Docker integration tests don't run in parallel to avoid flakiness. +func TestIntegrationDockerCLI(t *testing.T) { + if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" { + t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") + } + + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + + // Start a simple busybox container for all subtests to share. + ct, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infinity"}, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start test docker container") + t.Logf("Created container %q", ct.Container.Name) + t.Cleanup(func() { + assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name) + t.Logf("Purged container %q", ct.Container.Name) + }) + + // Wait for container to start. + require.Eventually(t, func() bool { + ct, ok := pool.ContainerByName(ct.Container.Name) + return ok && ct.Container.State.Running + }, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time") + + dcli := agentcontainers.NewDockerCLI(agentexec.DefaultExecer) + ctx := testutil.Context(t, testutil.WaitMedium) // Longer timeout for multiple subtests + containerName := strings.TrimPrefix(ct.Container.Name, "/") + + t.Run("DetectArchitecture", func(t *testing.T) { + t.Parallel() + + arch, err := dcli.DetectArchitecture(ctx, containerName) + require.NoError(t, err, "DetectArchitecture failed") + require.NotEmpty(t, arch, "arch has no content") + require.Equal(t, runtime.GOARCH, arch, "architecture does not match runtime, did you run this test with a remote Docker socket?") + + t.Logf("Detected architecture: %s", arch) + }) + + t.Run("Copy", func(t *testing.T) { + t.Parallel() + + want := "Help, I'm trapped!" + tempFile := filepath.Join(t.TempDir(), "test-file.txt") + err := os.WriteFile(tempFile, []byte(want), 0o600) + require.NoError(t, err, "create test file failed") + + destPath := "/tmp/copied-file.txt" + err = dcli.Copy(ctx, containerName, tempFile, destPath) + require.NoError(t, err, "Copy failed") + + got, err := dcli.ExecAs(ctx, containerName, "", "cat", destPath) + require.NoError(t, err, "ExecAs failed after Copy") + require.Equal(t, want, string(got), "copied file content did not match original") + + t.Logf("Successfully copied file from %s to container %s:%s", tempFile, containerName, destPath) + }) + + t.Run("ExecAs", func(t *testing.T) { + t.Parallel() + + // Test ExecAs without specifying user (should use container's default). + want := "root" + got, err := dcli.ExecAs(ctx, containerName, "", "whoami") + require.NoError(t, err, "ExecAs without user should succeed") + require.Equal(t, want, string(got), "ExecAs without user should output expected string") + + // Test ExecAs with numeric UID (non root). + want = "1000" + _, err = dcli.ExecAs(ctx, containerName, want, "whoami") + require.Error(t, err, "ExecAs with UID 1000 should fail as user does not exist in busybox") + require.Contains(t, err.Error(), "whoami: unknown uid 1000", "ExecAs with UID 1000 should return 'unknown uid' error") + + // Test ExecAs with root user (should convert "root" to "0", which still outputs root due to passwd). + want = "root" + got, err = dcli.ExecAs(ctx, containerName, "root", "whoami") + require.NoError(t, err, "ExecAs with root user should succeed") + require.Equal(t, want, string(got), "ExecAs with root user should output expected string") + + // Test ExecAs with numeric UID. + want = "root" + got, err = dcli.ExecAs(ctx, containerName, "0", "whoami") + require.NoError(t, err, "ExecAs with UID 0 should succeed") + require.Equal(t, want, string(got), "ExecAs with UID 0 should output expected string") + + // Test ExecAs with multiple arguments. + want = "multiple args test" + got, err = dcli.ExecAs(ctx, containerName, "", "sh", "-c", "echo '"+want+"'") + require.NoError(t, err, "ExecAs with multiple arguments should succeed") + require.Equal(t, want, string(got), "ExecAs with multiple arguments should output expected string") + + t.Logf("Successfully executed commands in container %s", containerName) + }) +} diff --git a/agent/agentcontainers/containers_test.go b/agent/agentcontainers/containers_test.go index 59befb2fd2be0..387c8dccc961d 100644 --- a/agent/agentcontainers/containers_test.go +++ b/agent/agentcontainers/containers_test.go @@ -78,7 +78,7 @@ func TestIntegrationDocker(t *testing.T) { return ok && ct.Container.State.Running }, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time") - dcl := agentcontainers.NewDocker(agentexec.DefaultExecer) + dcl := agentcontainers.NewDockerCLI(agentexec.DefaultExecer) ctx := testutil.Context(t, testutil.WaitShort) actual, err := dcl.List(ctx) require.NoError(t, err, "Could not list containers") diff --git a/agent/agentcontainers/devcontainer.go b/agent/agentcontainers/devcontainer.go index 09d4837d4b27a..f13963d7b63d7 100644 --- a/agent/agentcontainers/devcontainer.go +++ b/agent/agentcontainers/devcontainer.go @@ -18,6 +18,8 @@ const ( // DevcontainerConfigFileLabel is the label that contains the path to // the devcontainer.json configuration file. DevcontainerConfigFileLabel = "devcontainer.config_file" + // The default workspace folder inside the devcontainer. + DevcontainerDefaultContainerWorkspaceFolder = "/workspaces" ) const devcontainerUpScriptTemplate = ` diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 7e3122b182fdb..4e1ad93a715dc 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -17,38 +17,84 @@ import ( // DevcontainerCLI is an interface for the devcontainer CLI. type DevcontainerCLI interface { Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error) + Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error } -// DevcontainerCLIUpOptions are options for the devcontainer CLI up +// DevcontainerCLIUpOptions are options for the devcontainer CLI Up // command. type DevcontainerCLIUpOptions func(*devcontainerCLIUpConfig) +type devcontainerCLIUpConfig struct { + args []string // Additional arguments for the Up command. + stdout io.Writer + stderr io.Writer +} + // WithRemoveExistingContainer is an option to remove the existing // container. func WithRemoveExistingContainer() DevcontainerCLIUpOptions { return func(o *devcontainerCLIUpConfig) { - o.removeExistingContainer = true + o.args = append(o.args, "--remove-existing-container") } } -// WithOutput sets stdout and stderr writers for Up command logs. -func WithOutput(stdout, stderr io.Writer) DevcontainerCLIUpOptions { +// WithUpOutput sets additional stdout and stderr writers for logs +// during Up operations. +func WithUpOutput(stdout, stderr io.Writer) DevcontainerCLIUpOptions { return func(o *devcontainerCLIUpConfig) { o.stdout = stdout o.stderr = stderr } } -type devcontainerCLIUpConfig struct { - removeExistingContainer bool - stdout io.Writer - stderr io.Writer +// DevcontainerCLIExecOptions are options for the devcontainer CLI Exec +// command. +type DevcontainerCLIExecOptions func(*devcontainerCLIExecConfig) + +type devcontainerCLIExecConfig struct { + args []string // Additional arguments for the Exec command. + stdout io.Writer + stderr io.Writer +} + +// WithExecOutput sets additional stdout and stderr writers for logs +// during Exec operations. +func WithExecOutput(stdout, stderr io.Writer) DevcontainerCLIExecOptions { + return func(o *devcontainerCLIExecConfig) { + o.stdout = stdout + o.stderr = stderr + } +} + +// WithExecContainerID sets the container ID to target a specific +// container. +func WithExecContainerID(id string) DevcontainerCLIExecOptions { + return func(o *devcontainerCLIExecConfig) { + o.args = append(o.args, "--container-id", id) + } +} + +// WithRemoteEnv sets environment variables for the Exec command. +func WithRemoteEnv(env ...string) DevcontainerCLIExecOptions { + return func(o *devcontainerCLIExecConfig) { + for _, e := range env { + o.args = append(o.args, "--remote-env", e) + } + } } func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig { - conf := devcontainerCLIUpConfig{ - removeExistingContainer: false, + conf := devcontainerCLIUpConfig{} + for _, opt := range opts { + if opt != nil { + opt(&conf) + } } + return conf +} + +func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devcontainerCLIExecConfig { + conf := devcontainerCLIExecConfig{} for _, opt := range opts { if opt != nil { opt(&conf) @@ -73,7 +119,7 @@ func NewDevcontainerCLI(logger slog.Logger, execer agentexec.Execer) Devcontaine func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (string, error) { conf := applyDevcontainerCLIUpOptions(opts) - logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath), slog.F("recreate", conf.removeExistingContainer)) + logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) args := []string{ "up", @@ -83,9 +129,7 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st if configPath != "" { args = append(args, "--config", configPath) } - if conf.removeExistingContainer { - args = append(args, "--remove-existing-container") - } + args = append(args, conf.args...) cmd := d.execer.CommandContext(ctx, "devcontainer", args...) // Capture stdout for parsing and stream logs for both default and provided writers. @@ -117,6 +161,45 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st return result.ContainerID, nil } +func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error { + conf := applyDevcontainerCLIExecOptions(opts) + logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) + + args := []string{"exec"} + // For now, always set workspace folder even if --container-id is provided. + // Otherwise the environment of exec will be incomplete, like `pwd` will be + // /home/coder instead of /workspaces/coder. The downside is that the local + // `devcontainer.json` config will overwrite settings serialized in the + // container label. + if workspaceFolder != "" { + args = append(args, "--workspace-folder", workspaceFolder) + } + if configPath != "" { + args = append(args, "--config", configPath) + } + args = append(args, conf.args...) + args = append(args, cmd) + 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...) + + if err := c.Run(); err != nil { + return xerrors.Errorf("devcontainer exec failed: %w", err) + } + + return nil +} + // parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output // which is a JSON object. func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []byte) (result devcontainerCLIResult, err error) { diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index cdba0211ab94e..b8b4120d2e8ab 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -126,9 +126,116 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { }) } }) + + t.Run("Exec", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + workspaceFolder string + configPath string + cmd string + cmdArgs []string + opts []agentcontainers.DevcontainerCLIExecOptions + wantArgs string + wantError bool + }{ + { + name: "simple command", + workspaceFolder: "/test/workspace", + configPath: "", + cmd: "echo", + cmdArgs: []string{"hello"}, + wantArgs: "exec --workspace-folder /test/workspace echo hello", + wantError: false, + }, + { + name: "command with multiple args", + workspaceFolder: "/test/workspace", + configPath: "/test/config.json", + cmd: "ls", + cmdArgs: []string{"-la", "/workspace"}, + wantArgs: "exec --workspace-folder /test/workspace --config /test/config.json ls -la /workspace", + wantError: false, + }, + { + name: "empty command args", + workspaceFolder: "/test/workspace", + configPath: "", + cmd: "bash", + cmdArgs: nil, + wantArgs: "exec --workspace-folder /test/workspace bash", + wantError: false, + }, + { + name: "workspace not found", + workspaceFolder: "/nonexistent/workspace", + configPath: "", + cmd: "echo", + cmdArgs: []string{"test"}, + wantArgs: "exec --workspace-folder /nonexistent/workspace echo test", + wantError: true, + }, + { + name: "with container ID", + workspaceFolder: "/test/workspace", + configPath: "", + cmd: "echo", + cmdArgs: []string{"hello"}, + opts: []agentcontainers.DevcontainerCLIExecOptions{agentcontainers.WithExecContainerID("test-container-123")}, + wantArgs: "exec --workspace-folder /test/workspace --container-id test-container-123 echo hello", + wantError: false, + }, + { + name: "with container ID and config", + workspaceFolder: "/test/workspace", + configPath: "/test/config.json", + cmd: "bash", + cmdArgs: []string{"-c", "ls -la"}, + opts: []agentcontainers.DevcontainerCLIExecOptions{agentcontainers.WithExecContainerID("my-container")}, + wantArgs: "exec --workspace-folder /test/workspace --config /test/config.json --container-id my-container bash -c ls -la", + wantError: false, + }, + { + name: "with container ID and output capture", + workspaceFolder: "/test/workspace", + configPath: "", + cmd: "cat", + cmdArgs: []string{"/etc/hostname"}, + opts: []agentcontainers.DevcontainerCLIExecOptions{ + agentcontainers.WithExecContainerID("test-container-789"), + }, + wantArgs: "exec --workspace-folder /test/workspace --container-id test-container-789 cat /etc/hostname", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: tt.wantArgs, + wantError: tt.wantError, + logFile: "", // Exec doesn't need log file parsing + } + + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + err := dccli.Exec(ctx, tt.workspaceFolder, tt.configPath, tt.cmd, tt.cmdArgs, tt.opts...) + if tt.wantError { + assert.Error(t, err, "want error") + } else { + assert.NoError(t, err, "want no error") + } + }) + } + }) } -// TestDevcontainerCLI_WithOutput tests that WithOutput captures CLI +// TestDevcontainerCLI_WithOutput tests that WithUpOutput and WithExecOutput capture CLI // logs to provided writers. func TestDevcontainerCLI_WithOutput(t *testing.T) { t.Parallel() @@ -136,35 +243,77 @@ func TestDevcontainerCLI_WithOutput(t *testing.T) { // Prepare test executable and logger. testExePath, err := os.Executable() require.NoError(t, err, "get test executable path") - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - ctx := testutil.Context(t, testutil.WaitMedium) - - // Buffers to capture stdout and stderr. - outBuf := &bytes.Buffer{} - errBuf := &bytes.Buffer{} - - // Simulate CLI execution with a standard up.log file. - wantArgs := "up --log-format json --workspace-folder /test/workspace" - testExecer := &testDevcontainerExecer{ - testExePath: testExePath, - wantArgs: wantArgs, - wantError: false, - logFile: filepath.Join("testdata", "devcontainercli", "parse", "up.log"), - } - dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) - // Call Up with WithOutput to capture CLI logs. - containerID, err := dccli.Up(ctx, "/test/workspace", "", agentcontainers.WithOutput(outBuf, errBuf)) - require.NoError(t, err, "Up should succeed") - require.NotEmpty(t, containerID, "expected non-empty container ID") + t.Run("Up", func(t *testing.T) { + t.Parallel() + + // Buffers to capture stdout and stderr. + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + // Simulate CLI execution with a standard up.log file. + wantArgs := "up --log-format json --workspace-folder /test/workspace" + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: wantArgs, + wantError: false, + logFile: filepath.Join("testdata", "devcontainercli", "parse", "up.log"), + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) - // Read expected log content. - expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.log")) - require.NoError(t, err, "reading expected log file") + // Call Up with WithUpOutput to capture CLI logs. + ctx := testutil.Context(t, testutil.WaitMedium) + containerID, err := dccli.Up(ctx, "/test/workspace", "", agentcontainers.WithUpOutput(outBuf, errBuf)) + require.NoError(t, err, "Up should succeed") + require.NotEmpty(t, containerID, "expected non-empty container ID") - // Verify stdout buffer contains the CLI logs and stderr is empty. - assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") - assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") + // Read expected log content. + expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.log")) + require.NoError(t, err, "reading expected log file") + + // Verify stdout buffer contains the CLI logs and stderr is empty. + assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") + assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") + }) + + t.Run("Exec", func(t *testing.T) { + t.Parallel() + + logFile := filepath.Join(t.TempDir(), "exec.log") + f, err := os.Create(logFile) + require.NoError(t, err, "create exec log file") + _, err = f.WriteString("exec command log\n") + require.NoError(t, err, "write to exec log file") + err = f.Close() + require.NoError(t, err, "close exec log file") + + // Buffers to capture stdout and stderr. + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + // Simulate CLI execution for exec command with container ID. + wantArgs := "exec --workspace-folder /test/workspace --container-id test-container-456 echo hello" + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: wantArgs, + wantError: false, + logFile: logFile, + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + + // Call Exec with WithExecOutput and WithContainerID to capture any command output. + ctx := testutil.Context(t, testutil.WaitMedium) + err = dccli.Exec(ctx, "/test/workspace", "", "echo", []string{"hello"}, + agentcontainers.WithExecContainerID("test-container-456"), + agentcontainers.WithExecOutput(outBuf, errBuf), + ) + require.NoError(t, err, "Exec should succeed") + + assert.NotEmpty(t, outBuf.String(), "stdout buffer should not be empty for exec with log file") + assert.Empty(t, errBuf.String(), "stderr buffer should be empty") + }) } // testDevcontainerExecer implements the agentexec.Execer interface for testing. @@ -243,13 +392,16 @@ func TestDevcontainerHelperProcess(t *testing.T) { } logFilePath := os.Getenv("TEST_DEVCONTAINER_LOG_FILE") - output, err := os.ReadFile(logFilePath) - if err != nil { - fmt.Fprintf(os.Stderr, "Reading log file %s failed: %v\n", logFilePath, err) - os.Exit(2) + if logFilePath != "" { + // Read and output log file for commands that need it (like "up") + output, err := os.ReadFile(logFilePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Reading log file %s failed: %v\n", logFilePath, err) + os.Exit(2) + } + _, _ = io.Copy(os.Stdout, bytes.NewReader(output)) } - _, _ = io.Copy(os.Stdout, bytes.NewReader(output)) if os.Getenv("TEST_DEVCONTAINER_WANT_ERROR") == "true" { os.Exit(1) } diff --git a/agent/agentcontainers/subagent.go b/agent/agentcontainers/subagent.go new file mode 100644 index 0000000000000..70899fb96f70d --- /dev/null +++ b/agent/agentcontainers/subagent.go @@ -0,0 +1,128 @@ +package agentcontainers + +import ( + "context" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + agentproto "github.com/coder/coder/v2/agent/proto" +) + +// SubAgent represents an agent running in a dev container. +type SubAgent struct { + ID uuid.UUID + Name string + AuthToken uuid.UUID + Directory string + Architecture string + OperatingSystem string +} + +// SubAgentClient is an interface for managing sub agents and allows +// changing the implementation without having to deal with the +// agentproto package directly. +type SubAgentClient interface { + // List returns a list of all agents. + List(ctx context.Context) ([]SubAgent, error) + // Create adds a new agent. + Create(ctx context.Context, agent SubAgent) (SubAgent, error) + // Delete removes an agent by its ID. + Delete(ctx context.Context, id uuid.UUID) error +} + +// NewSubAgentClient returns a SubAgentClient that uses the provided +// agent API client. +type subAgentAPIClient struct { + logger slog.Logger + api agentproto.DRPCAgentClient26 +} + +var _ SubAgentClient = (*subAgentAPIClient)(nil) + +func NewSubAgentClientFromAPI(logger slog.Logger, agentAPI agentproto.DRPCAgentClient26) SubAgentClient { + if agentAPI == nil { + panic("developer error: agentAPI cannot be nil") + } + return &subAgentAPIClient{ + logger: logger.Named("subagentclient"), + api: agentAPI, + } +} + +func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) { + a.logger.Debug(ctx, "listing sub agents") + resp, err := a.api.ListSubAgents(ctx, &agentproto.ListSubAgentsRequest{}) + if err != nil { + return nil, err + } + + agents := make([]SubAgent, len(resp.Agents)) + for i, agent := range resp.Agents { + id, err := uuid.FromBytes(agent.GetId()) + if err != nil { + return nil, err + } + authToken, err := uuid.FromBytes(agent.GetAuthToken()) + if err != nil { + return nil, err + } + agents[i] = SubAgent{ + ID: id, + Name: agent.GetName(), + AuthToken: authToken, + } + } + return agents, nil +} + +func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (SubAgent, error) { + a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory)) + resp, err := a.api.CreateSubAgent(ctx, &agentproto.CreateSubAgentRequest{ + Name: agent.Name, + Directory: agent.Directory, + Architecture: agent.Architecture, + OperatingSystem: agent.OperatingSystem, + }) + if err != nil { + return SubAgent{}, err + } + + agent.Name = resp.Agent.Name + agent.ID, err = uuid.FromBytes(resp.Agent.Id) + if err != nil { + return agent, err + } + agent.AuthToken, err = uuid.FromBytes(resp.Agent.AuthToken) + if err != nil { + return agent, err + } + return agent, nil +} + +func (a *subAgentAPIClient) Delete(ctx context.Context, id uuid.UUID) error { + a.logger.Debug(ctx, "deleting sub agent", slog.F("id", id.String())) + _, err := a.api.DeleteSubAgent(ctx, &agentproto.DeleteSubAgentRequest{ + Id: id[:], + }) + return err +} + +// noopSubAgentClient is a SubAgentClient that does nothing. +type noopSubAgentClient struct{} + +var _ SubAgentClient = noopSubAgentClient{} + +func (noopSubAgentClient) List(_ context.Context) ([]SubAgent, error) { + return nil, nil +} + +func (noopSubAgentClient) Create(_ context.Context, _ SubAgent) (SubAgent, error) { + return SubAgent{}, xerrors.New("noopSubAgentClient does not support creating sub agents") +} + +func (noopSubAgentClient) Delete(_ context.Context, _ uuid.UUID) error { + return xerrors.New("noopSubAgentClient does not support deleting sub agents") +} diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index a957c61000c70..0a2df141ff3d4 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -163,6 +163,14 @@ func (c *Client) GetConnectionReports() []*agentproto.ReportConnectionRequest { return c.fakeAgentAPI.GetConnectionReports() } +func (c *Client) GetSubAgents() []*agentproto.SubAgent { + return c.fakeAgentAPI.GetSubAgents() +} + +func (c *Client) GetSubAgentDirectory(id uuid.UUID) (string, error) { + return c.fakeAgentAPI.GetSubAgentDirectory(id) +} + type FakeAgentAPI struct { sync.Mutex t testing.TB @@ -177,6 +185,8 @@ type FakeAgentAPI struct { metadata map[string]agentsdk.Metadata timings []*agentproto.Timing connectionReports []*agentproto.ReportConnectionRequest + subAgents map[uuid.UUID]*agentproto.SubAgent + subAgentDirs map[uuid.UUID]string getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error) getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error) @@ -365,16 +375,106 @@ func (f *FakeAgentAPI) GetConnectionReports() []*agentproto.ReportConnectionRequ return slices.Clone(f.connectionReports) } -func (*FakeAgentAPI) CreateSubAgent(_ context.Context, _ *agentproto.CreateSubAgentRequest) (*agentproto.CreateSubAgentResponse, error) { - panic("unimplemented") +func (f *FakeAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.CreateSubAgentRequest) (*agentproto.CreateSubAgentResponse, error) { + f.Lock() + defer f.Unlock() + + f.logger.Debug(ctx, "create sub agent called", slog.F("req", req)) + + // Generate IDs for the new sub-agent. + subAgentID := uuid.New() + authToken := uuid.New() + + // Create the sub-agent proto object. + subAgent := &agentproto.SubAgent{ + Id: subAgentID[:], + Name: req.Name, + AuthToken: authToken[:], + } + + // Store the sub-agent in our map. + if f.subAgents == nil { + f.subAgents = make(map[uuid.UUID]*agentproto.SubAgent) + } + f.subAgents[subAgentID] = subAgent + if f.subAgentDirs == nil { + f.subAgentDirs = make(map[uuid.UUID]string) + } + f.subAgentDirs[subAgentID] = req.GetDirectory() + + // For a fake implementation, we don't create workspace apps. + // Real implementations would handle req.Apps here. + return &agentproto.CreateSubAgentResponse{ + Agent: subAgent, + AppCreationErrors: nil, + }, nil +} + +func (f *FakeAgentAPI) DeleteSubAgent(ctx context.Context, req *agentproto.DeleteSubAgentRequest) (*agentproto.DeleteSubAgentResponse, error) { + f.Lock() + defer f.Unlock() + + f.logger.Debug(ctx, "delete sub agent called", slog.F("req", req)) + + subAgentID, err := uuid.FromBytes(req.Id) + if err != nil { + return nil, err + } + + // Remove the sub-agent from our map. + if f.subAgents != nil { + delete(f.subAgents, subAgentID) + } + + return &agentproto.DeleteSubAgentResponse{}, nil +} + +func (f *FakeAgentAPI) ListSubAgents(ctx context.Context, req *agentproto.ListSubAgentsRequest) (*agentproto.ListSubAgentsResponse, error) { + f.Lock() + defer f.Unlock() + + f.logger.Debug(ctx, "list sub agents called", slog.F("req", req)) + + var agents []*agentproto.SubAgent + if f.subAgents != nil { + agents = make([]*agentproto.SubAgent, 0, len(f.subAgents)) + for _, agent := range f.subAgents { + agents = append(agents, agent) + } + } + + return &agentproto.ListSubAgentsResponse{ + Agents: agents, + }, nil } -func (*FakeAgentAPI) DeleteSubAgent(_ context.Context, _ *agentproto.DeleteSubAgentRequest) (*agentproto.DeleteSubAgentResponse, error) { - panic("unimplemented") +func (f *FakeAgentAPI) GetSubAgents() []*agentproto.SubAgent { + f.Lock() + defer f.Unlock() + var agents []*agentproto.SubAgent + if f.subAgents != nil { + agents = make([]*agentproto.SubAgent, 0, len(f.subAgents)) + for _, agent := range f.subAgents { + agents = append(agents, agent) + } + } + return agents } -func (*FakeAgentAPI) ListSubAgents(_ context.Context, _ *agentproto.ListSubAgentsRequest) (*agentproto.ListSubAgentsResponse, error) { - panic("unimplemented") +func (f *FakeAgentAPI) GetSubAgentDirectory(id uuid.UUID) (string, error) { + f.Lock() + defer f.Unlock() + + if f.subAgentDirs == nil { + return "", xerrors.New("no sub-agent directories available") + } + + dir, ok := f.subAgentDirs[id] + if !ok { + return "", xerrors.New("sub-agent directory not found") + } + + return dir, nil } func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI { diff --git a/agent/api.go b/agent/api.go index 2e15530adc608..1c9a707fbb338 100644 --- a/agent/api.go +++ b/agent/api.go @@ -10,11 +10,12 @@ import ( "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() (http.Handler, func() error) { +func (a *agent) apiHandler(aAPI proto.DRPCAgentClient26) (http.Handler, func() error) { r := chi.NewRouter() r.Get("/", func(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{ @@ -45,6 +46,7 @@ func (a *agent) apiHandler() (http.Handler, func() error) { 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 && len(manifest.Devcontainers) > 0 { diff --git a/agent/proto/agent.pb.go b/agent/proto/agent.pb.go index 11d7fe59a1bfd..f3656acf3978b 100644 --- a/agent/proto/agent.pb.go +++ b/agent/proto/agent.pb.go @@ -620,6 +620,156 @@ func (Connection_Type) EnumDescriptor() ([]byte, []int) { return file_agent_proto_agent_proto_rawDescGZIP(), []int{33, 1} } +type CreateSubAgentRequest_DisplayApp int32 + +const ( + CreateSubAgentRequest_VSCODE CreateSubAgentRequest_DisplayApp = 0 + CreateSubAgentRequest_VSCODE_INSIDERS CreateSubAgentRequest_DisplayApp = 1 + CreateSubAgentRequest_WEB_TERMINAL CreateSubAgentRequest_DisplayApp = 2 + CreateSubAgentRequest_SSH_HELPER CreateSubAgentRequest_DisplayApp = 3 + CreateSubAgentRequest_PORT_FORWARDING_HELPER CreateSubAgentRequest_DisplayApp = 4 +) + +// Enum value maps for CreateSubAgentRequest_DisplayApp. +var ( + CreateSubAgentRequest_DisplayApp_name = map[int32]string{ + 0: "VSCODE", + 1: "VSCODE_INSIDERS", + 2: "WEB_TERMINAL", + 3: "SSH_HELPER", + 4: "PORT_FORWARDING_HELPER", + } + CreateSubAgentRequest_DisplayApp_value = map[string]int32{ + "VSCODE": 0, + "VSCODE_INSIDERS": 1, + "WEB_TERMINAL": 2, + "SSH_HELPER": 3, + "PORT_FORWARDING_HELPER": 4, + } +) + +func (x CreateSubAgentRequest_DisplayApp) Enum() *CreateSubAgentRequest_DisplayApp { + p := new(CreateSubAgentRequest_DisplayApp) + *p = x + return p +} + +func (x CreateSubAgentRequest_DisplayApp) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CreateSubAgentRequest_DisplayApp) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[11].Descriptor() +} + +func (CreateSubAgentRequest_DisplayApp) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[11] +} + +func (x CreateSubAgentRequest_DisplayApp) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CreateSubAgentRequest_DisplayApp.Descriptor instead. +func (CreateSubAgentRequest_DisplayApp) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{36, 0} +} + +type CreateSubAgentRequest_App_OpenIn int32 + +const ( + CreateSubAgentRequest_App_SLIM_WINDOW CreateSubAgentRequest_App_OpenIn = 0 + CreateSubAgentRequest_App_TAB CreateSubAgentRequest_App_OpenIn = 1 +) + +// Enum value maps for CreateSubAgentRequest_App_OpenIn. +var ( + CreateSubAgentRequest_App_OpenIn_name = map[int32]string{ + 0: "SLIM_WINDOW", + 1: "TAB", + } + CreateSubAgentRequest_App_OpenIn_value = map[string]int32{ + "SLIM_WINDOW": 0, + "TAB": 1, + } +) + +func (x CreateSubAgentRequest_App_OpenIn) Enum() *CreateSubAgentRequest_App_OpenIn { + p := new(CreateSubAgentRequest_App_OpenIn) + *p = x + return p +} + +func (x CreateSubAgentRequest_App_OpenIn) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CreateSubAgentRequest_App_OpenIn) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[12].Descriptor() +} + +func (CreateSubAgentRequest_App_OpenIn) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[12] +} + +func (x CreateSubAgentRequest_App_OpenIn) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CreateSubAgentRequest_App_OpenIn.Descriptor instead. +func (CreateSubAgentRequest_App_OpenIn) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{36, 0, 0} +} + +type CreateSubAgentRequest_App_Share int32 + +const ( + CreateSubAgentRequest_App_OWNER CreateSubAgentRequest_App_Share = 0 + CreateSubAgentRequest_App_AUTHENTICATED CreateSubAgentRequest_App_Share = 1 + CreateSubAgentRequest_App_PUBLIC CreateSubAgentRequest_App_Share = 2 +) + +// Enum value maps for CreateSubAgentRequest_App_Share. +var ( + CreateSubAgentRequest_App_Share_name = map[int32]string{ + 0: "OWNER", + 1: "AUTHENTICATED", + 2: "PUBLIC", + } + CreateSubAgentRequest_App_Share_value = map[string]int32{ + "OWNER": 0, + "AUTHENTICATED": 1, + "PUBLIC": 2, + } +) + +func (x CreateSubAgentRequest_App_Share) Enum() *CreateSubAgentRequest_App_Share { + p := new(CreateSubAgentRequest_App_Share) + *p = x + return p +} + +func (x CreateSubAgentRequest_App_Share) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CreateSubAgentRequest_App_Share) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[13].Descriptor() +} + +func (CreateSubAgentRequest_App_Share) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[13] +} + +func (x CreateSubAgentRequest_App_Share) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CreateSubAgentRequest_App_Share.Descriptor instead. +func (CreateSubAgentRequest_App_Share) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{36, 0, 1} +} + type WorkspaceApp struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2892,10 +3042,12 @@ type CreateSubAgentRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Directory string `protobuf:"bytes,2,opt,name=directory,proto3" json:"directory,omitempty"` - Architecture string `protobuf:"bytes,3,opt,name=architecture,proto3" json:"architecture,omitempty"` - OperatingSystem string `protobuf:"bytes,4,opt,name=operating_system,json=operatingSystem,proto3" json:"operating_system,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Directory string `protobuf:"bytes,2,opt,name=directory,proto3" json:"directory,omitempty"` + Architecture string `protobuf:"bytes,3,opt,name=architecture,proto3" json:"architecture,omitempty"` + OperatingSystem string `protobuf:"bytes,4,opt,name=operating_system,json=operatingSystem,proto3" json:"operating_system,omitempty"` + Apps []*CreateSubAgentRequest_App `protobuf:"bytes,5,rep,name=apps,proto3" json:"apps,omitempty"` + DisplayApps []CreateSubAgentRequest_DisplayApp `protobuf:"varint,6,rep,packed,name=display_apps,json=displayApps,proto3,enum=coder.agent.v2.CreateSubAgentRequest_DisplayApp" json:"display_apps,omitempty"` } func (x *CreateSubAgentRequest) Reset() { @@ -2958,12 +3110,27 @@ func (x *CreateSubAgentRequest) GetOperatingSystem() string { return "" } +func (x *CreateSubAgentRequest) GetApps() []*CreateSubAgentRequest_App { + if x != nil { + return x.Apps + } + return nil +} + +func (x *CreateSubAgentRequest) GetDisplayApps() []CreateSubAgentRequest_DisplayApp { + if x != nil { + return x.DisplayApps + } + return nil +} + type CreateSubAgentResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Agent *SubAgent `protobuf:"bytes,1,opt,name=agent,proto3" json:"agent,omitempty"` + Agent *SubAgent `protobuf:"bytes,1,opt,name=agent,proto3" json:"agent,omitempty"` + AppCreationErrors []*CreateSubAgentResponse_AppCreationError `protobuf:"bytes,2,rep,name=app_creation_errors,json=appCreationErrors,proto3" json:"app_creation_errors,omitempty"` } func (x *CreateSubAgentResponse) Reset() { @@ -3005,6 +3172,13 @@ func (x *CreateSubAgentResponse) GetAgent() *SubAgent { return nil } +func (x *CreateSubAgentResponse) GetAppCreationErrors() []*CreateSubAgentResponse_AppCreationError { + if x != nil { + return x.AppCreationErrors + } + return nil +} + type DeleteSubAgentRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -3907,6 +4081,275 @@ func (x *PushResourcesMonitoringUsageRequest_Datapoint_VolumeUsage) GetTotal() i return 0 } +type CreateSubAgentRequest_App struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"` + Command *string `protobuf:"bytes,2,opt,name=command,proto3,oneof" json:"command,omitempty"` + DisplayName *string `protobuf:"bytes,3,opt,name=display_name,json=displayName,proto3,oneof" json:"display_name,omitempty"` + External *bool `protobuf:"varint,4,opt,name=external,proto3,oneof" json:"external,omitempty"` + Group *string `protobuf:"bytes,5,opt,name=group,proto3,oneof" json:"group,omitempty"` + Healthcheck *CreateSubAgentRequest_App_Healthcheck `protobuf:"bytes,6,opt,name=healthcheck,proto3,oneof" json:"healthcheck,omitempty"` + Hidden *bool `protobuf:"varint,7,opt,name=hidden,proto3,oneof" json:"hidden,omitempty"` + Icon *string `protobuf:"bytes,8,opt,name=icon,proto3,oneof" json:"icon,omitempty"` + OpenIn *CreateSubAgentRequest_App_OpenIn `protobuf:"varint,9,opt,name=open_in,json=openIn,proto3,enum=coder.agent.v2.CreateSubAgentRequest_App_OpenIn,oneof" json:"open_in,omitempty"` + Order *int32 `protobuf:"varint,10,opt,name=order,proto3,oneof" json:"order,omitempty"` + Share *CreateSubAgentRequest_App_Share `protobuf:"varint,11,opt,name=share,proto3,enum=coder.agent.v2.CreateSubAgentRequest_App_Share,oneof" json:"share,omitempty"` + Subdomain *bool `protobuf:"varint,12,opt,name=subdomain,proto3,oneof" json:"subdomain,omitempty"` + Url *string `protobuf:"bytes,13,opt,name=url,proto3,oneof" json:"url,omitempty"` +} + +func (x *CreateSubAgentRequest_App) Reset() { + *x = CreateSubAgentRequest_App{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateSubAgentRequest_App) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSubAgentRequest_App) ProtoMessage() {} + +func (x *CreateSubAgentRequest_App) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[56] + 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 CreateSubAgentRequest_App.ProtoReflect.Descriptor instead. +func (*CreateSubAgentRequest_App) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{36, 0} +} + +func (x *CreateSubAgentRequest_App) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *CreateSubAgentRequest_App) GetCommand() string { + if x != nil && x.Command != nil { + return *x.Command + } + return "" +} + +func (x *CreateSubAgentRequest_App) GetDisplayName() string { + if x != nil && x.DisplayName != nil { + return *x.DisplayName + } + return "" +} + +func (x *CreateSubAgentRequest_App) GetExternal() bool { + if x != nil && x.External != nil { + return *x.External + } + return false +} + +func (x *CreateSubAgentRequest_App) GetGroup() string { + if x != nil && x.Group != nil { + return *x.Group + } + return "" +} + +func (x *CreateSubAgentRequest_App) GetHealthcheck() *CreateSubAgentRequest_App_Healthcheck { + if x != nil { + return x.Healthcheck + } + return nil +} + +func (x *CreateSubAgentRequest_App) GetHidden() bool { + if x != nil && x.Hidden != nil { + return *x.Hidden + } + return false +} + +func (x *CreateSubAgentRequest_App) GetIcon() string { + if x != nil && x.Icon != nil { + return *x.Icon + } + return "" +} + +func (x *CreateSubAgentRequest_App) GetOpenIn() CreateSubAgentRequest_App_OpenIn { + if x != nil && x.OpenIn != nil { + return *x.OpenIn + } + return CreateSubAgentRequest_App_SLIM_WINDOW +} + +func (x *CreateSubAgentRequest_App) GetOrder() int32 { + if x != nil && x.Order != nil { + return *x.Order + } + return 0 +} + +func (x *CreateSubAgentRequest_App) GetShare() CreateSubAgentRequest_App_Share { + if x != nil && x.Share != nil { + return *x.Share + } + return CreateSubAgentRequest_App_OWNER +} + +func (x *CreateSubAgentRequest_App) GetSubdomain() bool { + if x != nil && x.Subdomain != nil { + return *x.Subdomain + } + return false +} + +func (x *CreateSubAgentRequest_App) GetUrl() string { + if x != nil && x.Url != nil { + return *x.Url + } + return "" +} + +type CreateSubAgentRequest_App_Healthcheck struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Interval int32 `protobuf:"varint,1,opt,name=interval,proto3" json:"interval,omitempty"` + Threshold int32 `protobuf:"varint,2,opt,name=threshold,proto3" json:"threshold,omitempty"` + Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *CreateSubAgentRequest_App_Healthcheck) Reset() { + *x = CreateSubAgentRequest_App_Healthcheck{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateSubAgentRequest_App_Healthcheck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSubAgentRequest_App_Healthcheck) ProtoMessage() {} + +func (x *CreateSubAgentRequest_App_Healthcheck) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[57] + 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 CreateSubAgentRequest_App_Healthcheck.ProtoReflect.Descriptor instead. +func (*CreateSubAgentRequest_App_Healthcheck) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{36, 0, 0} +} + +func (x *CreateSubAgentRequest_App_Healthcheck) GetInterval() int32 { + if x != nil { + return x.Interval + } + return 0 +} + +func (x *CreateSubAgentRequest_App_Healthcheck) GetThreshold() int32 { + if x != nil { + return x.Threshold + } + return 0 +} + +func (x *CreateSubAgentRequest_App_Healthcheck) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type CreateSubAgentResponse_AppCreationError struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Index int32 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"` + Field *string `protobuf:"bytes,2,opt,name=field,proto3,oneof" json:"field,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *CreateSubAgentResponse_AppCreationError) Reset() { + *x = CreateSubAgentResponse_AppCreationError{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateSubAgentResponse_AppCreationError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSubAgentResponse_AppCreationError) ProtoMessage() {} + +func (x *CreateSubAgentResponse_AppCreationError) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[58] + 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 CreateSubAgentResponse_AppCreationError.ProtoReflect.Descriptor instead. +func (*CreateSubAgentResponse_AppCreationError) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{37, 0} +} + +func (x *CreateSubAgentResponse_AppCreationError) GetIndex() int32 { + if x != nil { + return x.Index + } + return 0 +} + +func (x *CreateSubAgentResponse_AppCreationError) GetField() string { + if x != nil && x.Field != nil { + return *x.Field + } + return "" +} + +func (x *CreateSubAgentResponse_AppCreationError) GetError() string { + if x != nil { + return x.Error + } + return "" +} + var File_agent_proto_agent_proto protoreflect.FileDescriptor var file_agent_proto_agent_proto_rawDesc = []byte{ @@ -4433,7 +4876,7 @@ var file_agent_proto_agent_proto_rawDesc = []byte{ 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, - 0x98, 0x01, 0x0a, 0x15, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, + 0xfd, 0x09, 0x0a, 0x15, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, @@ -4442,137 +4885,220 @@ var file_agent_proto_agent_proto_rawDesc = []byte{ 0x09, 0x52, 0x0c, 0x61, 0x72, 0x63, 0x68, 0x69, 0x74, 0x65, 0x63, 0x74, 0x75, 0x72, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61, - 0x74, 0x69, 0x6e, 0x67, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x22, 0x48, 0x0a, 0x16, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x22, 0x27, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, - 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, - 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x22, 0x18, 0x0a, - 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x53, - 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, - 0x49, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x06, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, - 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x2a, 0x63, 0x0a, 0x09, 0x41, 0x70, - 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x50, 0x50, 0x5f, 0x48, - 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, - 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, - 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, - 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, - 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x32, - 0x91, 0x0d, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, - 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, - 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x61, - 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, - 0x65, 0x72, 0x12, 0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, - 0x73, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, - 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, - 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x26, 0x2e, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, - 0x12, 0x72, 0x0a, 0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, - 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, - 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, - 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x74, 0x69, 0x6e, 0x67, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x3d, 0x0a, 0x04, 0x61, 0x70, + 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, + 0x41, 0x70, 0x70, 0x52, 0x04, 0x61, 0x70, 0x70, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x64, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x61, 0x70, 0x70, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0e, 0x32, + 0x30, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, + 0x70, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, 0x70, 0x73, 0x1a, 0xe1, + 0x06, 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, 0x1d, 0x0a, 0x07, 0x63, 0x6f, + 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x88, 0x01, 0x01, 0x12, 0x26, 0x0a, 0x0c, 0x64, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x01, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, + 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x88, + 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x03, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x88, 0x01, 0x01, 0x12, 0x5c, 0x0a, + 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x48, 0x65, + 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x48, 0x04, 0x52, 0x0b, 0x68, 0x65, 0x61, + 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, + 0x69, 0x64, 0x64, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x48, 0x05, 0x52, 0x06, 0x68, + 0x69, 0x64, 0x64, 0x65, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x88, 0x01, + 0x01, 0x12, 0x4e, 0x0a, 0x07, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x6e, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x30, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x4f, 0x70, + 0x65, 0x6e, 0x49, 0x6e, 0x48, 0x07, 0x52, 0x06, 0x6f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x88, 0x01, + 0x01, 0x12, 0x19, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, + 0x48, 0x08, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x4a, 0x0a, 0x05, + 0x73, 0x68, 0x61, 0x72, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2f, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x53, 0x68, 0x61, 0x72, 0x65, 0x48, 0x09, 0x52, 0x05, + 0x73, 0x68, 0x61, 0x72, 0x65, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x73, 0x75, 0x62, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0a, 0x52, 0x09, 0x73, + 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x75, + 0x72, 0x6c, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x48, 0x0b, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x88, + 0x01, 0x01, 0x1a, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, + 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x01, 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, 0x02, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, + 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x22, 0x0a, + 0x06, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x12, 0x0f, 0x0a, 0x0b, 0x53, 0x4c, 0x49, 0x4d, 0x5f, + 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x41, 0x42, 0x10, + 0x01, 0x22, 0x31, 0x0a, 0x05, 0x53, 0x68, 0x61, 0x72, 0x65, 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, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x42, 0x08, + 0x0a, 0x06, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x68, 0x65, 0x61, + 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x69, 0x64, + 0x64, 0x65, 0x6e, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x69, 0x63, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, + 0x5f, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x6e, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6f, 0x72, 0x64, + 0x65, 0x72, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x42, 0x0c, 0x0a, 0x0a, + 0x5f, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x75, + 0x72, 0x6c, 0x22, 0x6b, 0x0a, 0x0a, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, 0x70, + 0x12, 0x0a, 0x0a, 0x06, 0x56, 0x53, 0x43, 0x4f, 0x44, 0x45, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, + 0x56, 0x53, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x49, 0x44, 0x45, 0x52, 0x53, 0x10, + 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x57, 0x45, 0x42, 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, + 0x4c, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x53, 0x53, 0x48, 0x5f, 0x48, 0x45, 0x4c, 0x50, 0x45, + 0x52, 0x10, 0x03, 0x12, 0x1a, 0x0a, 0x16, 0x50, 0x4f, 0x52, 0x54, 0x5f, 0x46, 0x4f, 0x52, 0x57, + 0x41, 0x52, 0x44, 0x49, 0x4e, 0x47, 0x5f, 0x48, 0x45, 0x4c, 0x50, 0x45, 0x52, 0x10, 0x04, 0x22, + 0x96, 0x02, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x75, 0x62, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x52, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x67, 0x0a, 0x13, 0x61, 0x70, + 0x70, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, + 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x41, 0x70, 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, + 0x52, 0x11, 0x61, 0x70, 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x73, 0x1a, 0x63, 0x0a, 0x10, 0x41, 0x70, 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x19, 0x0a, + 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, + 0x66, 0x69, 0x65, 0x6c, 0x64, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x42, 0x08, + 0x0a, 0x06, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x22, 0x27, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, + 0x64, 0x22, 0x18, 0x0a, 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x0a, 0x14, 0x4c, + 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x22, 0x49, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x06, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x75, + 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x2a, 0x63, + 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, + 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, + 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, + 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, + 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, + 0x59, 0x10, 0x04, 0x32, 0x91, 0x0d, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, + 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, + 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, + 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, + 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, - 0x72, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, - 0x72, 0x74, 0x75, 0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x2e, 0x63, 0x6f, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, + 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, + 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, + 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, + 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72, 0x0a, 0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, + 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, + 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, + 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, + 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, - 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x77, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x41, - 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, - 0x72, 0x73, 0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, - 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, - 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x7e, 0x0a, 0x0f, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, - 0x65, 0x74, 0x65, 0x64, 0x12, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, - 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, - 0x74, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, - 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x9e, 0x01, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, + 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x77, 0x0a, 0x16, + 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, + 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, + 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, + 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7e, 0x0a, 0x0f, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, + 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x35, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, + 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x9e, 0x01, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, + 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, + 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x89, 0x01, 0x0a, 0x1c, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x12, 0x33, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, - 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, - 0x0a, 0x10, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, - 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x12, 0x5f, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, - 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, - 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, - 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, - 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, - 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, - 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x42, 0x27, 0x5a, 0x25, 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, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x89, 0x01, 0x0a, 0x1c, 0x50, 0x75, 0x73, 0x68, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, + 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x33, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, + 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, + 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, + 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x5f, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x0d, 0x4c, 0x69, 0x73, + 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x27, 0x5a, 0x25, 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, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -4587,8 +5113,8 @@ func file_agent_proto_agent_proto_rawDescGZIP() []byte { return file_agent_proto_agent_proto_rawDescData } -var file_agent_proto_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 11) -var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 56) +var file_agent_proto_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 14) +var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 59) var file_agent_proto_agent_proto_goTypes = []interface{}{ (AppHealth)(0), // 0: coder.agent.v2.AppHealth (WorkspaceApp_SharingLevel)(0), // 1: coder.agent.v2.WorkspaceApp.SharingLevel @@ -4601,158 +5127,170 @@ var file_agent_proto_agent_proto_goTypes = []interface{}{ (Timing_Status)(0), // 8: coder.agent.v2.Timing.Status (Connection_Action)(0), // 9: coder.agent.v2.Connection.Action (Connection_Type)(0), // 10: coder.agent.v2.Connection.Type - (*WorkspaceApp)(nil), // 11: coder.agent.v2.WorkspaceApp - (*WorkspaceAgentScript)(nil), // 12: coder.agent.v2.WorkspaceAgentScript - (*WorkspaceAgentMetadata)(nil), // 13: coder.agent.v2.WorkspaceAgentMetadata - (*Manifest)(nil), // 14: coder.agent.v2.Manifest - (*WorkspaceAgentDevcontainer)(nil), // 15: coder.agent.v2.WorkspaceAgentDevcontainer - (*GetManifestRequest)(nil), // 16: coder.agent.v2.GetManifestRequest - (*ServiceBanner)(nil), // 17: coder.agent.v2.ServiceBanner - (*GetServiceBannerRequest)(nil), // 18: coder.agent.v2.GetServiceBannerRequest - (*Stats)(nil), // 19: coder.agent.v2.Stats - (*UpdateStatsRequest)(nil), // 20: coder.agent.v2.UpdateStatsRequest - (*UpdateStatsResponse)(nil), // 21: coder.agent.v2.UpdateStatsResponse - (*Lifecycle)(nil), // 22: coder.agent.v2.Lifecycle - (*UpdateLifecycleRequest)(nil), // 23: coder.agent.v2.UpdateLifecycleRequest - (*BatchUpdateAppHealthRequest)(nil), // 24: coder.agent.v2.BatchUpdateAppHealthRequest - (*BatchUpdateAppHealthResponse)(nil), // 25: coder.agent.v2.BatchUpdateAppHealthResponse - (*Startup)(nil), // 26: coder.agent.v2.Startup - (*UpdateStartupRequest)(nil), // 27: coder.agent.v2.UpdateStartupRequest - (*Metadata)(nil), // 28: coder.agent.v2.Metadata - (*BatchUpdateMetadataRequest)(nil), // 29: coder.agent.v2.BatchUpdateMetadataRequest - (*BatchUpdateMetadataResponse)(nil), // 30: coder.agent.v2.BatchUpdateMetadataResponse - (*Log)(nil), // 31: coder.agent.v2.Log - (*BatchCreateLogsRequest)(nil), // 32: coder.agent.v2.BatchCreateLogsRequest - (*BatchCreateLogsResponse)(nil), // 33: coder.agent.v2.BatchCreateLogsResponse - (*GetAnnouncementBannersRequest)(nil), // 34: coder.agent.v2.GetAnnouncementBannersRequest - (*GetAnnouncementBannersResponse)(nil), // 35: coder.agent.v2.GetAnnouncementBannersResponse - (*BannerConfig)(nil), // 36: coder.agent.v2.BannerConfig - (*WorkspaceAgentScriptCompletedRequest)(nil), // 37: coder.agent.v2.WorkspaceAgentScriptCompletedRequest - (*WorkspaceAgentScriptCompletedResponse)(nil), // 38: coder.agent.v2.WorkspaceAgentScriptCompletedResponse - (*Timing)(nil), // 39: coder.agent.v2.Timing - (*GetResourcesMonitoringConfigurationRequest)(nil), // 40: coder.agent.v2.GetResourcesMonitoringConfigurationRequest - (*GetResourcesMonitoringConfigurationResponse)(nil), // 41: coder.agent.v2.GetResourcesMonitoringConfigurationResponse - (*PushResourcesMonitoringUsageRequest)(nil), // 42: coder.agent.v2.PushResourcesMonitoringUsageRequest - (*PushResourcesMonitoringUsageResponse)(nil), // 43: coder.agent.v2.PushResourcesMonitoringUsageResponse - (*Connection)(nil), // 44: coder.agent.v2.Connection - (*ReportConnectionRequest)(nil), // 45: coder.agent.v2.ReportConnectionRequest - (*SubAgent)(nil), // 46: coder.agent.v2.SubAgent - (*CreateSubAgentRequest)(nil), // 47: coder.agent.v2.CreateSubAgentRequest - (*CreateSubAgentResponse)(nil), // 48: coder.agent.v2.CreateSubAgentResponse - (*DeleteSubAgentRequest)(nil), // 49: coder.agent.v2.DeleteSubAgentRequest - (*DeleteSubAgentResponse)(nil), // 50: coder.agent.v2.DeleteSubAgentResponse - (*ListSubAgentsRequest)(nil), // 51: coder.agent.v2.ListSubAgentsRequest - (*ListSubAgentsResponse)(nil), // 52: coder.agent.v2.ListSubAgentsResponse - (*WorkspaceApp_Healthcheck)(nil), // 53: coder.agent.v2.WorkspaceApp.Healthcheck - (*WorkspaceAgentMetadata_Result)(nil), // 54: coder.agent.v2.WorkspaceAgentMetadata.Result - (*WorkspaceAgentMetadata_Description)(nil), // 55: coder.agent.v2.WorkspaceAgentMetadata.Description - nil, // 56: coder.agent.v2.Manifest.EnvironmentVariablesEntry - nil, // 57: coder.agent.v2.Stats.ConnectionsByProtoEntry - (*Stats_Metric)(nil), // 58: coder.agent.v2.Stats.Metric - (*Stats_Metric_Label)(nil), // 59: coder.agent.v2.Stats.Metric.Label - (*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 60: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate - (*GetResourcesMonitoringConfigurationResponse_Config)(nil), // 61: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Config - (*GetResourcesMonitoringConfigurationResponse_Memory)(nil), // 62: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Memory - (*GetResourcesMonitoringConfigurationResponse_Volume)(nil), // 63: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Volume - (*PushResourcesMonitoringUsageRequest_Datapoint)(nil), // 64: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint - (*PushResourcesMonitoringUsageRequest_Datapoint_MemoryUsage)(nil), // 65: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.MemoryUsage - (*PushResourcesMonitoringUsageRequest_Datapoint_VolumeUsage)(nil), // 66: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.VolumeUsage - (*durationpb.Duration)(nil), // 67: google.protobuf.Duration - (*proto.DERPMap)(nil), // 68: coder.tailnet.v2.DERPMap - (*timestamppb.Timestamp)(nil), // 69: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 70: google.protobuf.Empty + (CreateSubAgentRequest_DisplayApp)(0), // 11: coder.agent.v2.CreateSubAgentRequest.DisplayApp + (CreateSubAgentRequest_App_OpenIn)(0), // 12: coder.agent.v2.CreateSubAgentRequest.App.OpenIn + (CreateSubAgentRequest_App_Share)(0), // 13: coder.agent.v2.CreateSubAgentRequest.App.Share + (*WorkspaceApp)(nil), // 14: coder.agent.v2.WorkspaceApp + (*WorkspaceAgentScript)(nil), // 15: coder.agent.v2.WorkspaceAgentScript + (*WorkspaceAgentMetadata)(nil), // 16: coder.agent.v2.WorkspaceAgentMetadata + (*Manifest)(nil), // 17: coder.agent.v2.Manifest + (*WorkspaceAgentDevcontainer)(nil), // 18: coder.agent.v2.WorkspaceAgentDevcontainer + (*GetManifestRequest)(nil), // 19: coder.agent.v2.GetManifestRequest + (*ServiceBanner)(nil), // 20: coder.agent.v2.ServiceBanner + (*GetServiceBannerRequest)(nil), // 21: coder.agent.v2.GetServiceBannerRequest + (*Stats)(nil), // 22: coder.agent.v2.Stats + (*UpdateStatsRequest)(nil), // 23: coder.agent.v2.UpdateStatsRequest + (*UpdateStatsResponse)(nil), // 24: coder.agent.v2.UpdateStatsResponse + (*Lifecycle)(nil), // 25: coder.agent.v2.Lifecycle + (*UpdateLifecycleRequest)(nil), // 26: coder.agent.v2.UpdateLifecycleRequest + (*BatchUpdateAppHealthRequest)(nil), // 27: coder.agent.v2.BatchUpdateAppHealthRequest + (*BatchUpdateAppHealthResponse)(nil), // 28: coder.agent.v2.BatchUpdateAppHealthResponse + (*Startup)(nil), // 29: coder.agent.v2.Startup + (*UpdateStartupRequest)(nil), // 30: coder.agent.v2.UpdateStartupRequest + (*Metadata)(nil), // 31: coder.agent.v2.Metadata + (*BatchUpdateMetadataRequest)(nil), // 32: coder.agent.v2.BatchUpdateMetadataRequest + (*BatchUpdateMetadataResponse)(nil), // 33: coder.agent.v2.BatchUpdateMetadataResponse + (*Log)(nil), // 34: coder.agent.v2.Log + (*BatchCreateLogsRequest)(nil), // 35: coder.agent.v2.BatchCreateLogsRequest + (*BatchCreateLogsResponse)(nil), // 36: coder.agent.v2.BatchCreateLogsResponse + (*GetAnnouncementBannersRequest)(nil), // 37: coder.agent.v2.GetAnnouncementBannersRequest + (*GetAnnouncementBannersResponse)(nil), // 38: coder.agent.v2.GetAnnouncementBannersResponse + (*BannerConfig)(nil), // 39: coder.agent.v2.BannerConfig + (*WorkspaceAgentScriptCompletedRequest)(nil), // 40: coder.agent.v2.WorkspaceAgentScriptCompletedRequest + (*WorkspaceAgentScriptCompletedResponse)(nil), // 41: coder.agent.v2.WorkspaceAgentScriptCompletedResponse + (*Timing)(nil), // 42: coder.agent.v2.Timing + (*GetResourcesMonitoringConfigurationRequest)(nil), // 43: coder.agent.v2.GetResourcesMonitoringConfigurationRequest + (*GetResourcesMonitoringConfigurationResponse)(nil), // 44: coder.agent.v2.GetResourcesMonitoringConfigurationResponse + (*PushResourcesMonitoringUsageRequest)(nil), // 45: coder.agent.v2.PushResourcesMonitoringUsageRequest + (*PushResourcesMonitoringUsageResponse)(nil), // 46: coder.agent.v2.PushResourcesMonitoringUsageResponse + (*Connection)(nil), // 47: coder.agent.v2.Connection + (*ReportConnectionRequest)(nil), // 48: coder.agent.v2.ReportConnectionRequest + (*SubAgent)(nil), // 49: coder.agent.v2.SubAgent + (*CreateSubAgentRequest)(nil), // 50: coder.agent.v2.CreateSubAgentRequest + (*CreateSubAgentResponse)(nil), // 51: coder.agent.v2.CreateSubAgentResponse + (*DeleteSubAgentRequest)(nil), // 52: coder.agent.v2.DeleteSubAgentRequest + (*DeleteSubAgentResponse)(nil), // 53: coder.agent.v2.DeleteSubAgentResponse + (*ListSubAgentsRequest)(nil), // 54: coder.agent.v2.ListSubAgentsRequest + (*ListSubAgentsResponse)(nil), // 55: coder.agent.v2.ListSubAgentsResponse + (*WorkspaceApp_Healthcheck)(nil), // 56: coder.agent.v2.WorkspaceApp.Healthcheck + (*WorkspaceAgentMetadata_Result)(nil), // 57: coder.agent.v2.WorkspaceAgentMetadata.Result + (*WorkspaceAgentMetadata_Description)(nil), // 58: coder.agent.v2.WorkspaceAgentMetadata.Description + nil, // 59: coder.agent.v2.Manifest.EnvironmentVariablesEntry + nil, // 60: coder.agent.v2.Stats.ConnectionsByProtoEntry + (*Stats_Metric)(nil), // 61: coder.agent.v2.Stats.Metric + (*Stats_Metric_Label)(nil), // 62: coder.agent.v2.Stats.Metric.Label + (*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 63: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate + (*GetResourcesMonitoringConfigurationResponse_Config)(nil), // 64: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Config + (*GetResourcesMonitoringConfigurationResponse_Memory)(nil), // 65: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Memory + (*GetResourcesMonitoringConfigurationResponse_Volume)(nil), // 66: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Volume + (*PushResourcesMonitoringUsageRequest_Datapoint)(nil), // 67: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint + (*PushResourcesMonitoringUsageRequest_Datapoint_MemoryUsage)(nil), // 68: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.MemoryUsage + (*PushResourcesMonitoringUsageRequest_Datapoint_VolumeUsage)(nil), // 69: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.VolumeUsage + (*CreateSubAgentRequest_App)(nil), // 70: coder.agent.v2.CreateSubAgentRequest.App + (*CreateSubAgentRequest_App_Healthcheck)(nil), // 71: coder.agent.v2.CreateSubAgentRequest.App.Healthcheck + (*CreateSubAgentResponse_AppCreationError)(nil), // 72: coder.agent.v2.CreateSubAgentResponse.AppCreationError + (*durationpb.Duration)(nil), // 73: google.protobuf.Duration + (*proto.DERPMap)(nil), // 74: coder.tailnet.v2.DERPMap + (*timestamppb.Timestamp)(nil), // 75: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 76: google.protobuf.Empty } var file_agent_proto_agent_proto_depIdxs = []int32{ 1, // 0: coder.agent.v2.WorkspaceApp.sharing_level:type_name -> coder.agent.v2.WorkspaceApp.SharingLevel - 53, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.Healthcheck + 56, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.Healthcheck 2, // 2: coder.agent.v2.WorkspaceApp.health:type_name -> coder.agent.v2.WorkspaceApp.Health - 67, // 3: coder.agent.v2.WorkspaceAgentScript.timeout:type_name -> google.protobuf.Duration - 54, // 4: coder.agent.v2.WorkspaceAgentMetadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result - 55, // 5: coder.agent.v2.WorkspaceAgentMetadata.description:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description - 56, // 6: coder.agent.v2.Manifest.environment_variables:type_name -> coder.agent.v2.Manifest.EnvironmentVariablesEntry - 68, // 7: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap - 12, // 8: coder.agent.v2.Manifest.scripts:type_name -> coder.agent.v2.WorkspaceAgentScript - 11, // 9: coder.agent.v2.Manifest.apps:type_name -> coder.agent.v2.WorkspaceApp - 55, // 10: coder.agent.v2.Manifest.metadata:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description - 15, // 11: coder.agent.v2.Manifest.devcontainers:type_name -> coder.agent.v2.WorkspaceAgentDevcontainer - 57, // 12: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry - 58, // 13: coder.agent.v2.Stats.metrics:type_name -> coder.agent.v2.Stats.Metric - 19, // 14: coder.agent.v2.UpdateStatsRequest.stats:type_name -> coder.agent.v2.Stats - 67, // 15: coder.agent.v2.UpdateStatsResponse.report_interval:type_name -> google.protobuf.Duration + 73, // 3: coder.agent.v2.WorkspaceAgentScript.timeout:type_name -> google.protobuf.Duration + 57, // 4: coder.agent.v2.WorkspaceAgentMetadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result + 58, // 5: coder.agent.v2.WorkspaceAgentMetadata.description:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description + 59, // 6: coder.agent.v2.Manifest.environment_variables:type_name -> coder.agent.v2.Manifest.EnvironmentVariablesEntry + 74, // 7: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap + 15, // 8: coder.agent.v2.Manifest.scripts:type_name -> coder.agent.v2.WorkspaceAgentScript + 14, // 9: coder.agent.v2.Manifest.apps:type_name -> coder.agent.v2.WorkspaceApp + 58, // 10: coder.agent.v2.Manifest.metadata:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description + 18, // 11: coder.agent.v2.Manifest.devcontainers:type_name -> coder.agent.v2.WorkspaceAgentDevcontainer + 60, // 12: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry + 61, // 13: coder.agent.v2.Stats.metrics:type_name -> coder.agent.v2.Stats.Metric + 22, // 14: coder.agent.v2.UpdateStatsRequest.stats:type_name -> coder.agent.v2.Stats + 73, // 15: coder.agent.v2.UpdateStatsResponse.report_interval:type_name -> google.protobuf.Duration 4, // 16: coder.agent.v2.Lifecycle.state:type_name -> coder.agent.v2.Lifecycle.State - 69, // 17: coder.agent.v2.Lifecycle.changed_at:type_name -> google.protobuf.Timestamp - 22, // 18: coder.agent.v2.UpdateLifecycleRequest.lifecycle:type_name -> coder.agent.v2.Lifecycle - 60, // 19: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate + 75, // 17: coder.agent.v2.Lifecycle.changed_at:type_name -> google.protobuf.Timestamp + 25, // 18: coder.agent.v2.UpdateLifecycleRequest.lifecycle:type_name -> coder.agent.v2.Lifecycle + 63, // 19: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate 5, // 20: coder.agent.v2.Startup.subsystems:type_name -> coder.agent.v2.Startup.Subsystem - 26, // 21: coder.agent.v2.UpdateStartupRequest.startup:type_name -> coder.agent.v2.Startup - 54, // 22: coder.agent.v2.Metadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result - 28, // 23: coder.agent.v2.BatchUpdateMetadataRequest.metadata:type_name -> coder.agent.v2.Metadata - 69, // 24: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp + 29, // 21: coder.agent.v2.UpdateStartupRequest.startup:type_name -> coder.agent.v2.Startup + 57, // 22: coder.agent.v2.Metadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result + 31, // 23: coder.agent.v2.BatchUpdateMetadataRequest.metadata:type_name -> coder.agent.v2.Metadata + 75, // 24: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp 6, // 25: coder.agent.v2.Log.level:type_name -> coder.agent.v2.Log.Level - 31, // 26: coder.agent.v2.BatchCreateLogsRequest.logs:type_name -> coder.agent.v2.Log - 36, // 27: coder.agent.v2.GetAnnouncementBannersResponse.announcement_banners:type_name -> coder.agent.v2.BannerConfig - 39, // 28: coder.agent.v2.WorkspaceAgentScriptCompletedRequest.timing:type_name -> coder.agent.v2.Timing - 69, // 29: coder.agent.v2.Timing.start:type_name -> google.protobuf.Timestamp - 69, // 30: coder.agent.v2.Timing.end:type_name -> google.protobuf.Timestamp + 34, // 26: coder.agent.v2.BatchCreateLogsRequest.logs:type_name -> coder.agent.v2.Log + 39, // 27: coder.agent.v2.GetAnnouncementBannersResponse.announcement_banners:type_name -> coder.agent.v2.BannerConfig + 42, // 28: coder.agent.v2.WorkspaceAgentScriptCompletedRequest.timing:type_name -> coder.agent.v2.Timing + 75, // 29: coder.agent.v2.Timing.start:type_name -> google.protobuf.Timestamp + 75, // 30: coder.agent.v2.Timing.end:type_name -> google.protobuf.Timestamp 7, // 31: coder.agent.v2.Timing.stage:type_name -> coder.agent.v2.Timing.Stage 8, // 32: coder.agent.v2.Timing.status:type_name -> coder.agent.v2.Timing.Status - 61, // 33: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.config:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Config - 62, // 34: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.memory:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Memory - 63, // 35: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.volumes:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Volume - 64, // 36: coder.agent.v2.PushResourcesMonitoringUsageRequest.datapoints:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint + 64, // 33: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.config:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Config + 65, // 34: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.memory:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Memory + 66, // 35: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.volumes:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Volume + 67, // 36: coder.agent.v2.PushResourcesMonitoringUsageRequest.datapoints:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint 9, // 37: coder.agent.v2.Connection.action:type_name -> coder.agent.v2.Connection.Action 10, // 38: coder.agent.v2.Connection.type:type_name -> coder.agent.v2.Connection.Type - 69, // 39: coder.agent.v2.Connection.timestamp:type_name -> google.protobuf.Timestamp - 44, // 40: coder.agent.v2.ReportConnectionRequest.connection:type_name -> coder.agent.v2.Connection - 46, // 41: coder.agent.v2.CreateSubAgentResponse.agent:type_name -> coder.agent.v2.SubAgent - 46, // 42: coder.agent.v2.ListSubAgentsResponse.agents:type_name -> coder.agent.v2.SubAgent - 67, // 43: coder.agent.v2.WorkspaceApp.Healthcheck.interval:type_name -> google.protobuf.Duration - 69, // 44: coder.agent.v2.WorkspaceAgentMetadata.Result.collected_at:type_name -> google.protobuf.Timestamp - 67, // 45: coder.agent.v2.WorkspaceAgentMetadata.Description.interval:type_name -> google.protobuf.Duration - 67, // 46: coder.agent.v2.WorkspaceAgentMetadata.Description.timeout:type_name -> google.protobuf.Duration - 3, // 47: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type - 59, // 48: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.Label - 0, // 49: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth - 69, // 50: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.collected_at:type_name -> google.protobuf.Timestamp - 65, // 51: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.memory:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.MemoryUsage - 66, // 52: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.volumes:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.VolumeUsage - 16, // 53: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest - 18, // 54: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest - 20, // 55: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest - 23, // 56: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest - 24, // 57: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest - 27, // 58: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest - 29, // 59: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest - 32, // 60: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest - 34, // 61: coder.agent.v2.Agent.GetAnnouncementBanners:input_type -> coder.agent.v2.GetAnnouncementBannersRequest - 37, // 62: coder.agent.v2.Agent.ScriptCompleted:input_type -> coder.agent.v2.WorkspaceAgentScriptCompletedRequest - 40, // 63: coder.agent.v2.Agent.GetResourcesMonitoringConfiguration:input_type -> coder.agent.v2.GetResourcesMonitoringConfigurationRequest - 42, // 64: coder.agent.v2.Agent.PushResourcesMonitoringUsage:input_type -> coder.agent.v2.PushResourcesMonitoringUsageRequest - 45, // 65: coder.agent.v2.Agent.ReportConnection:input_type -> coder.agent.v2.ReportConnectionRequest - 47, // 66: coder.agent.v2.Agent.CreateSubAgent:input_type -> coder.agent.v2.CreateSubAgentRequest - 49, // 67: coder.agent.v2.Agent.DeleteSubAgent:input_type -> coder.agent.v2.DeleteSubAgentRequest - 51, // 68: coder.agent.v2.Agent.ListSubAgents:input_type -> coder.agent.v2.ListSubAgentsRequest - 14, // 69: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest - 17, // 70: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner - 21, // 71: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse - 22, // 72: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle - 25, // 73: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse - 26, // 74: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup - 30, // 75: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse - 33, // 76: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse - 35, // 77: coder.agent.v2.Agent.GetAnnouncementBanners:output_type -> coder.agent.v2.GetAnnouncementBannersResponse - 38, // 78: coder.agent.v2.Agent.ScriptCompleted:output_type -> coder.agent.v2.WorkspaceAgentScriptCompletedResponse - 41, // 79: coder.agent.v2.Agent.GetResourcesMonitoringConfiguration:output_type -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse - 43, // 80: coder.agent.v2.Agent.PushResourcesMonitoringUsage:output_type -> coder.agent.v2.PushResourcesMonitoringUsageResponse - 70, // 81: coder.agent.v2.Agent.ReportConnection:output_type -> google.protobuf.Empty - 48, // 82: coder.agent.v2.Agent.CreateSubAgent:output_type -> coder.agent.v2.CreateSubAgentResponse - 50, // 83: coder.agent.v2.Agent.DeleteSubAgent:output_type -> coder.agent.v2.DeleteSubAgentResponse - 52, // 84: coder.agent.v2.Agent.ListSubAgents:output_type -> coder.agent.v2.ListSubAgentsResponse - 69, // [69:85] is the sub-list for method output_type - 53, // [53:69] is the sub-list for method input_type - 53, // [53:53] is the sub-list for extension type_name - 53, // [53:53] is the sub-list for extension extendee - 0, // [0:53] is the sub-list for field type_name + 75, // 39: coder.agent.v2.Connection.timestamp:type_name -> google.protobuf.Timestamp + 47, // 40: coder.agent.v2.ReportConnectionRequest.connection:type_name -> coder.agent.v2.Connection + 70, // 41: coder.agent.v2.CreateSubAgentRequest.apps:type_name -> coder.agent.v2.CreateSubAgentRequest.App + 11, // 42: coder.agent.v2.CreateSubAgentRequest.display_apps:type_name -> coder.agent.v2.CreateSubAgentRequest.DisplayApp + 49, // 43: coder.agent.v2.CreateSubAgentResponse.agent:type_name -> coder.agent.v2.SubAgent + 72, // 44: coder.agent.v2.CreateSubAgentResponse.app_creation_errors:type_name -> coder.agent.v2.CreateSubAgentResponse.AppCreationError + 49, // 45: coder.agent.v2.ListSubAgentsResponse.agents:type_name -> coder.agent.v2.SubAgent + 73, // 46: coder.agent.v2.WorkspaceApp.Healthcheck.interval:type_name -> google.protobuf.Duration + 75, // 47: coder.agent.v2.WorkspaceAgentMetadata.Result.collected_at:type_name -> google.protobuf.Timestamp + 73, // 48: coder.agent.v2.WorkspaceAgentMetadata.Description.interval:type_name -> google.protobuf.Duration + 73, // 49: coder.agent.v2.WorkspaceAgentMetadata.Description.timeout:type_name -> google.protobuf.Duration + 3, // 50: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type + 62, // 51: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.Label + 0, // 52: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth + 75, // 53: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.collected_at:type_name -> google.protobuf.Timestamp + 68, // 54: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.memory:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.MemoryUsage + 69, // 55: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.volumes:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.VolumeUsage + 71, // 56: coder.agent.v2.CreateSubAgentRequest.App.healthcheck:type_name -> coder.agent.v2.CreateSubAgentRequest.App.Healthcheck + 12, // 57: coder.agent.v2.CreateSubAgentRequest.App.open_in:type_name -> coder.agent.v2.CreateSubAgentRequest.App.OpenIn + 13, // 58: coder.agent.v2.CreateSubAgentRequest.App.share:type_name -> coder.agent.v2.CreateSubAgentRequest.App.Share + 19, // 59: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest + 21, // 60: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest + 23, // 61: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest + 26, // 62: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest + 27, // 63: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest + 30, // 64: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest + 32, // 65: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest + 35, // 66: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest + 37, // 67: coder.agent.v2.Agent.GetAnnouncementBanners:input_type -> coder.agent.v2.GetAnnouncementBannersRequest + 40, // 68: coder.agent.v2.Agent.ScriptCompleted:input_type -> coder.agent.v2.WorkspaceAgentScriptCompletedRequest + 43, // 69: coder.agent.v2.Agent.GetResourcesMonitoringConfiguration:input_type -> coder.agent.v2.GetResourcesMonitoringConfigurationRequest + 45, // 70: coder.agent.v2.Agent.PushResourcesMonitoringUsage:input_type -> coder.agent.v2.PushResourcesMonitoringUsageRequest + 48, // 71: coder.agent.v2.Agent.ReportConnection:input_type -> coder.agent.v2.ReportConnectionRequest + 50, // 72: coder.agent.v2.Agent.CreateSubAgent:input_type -> coder.agent.v2.CreateSubAgentRequest + 52, // 73: coder.agent.v2.Agent.DeleteSubAgent:input_type -> coder.agent.v2.DeleteSubAgentRequest + 54, // 74: coder.agent.v2.Agent.ListSubAgents:input_type -> coder.agent.v2.ListSubAgentsRequest + 17, // 75: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest + 20, // 76: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner + 24, // 77: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse + 25, // 78: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle + 28, // 79: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse + 29, // 80: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup + 33, // 81: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse + 36, // 82: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse + 38, // 83: coder.agent.v2.Agent.GetAnnouncementBanners:output_type -> coder.agent.v2.GetAnnouncementBannersResponse + 41, // 84: coder.agent.v2.Agent.ScriptCompleted:output_type -> coder.agent.v2.WorkspaceAgentScriptCompletedResponse + 44, // 85: coder.agent.v2.Agent.GetResourcesMonitoringConfiguration:output_type -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse + 46, // 86: coder.agent.v2.Agent.PushResourcesMonitoringUsage:output_type -> coder.agent.v2.PushResourcesMonitoringUsageResponse + 76, // 87: coder.agent.v2.Agent.ReportConnection:output_type -> google.protobuf.Empty + 51, // 88: coder.agent.v2.Agent.CreateSubAgent:output_type -> coder.agent.v2.CreateSubAgentResponse + 53, // 89: coder.agent.v2.Agent.DeleteSubAgent:output_type -> coder.agent.v2.DeleteSubAgentResponse + 55, // 90: coder.agent.v2.Agent.ListSubAgents:output_type -> coder.agent.v2.ListSubAgentsResponse + 75, // [75:91] is the sub-list for method output_type + 59, // [59:75] is the sub-list for method input_type + 59, // [59:59] is the sub-list for extension type_name + 59, // [59:59] is the sub-list for extension extendee + 0, // [0:59] is the sub-list for field type_name } func init() { file_agent_proto_agent_proto_init() } @@ -5409,18 +5947,56 @@ func file_agent_proto_agent_proto_init() { return nil } } + file_agent_proto_agent_proto_msgTypes[56].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateSubAgentRequest_App); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[57].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateSubAgentRequest_App_Healthcheck); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[58].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateSubAgentResponse_AppCreationError); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } file_agent_proto_agent_proto_msgTypes[3].OneofWrappers = []interface{}{} file_agent_proto_agent_proto_msgTypes[30].OneofWrappers = []interface{}{} file_agent_proto_agent_proto_msgTypes[33].OneofWrappers = []interface{}{} file_agent_proto_agent_proto_msgTypes[53].OneofWrappers = []interface{}{} + file_agent_proto_agent_proto_msgTypes[56].OneofWrappers = []interface{}{} + file_agent_proto_agent_proto_msgTypes[58].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_agent_proto_agent_proto_rawDesc, - NumEnums: 11, - NumMessages: 56, + NumEnums: 14, + NumMessages: 59, NumExtensions: 0, NumServices: 1, }, diff --git a/agent/proto/agent.proto b/agent/proto/agent.proto index 53385d97f8b29..e9455c449fdb7 100644 --- a/agent/proto/agent.proto +++ b/agent/proto/agent.proto @@ -388,10 +388,62 @@ message CreateSubAgentRequest { string directory = 2; string architecture = 3; string operating_system = 4; + + message App { + message Healthcheck { + int32 interval = 1; + int32 threshold = 2; + string url = 3; + } + + enum OpenIn { + SLIM_WINDOW = 0; + TAB = 1; + } + + enum Share { + OWNER = 0; + AUTHENTICATED = 1; + PUBLIC = 2; + } + + string slug = 1; + optional string command = 2; + optional string display_name = 3; + optional bool external = 4; + optional string group = 5; + optional Healthcheck healthcheck = 6; + optional bool hidden = 7; + optional string icon = 8; + optional OpenIn open_in = 9; + optional int32 order = 10; + optional Share share = 11; + optional bool subdomain = 12; + optional string url = 13; + } + + repeated App apps = 5; + + enum DisplayApp { + VSCODE = 0; + VSCODE_INSIDERS = 1; + WEB_TERMINAL = 2; + SSH_HELPER = 3; + PORT_FORWARDING_HELPER = 4; + } + + repeated DisplayApp display_apps = 6; } message CreateSubAgentResponse { + message AppCreationError { + int32 index = 1; + optional string field = 2; + string error = 3; + } + SubAgent agent = 1; + repeated AppCreationError app_creation_errors = 2; } message DeleteSubAgentRequest { diff --git a/cli/agent.go b/cli/agent.go index deca447664337..5d6037f9930ec 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -28,6 +28,7 @@ import ( "github.com/coder/serpent" "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/agent/agentssh" "github.com/coder/coder/v2/agent/reaper" @@ -362,6 +363,9 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { BlockFileTransfer: blockFileTransfer, Execer: execer, ExperimentalDevcontainersEnabled: experimentalDevcontainersEnabled, + ContainerAPIOptions: []agentcontainers.Option{ + agentcontainers.WithSubAgentURL(r.agentURL.String()), + }, }) promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger) diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index fbc913e7b81d3..8d1f5302ce7ba 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -168,6 +168,12 @@ func StartWithAssert(t *testing.T, inv *serpent.Invocation, assertCallback func( switch { case errors.Is(err, context.Canceled): return + case err != nil && strings.Contains(err.Error(), "driver: bad connection"): + // When we cancel the context on a query that's being executed within + // a transaction, sometimes, instead of a context.Canceled error we get + // a "driver: bad connection" error. + // https://github.com/lib/pq/issues/1137 + return default: assert.NoError(t, err) } diff --git a/cli/configssh.go b/cli/configssh.go index cfea6b377f6ee..c1be60b604a9e 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -112,14 +112,19 @@ func (o sshConfigOptions) equal(other sshConfigOptions) bool { } func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { - escapedCoderBinary, err := sshConfigExecEscape(o.coderBinaryPath, o.forceUnixSeparators) + escapedCoderBinaryProxy, err := sshConfigProxyCommandEscape(o.coderBinaryPath, o.forceUnixSeparators) if err != nil { - return xerrors.Errorf("escape coder binary for ssh failed: %w", err) + return xerrors.Errorf("escape coder binary for ProxyCommand failed: %w", err) } - escapedGlobalConfig, err := sshConfigExecEscape(o.globalConfigPath, o.forceUnixSeparators) + escapedCoderBinaryMatchExec, err := sshConfigMatchExecEscape(o.coderBinaryPath) if err != nil { - return xerrors.Errorf("escape global config for ssh failed: %w", err) + return xerrors.Errorf("escape coder binary for Match exec failed: %w", err) + } + + escapedGlobalConfig, err := sshConfigProxyCommandEscape(o.globalConfigPath, o.forceUnixSeparators) + if err != nil { + return xerrors.Errorf("escape global config for ProxyCommand failed: %w", err) } rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig) @@ -155,7 +160,7 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { _, _ = buf.WriteString("\t") _, _ = fmt.Fprintf(buf, "ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h", - escapedCoderBinary, rootFlags, flags, o.userHostPrefix, + escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix, ) _, _ = buf.WriteString("\n") } @@ -174,11 +179,11 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { // the ^^ options should always apply, but we only want to use the proxy command if Coder Connect is not running. if !o.skipProxyCommand { _, _ = fmt.Fprintf(buf, "\nMatch host *.%s !exec \"%s connect exists %%h\"\n", - o.hostnameSuffix, escapedCoderBinary) + o.hostnameSuffix, escapedCoderBinaryMatchExec) _, _ = buf.WriteString("\t") _, _ = fmt.Fprintf(buf, "ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h", - escapedCoderBinary, rootFlags, flags, o.hostnameSuffix, + escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix, ) _, _ = buf.WriteString("\n") } @@ -759,7 +764,8 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after [] return data, nil, nil, nil } -// sshConfigExecEscape quotes the string if it contains spaces, as per +// sshConfigProxyCommandEscape prepares the path for use in ProxyCommand. +// It quotes the string if it contains spaces, as per // `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to // run the command, and as such the formatting/escape requirements // cannot simply be covered by `fmt.Sprintf("%q", path)`. @@ -804,7 +810,7 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after [] // This is a control flag, and that is ok. It is a control flag // based on the OS of the user. Making this a different file is excessive. // nolint:revive -func sshConfigExecEscape(path string, forceUnixPath bool) (string, error) { +func sshConfigProxyCommandEscape(path string, forceUnixPath bool) (string, error) { if forceUnixPath { // This is a workaround for #7639, where the filepath separator is // incorrectly the Windows separator (\) instead of the unix separator (/). @@ -814,9 +820,9 @@ func sshConfigExecEscape(path string, forceUnixPath bool) (string, error) { // This is unlikely to ever happen, but newlines are allowed on // certain filesystems, but cannot be used inside ssh config. if strings.ContainsAny(path, "\n") { - return "", xerrors.Errorf("invalid path: %s", path) + return "", xerrors.Errorf("invalid path: %q", path) } - // In the unlikely even that a path contains quotes, they must be + // In the unlikely event that a path contains quotes, they must be // escaped so that they are not interpreted as shell quotes. if strings.Contains(path, "\"") { path = strings.ReplaceAll(path, "\"", "\\\"") diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go index d3eee395de0a3..acf534e7ae157 100644 --- a/cli/configssh_internal_test.go +++ b/cli/configssh_internal_test.go @@ -139,7 +139,7 @@ func Test_sshConfigSplitOnCoderSection(t *testing.T) { // This test tries to mimic the behavior of OpenSSH // when executing e.g. a ProxyCommand. // nolint:tparallel -func Test_sshConfigExecEscape(t *testing.T) { +func Test_sshConfigProxyCommandEscape(t *testing.T) { t.Parallel() tests := []struct { @@ -171,7 +171,7 @@ func Test_sshConfigExecEscape(t *testing.T) { err = os.WriteFile(bin, contents, 0o755) //nolint:gosec require.NoError(t, err) - escaped, err := sshConfigExecEscape(bin, false) + escaped, err := sshConfigProxyCommandEscape(bin, false) if tt.wantErr { require.Error(t, err) return @@ -186,6 +186,63 @@ func Test_sshConfigExecEscape(t *testing.T) { } } +// This test tries to mimic the behavior of OpenSSH +// when executing e.g. a match exec command. +// nolint:tparallel +func Test_sshConfigMatchExecEscape(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErrOther bool + wantErrWindows bool + }{ + {"no spaces", "simple", false, false}, + {"spaces", "path with spaces", false, false}, + {"quotes", "path with \"quotes\"", true, true}, + {"backslashes", "path with\\backslashes", false, false}, + {"tabs", "path with \ttabs", false, true}, + {"newline fails", "path with \nnewline", true, true}, + } + // nolint:paralleltest // Fixes a flake + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cmd := "/bin/sh" + arg := "-c" + contents := []byte("#!/bin/sh\necho yay\n") + if runtime.GOOS == "windows" { + cmd = "cmd.exe" + arg = "/c" + contents = []byte("@echo yay\n") + } + + dir := filepath.Join(t.TempDir(), tt.path) + bin := filepath.Join(dir, "coder.bat") // Windows will treat it as batch, Linux doesn't care + escaped, err := sshConfigMatchExecEscape(bin) + if (runtime.GOOS == "windows" && tt.wantErrWindows) || (runtime.GOOS != "windows" && tt.wantErrOther) { + require.Error(t, err) + return + } + require.NoError(t, err) + + err = os.MkdirAll(dir, 0o755) + require.NoError(t, err) + + err = os.WriteFile(bin, contents, 0o755) //nolint:gosec + require.NoError(t, err) + + // OpenSSH processes %% escape sequences into % + escaped = strings.ReplaceAll(escaped, "%%", "%") + b, err := exec.Command(cmd, arg, escaped).CombinedOutput() //nolint:gosec + require.NoError(t, err) + got := strings.TrimSpace(string(b)) + require.Equal(t, "yay", got) + }) + } +} + func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) { t.Parallel() @@ -236,7 +293,7 @@ func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - found, err := sshConfigExecEscape(tt.path, tt.forceUnix) + found, err := sshConfigProxyCommandEscape(tt.path, tt.forceUnix) if tt.wantErr { require.Error(t, err) return diff --git a/cli/configssh_other.go b/cli/configssh_other.go index fde7cc0e47e63..07417487e8c8f 100644 --- a/cli/configssh_other.go +++ b/cli/configssh_other.go @@ -2,4 +2,35 @@ package cli +import ( + "strings" + + "golang.org/x/xerrors" +) + var hideForceUnixSlashes = true + +// sshConfigMatchExecEscape prepares the path for use in `Match exec` statement. +// +// OpenSSH parses the Match line with a very simple tokenizer that accepts "-enclosed strings for the exec command, and +// has no supported escape sequences for ". This means we cannot include " within the command to execute. +func sshConfigMatchExecEscape(path string) (string, error) { + // This is unlikely to ever happen, but newlines are allowed on + // certain filesystems, but cannot be used inside ssh config. + if strings.ContainsAny(path, "\n") { + return "", xerrors.Errorf("invalid path: %s", path) + } + // Quotes are allowed in path names on unix-like file systems, but OpenSSH's parsing of `Match exec` doesn't allow + // them. + if strings.Contains(path, `"`) { + return "", xerrors.Errorf("path must not contain quotes: %q", path) + } + + // OpenSSH passes the match exec string directly to the user's shell. sh, bash and zsh accept spaces, tabs and + // backslashes simply escaped by a `\`. It's hard to predict exactly what more exotic shells might do, but this + // should work for macOS and most Linux distros in their default configuration. + path = strings.ReplaceAll(path, `\`, `\\`) // must be first, since later replacements add backslashes. + path = strings.ReplaceAll(path, " ", "\\ ") + path = strings.ReplaceAll(path, "\t", "\\\t") + return path, nil +} diff --git a/cli/configssh_windows.go b/cli/configssh_windows.go index 642a388fc873c..5df0d6b50c00e 100644 --- a/cli/configssh_windows.go +++ b/cli/configssh_windows.go @@ -2,5 +2,58 @@ package cli +import ( + "fmt" + "strings" + + "golang.org/x/xerrors" +) + // Must be a var for unit tests to conform behavior var hideForceUnixSlashes = false + +// sshConfigMatchExecEscape prepares the path for use in `Match exec` statement. +// +// OpenSSH parses the Match line with a very simple tokenizer that accepts "-enclosed strings for the exec command, and +// has no supported escape sequences for ". This means we cannot include " within the command to execute. +// +// To make matters worse, on Windows, OpenSSH passes the string directly to cmd.exe for execution, and as far as I can +// tell, the only supported way to call a path that has spaces in it is to surround it with ". +// +// So, we can't actually include " directly, but here is a horrible workaround: +// +// "for /f %%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%aC:\Program Files\Coder\bin\coder.exe%%a connect exists %h" +// +// The key insight here is to store the character " in a variable (%a in this case, but the % itself needs to be +// escaped, so it becomes %%a), and then use that variable to construct the double-quoted path: +// +// %%aC:\Program Files\Coder\bin\coder.exe%%a. +// +// How do we generate a single " character without actually using that character? I couldn't find any command in cmd.exe +// to do it, but powershell.exe can convert ASCII to characters like this: `[char]34` (where 34 is the code point for "). +// +// Other notes: +// - @ in `@cmd.exe` suppresses echoing it, so you don't get this command printed +// - we need another invocation of cmd.exe (e.g. `do @cmd.exe /c %%aC:\Program Files\Coder\bin\coder.exe%%a`). Without +// it the double-quote gets interpreted as part of the path, and you get: '"C:\Program' is not recognized. +// Constructing the string and then passing it to another instance of cmd.exe does this trick here. +// - OpenSSH passes the `Match exec` command to cmd.exe regardless of whether the user has a unix-like shell like +// git bash, so we don't have a `forceUnixPath` option like for the ProxyCommand which does respect the user's +// configured shell on Windows. +func sshConfigMatchExecEscape(path string) (string, error) { + // This is unlikely to ever happen, but newlines are allowed on + // certain filesystems, but cannot be used inside ssh config. + if strings.ContainsAny(path, "\n") { + return "", xerrors.Errorf("invalid path: %s", path) + } + // Windows does not allow double-quotes or tabs in paths. If we get one it is an error. + if strings.ContainsAny(path, "\"\t") { + return "", xerrors.Errorf("path must not contain quotes or tabs: %q", path) + } + + if strings.ContainsAny(path, " ") { + // c.f. function comment for how this works. + path = fmt.Sprintf("for /f %%%%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%%%a%s%%%%a", path) //nolint:gocritic // We don't want %q here. + } + return path, nil +} diff --git a/cli/exp_rpty_test.go b/cli/exp_rpty_test.go index 355cc1741b5a9..923bf09bb0e15 100644 --- a/cli/exp_rpty_test.go +++ b/cli/exp_rpty_test.go @@ -9,6 +9,7 @@ import ( "github.com/ory/dockertest/v3/docker" "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" @@ -111,6 +112,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"), + ) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() diff --git a/cli/open_test.go b/cli/open_test.go index 97d24f0634d9d..f7180ab260fbd 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -306,8 +306,8 @@ func TestOpenVSCodeDevContainer(t *testing.T) { containerFolder := "/workspace/coder" ctrl := gomock.NewController(t) - mcl := acmock.NewMockLister(ctrl) - mcl.EXPECT().List(gomock.Any()).Return( + mccli := acmock.NewMockContainerCLI(ctrl) + mccli.EXPECT().List(gomock.Any()).Return( codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -337,7 +337,10 @@ func TestOpenVSCodeDevContainer(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mcl)) + o.ContainerAPIOptions = append(o.ContainerAPIOptions, + agentcontainers.WithContainerCLI(mccli), + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + ) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() @@ -481,8 +484,8 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { containerFolder := "/workspace/coder" ctrl := gomock.NewController(t) - mcl := acmock.NewMockLister(ctrl) - mcl.EXPECT().List(gomock.Any()).Return( + mccli := acmock.NewMockContainerCLI(ctrl) + mccli.EXPECT().List(gomock.Any()).Return( codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -511,7 +514,10 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mcl)) + o.ContainerAPIOptions = append(o.ContainerAPIOptions, + agentcontainers.WithContainerCLI(mccli), + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + ) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() diff --git a/cli/server.go b/cli/server.go index 9f55b63fc765c..d9badd02d9fbf 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2359,6 +2359,10 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d if err != nil { return nil, xerrors.Errorf("get postgres version: %w", err) } + defer version.Close() + if version.Err() != nil { + return nil, xerrors.Errorf("version select: %w", version.Err()) + } if !version.Next() { return nil, xerrors.Errorf("no rows returned for version select") } @@ -2367,7 +2371,6 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d if err != nil { return nil, xerrors.Errorf("scan version: %w", err) } - _ = version.Close() if versionNum < 130000 { return nil, xerrors.Errorf("PostgreSQL version must be v13.0.0 or higher! Got: %d", versionNum) diff --git a/cli/ssh.go b/cli/ssh.go index 51f53e10bcbd2..4adbf12cccf7e 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -16,7 +16,6 @@ import ( "path/filepath" "regexp" "slices" - "strconv" "strings" "sync" "time" @@ -31,7 +30,6 @@ import ( "golang.org/x/term" "golang.org/x/xerrors" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" - "tailscale.com/tailcfg" "tailscale.com/types/netlogtype" "cdr.dev/slog" @@ -40,11 +38,13 @@ import ( "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/coderd/autobuild/notify" + "github.com/coder/coder/v2/coderd/util/maps" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/pty" + "github.com/coder/coder/v2/tailnet" "github.com/coder/quartz" "github.com/coder/retry" "github.com/coder/serpent" @@ -1456,28 +1456,6 @@ func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn, } node := agentConn.Node() derpMap := agentConn.DERPMap() - derpLatency := map[string]float64{} - - // Convert DERP region IDs to friendly names for display in the UI. - for rawRegion, latency := range node.DERPLatency { - regionParts := strings.SplitN(rawRegion, "-", 2) - regionID, err := strconv.Atoi(regionParts[0]) - if err != nil { - continue - } - region, found := derpMap.Regions[regionID] - if !found { - // It's possible that a workspace agent is using an old DERPMap - // and reports regions that do not exist. If that's the case, - // report the region as unknown! - region = &tailcfg.DERPRegion{ - RegionID: regionID, - RegionName: fmt.Sprintf("Unnamed %d", regionID), - } - } - // Convert the microseconds to milliseconds. - derpLatency[region.RegionName] = latency * 1000 - } totalRx := uint64(0) totalTx := uint64(0) @@ -1491,27 +1469,20 @@ func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn, uploadSecs := float64(totalTx) / dur.Seconds() downloadSecs := float64(totalRx) / dur.Seconds() - // Sometimes the preferred DERP doesn't match the one we're actually - // connected with. Perhaps because the agent prefers a different DERP and - // we're using that server instead. - preferredDerpID := node.PreferredDERP - if pingResult.DERPRegionID != 0 { - preferredDerpID = pingResult.DERPRegionID - } - preferredDerp, ok := derpMap.Regions[preferredDerpID] - preferredDerpName := fmt.Sprintf("Unnamed %d", preferredDerpID) - if ok { - preferredDerpName = preferredDerp.RegionName - } + preferredDerpName := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + derpLatency := tailnet.ExtractDERPLatency(node, derpMap) if _, ok := derpLatency[preferredDerpName]; !ok { derpLatency[preferredDerpName] = 0 } + derpLatencyMs := maps.Map(derpLatency, func(dur time.Duration) float64 { + return float64(dur) / float64(time.Millisecond) + }) return &sshNetworkStats{ P2P: p2p, Latency: float64(latency.Microseconds()) / 1000, PreferredDERP: preferredDerpName, - DERPLatency: derpLatency, + DERPLatency: derpLatencyMs, UploadBytesSec: int64(uploadSecs), DownloadBytesSec: int64(downloadSecs), }, nil diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 8845200273697..bee075283c083 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -2032,6 +2032,9 @@ func TestSSH_Container(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"), + ) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() @@ -2057,7 +2060,7 @@ func TestSSH_Container(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, workspace, agentToken := setupWorkspaceForAgent(t) ctrl := gomock.NewController(t) - mLister := acmock.NewMockLister(ctrl) + mLister := acmock.NewMockContainerCLI(ctrl) mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -2069,7 +2072,10 @@ func TestSSH_Container(t *testing.T) { }, nil).AnyTimes() _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mLister)) + o.ContainerAPIOptions = append(o.ContainerAPIOptions, + agentcontainers.WithContainerCLI(mLister), + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + ) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index c37c89c4efe2a..5304f2ce262ee 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -15,7 +15,7 @@ "template_allow_user_cancel_workspace_jobs": false, "template_active_version_id": "============[version ID]============", "template_require_active_version": false, - "template_use_classic_parameter_flow": false, + "template_use_classic_parameter_flow": true, "latest_build": { "id": "========[workspace build ID]========", "created_at": "====[timestamp]=====", diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 1cefe8767f3b0..26e63ceb8418f 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -332,6 +332,10 @@ NETWORKING / HTTP OPTIONS: The maximum lifetime duration users can specify when creating an API token. + --max-admin-token-lifetime duration, $CODER_MAX_ADMIN_TOKEN_LIFETIME (default: 168h0m0s) + The maximum lifetime duration administrators can specify when creating + an API token. + --proxy-health-interval duration, $CODER_PROXY_HEALTH_INTERVAL (default: 1m0s) The interval in which coderd should be checking the status of workspace proxies. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 7403819a2d10b..cc064e8fa2d6e 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -25,6 +25,10 @@ networking: # The maximum lifetime duration users can specify when creating an API token. # (default: 876600h0m0s, type: duration) maxTokenLifetime: 876600h0m0s + # The maximum lifetime duration administrators can specify when creating an API + # token. + # (default: 168h0m0s, type: duration) + maxAdminTokenLifetime: 168h0m0s # The token expiry duration for browser sessions. Sessions may last longer if they # are actively making requests, but this functionality can be disabled via # --disable-session-expiry-refresh. diff --git a/cli/vpndaemon_windows.go b/cli/vpndaemon_windows.go index 227bd0fe8e0db..cf74558ffa1ab 100644 --- a/cli/vpndaemon_windows.go +++ b/cli/vpndaemon_windows.go @@ -65,7 +65,6 @@ func (r *RootCmd) vpnDaemonRun() *serpent.Command { logger.Info(ctx, "starting tunnel") tunnel, err := vpn.NewTunnel(ctx, logger, pipe, vpn.NewClient(), vpn.UseOSNetworkingStack(), - vpn.UseAsLogger(), vpn.UseCustomLogSinks(sinks...), ) if err != nil { diff --git a/coderd/agentapi/audit_test.go b/coderd/agentapi/audit_test.go index 8b4ae3ea60f77..b881fde5d22bc 100644 --- a/coderd/agentapi/audit_test.go +++ b/coderd/agentapi/audit_test.go @@ -135,7 +135,7 @@ func TestAuditReport(t *testing.T) { }, }) - mAudit.Contains(t, database.AuditLog{ + require.True(t, mAudit.Contains(t, database.AuditLog{ Time: dbtime.Time(tt.time).In(time.UTC), Action: agentProtoConnectionActionToAudit(t, *tt.action), OrganizationID: workspace.OrganizationID, @@ -146,7 +146,7 @@ func TestAuditReport(t *testing.T) { ResourceTarget: agent.Name, Ip: pqtype.Inet{Valid: true, IPNet: net.IPNet{IP: net.ParseIP(tt.ip), Mask: net.CIDRMask(32, 32)}}, StatusCode: tt.status, - }) + })) // Check some additional fields. var m map[string]any diff --git a/coderd/agentapi/subagent.go b/coderd/agentapi/subagent.go index 3b9ae9674d91e..ae668c96e5b86 100644 --- a/coderd/agentapi/subagent.go +++ b/coderd/agentapi/subagent.go @@ -2,6 +2,9 @@ package agentapi import ( "context" + "database/sql" + "errors" + "fmt" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" @@ -11,6 +14,7 @@ import ( 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" ) @@ -37,14 +41,45 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create agentName := req.Name if agentName == "" { - return nil, xerrors.Errorf("agent name cannot be empty") + return nil, codersdk.ValidationError{ + Field: "name", + Detail: "agent name cannot be empty", + } } if !provisioner.AgentNameRegex.MatchString(agentName) { - return nil, xerrors.Errorf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex.String()) + return nil, codersdk.ValidationError{ + Field: "name", + Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex), + } } createdAt := a.Clock.Now() + displayApps := make([]database.DisplayApp, 0, len(req.DisplayApps)) + for idx, displayApp := range req.DisplayApps { + var app database.DisplayApp + + switch displayApp { + case agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER: + app = database.DisplayAppPortForwardingHelper + case agentproto.CreateSubAgentRequest_SSH_HELPER: + app = database.DisplayAppSSHHelper + case agentproto.CreateSubAgentRequest_VSCODE: + app = database.DisplayAppVscode + case agentproto.CreateSubAgentRequest_VSCODE_INSIDERS: + app = database.DisplayAppVscodeInsiders + case agentproto.CreateSubAgentRequest_WEB_TERMINAL: + app = database.DisplayAppWebTerminal + default: + return nil, codersdk.ValidationError{ + Field: fmt.Sprintf("display_apps[%d]", idx), + Detail: fmt.Sprintf("%q is not a valid display app", displayApp), + } + } + + displayApps = append(displayApps, app) + } + subAgent, err := a.Database.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{ ID: uuid.New(), ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID}, @@ -63,7 +98,7 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create ConnectionTimeoutSeconds: parentAgent.ConnectionTimeoutSeconds, TroubleshootingURL: parentAgent.TroubleshootingURL, MOTDFile: "", - DisplayApps: []database.DisplayApp{}, + DisplayApps: displayApps, DisplayOrder: 0, APIKeyScope: parentAgent.APIKeyScope, }) @@ -71,12 +106,127 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create return nil, xerrors.Errorf("insert sub agent: %w", err) } + var appCreationErrors []*agentproto.CreateSubAgentResponse_AppCreationError + appSlugs := make(map[string]struct{}) + + for i, app := range req.Apps { + err := func() error { + slug := app.Slug + if slug == "" { + return codersdk.ValidationError{ + Field: "slug", + Detail: "must not be empty", + } + } + if !provisioner.AppSlugRegex.MatchString(slug) { + return codersdk.ValidationError{ + Field: "slug", + Detail: fmt.Sprintf("%q does not match regex %q", slug, provisioner.AppSlugRegex), + } + } + if _, exists := appSlugs[slug]; exists { + return codersdk.ValidationError{ + Field: "slug", + Detail: fmt.Sprintf("%q is already in use", slug), + } + } + appSlugs[slug] = struct{}{} + + health := database.WorkspaceAppHealthDisabled + if app.Healthcheck == nil { + app.Healthcheck = &agentproto.CreateSubAgentRequest_App_Healthcheck{} + } + if app.Healthcheck.Url != "" { + health = database.WorkspaceAppHealthInitializing + } + + var sharingLevel database.AppSharingLevel + switch app.GetShare() { + case agentproto.CreateSubAgentRequest_App_OWNER: + sharingLevel = database.AppSharingLevelOwner + case agentproto.CreateSubAgentRequest_App_AUTHENTICATED: + sharingLevel = database.AppSharingLevelAuthenticated + case agentproto.CreateSubAgentRequest_App_PUBLIC: + sharingLevel = database.AppSharingLevelPublic + default: + return codersdk.ValidationError{ + Field: "share", + Detail: fmt.Sprintf("%q is not a valid app sharing level", app.GetShare()), + } + } + + var openIn database.WorkspaceAppOpenIn + switch app.GetOpenIn() { + case agentproto.CreateSubAgentRequest_App_SLIM_WINDOW: + openIn = database.WorkspaceAppOpenInSlimWindow + case agentproto.CreateSubAgentRequest_App_TAB: + openIn = database.WorkspaceAppOpenInTab + default: + return codersdk.ValidationError{ + Field: "open_in", + Detail: fmt.Sprintf("%q is not an open in setting", app.GetOpenIn()), + } + } + + _, err := a.Database.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ + ID: uuid.New(), + CreatedAt: createdAt, + AgentID: subAgent.ID, + Slug: app.Slug, + DisplayName: app.GetDisplayName(), + Icon: app.GetIcon(), + Command: sql.NullString{ + Valid: app.GetCommand() != "", + String: app.GetCommand(), + }, + Url: sql.NullString{ + Valid: app.GetUrl() != "", + String: app.GetUrl(), + }, + External: app.GetExternal(), + Subdomain: app.GetSubdomain(), + SharingLevel: sharingLevel, + HealthcheckUrl: app.Healthcheck.Url, + HealthcheckInterval: app.Healthcheck.Interval, + HealthcheckThreshold: app.Healthcheck.Threshold, + Health: health, + DisplayOrder: app.GetOrder(), + Hidden: app.GetHidden(), + OpenIn: openIn, + DisplayGroup: sql.NullString{ + Valid: app.GetGroup() != "", + String: app.GetGroup(), + }, + }) + if err != nil { + return xerrors.Errorf("insert workspace app: %w", err) + } + + return nil + }() + if err != nil { + appErr := &agentproto.CreateSubAgentResponse_AppCreationError{ + Index: int32(i), //nolint:gosec // This would only overflow if we created 2 billion apps. + Error: err.Error(), + } + + var validationErr codersdk.ValidationError + if errors.As(err, &validationErr) { + appErr.Field = &validationErr.Field + appErr.Error = validationErr.Detail + } + + appCreationErrors = append(appCreationErrors, appErr) + } + } + return &agentproto.CreateSubAgentResponse{ Agent: &agentproto.SubAgent{ Name: subAgent.Name, Id: subAgent.ID[:], AuthToken: subAgent.AuthToken[:], }, + AppCreationErrors: appCreationErrors, }, nil } diff --git a/coderd/agentapi/subagent_test.go b/coderd/agentapi/subagent_test.go index 2ca1b35451945..cd7c892189fa5 100644 --- a/coderd/agentapi/subagent_test.go +++ b/coderd/agentapi/subagent_test.go @@ -21,6 +21,8 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "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/testutil" "github.com/coder/quartz" ) @@ -92,12 +94,12 @@ func TestSubAgentAPI(t *testing.T) { t.Parallel() tests := []struct { - name string - agentName string - agentDir string - agentArch string - agentOS string - shouldErr bool + name string + agentName string + agentDir string + agentArch string + agentOS string + expectedError *codersdk.ValidationError }{ { name: "Ok", @@ -112,7 +114,10 @@ func TestSubAgentAPI(t *testing.T) { agentDir: "/workspaces/wibble", agentArch: "amd64", agentOS: "linux", - shouldErr: true, + expectedError: &codersdk.ValidationError{ + Field: "name", + Detail: "agent name \"some_child_agent\" does not match regex \"(?i)^[a-z0-9](-?[a-z0-9])*$\"", + }, }, { name: "EmptyName", @@ -120,7 +125,10 @@ func TestSubAgentAPI(t *testing.T) { agentDir: "/workspaces/wibble", agentArch: "amd64", agentOS: "linux", - shouldErr: true, + expectedError: &codersdk.ValidationError{ + Field: "name", + Detail: "agent name cannot be empty", + }, }, } @@ -142,8 +150,11 @@ func TestSubAgentAPI(t *testing.T) { Architecture: tt.agentArch, OperatingSystem: tt.agentOS, }) - if tt.shouldErr { + if tt.expectedError != nil { require.Error(t, err) + var validationErr codersdk.ValidationError + require.ErrorAs(t, err, &validationErr) + require.Equal(t, *tt.expectedError, validationErr) } else { require.NoError(t, err) @@ -164,6 +175,590 @@ func TestSubAgentAPI(t *testing.T) { } }) + type expectedAppError struct { + index int32 + field string + error string + } + + t.Run("CreateSubAgentWithApps", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + apps []*proto.CreateSubAgentRequest_App + expectApps []database.WorkspaceApp + expectedAppErrors []expectedAppError + }{ + { + name: "OK", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "code-server", + DisplayName: ptr.Ref("VS Code"), + Icon: ptr.Ref("/icon/code.svg"), + Url: ptr.Ref("http://localhost:13337"), + Share: proto.CreateSubAgentRequest_App_OWNER.Enum(), + Subdomain: ptr.Ref(false), + OpenIn: proto.CreateSubAgentRequest_App_SLIM_WINDOW.Enum(), + Healthcheck: &proto.CreateSubAgentRequest_App_Healthcheck{ + Interval: 5, + Threshold: 6, + Url: "http://localhost:13337/healthz", + }, + }, + { + Slug: "vim", + Command: ptr.Ref("vim"), + DisplayName: ptr.Ref("Vim"), + Icon: ptr.Ref("/icon/vim.svg"), + }, + }, + expectApps: []database.WorkspaceApp{ + { + Slug: "code-server", + DisplayName: "VS Code", + Icon: "/icon/code.svg", + Command: sql.NullString{}, + Url: sql.NullString{Valid: true, String: "http://localhost:13337"}, + HealthcheckUrl: "http://localhost:13337/healthz", + HealthcheckInterval: 5, + HealthcheckThreshold: 6, + Health: database.WorkspaceAppHealthInitializing, + Subdomain: false, + SharingLevel: database.AppSharingLevelOwner, + External: false, + DisplayOrder: 0, + Hidden: false, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + DisplayGroup: sql.NullString{}, + }, + { + Slug: "vim", + DisplayName: "Vim", + Icon: "/icon/vim.svg", + Command: sql.NullString{Valid: true, String: "vim"}, + Health: database.WorkspaceAppHealthDisabled, + SharingLevel: database.AppSharingLevelOwner, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + }, + }, + { + name: "EmptyAppSlug", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "", + DisplayName: ptr.Ref("App"), + }, + }, + expectApps: []database.WorkspaceApp{}, + expectedAppErrors: []expectedAppError{ + { + index: 0, + field: "slug", + error: "must not be empty", + }, + }, + }, + { + name: "InvalidAppSlugWithUnderscores", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "invalid_slug_with_underscores", + DisplayName: ptr.Ref("App"), + }, + }, + expectApps: []database.WorkspaceApp{}, + expectedAppErrors: []expectedAppError{ + { + index: 0, + field: "slug", + error: "\"invalid_slug_with_underscores\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", + }, + }, + }, + { + name: "InvalidAppSlugWithUppercase", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "InvalidSlug", + DisplayName: ptr.Ref("App"), + }, + }, + expectApps: []database.WorkspaceApp{}, + expectedAppErrors: []expectedAppError{ + { + index: 0, + field: "slug", + error: "\"InvalidSlug\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", + }, + }, + }, + { + name: "InvalidAppSlugStartsWithHyphen", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "-invalid-app", + DisplayName: ptr.Ref("App"), + }, + }, + expectApps: []database.WorkspaceApp{}, + expectedAppErrors: []expectedAppError{ + { + index: 0, + field: "slug", + error: "\"-invalid-app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", + }, + }, + }, + { + name: "InvalidAppSlugEndsWithHyphen", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "invalid-app-", + DisplayName: ptr.Ref("App"), + }, + }, + expectApps: []database.WorkspaceApp{}, + expectedAppErrors: []expectedAppError{ + { + index: 0, + field: "slug", + error: "\"invalid-app-\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", + }, + }, + }, + { + name: "InvalidAppSlugWithDoubleHyphens", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "invalid--app", + DisplayName: ptr.Ref("App"), + }, + }, + expectApps: []database.WorkspaceApp{}, + expectedAppErrors: []expectedAppError{ + { + index: 0, + field: "slug", + error: "\"invalid--app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", + }, + }, + }, + { + name: "InvalidAppSlugWithSpaces", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "invalid app", + DisplayName: ptr.Ref("App"), + }, + }, + expectApps: []database.WorkspaceApp{}, + expectedAppErrors: []expectedAppError{ + { + index: 0, + field: "slug", + error: "\"invalid app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", + }, + }, + }, + { + name: "MultipleAppsWithErrorInSecond", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "valid-app", + DisplayName: ptr.Ref("Valid App"), + }, + { + Slug: "Invalid_App", + DisplayName: ptr.Ref("Invalid App"), + }, + }, + expectApps: []database.WorkspaceApp{ + { + Slug: "valid-app", + DisplayName: "Valid App", + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + }, + expectedAppErrors: []expectedAppError{ + { + index: 1, + field: "slug", + error: "\"Invalid_App\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", + }, + }, + }, + { + name: "AppWithAllSharingLevels", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "owner-app", + Share: proto.CreateSubAgentRequest_App_OWNER.Enum(), + }, + { + Slug: "authenticated-app", + Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(), + }, + { + Slug: "public-app", + Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(), + }, + }, + expectApps: []database.WorkspaceApp{ + { + Slug: "authenticated-app", + SharingLevel: database.AppSharingLevelAuthenticated, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + { + Slug: "owner-app", + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + { + Slug: "public-app", + SharingLevel: database.AppSharingLevelPublic, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + }, + }, + { + name: "AppWithDifferentOpenInOptions", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "window-app", + OpenIn: proto.CreateSubAgentRequest_App_SLIM_WINDOW.Enum(), + }, + { + Slug: "tab-app", + OpenIn: proto.CreateSubAgentRequest_App_TAB.Enum(), + }, + }, + expectApps: []database.WorkspaceApp{ + { + Slug: "tab-app", + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInTab, + }, + { + Slug: "window-app", + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + }, + }, + { + name: "AppWithAllOptionalFields", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "full-app", + Command: ptr.Ref("echo hello"), + DisplayName: ptr.Ref("Full Featured App"), + External: ptr.Ref(true), + Group: ptr.Ref("Development"), + Hidden: ptr.Ref(true), + Icon: ptr.Ref("/icon/app.svg"), + Order: ptr.Ref(int32(10)), + Subdomain: ptr.Ref(true), + Url: ptr.Ref("http://localhost:8080"), + Healthcheck: &proto.CreateSubAgentRequest_App_Healthcheck{ + Interval: 30, + Threshold: 3, + Url: "http://localhost:8080/health", + }, + }, + }, + expectApps: []database.WorkspaceApp{ + { + Slug: "full-app", + Command: sql.NullString{Valid: true, String: "echo hello"}, + DisplayName: "Full Featured App", + External: true, + DisplayGroup: sql.NullString{Valid: true, String: "Development"}, + Hidden: true, + Icon: "/icon/app.svg", + DisplayOrder: 10, + Subdomain: true, + Url: sql.NullString{Valid: true, String: "http://localhost:8080"}, + HealthcheckUrl: "http://localhost:8080/health", + HealthcheckInterval: 30, + HealthcheckThreshold: 3, + Health: database.WorkspaceAppHealthInitializing, + SharingLevel: database.AppSharingLevelOwner, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + }, + }, + { + name: "AppWithoutHealthcheck", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "no-health-app", + }, + }, + expectApps: []database.WorkspaceApp{ + { + Slug: "no-health-app", + Health: database.WorkspaceAppHealthDisabled, + SharingLevel: database.AppSharingLevelOwner, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + HealthcheckUrl: "", + HealthcheckInterval: 0, + HealthcheckThreshold: 0, + }, + }, + }, + { + name: "DuplicateAppSlugs", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "duplicate-app", + DisplayName: ptr.Ref("First App"), + }, + { + Slug: "duplicate-app", + DisplayName: ptr.Ref("Second App"), + }, + }, + expectApps: []database.WorkspaceApp{ + { + Slug: "duplicate-app", + DisplayName: "First App", + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + }, + expectedAppErrors: []expectedAppError{ + { + index: 1, + field: "slug", + error: "\"duplicate-app\" is already in use", + }, + }, + }, + { + name: "MultipleDuplicateAppSlugs", + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "valid-app", + DisplayName: ptr.Ref("Valid App"), + }, + { + Slug: "duplicate-app", + DisplayName: ptr.Ref("First Duplicate"), + }, + { + Slug: "duplicate-app", + DisplayName: ptr.Ref("Second Duplicate"), + }, + { + Slug: "duplicate-app", + DisplayName: ptr.Ref("Third Duplicate"), + }, + }, + expectApps: []database.WorkspaceApp{ + { + Slug: "duplicate-app", + DisplayName: "First Duplicate", + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + { + Slug: "valid-app", + DisplayName: "Valid App", + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }, + }, + expectedAppErrors: []expectedAppError{ + { + index: 2, + field: "slug", + error: "\"duplicate-app\" is already in use", + }, + { + index: 3, + field: "slug", + error: "\"duplicate-app\" is already in use", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + log := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + + db, org := newDatabaseWithOrg(t) + user, agent := newUserWithWorkspaceAgent(t, db, org) + api := newAgentAPI(t, log, db, clock, user, org, agent) + + createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ + Name: "child-agent", + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + Apps: tt.apps, + }) + require.NoError(t, err) + + agentID, err := uuid.FromBytes(createResp.Agent.Id) + require.NoError(t, err) + + apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + require.NoError(t, err) + + // Sort the apps for determinism + slices.SortFunc(apps, func(a, b database.WorkspaceApp) int { + return cmp.Compare(a.Slug, b.Slug) + }) + slices.SortFunc(tt.expectApps, func(a, b database.WorkspaceApp) int { + return cmp.Compare(a.Slug, b.Slug) + }) + + require.Len(t, apps, len(tt.expectApps)) + + for idx, app := range apps { + assert.Equal(t, tt.expectApps[idx].Slug, app.Slug) + assert.Equal(t, tt.expectApps[idx].Command, app.Command) + assert.Equal(t, tt.expectApps[idx].DisplayName, app.DisplayName) + assert.Equal(t, tt.expectApps[idx].External, app.External) + assert.Equal(t, tt.expectApps[idx].DisplayGroup, app.DisplayGroup) + assert.Equal(t, tt.expectApps[idx].HealthcheckInterval, app.HealthcheckInterval) + assert.Equal(t, tt.expectApps[idx].HealthcheckThreshold, app.HealthcheckThreshold) + assert.Equal(t, tt.expectApps[idx].HealthcheckUrl, app.HealthcheckUrl) + assert.Equal(t, tt.expectApps[idx].Hidden, app.Hidden) + assert.Equal(t, tt.expectApps[idx].Icon, app.Icon) + assert.Equal(t, tt.expectApps[idx].OpenIn, app.OpenIn) + assert.Equal(t, tt.expectApps[idx].DisplayOrder, app.DisplayOrder) + assert.Equal(t, tt.expectApps[idx].SharingLevel, app.SharingLevel) + assert.Equal(t, tt.expectApps[idx].Subdomain, app.Subdomain) + assert.Equal(t, tt.expectApps[idx].Url, app.Url) + } + + // Verify expected app creation errors + require.Len(t, createResp.AppCreationErrors, len(tt.expectedAppErrors), "Number of app creation errors should match expected") + + // Build a map of actual errors by index for easier testing + actualErrorMap := make(map[int32]*proto.CreateSubAgentResponse_AppCreationError) + for _, appErr := range createResp.AppCreationErrors { + actualErrorMap[appErr.Index] = appErr + } + + // Verify each expected error + for _, expectedErr := range tt.expectedAppErrors { + actualErr, exists := actualErrorMap[expectedErr.index] + require.True(t, exists, "Expected app creation error at index %d", expectedErr.index) + + require.NotNil(t, actualErr.Field, "Field should be set for validation error at index %d", expectedErr.index) + require.Equal(t, expectedErr.field, *actualErr.Field, "Field name should match for error at index %d", expectedErr.index) + require.Contains(t, actualErr.Error, expectedErr.error, "Error message should contain expected text for error at index %d", expectedErr.index) + } + }) + } + + t.Run("ValidationErrorFieldMapping", func(t *testing.T) { + t.Parallel() + + log := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + + db, org := newDatabaseWithOrg(t) + user, agent := newUserWithWorkspaceAgent(t, db, org) + api := newAgentAPI(t, log, db, clock, user, org, agent) + + // Test different types of validation errors to ensure field mapping works correctly + createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ + Name: "validation-test-agent", + Directory: "/workspace", + Architecture: "amd64", + OperatingSystem: "linux", + Apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "", // Empty slug - should error on apps[0].slug + DisplayName: ptr.Ref("Empty Slug App"), + }, + { + Slug: "Invalid_Slug_With_Underscores", // Invalid characters - should error on apps[1].slug + DisplayName: ptr.Ref("Invalid Characters App"), + }, + { + Slug: "duplicate-slug", // First occurrence - should succeed + DisplayName: ptr.Ref("First Duplicate"), + }, + { + Slug: "duplicate-slug", // Duplicate - should error on apps[3].slug + DisplayName: ptr.Ref("Second Duplicate"), + }, + { + Slug: "-invalid-start", // Invalid start character - should error on apps[4].slug + DisplayName: ptr.Ref("Invalid Start App"), + }, + }, + }) + + // Agent should be created successfully + require.NoError(t, err) + require.NotNil(t, createResp.Agent) + + // Should have 4 app creation errors (indices 0, 1, 3, 4) + require.Len(t, createResp.AppCreationErrors, 4) + + errorMap := make(map[int32]*proto.CreateSubAgentResponse_AppCreationError) + for _, appErr := range createResp.AppCreationErrors { + errorMap[appErr.Index] = appErr + } + + // Verify each specific validation error and its field + require.Contains(t, errorMap, int32(0)) + require.NotNil(t, errorMap[0].Field) + require.Equal(t, "slug", *errorMap[0].Field) + require.Contains(t, errorMap[0].Error, "must not be empty") + + require.Contains(t, errorMap, int32(1)) + require.NotNil(t, errorMap[1].Field) + require.Equal(t, "slug", *errorMap[1].Field) + require.Contains(t, errorMap[1].Error, "Invalid_Slug_With_Underscores") + + require.Contains(t, errorMap, int32(3)) + require.NotNil(t, errorMap[3].Field) + require.Equal(t, "slug", *errorMap[3].Field) + require.Contains(t, errorMap[3].Error, "duplicate-slug") + + require.Contains(t, errorMap, int32(4)) + require.NotNil(t, errorMap[4].Field) + require.Equal(t, "slug", *errorMap[4].Field) + require.Contains(t, errorMap[4].Error, "-invalid-start") + + // Verify only the valid app (index 2) was created + agentID, err := uuid.FromBytes(createResp.Agent.Id) + require.NoError(t, err) + + 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, "First Duplicate", apps[0].DisplayName) + }) + }) + t.Run("DeleteSubAgent", func(t *testing.T) { t.Parallel() @@ -279,6 +874,267 @@ func TestSubAgentAPI(t *testing.T) { _, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) //nolint:gocritic // this is a test. require.NoError(t, err) }) + + t.Run("DeletesWorkspaceApps", func(t *testing.T) { + t.Parallel() + + // Skip test on in-memory database since CASCADE DELETE is not implemented + if !dbtestutil.WillUsePostgres() { + t.Skip("CASCADE DELETE behavior requires PostgreSQL") + } + + log := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + + db, org := newDatabaseWithOrg(t) + user, agent := newUserWithWorkspaceAgent(t, db, org) + api := newAgentAPI(t, log, db, clock, user, org, agent) + + // Given: A sub agent with workspace apps + createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ + Name: "child-agent-with-apps", + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + Apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "code-server", + DisplayName: ptr.Ref("VS Code"), + Icon: ptr.Ref("/icon/code.svg"), + Url: ptr.Ref("http://localhost:13337"), + }, + { + Slug: "vim", + Command: ptr.Ref("vim"), + DisplayName: ptr.Ref("Vim"), + }, + }, + }) + require.NoError(t, err) + + subAgentID, err := uuid.FromBytes(createResp.Agent.Id) + require.NoError(t, err) + + // Verify that the apps were created + apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), subAgentID) //nolint:gocritic // this is a test. + require.NoError(t, err) + require.Len(t, apps, 2) + + // When: We delete the sub agent + _, err = api.DeleteSubAgent(ctx, &proto.DeleteSubAgentRequest{ + Id: createResp.Agent.Id, + }) + require.NoError(t, err) + + // Then: The agent is deleted + _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), subAgentID) //nolint:gocritic // this is a test. + require.ErrorIs(t, err, sql.ErrNoRows) + + // And: The apps are also deleted (due to CASCADE DELETE) + // Use raw database since authorization layer requires agent to exist + appsAfterDeletion, err := db.GetWorkspaceAppsByAgentID(ctx, subAgentID) + require.NoError(t, err) + require.Empty(t, appsAfterDeletion) + }) + }) + + t.Run("CreateSubAgentWithDisplayApps", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + displayApps []proto.CreateSubAgentRequest_DisplayApp + expectedApps []database.DisplayApp + expectedError *codersdk.ValidationError + }{ + { + name: "NoDisplayApps", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{}, + expectedApps: []database.DisplayApp{}, + }, + { + name: "SingleDisplayApp_VSCode", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppVscode, + }, + }, + { + name: "SingleDisplayApp_VSCodeInsiders", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE_INSIDERS, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppVscodeInsiders, + }, + }, + { + name: "SingleDisplayApp_WebTerminal", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_WEB_TERMINAL, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppWebTerminal, + }, + }, + { + name: "SingleDisplayApp_SSHHelper", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_SSH_HELPER, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppSSHHelper, + }, + }, + { + name: "SingleDisplayApp_PortForwardingHelper", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppPortForwardingHelper, + }, + }, + { + name: "MultipleDisplayApps", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE, + proto.CreateSubAgentRequest_WEB_TERMINAL, + proto.CreateSubAgentRequest_SSH_HELPER, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppVscode, + database.DisplayAppWebTerminal, + database.DisplayAppSSHHelper, + }, + }, + { + name: "AllDisplayApps", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE, + proto.CreateSubAgentRequest_VSCODE_INSIDERS, + proto.CreateSubAgentRequest_WEB_TERMINAL, + proto.CreateSubAgentRequest_SSH_HELPER, + proto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppVscode, + database.DisplayAppVscodeInsiders, + database.DisplayAppWebTerminal, + database.DisplayAppSSHHelper, + database.DisplayAppPortForwardingHelper, + }, + }, + { + name: "InvalidDisplayApp", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_DisplayApp(9999), // Invalid enum value + }, + expectedError: &codersdk.ValidationError{ + Field: "display_apps[0]", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + log := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitLong) + clock := quartz.NewMock(t) + + db, org := newDatabaseWithOrg(t) + user, agent := newUserWithWorkspaceAgent(t, db, org) + api := newAgentAPI(t, log, db, clock, user, org, agent) + + createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ + Name: "test-agent", + Directory: "/workspaces/test", + Architecture: "amd64", + OperatingSystem: "linux", + DisplayApps: tt.displayApps, + }) + if tt.expectedError != nil { + require.Error(t, err) + require.Nil(t, createResp) + + var validationErr codersdk.ValidationError + require.ErrorAs(t, err, &validationErr) + require.Equal(t, tt.expectedError.Field, validationErr.Field) + require.Contains(t, validationErr.Detail, "is not a valid display app") + } else { + require.NoError(t, err) + require.NotNil(t, createResp.Agent) + + agentID, err := uuid.FromBytes(createResp.Agent.Id) + require.NoError(t, err) + + subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + require.NoError(t, err) + + require.Equal(t, len(tt.expectedApps), len(subAgent.DisplayApps), "display apps count mismatch") + + for i, expectedApp := range tt.expectedApps { + require.Equal(t, expectedApp, subAgent.DisplayApps[i], "display app at index %d doesn't match", i) + } + } + }) + } + }) + + t.Run("CreateSubAgentWithDisplayAppsAndApps", func(t *testing.T) { + t.Parallel() + + log := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitLong) + clock := quartz.NewMock(t) + + db, org := newDatabaseWithOrg(t) + user, agent := newUserWithWorkspaceAgent(t, db, org) + api := newAgentAPI(t, log, db, clock, user, org, agent) + + // Test that display apps and regular apps can coexist + createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ + Name: "test-agent", + Directory: "/workspaces/test", + Architecture: "amd64", + OperatingSystem: "linux", + DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE, + proto.CreateSubAgentRequest_WEB_TERMINAL, + }, + Apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "custom-app", + DisplayName: ptr.Ref("Custom App"), + Url: ptr.Ref("http://localhost:8080"), + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, createResp.Agent) + require.Empty(t, createResp.AppCreationErrors) + + agentID, err := uuid.FromBytes(createResp.Agent.Id) + require.NoError(t, err) + + // Verify display apps + subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + require.NoError(t, err) + require.Len(t, subAgent.DisplayApps, 2) + require.Equal(t, database.DisplayAppVscode, subAgent.DisplayApps[0]) + require.Equal(t, database.DisplayAppWebTerminal, subAgent.DisplayApps[1]) + + // Verify regular apps + 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, "Custom App", apps[0].DisplayName) }) t.Run("ListSubAgents", func(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 07a0407c0014d..5dc293e2e706e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12745,7 +12745,6 @@ const docTemplate = `{ "notifications", "workspace-usage", "web-push", - "dynamic-parameters", "workspace-prebuilds", "agentic-chat", "ai-tasks" @@ -12754,7 +12753,6 @@ const docTemplate = `{ "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.", - "ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.", "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.", @@ -12767,7 +12765,6 @@ const docTemplate = `{ "ExperimentNotifications", "ExperimentWorkspaceUsage", "ExperimentWebPush", - "ExperimentDynamicParameters", "ExperimentWorkspacePrebuilds", "ExperimentAgenticChat", "ExperimentAITasks" @@ -15705,6 +15702,9 @@ const docTemplate = `{ "description": "DisableExpiryRefresh will disable automatically refreshing api\nkeys when they are used from the api. This means the api key lifetime at\ncreation is the lifetime of the api key.", "type": "boolean" }, + "max_admin_token_lifetime": { + "type": "integer" + }, "max_token_lifetime": { "type": "integer" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 076f170d27e72..ff48e99d393fc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11438,7 +11438,6 @@ "notifications", "workspace-usage", "web-push", - "dynamic-parameters", "workspace-prebuilds", "agentic-chat", "ai-tasks" @@ -11447,7 +11446,6 @@ "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.", - "ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.", "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.", @@ -11460,7 +11458,6 @@ "ExperimentNotifications", "ExperimentWorkspaceUsage", "ExperimentWebPush", - "ExperimentDynamicParameters", "ExperimentWorkspacePrebuilds", "ExperimentAgenticChat", "ExperimentAITasks" @@ -14283,6 +14280,9 @@ "description": "DisableExpiryRefresh will disable automatically refreshing api\nkeys when they are used from the api. This means the api key lifetime at\ncreation is the lifetime of the api key.", "type": "boolean" }, + "max_admin_token_lifetime": { + "type": "integer" + }, "max_token_lifetime": { "type": "integer" } diff --git a/coderd/apikey.go b/coderd/apikey.go index ddcf7767719e5..895be440ef930 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/codersdk" @@ -75,7 +76,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { } if createToken.Lifetime != 0 { - err := api.validateAPIKeyLifetime(createToken.Lifetime) + err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to validate create API key request.", @@ -338,35 +339,69 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) { // @Success 200 {object} codersdk.TokenConfig // @Router /users/{user}/keys/tokens/tokenconfig [get] func (api *API) tokenConfig(rw http.ResponseWriter, r *http.Request) { - values, err := api.DeploymentValues.WithoutSecrets() + user := httpmw.UserParam(r) + maxLifetime, err := api.getMaxTokenLifetime(r.Context(), user.ID) if err != nil { - httpapi.InternalServerError(rw, err) + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get token configuration.", + Detail: err.Error(), + }) return } httpapi.Write( r.Context(), rw, http.StatusOK, codersdk.TokenConfig{ - MaxTokenLifetime: values.Sessions.MaximumTokenDuration.Value(), + MaxTokenLifetime: maxLifetime, }, ) } -func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error { +func (api *API) validateAPIKeyLifetime(ctx context.Context, userID uuid.UUID, lifetime time.Duration) error { if lifetime <= 0 { return xerrors.New("lifetime must be positive number greater than 0") } - if lifetime > api.DeploymentValues.Sessions.MaximumTokenDuration.Value() { + maxLifetime, err := api.getMaxTokenLifetime(ctx, userID) + if err != nil { + return xerrors.Errorf("failed to get max token lifetime: %w", err) + } + + if lifetime > maxLifetime { return xerrors.Errorf( "lifetime must be less than %v", - api.DeploymentValues.Sessions.MaximumTokenDuration, + maxLifetime, ) } return nil } +// getMaxTokenLifetime returns the maximum allowed token lifetime for a user. +// It distinguishes between regular users and owners. +func (api *API) getMaxTokenLifetime(ctx context.Context, userID uuid.UUID) (time.Duration, error) { + subject, _, err := httpmw.UserRBACSubject(ctx, api.Database, userID, rbac.ScopeAll) + if err != nil { + return 0, xerrors.Errorf("failed to get user rbac subject: %w", err) + } + + roles, err := subject.Roles.Expand() + if err != nil { + return 0, xerrors.Errorf("failed to expand user roles: %w", err) + } + + maxLifetime := api.DeploymentValues.Sessions.MaximumTokenDuration.Value() + for _, role := range roles { + if role.Identifier.Name == codersdk.RoleOwner { + // Owners have a different max lifetime. + maxLifetime = api.DeploymentValues.Sessions.MaximumAdminTokenDuration.Value() + break + } + } + + return maxLifetime, nil +} + func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (*http.Cookie, *database.APIKey, error) { key, sessionToken, err := apikey.Generate(params) if err != nil { diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 43e3325339983..dbf5a3520a6f0 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -144,6 +144,88 @@ func TestTokenUserSetMaxLifetime(t *testing.T) { require.ErrorContains(t, err, "lifetime must be less") } +func TestTokenAdminSetMaxLifetime(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + dc := coderdtest.DeploymentValues(t) + dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 7) + dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(time.Hour * 24 * 14) + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: dc, + }) + adminUser := coderdtest.CreateFirstUser(t, client) + nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID) + + // Admin should be able to create a token with a lifetime longer than the non-admin max. + _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 10, + }) + require.NoError(t, err) + + // Admin should NOT be able to create a token with a lifetime longer than the admin max. + _, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 15, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "lifetime must be less") + + // Non-admin should NOT be able to create a token with a lifetime longer than the non-admin max. + _, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 8, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "lifetime must be less") + + // Non-admin should be able to create a token with a lifetime shorter than the non-admin max. + _, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 6, + }) + require.NoError(t, err) +} + +func TestTokenAdminSetMaxLifetimeShorter(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + dc := coderdtest.DeploymentValues(t) + dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 14) + dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(time.Hour * 24 * 7) + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: dc, + }) + adminUser := coderdtest.CreateFirstUser(t, client) + nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID) + + // Admin should NOT be able to create a token with a lifetime longer than the admin max. + _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 8, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "lifetime must be less") + + // Admin should be able to create a token with a lifetime shorter than the admin max. + _, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 6, + }) + require.NoError(t, err) + + // Non-admin should be able to create a token with a lifetime longer than the admin max. + _, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 10, + }) + require.NoError(t, err) + + // Non-admin should NOT be able to create a token with a lifetime longer than the non-admin max. + _, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 15, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "lifetime must be less") +} + func TestTokenCustomDefaultLifetime(t *testing.T) { t.Parallel() diff --git a/coderd/coderd.go b/coderd/coderd.go index 0b8a13befde56..8cc5435542189 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1153,9 +1153,6 @@ func New(options *Options) *API { }) r.Group(func(r chi.Router) { - r.Use( - httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentDynamicParameters), - ) r.Route("/dynamic-parameters", func(r chi.Router) { r.Post("/evaluate", api.templateVersionDynamicParametersEvaluate) r.Get("/", api.templateVersionDynamicParametersWebsocket) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 40b1423a0f730..4a7871f21d15d 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -47,14 +47,6 @@ func ListLazy[F any, T any](convert func(F) T) func(list []F) []T { } } -func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T { - into := make(map[K]T) - for k, item := range params { - into[k] = convert(item) - } - return into -} - type ExternalAuthMeta struct { Authenticated bool ValidateError string @@ -386,6 +378,7 @@ func WorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, workspaceAgent := codersdk.WorkspaceAgent{ ID: dbAgent.ID, + ParentID: dbAgent.ParentID, CreatedAt: dbAgent.CreatedAt, UpdatedAt: dbAgent.UpdatedAt, ResourceID: dbAgent.ResourceID, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5290d65823117..5bfa015af3d78 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -333,7 +333,7 @@ var ( orgID.String(): {}, }, User: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionCreateAgent, policy.ActionDeleteAgent}, + rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreateAgent, policy.ActionDeleteAgent}, }), }, }), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index f838a93d24c78..cc63844ce16a3 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9345,6 +9345,7 @@ func (q *FakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl AllowUserAutostart: true, AllowUserAutostop: true, MaxPortSharingLevel: arg.MaxPortSharingLevel, + UseClassicParameterFlow: true, } q.templates = append(q.templates, template) return nil diff --git a/coderd/database/dbtestutil/db.go b/coderd/database/dbtestutil/db.go index c76be1ed52a9d..fa3567c490826 100644 --- a/coderd/database/dbtestutil/db.go +++ b/coderd/database/dbtestutil/db.go @@ -298,7 +298,7 @@ func PGDumpSchemaOnly(dbURL string) ([]byte, error) { "run", "--rm", "--network=host", - fmt.Sprintf("gcr.io/coder-dev-1/postgres:%d", minimumPostgreSQLVersion), + fmt.Sprintf("%s:%d", postgresImage, minimumPostgreSQLVersion), }, cmdArgs...) } cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) //#nosec diff --git a/coderd/database/dbtestutil/postgres.go b/coderd/database/dbtestutil/postgres.go index c0b35a03529ca..e282da583a43b 100644 --- a/coderd/database/dbtestutil/postgres.go +++ b/coderd/database/dbtestutil/postgres.go @@ -26,6 +26,8 @@ import ( "github.com/coder/retry" ) +const postgresImage = "us-docker.pkg.dev/coder-v2-images-public/public/postgres" + type ConnectionParams struct { Username string Password string @@ -379,8 +381,8 @@ func openContainer(t TBSubset, opts DBContainerOptions) (container, func(), erro return container{}, nil, xerrors.Errorf("create tempdir: %w", err) } runOptions := dockertest.RunOptions{ - Repository: "gcr.io/coder-dev-1/postgres", - Tag: "13", + Repository: postgresImage, + Tag: strconv.Itoa(minimumPostgreSQLVersion), Env: []string{ "POSTGRES_PASSWORD=postgres", "POSTGRES_USER=postgres", diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 22a0b3d5a8adc..e4cee2333efc4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1626,7 +1626,7 @@ CREATE TABLE templates ( deprecated text DEFAULT ''::text NOT NULL, activity_bump bigint DEFAULT '3600000000000'::bigint NOT NULL, max_port_sharing_level app_sharing_level DEFAULT 'owner'::app_sharing_level NOT NULL, - use_classic_parameter_flow boolean DEFAULT false NOT NULL + use_classic_parameter_flow boolean DEFAULT true NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; diff --git a/coderd/database/migrations/000334_dynamic_parameters_opt_out.down.sql b/coderd/database/migrations/000334_dynamic_parameters_opt_out.down.sql new file mode 100644 index 0000000000000..d18fcc87e87da --- /dev/null +++ b/coderd/database/migrations/000334_dynamic_parameters_opt_out.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE templates ALTER COLUMN use_classic_parameter_flow SET DEFAULT false; + +UPDATE templates SET use_classic_parameter_flow = false diff --git a/coderd/database/migrations/000334_dynamic_parameters_opt_out.up.sql b/coderd/database/migrations/000334_dynamic_parameters_opt_out.up.sql new file mode 100644 index 0000000000000..342275f64ad9c --- /dev/null +++ b/coderd/database/migrations/000334_dynamic_parameters_opt_out.up.sql @@ -0,0 +1,4 @@ +-- All templates should opt out of dynamic parameters by default. +ALTER TABLE templates ALTER COLUMN use_classic_parameter_flow SET DEFAULT true; + +UPDATE templates SET use_classic_parameter_flow = true diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 65dc9e6267310..cd843bd97aa7a 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -283,7 +283,7 @@ func TestMigrateUpWithFixtures(t *testing.T) { if len(emptyTables) > 0 { t.Log("The following tables have zero rows, consider adding fixtures for them or create a full database dump:") t.Errorf("tables have zero rows: %v", emptyTables) - t.Log("See https://github.com/coder/coder/blob/main/docs/CONTRIBUTING.md#database-fixtures-for-testing-migrations for more information") + t.Log("See https://github.com/coder/coder/blob/main/docs/about/contributing/backend.md#database-fixtures-for-testing-migrations for more information") } }) diff --git a/coderd/database/pubsub/pubsub.go b/coderd/database/pubsub/pubsub.go index 8019754e15bd9..c4b454abdfbda 100644 --- a/coderd/database/pubsub/pubsub.go +++ b/coderd/database/pubsub/pubsub.go @@ -552,15 +552,14 @@ func (p *PGPubsub) startListener(ctx context.Context, connectURL string) error { sentErrCh = true }), } - select { - case err = <-errCh: - if err != nil { - _ = p.pgListener.Close() - return xerrors.Errorf("create pq listener: %w", err) - } - case <-ctx.Done(): + // We don't respect context cancellation here. There's a bug in the pq library + // where if you close the listener before or while the connection is being + // established, the connection will be established anyway, and will not be + // closed. + // https://github.com/lib/pq/issues/1192 + if err := <-errCh; err != nil { _ = p.pgListener.Close() - return ctx.Err() + return xerrors.Errorf("create pq listener: %w", err) } return nil } diff --git a/coderd/files/cache.go b/coderd/files/cache.go index 48587eb402351..92b8ea33ed52f 100644 --- a/coderd/files/cache.go +++ b/coderd/files/cache.go @@ -134,7 +134,8 @@ type fetcher func(context.Context, uuid.UUID) (cacheEntryValue, error) // calls for the same fileID will only result in one fetch, and that parallel // calls for distinct fileIDs will fetch in parallel. // -// Every call to Acquire must have a matching call to Release. +// 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) (fs.FS, error) { // It's important that this `Load` call occurs outside of `prepare`, after the // mutex has been released, or we would continue to hold the lock until the diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index bc357bf2e35f2..4bb3f9ec953aa 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -221,7 +221,9 @@ func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) defer encoder.Close(websocket.StatusNormalClosure) // Log the request immediately instead of after it completes. - loggermw.RequestLoggerFromContext(ctx).WriteLog(ctx, http.StatusAccepted) + if rl := loggermw.RequestLoggerFromContext(ctx); rl != nil { + rl.WriteLog(ctx, http.StatusAccepted) + } for { select { diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 1a2c418a014bb..11588a09fb797 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -135,6 +135,8 @@ func (m *Manager) WithHandlers(reg map[database.NotificationMethod]Handler) { m.handlers = reg } +var ErrManagerAlreadyClosed = xerrors.New("manager already closed") + // Run initiates the control loop in the background, which spawns a given number of notifier goroutines. // Manager requires system-level permissions to interact with the store. // Run is only intended to be run once. @@ -146,7 +148,11 @@ func (m *Manager) Run(ctx context.Context) { go func() { err := m.loop(ctx) if err != nil { - m.log.Error(ctx, "notification manager stopped with error", slog.Error(err)) + if xerrors.Is(err, ErrManagerAlreadyClosed) { + m.log.Warn(ctx, "notification manager stopped with error", slog.Error(err)) + } else { + m.log.Error(ctx, "notification manager stopped with error", slog.Error(err)) + } } }() }) @@ -163,7 +169,7 @@ func (m *Manager) loop(ctx context.Context) error { m.mu.Lock() if m.closed { m.mu.Unlock() - return xerrors.New("manager already closed") + return ErrManagerAlreadyClosed } var eg errgroup.Group diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 98a5d546eaffc..640dc3ad22e55 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -29,9 +29,7 @@ import ( func TestDynamicParametersOwnerSSHPublicKey(t *testing.T) { t.Parallel() - cfg := coderdtest.DeploymentValues(t) - cfg.Experiments = []string{string(codersdk.ExperimentDynamicParameters)} - ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, DeploymentValues: cfg}) + ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, ownerClient) templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) @@ -251,6 +249,7 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { Value: "GO", }, } + request.EnableDynamicParameters = true }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) @@ -276,7 +275,7 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { EnableDynamicParameters: ptr.Ref(true), }) require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, bld.ID) latestParams, err := setup.client.WorkspaceBuildParameters(ctx, bld.ID) require.NoError(t, err) @@ -354,14 +353,11 @@ type dynamicParamsTest struct { } func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest { - cfg := coderdtest.DeploymentValues(t) - cfg.Experiments = []string{string(codersdk.ExperimentDynamicParameters)} ownerClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: args.db, Pubsub: args.ps, IncludeProvisionerDaemon: true, ProvisionerDaemonVersion: args.provisionerDaemonVersion, - DeploymentValues: cfg, }) owner := coderdtest.CreateFirstUser(t, ownerClient) @@ -384,6 +380,12 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn 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, version.ID) if args.expectWebsocketError { diff --git a/coderd/provisionerdserver/acquirer_test.go b/coderd/provisionerdserver/acquirer_test.go index 91e5964f1e8ed..e90fb3df0198a 100644 --- a/coderd/provisionerdserver/acquirer_test.go +++ b/coderd/provisionerdserver/acquirer_test.go @@ -18,7 +18,6 @@ import ( "go.uber.org/goleak" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" @@ -34,8 +33,7 @@ func TestMain(m *testing.M) { // TestAcquirer_Store tests that a database.Store is accepted as a provisionerdserver.AcquirerStore func TestAcquirer_Store(t *testing.T) { t.Parallel() - db := dbmem.New() - ps := pubsub.NewInMemory() + db, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() logger := testutil.Logger(t) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 111f4185d3910..31165cf89f65b 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -739,6 +739,9 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo Metadata: &sdkproto.Metadata{ CoderUrl: s.AccessURL.String(), WorkspaceName: input.WorkspaceName, + // There is no owner for a template import, but we can assume + // the "Everyone" group as a placeholder. + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, } @@ -759,6 +762,9 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo UserVariableValues: convertVariableValues(userVariableValues), Metadata: &sdkproto.Metadata{ CoderUrl: s.AccessURL.String(), + // There is no owner for a template import, but we can assume + // the "Everyone" group as a placeholder. + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, } diff --git a/coderd/provisionerdserver/provisionerdserver_internal_test.go b/coderd/provisionerdserver/provisionerdserver_internal_test.go index eb616eb4c2795..68802698e9682 100644 --- a/coderd/provisionerdserver/provisionerdserver_internal_test.go +++ b/coderd/provisionerdserver/provisionerdserver_internal_test.go @@ -11,7 +11,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/testutil" ) @@ -21,14 +21,14 @@ func TestObtainOIDCAccessToken(t *testing.T) { ctx := context.Background() t.Run("NoToken", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) _, err := obtainOIDCAccessToken(ctx, db, nil, uuid.Nil) require.NoError(t, err) }) t.Run("InvalidConfig", func(t *testing.T) { // We still want OIDC to succeed even if exchanging the token fails. t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) user := dbgen.User(t, db, database.User{}) dbgen.UserLink(t, db, database.UserLink{ UserID: user.ID, @@ -40,7 +40,7 @@ func TestObtainOIDCAccessToken(t *testing.T) { }) t.Run("MissingLink", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) user := dbgen.User(t, db, database.User{ LoginType: database.LoginTypeOIDC, }) @@ -50,7 +50,7 @@ func TestObtainOIDCAccessToken(t *testing.T) { }) t.Run("Exchange", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) user := dbgen.User(t, db, database.User{}) dbgen.UserLink(t, db, database.UserLink{ UserID: user.ID, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 1756aa68e15fc..695437068f50f 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -7,6 +7,7 @@ import ( "io" "net/url" "slices" + "sort" "strconv" "strings" "sync" @@ -31,7 +32,6 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" @@ -164,6 +164,8 @@ func TestAcquireJob(t *testing.T) { Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Input: json.RawMessage("{}"), + Tags: pd.Tags, }) require.NoError(t, err) _, err = tc.acquire(ctx, srv) @@ -175,7 +177,7 @@ func TestAcquireJob(t *testing.T) { sdkproto.PrebuiltWorkspaceBuildStage_CLAIM, } { prebuiltWorkspaceBuildStage := prebuiltWorkspaceBuildStage - t.Run(tc.name+"_WorkspaceBuildJob", func(t *testing.T) { + 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 // create an API key with an expiration within the bounds of the @@ -240,10 +242,12 @@ func TestAcquireJob(t *testing.T) { Name: "template", Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, + CreatedBy: user.ID, }) - file := dbgen.File(t, db, database.File{CreatedBy: user.ID}) - versionFile := dbgen.File(t, db, database.File{CreatedBy: user.ID}) + file := dbgen.File(t, db, database.File{CreatedBy: user.ID, Hash: "1"}) + versionFile := dbgen.File(t, db, database.File{CreatedBy: user.ID, Hash: "2"}) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -293,35 +297,33 @@ func TestAcquireJob(t *testing.T) { Required: true, Sensitive: false, }) - workspace := database.WorkspaceTable{ + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ TemplateID: template.ID, OwnerID: user.ID, OrganizationID: pd.OrganizationID, - } - workspace = dbgen.Workspace(t, db, workspace) - build := database.WorkspaceBuild{ - WorkspaceID: workspace.ID, - BuildNumber: 1, - JobID: uuid.New(), - TemplateVersionID: version.ID, - Transition: database.WorkspaceTransitionStart, - Reason: database.BuildReasonInitiator, - } - build = dbgen.WorkspaceBuild(t, db, build) - input := provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, - } - dbJob := database.ProvisionerJob{ - ID: build.JobID, + }) + buildID := uuid.New() + dbJob := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ OrganizationID: pd.OrganizationID, InitiatorID: user.ID, Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, FileID: file.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, - Input: must(json.Marshal(input)), - } - dbJob = dbgen.ProvisionerJob(t, db, ps, dbJob) + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: buildID, + })), + Tags: pd.Tags, + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: buildID, + WorkspaceID: workspace.ID, + BuildNumber: 1, + JobID: dbJob.ID, + TemplateVersionID: version.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + }) var agent database.WorkspaceAgent if prebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM { @@ -332,25 +334,12 @@ func TestAcquireJob(t *testing.T) { ResourceID: resource.ID, AuthToken: uuid.New(), }) - // At this point we have an unclaimed workspace and build, now we need to setup the claim - // build - build = database.WorkspaceBuild{ - WorkspaceID: workspace.ID, - BuildNumber: 2, - JobID: uuid.New(), - TemplateVersionID: version.ID, - Transition: database.WorkspaceTransitionStart, - Reason: database.BuildReasonInitiator, - InitiatorID: user.ID, - } - build = dbgen.WorkspaceBuild(t, db, build) - - input = provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, + buildID := uuid.New() + input := provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: buildID, PrebuiltWorkspaceBuildStage: prebuiltWorkspaceBuildStage, } dbJob = database.ProvisionerJob{ - ID: build.JobID, OrganizationID: pd.OrganizationID, InitiatorID: user.ID, Provisioner: database.ProvisionerTypeEcho, @@ -358,8 +347,22 @@ func TestAcquireJob(t *testing.T) { FileID: file.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, Input: must(json.Marshal(input)), + Tags: pd.Tags, } dbJob = dbgen.ProvisionerJob(t, db, ps, dbJob) + // At this point we have an unclaimed workspace and build, now we need to setup the claim + // build. + build = database.WorkspaceBuild{ + ID: buildID, + WorkspaceID: workspace.ID, + BuildNumber: 2, + JobID: dbJob.ID, + TemplateVersionID: version.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + InitiatorID: user.ID, + } + build = dbgen.WorkspaceBuild(t, db, build) } startPublished := make(chan struct{}) @@ -387,26 +390,19 @@ func TestAcquireJob(t *testing.T) { // an import version job that we need to ignore. job, err = tc.acquire(ctx, srv) require.NoError(t, err) - if _, ok := job.Type.(*proto.AcquiredJob_WorkspaceBuild_); ok { - break - } - } - - <-startPublished - - if prebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM { - for { + if job, ok := job.Type.(*proto.AcquiredJob_WorkspaceBuild_); ok { // In the case of a prebuild claim, there is a second build, which is the // one that we're interested in. - job, err = tc.acquire(ctx, srv) - require.NoError(t, err) - if _, ok := job.Type.(*proto.AcquiredJob_WorkspaceBuild_); ok { - break + if prebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM && + job.WorkspaceBuild.Metadata.PrebuiltWorkspaceBuildStage != prebuiltWorkspaceBuildStage { + continue } + break } - <-startPublished } + <-startPublished + got, err := json.Marshal(job.Type) require.NoError(t, err) @@ -480,26 +476,29 @@ func TestAcquireJob(t *testing.T) { require.JSONEq(t, string(want), string(got)) - // Assert that we delete the session token whenever - // a stop is issued. - stopbuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspace.ID, - BuildNumber: 2, - JobID: uuid.New(), - TemplateVersionID: version.ID, - Transition: database.WorkspaceTransitionStop, - Reason: database.BuildReasonInitiator, - }) - _ = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ - ID: stopbuild.ID, + stopbuildID := uuid.New() + stopJob := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + ID: stopbuildID, InitiatorID: user.ID, Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, FileID: file.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: stopbuild.ID, + WorkspaceBuildID: stopbuildID, })), + Tags: pd.Tags, + }) + // Assert that we delete the session token whenever + // a stop is issued. + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: stopbuildID, + WorkspaceID: workspace.ID, + BuildNumber: 3, + JobID: stopJob.ID, + TemplateVersionID: version.ID, + Transition: database.WorkspaceTransitionStop, + Reason: database.BuildReasonInitiator, }) stopPublished := make(chan struct{}) @@ -534,7 +533,7 @@ func TestAcquireJob(t *testing.T) { } t.Run(tc.name+"_TemplateVersionDryRun", func(t *testing.T) { t.Parallel() - srv, db, ps, _ := setup(t, false, nil) + srv, db, ps, pd := setup(t, false, nil) ctx := context.Background() user := dbgen.User(t, db, database.User{}) @@ -550,6 +549,7 @@ func TestAcquireJob(t *testing.T) { TemplateVersionID: version.ID, WorkspaceName: "testing", })), + Tags: pd.Tags, }) job, err := tc.acquire(ctx, srv) @@ -568,8 +568,9 @@ func TestAcquireJob(t *testing.T) { want, err := json.Marshal(&proto.AcquiredJob_TemplateDryRun_{ TemplateDryRun: &proto.AcquiredJob_TemplateDryRun{ Metadata: &sdkproto.Metadata{ - CoderUrl: (&url.URL{}).String(), - WorkspaceName: "testing", + CoderUrl: (&url.URL{}).String(), + WorkspaceName: "testing", + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, }) @@ -578,7 +579,7 @@ func TestAcquireJob(t *testing.T) { }) t.Run(tc.name+"_TemplateVersionImport", func(t *testing.T) { t.Parallel() - srv, db, ps, _ := setup(t, false, nil) + srv, db, ps, pd := setup(t, false, nil) ctx := context.Background() user := dbgen.User(t, db, database.User{}) @@ -589,6 +590,7 @@ func TestAcquireJob(t *testing.T) { Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionImport, + Tags: pd.Tags, }) job, err := tc.acquire(ctx, srv) @@ -600,7 +602,8 @@ func TestAcquireJob(t *testing.T) { want, err := json.Marshal(&proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ Metadata: &sdkproto.Metadata{ - CoderUrl: (&url.URL{}).String(), + CoderUrl: (&url.URL{}).String(), + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, }) @@ -609,7 +612,7 @@ func TestAcquireJob(t *testing.T) { }) t.Run(tc.name+"_TemplateVersionImportWithUserVariable", func(t *testing.T) { t.Parallel() - srv, db, ps, _ := setup(t, false, nil) + srv, db, ps, pd := setup(t, false, nil) user := dbgen.User(t, db, database.User{}) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{}) @@ -626,6 +629,7 @@ func TestAcquireJob(t *testing.T) { {Name: "first", Value: "first_value"}, }, })), + Tags: pd.Tags, }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -643,7 +647,8 @@ func TestAcquireJob(t *testing.T) { {Name: "first", Sensitive: true, Value: "first_value"}, }, Metadata: &sdkproto.Metadata{ - CoderUrl: (&url.URL{}).String(), + CoderUrl: (&url.URL{}).String(), + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, }) @@ -671,12 +676,14 @@ func TestUpdateJob(t *testing.T) { }) t.Run("NotRunning", func(t *testing.T) { t.Parallel() - srv, db, _, _ := setup(t, false, nil) + srv, db, _, pd := setup(t, false, nil) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: uuid.New(), Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Input: json.RawMessage("{}"), + Tags: pd.Tags, }) require.NoError(t, err) _, err = srv.UpdateJob(ctx, &proto.UpdateJobRequest{ @@ -687,12 +694,14 @@ func TestUpdateJob(t *testing.T) { // This test prevents runners from updating jobs they don't own! t.Run("NotOwner", func(t *testing.T) { t.Parallel() - srv, db, _, _ := setup(t, false, nil) + srv, db, _, pd := setup(t, false, nil) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: uuid.New(), Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Input: json.RawMessage("{}"), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -701,6 +710,11 @@ func TestUpdateJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) _, err = srv.UpdateJob(ctx, &proto.UpdateJobRequest{ @@ -709,12 +723,14 @@ func TestUpdateJob(t *testing.T) { require.ErrorContains(t, err, "you don't own this job") }) - setupJob := func(t *testing.T, db database.Store, srvID uuid.UUID) uuid.UUID { + setupJob := func(t *testing.T, db database.Store, srvID uuid.UUID, tags database.StringMap) uuid.UUID { job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: uuid.New(), Provisioner: database.ProvisionerTypeEcho, Type: database.ProvisionerJobTypeTemplateVersionImport, StorageMethod: database.ProvisionerStorageMethodFile, + Input: json.RawMessage("{}"), + Tags: tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -723,6 +739,11 @@ func TestUpdateJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) return job.ID @@ -731,7 +752,7 @@ func TestUpdateJob(t *testing.T) { t.Run("Success", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID) + job := setupJob(t, db, pd.ID, pd.Tags) _, err := srv.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.String(), }) @@ -741,7 +762,7 @@ func TestUpdateJob(t *testing.T) { t.Run("Logs", func(t *testing.T) { t.Parallel() srv, db, ps, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID) + job := setupJob(t, db, pd.ID, pd.Tags) published := make(chan struct{}) @@ -766,7 +787,7 @@ func TestUpdateJob(t *testing.T) { t.Run("Readme", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID) + job := setupJob(t, db, pd.ID, pd.Tags) versionID := uuid.New() err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ ID: versionID, @@ -792,7 +813,7 @@ func TestUpdateJob(t *testing.T) { defer cancel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID) + job := setupJob(t, db, pd.ID, pd.Tags) versionID := uuid.New() err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ ID: versionID, @@ -839,7 +860,7 @@ func TestUpdateJob(t *testing.T) { defer cancel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID) + job := setupJob(t, db, pd.ID, pd.Tags) versionID := uuid.New() err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ ID: versionID, @@ -885,7 +906,7 @@ func TestUpdateJob(t *testing.T) { defer cancel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID) + job := setupJob(t, db, pd.ID, pd.Tags) versionID := uuid.New() err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ ID: versionID, @@ -930,12 +951,14 @@ func TestFailJob(t *testing.T) { // This test prevents runners from updating jobs they don't own! t.Run("NotOwner", func(t *testing.T) { t.Parallel() - srv, db, _, _ := setup(t, false, nil) + srv, db, _, pd := setup(t, false, nil) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: uuid.New(), Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionImport, + Input: json.RawMessage("{}"), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -944,6 +967,11 @@ func TestFailJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) _, err = srv.FailJob(ctx, &proto.FailedJob{ @@ -959,6 +987,8 @@ func TestFailJob(t *testing.T) { Provisioner: database.ProvisionerTypeEcho, Type: database.ProvisionerJobTypeTemplateVersionImport, StorageMethod: database.ProvisionerStorageMethodFile, + Input: json.RawMessage("{}"), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -967,6 +997,11 @@ func TestFailJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ @@ -993,11 +1028,19 @@ func TestFailJob(t *testing.T) { auditor: auditor, }) org := dbgen.Organization(t, db, database.Organization{}) - workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + u := dbgen.User(t, db, database.User{}) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: u.ID, + }) + workspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{ ID: uuid.New(), AutomaticUpdates: database.AutomaticUpdatesNever, OrganizationID: org.ID, + TemplateID: tpl.ID, + OwnerID: u.ID, }) + require.NoError(t, err) buildID := uuid.New() input, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{ WorkspaceBuildID: buildID, @@ -1011,6 +1054,7 @@ func TestFailJob(t *testing.T) { Provisioner: database.ProvisionerTypeEcho, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: database.ProvisionerStorageMethodFile, + Tags: pd.Tags, }) require.NoError(t, err) err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ @@ -1029,6 +1073,11 @@ func TestFailJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) @@ -1103,6 +1152,8 @@ func TestCompleteJob(t *testing.T) { StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeWorkspaceBuild, OrganizationID: pd.OrganizationID, + Input: json.RawMessage("{}"), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1112,6 +1163,11 @@ func TestCompleteJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ @@ -1142,6 +1198,7 @@ func TestCompleteJob(t *testing.T) { Input: []byte(`{"template_version_id": "` + versionID.String() + `"}`), StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionImport, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1150,7 +1207,9 @@ func TestCompleteJob(t *testing.T) { UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -1189,6 +1248,8 @@ func TestCompleteJob(t *testing.T) { Provisioner: database.ProvisionerTypeEcho, Type: database.ProvisionerJobTypeTemplateVersionDryRun, StorageMethod: database.ProvisionerStorageMethodFile, + Input: json.RawMessage("{}"), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1196,7 +1257,9 @@ func TestCompleteJob(t *testing.T) { UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -1272,7 +1335,9 @@ func TestCompleteJob(t *testing.T) { UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -1303,7 +1368,7 @@ func TestCompleteJob(t *testing.T) { }}, Timings: []*sdkproto.Timing{ { - Stage: "test", + Stage: "init", Source: "test-source", Resource: "test-resource", Action: "test-action", @@ -1311,7 +1376,7 @@ func TestCompleteJob(t *testing.T) { End: timestamppb.Now(), }, { - Stage: "test2", + Stage: "plan", Source: "test-source2", Resource: "test-resource2", Action: "test-action2", @@ -1380,7 +1445,7 @@ func TestCompleteJob(t *testing.T) { timings, err := db.GetProvisionerJobTimingsByJobID(ctx, job.ID) require.NoError(t, err) require.Len(t, timings, 1, "Expected one timing entry to be created") - require.Equal(t, "test", string(timings[0].Stage), "Timing stage should match what was sent") + require.Equal(t, "init", string(timings[0].Stage), "Timing stage should match what was sent") }) }) @@ -1402,6 +1467,7 @@ func TestCompleteJob(t *testing.T) { StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeWorkspaceBuild, OrganizationID: pd.OrganizationID, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1410,7 +1476,9 @@ func TestCompleteJob(t *testing.T) { UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -1456,6 +1524,7 @@ func TestCompleteJob(t *testing.T) { StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeWorkspaceBuild, OrganizationID: pd.OrganizationID, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1465,6 +1534,11 @@ func TestCompleteJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) completeJob := func() { @@ -1514,6 +1588,7 @@ func TestCompleteJob(t *testing.T) { Input: []byte(`{"template_version_id": "` + versionID.String() + `"}`), StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeWorkspaceBuild, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1523,6 +1598,11 @@ func TestCompleteJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) completeJob := func() { @@ -1746,6 +1826,11 @@ func TestCompleteJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: c.now, + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) @@ -1827,6 +1912,8 @@ func TestCompleteJob(t *testing.T) { Provisioner: database.ProvisionerTypeEcho, Type: database.ProvisionerJobTypeTemplateVersionDryRun, StorageMethod: database.ProvisionerStorageMethodFile, + Input: json.RawMessage("{}"), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1835,6 +1922,11 @@ func TestCompleteJob(t *testing.T) { Valid: true, }, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) @@ -1913,7 +2005,8 @@ func TestCompleteJob(t *testing.T) { Transition: database.WorkspaceTransitionStart, }}, provisionerJobParams: database.InsertProvisionerJobParams{ - Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Input: json.RawMessage("{}"), }, }, { @@ -2057,6 +2150,10 @@ func TestCompleteJob(t *testing.T) { if jobParams.StorageMethod == "" { jobParams.StorageMethod = database.ProvisionerStorageMethodFile } + if jobParams.Tags == nil { + jobParams.Tags = pd.Tags + } + user := dbgen.User(t, db, database.User{}) job, err := db.InsertProvisionerJob(ctx, jobParams) tpl := dbgen.Template(t, db, database.Template{ @@ -2067,7 +2164,9 @@ func TestCompleteJob(t *testing.T) { JobID: job.ID, }) workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ - TemplateID: tpl.ID, + TemplateID: tpl.ID, + OrganizationID: pd.OrganizationID, + OwnerID: user.ID, }) _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ ID: workspaceBuildID, @@ -2082,7 +2181,9 @@ func TestCompleteJob(t *testing.T) { UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{jobParams.Provisioner}, + Types: []database.ProvisionerType{jobParams.Provisioner}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -2173,22 +2274,34 @@ func TestCompleteJob(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - Input: input, - Provisioner: database.ProvisionerTypeEcho, - StorageMethod: database.ProvisionerStorageMethodFile, - Type: database.ProvisionerJobTypeWorkspaceBuild, + ID: uuid.New(), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + OrganizationID: pd.OrganizationID, + InitiatorID: uuid.New(), + Input: input, + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Tags: pd.Tags, }) require.NoError(t, err) + user := dbgen.User(t, db, database.User{}) tpl := dbgen.Template(t, db, database.Template{ OrganizationID: pd.OrganizationID, + CreatedBy: user.ID, }) tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: job.ID, + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + JobID: job.ID, + CreatedBy: user.ID, }) workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ - TemplateID: tpl.ID, + TemplateID: tpl.ID, + OrganizationID: pd.OrganizationID, + OwnerID: user.ID, }) _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ ID: buildID, @@ -2197,11 +2310,14 @@ func TestCompleteJob(t *testing.T) { TemplateVersionID: tv.ID, }) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, WorkerID: uuid.NullUUID{ UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -2295,7 +2411,9 @@ func TestCompleteJob(t *testing.T) { UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -2542,7 +2660,8 @@ func TestInsertWorkspaceResource(t *testing.T) { } t.Run("NoAgents", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() err := insert(db, job, &sdkproto.Resource{ Name: "something", @@ -2555,7 +2674,9 @@ func TestInsertWorkspaceResource(t *testing.T) { }) t.Run("InvalidAgentToken", func(t *testing.T) { t.Parallel() - err := insert(dbmem.New(), uuid.New(), &sdkproto.Resource{ + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) + err := insert(db, uuid.New(), &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -2569,7 +2690,9 @@ func TestInsertWorkspaceResource(t *testing.T) { }) t.Run("DuplicateApps", func(t *testing.T) { t.Parallel() - err := insert(dbmem.New(), uuid.New(), &sdkproto.Resource{ + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) + err := insert(db, uuid.New(), &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -2582,7 +2705,10 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.ErrorContains(t, err, `duplicate app slug, must be unique per template: "a"`) - err = insert(dbmem.New(), uuid.New(), &sdkproto.Resource{ + + db, _ = dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) + err = insert(db, uuid.New(), &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -2601,7 +2727,8 @@ func TestInsertWorkspaceResource(t *testing.T) { }) t.Run("AppSlugInvalid", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() err := insert(db, job, &sdkproto.Resource{ Name: "something", @@ -2639,7 +2766,8 @@ func TestInsertWorkspaceResource(t *testing.T) { }) t.Run("DuplicateAgentNames", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() // case-insensitive-unique err := insert(db, job, &sdkproto.Resource{ @@ -2665,7 +2793,8 @@ func TestInsertWorkspaceResource(t *testing.T) { }) t.Run("AgentNameInvalid", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() err := insert(db, job, &sdkproto.Resource{ Name: "something", @@ -2694,7 +2823,8 @@ func TestInsertWorkspaceResource(t *testing.T) { }) t.Run("Success", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() err := insert(db, job, &sdkproto.Resource{ Name: "something", @@ -2751,7 +2881,7 @@ func TestInsertWorkspaceResource(t *testing.T) { "else": "I laugh in the face of danger.", }) require.NoError(t, err) - got, err := agent.EnvironmentVariables.RawMessage.MarshalJSON() + got, err := json.Marshal(agent.EnvironmentVariables.RawMessage) require.NoError(t, err) require.Equal(t, want, got) require.ElementsMatch(t, []database.DisplayApp{ @@ -2763,7 +2893,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("AllDisplayApps", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() err := insert(db, job, &sdkproto.Resource{ Name: "something", @@ -2792,7 +2923,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("DisableDefaultApps", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() err := insert(db, job, &sdkproto.Resource{ Name: "something", @@ -2817,7 +2949,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("ResourcesMonitoring", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() err := insert(db, job, &sdkproto.Resource{ Name: "something", @@ -2869,7 +3002,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("Devcontainers", func(t *testing.T) { t.Parallel() - db := dbmem.New() + db, _ := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) job := uuid.New() err := insert(db, job, &sdkproto.Resource{ Name: "something", @@ -2891,6 +3025,9 @@ func TestInsertWorkspaceResource(t *testing.T) { require.Len(t, agents, 1) agent := agents[0] devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, agent.ID) + sort.Slice(devcontainers, func(i, j int) bool { + return devcontainers[i].Name > devcontainers[j].Name + }) require.NoError(t, err) require.Len(t, devcontainers, 2) require.Equal(t, "foo", devcontainers[0].Name) @@ -2986,6 +3123,8 @@ func TestNotifications(t *testing.T) { WorkspaceBuildID: build.ID, })), OrganizationID: pd.OrganizationID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), }) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, @@ -2993,7 +3132,9 @@ func TestNotifications(t *testing.T) { UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -3106,6 +3247,8 @@ func TestNotifications(t *testing.T) { WorkspaceBuildID: build.ID, })), OrganizationID: pd.OrganizationID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), }) _, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, @@ -3113,7 +3256,9 @@ func TestNotifications(t *testing.T) { UUID: pd.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + ProvisionerTags: must(json.Marshal(job.Tags)), + StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, }) require.NoError(t, err) @@ -3179,9 +3324,11 @@ func TestNotifications(t *testing.T) { OrganizationID: pd.OrganizationID, }) _, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ - OrganizationID: pd.OrganizationID, - WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true}, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + 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) @@ -3227,8 +3374,8 @@ type overrides struct { func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisionerDaemonServer, database.Store, pubsub.Pubsub, database.ProvisionerDaemon) { t.Helper() logger := testutil.Logger(t) - db := dbmem.New() - ps := pubsub.NewInMemory() + db, ps := dbtestutil.NewDB(t) + dbtestutil.DisableForeignKeysAndTriggers(t, db) defOrg, err := db.GetDefaultOrganization(context.Background()) require.NoError(t, err, "default org not found") diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 5a8a0a5126cc0..800b2916efef3 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -557,7 +557,9 @@ func (f *logFollower) follow() { } // Log the request immediately instead of after it completes. - loggermw.RequestLoggerFromContext(f.ctx).WriteLog(f.ctx, http.StatusAccepted) + if rl := loggermw.RequestLoggerFromContext(f.ctx); rl != nil { + rl.WriteLog(f.ctx, http.StatusAccepted) + } // no need to wait if the job is done if f.complete { diff --git a/coderd/templates_test.go b/coderd/templates_test.go index f5fbe49741838..f8f2b1372263c 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -1548,7 +1548,7 @@ func TestPatchTemplateMeta(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - require.False(t, template.UseClassicParameterFlow, "default is false") + require.True(t, template.UseClassicParameterFlow, "default is true") bTrue := true bFalse := false diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 7f6dcf771ab5d..6d224818a6a46 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1577,10 +1577,10 @@ func TestUserOIDC(t *testing.T) { }) require.Equal(t, http.StatusOK, resp.StatusCode) - auditor.Contains(t, database.AuditLog{ + require.True(t, auditor.Contains(t, database.AuditLog{ ResourceType: database.ResourceTypeUser, AdditionalFields: json.RawMessage(`{"automatic_actor":"coder","automatic_subsystem":"dormancy"}`), - }) + })) me, err := client.User(ctx, "me") require.NoError(t, err) diff --git a/coderd/util/maps/maps.go b/coderd/util/maps/maps.go index 8aaa6669cb8af..6a858bf3f7085 100644 --- a/coderd/util/maps/maps.go +++ b/coderd/util/maps/maps.go @@ -6,6 +6,14 @@ import ( "golang.org/x/exp/constraints" ) +func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T { + into := make(map[K]T) + for k, item := range params { + into[k] = convert(item) + } + return into +} + // Subset returns true if all the keys of a are present // in b and have the same values. // If the corresponding value of a[k] is the zero value in diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 6b25fcbcfeaf6..ed3f554a89b75 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -578,7 +578,9 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { defer t.Stop() // Log the request immediately instead of after it completes. - loggermw.RequestLoggerFromContext(ctx).WriteLog(ctx, http.StatusAccepted) + if rl := loggermw.RequestLoggerFromContext(ctx); rl != nil { + rl.WriteLog(ctx, http.StatusAccepted) + } go func() { defer func() { @@ -1047,7 +1049,9 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { defer encoder.Close(websocket.StatusGoingAway) // Log the request immediately instead of after it completes. - loggermw.RequestLoggerFromContext(ctx).WriteLog(ctx, http.StatusAccepted) + if rl := loggermw.RequestLoggerFromContext(ctx); rl != nil { + rl.WriteLog(ctx, http.StatusAccepted) + } go func(ctx context.Context) { // TODO(mafredri): Is this too frequent? Use separate ping disconnect timeout? @@ -1501,7 +1505,9 @@ func (api *API) watchWorkspaceAgentMetadata( defer sendTicker.Stop() // Log the request immediately instead of after it completes. - loggermw.RequestLoggerFromContext(ctx).WriteLog(ctx, http.StatusAccepted) + if rl := loggermw.RequestLoggerFromContext(ctx); rl != nil { + rl.WriteLog(ctx, http.StatusAccepted) + } // Send initial metadata. sendMetadata() diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index a9b981f820be2..ec0b692886918 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1252,6 +1252,9 @@ func TestWorkspaceAgentContainers(t *testing.T) { }).Do() _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true + o.ContainerAPIOptions = append(o.ContainerAPIOptions, + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + ) }) resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() require.Len(t, resources, 1, "expected one resource") @@ -1320,18 +1323,18 @@ func TestWorkspaceAgentContainers(t *testing.T) { for _, tc := range []struct { name string - setupMock func(*acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) + setupMock func(*acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error) }{ { name: "test response", - setupMock: func(mcl *acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { + setupMock: func(mcl *acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error) { mcl.EXPECT().List(gomock.Any()).Return(testResponse, nil).AnyTimes() return testResponse, nil }, }, { name: "error response", - setupMock: func(mcl *acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { + setupMock: func(mcl *acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error) { mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError).AnyTimes() return codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError }, @@ -1342,7 +1345,7 @@ func TestWorkspaceAgentContainers(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) - mcl := acmock.NewMockLister(ctrl) + mcl := acmock.NewMockContainerCLI(ctrl) expected, expectedErr := tc.setupMock(mcl) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ @@ -1358,7 +1361,10 @@ func TestWorkspaceAgentContainers(t *testing.T) { _ = 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.WithLister(mcl)) + o.ContainerAPIOptions = append(o.ContainerAPIOptions, + agentcontainers.WithContainerCLI(mcl), + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + ) }) resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() require.Len(t, resources, 1, "expected one resource") @@ -1397,14 +1403,15 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { agentcontainers.DevcontainerConfigFileLabel: configFile, } devContainer = codersdk.WorkspaceAgentContainer{ - ID: uuid.NewString(), - CreatedAt: dbtime.Now(), - FriendlyName: testutil.GetRandomName(t), - Image: "busybox:latest", - Labels: dcLabels, - Running: true, - Status: "running", - DevcontainerDirty: true, + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: testutil.GetRandomName(t), + Image: "busybox:latest", + Labels: dcLabels, + Running: true, + Status: "running", + DevcontainerDirty: true, + DevcontainerStatus: codersdk.WorkspaceAgentDevcontainerStatusRunning, } plainContainer = codersdk.WorkspaceAgentContainer{ ID: uuid.NewString(), @@ -1419,29 +1426,31 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { for _, tc := range []struct { name string - setupMock func(*acmock.MockLister, *acmock.MockDevcontainerCLI) (status int) + setupMock func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) (status int) }{ { name: "Recreate", - setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { - mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + 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().Up(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return("someid", nil).Times(1) return 0 }, }, { name: "Container does not exist", - setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { - mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, nil).AnyTimes() + 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(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { - mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + 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 @@ -1452,9 +1461,9 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) - mcl := acmock.NewMockLister(ctrl) + mccli := acmock.NewMockContainerCLI(ctrl) mdccli := acmock.NewMockDevcontainerCLI(ctrl) - wantStatus := tc.setupMock(mcl, mdccli) + wantStatus := tc.setupMock(mccli, mdccli) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ Logger: &logger, @@ -1471,9 +1480,10 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { o.ExperimentalDevcontainersEnabled = true o.ContainerAPIOptions = append( o.ContainerAPIOptions, - agentcontainers.WithLister(mcl), + agentcontainers.WithContainerCLI(mccli), agentcontainers.WithDevcontainerCLI(mdccli), agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerLocalFolderLabel, workspaceFolder), ) }) resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index c01004653f86e..4d90948a8f9a1 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -384,20 +384,8 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { builder = builder.State(createBuild.ProvisionerState) } - // Only defer to dynamic parameters if the experiment is enabled. - if api.Experiments.Enabled(codersdk.ExperimentDynamicParameters) { - if createBuild.EnableDynamicParameters != nil { - // Explicit opt-in - builder = builder.DynamicParameters(*createBuild.EnableDynamicParameters) - } - } else { - if createBuild.EnableDynamicParameters != nil { - api.Logger.Warn(ctx, "ignoring dynamic parameter field sent by request, the experiment is not enabled", - slog.F("field", *createBuild.EnableDynamicParameters), - slog.F("user", apiKey.UserID.String()), - slog.F("transition", string(createBuild.Transition)), - ) - } + if createBuild.EnableDynamicParameters != nil { + builder = builder.DynamicParameters(*createBuild.EnableDynamicParameters) } workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build( diff --git a/coderd/workspaces.go b/coderd/workspaces.go index fe0c2d3f609a2..d38de99e95eba 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -717,7 +717,7 @@ func createWorkspace( builder = builder.MarkPrebuiltWorkspaceClaim() } - if req.EnableDynamicParameters && api.Experiments.Enabled(codersdk.ExperimentDynamicParameters) { + if req.EnableDynamicParameters { builder = builder.DynamicParameters(req.EnableDynamicParameters) } diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index bcc2cef40ebdc..201ef0c53a307 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -1042,8 +1042,15 @@ func (b *Builder) checkRunningBuild() error { } func (b *Builder) usingDynamicParameters() bool { - if !b.experiments.Enabled(codersdk.ExperimentDynamicParameters) { - // Experiment required + 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 + } + if tpl.UseClassicParameterFlow { return false } @@ -1056,15 +1063,7 @@ func (b *Builder) usingDynamicParameters() bool { return false } - 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 - } - return !tpl.UseClassicParameterFlow + return true } func ProvisionerVersionSupportsDynamicParameters(version string) bool { diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index abe5e3fe9b8b7..58999a33e6e5e 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -894,10 +894,11 @@ func withTemplate(mTx *dbmock.MockStore) { mTx.EXPECT().GetTemplateByID(gomock.Any(), templateID). Times(1). Return(database.Template{ - ID: templateID, - OrganizationID: orgID, - Provisioner: database.ProvisionerTypeTerraform, - ActiveVersionID: activeVersionID, + ID: templateID, + OrganizationID: orgID, + Provisioner: database.ProvisionerTypeTerraform, + ActiveVersionID: activeVersionID, + UseClassicParameterFlow: true, }, nil) } diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index e3b036dcdf00a..f44c19b998e21 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -102,6 +102,7 @@ type PostMetadataRequest struct { type PostMetadataRequestDeprecated = codersdk.WorkspaceAgentMetadataResult type Manifest struct { + ParentID uuid.UUID `json:"parent_id"` AgentID uuid.UUID `json:"agent_id"` AgentName string `json:"agent_name"` // OwnerUsername and WorkspaceID are used by an open-source user to identify the workspace. diff --git a/codersdk/agentsdk/convert.go b/codersdk/agentsdk/convert.go index 2b7dff950a3e7..d01c9e527fce9 100644 --- a/codersdk/agentsdk/convert.go +++ b/codersdk/agentsdk/convert.go @@ -15,6 +15,14 @@ import ( ) func ManifestFromProto(manifest *proto.Manifest) (Manifest, error) { + parentID := uuid.Nil + if pid := manifest.GetParentId(); pid != nil { + var err error + parentID, err = uuid.FromBytes(pid) + if err != nil { + return Manifest{}, xerrors.Errorf("error converting workspace agent parent ID: %w", err) + } + } apps, err := AppsFromProto(manifest.Apps) if err != nil { return Manifest{}, xerrors.Errorf("error converting workspace agent apps: %w", err) @@ -36,6 +44,7 @@ func ManifestFromProto(manifest *proto.Manifest) (Manifest, error) { return Manifest{}, xerrors.Errorf("error converting workspace agent devcontainers: %w", err) } return Manifest{ + ParentID: parentID, AgentID: agentID, AgentName: manifest.AgentName, OwnerName: manifest.OwnerUsername, @@ -62,6 +71,7 @@ func ProtoFromManifest(manifest Manifest) (*proto.Manifest, error) { return nil, xerrors.Errorf("convert workspace apps: %w", err) } return &proto.Manifest{ + ParentId: manifest.ParentID[:], AgentId: manifest.AgentID[:], AgentName: manifest.AgentName, OwnerUsername: manifest.OwnerName, diff --git a/codersdk/agentsdk/convert_test.go b/codersdk/agentsdk/convert_test.go index 09482b1694910..f324d504b838a 100644 --- a/codersdk/agentsdk/convert_test.go +++ b/codersdk/agentsdk/convert_test.go @@ -19,6 +19,7 @@ import ( func TestManifest(t *testing.T) { t.Parallel() manifest := agentsdk.Manifest{ + ParentID: uuid.New(), AgentID: uuid.New(), AgentName: "test-agent", OwnerName: "test-owner", @@ -142,6 +143,7 @@ func TestManifest(t *testing.T) { require.NoError(t, err) back, err := agentsdk.ManifestFromProto(p) require.NoError(t, err) + require.Equal(t, manifest.ParentID, back.ParentID) require.Equal(t, manifest.AgentID, back.AgentID) require.Equal(t, manifest.AgentName, back.AgentName) require.Equal(t, manifest.OwnerName, back.OwnerName) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 696e6bda52682..23715e50a8aba 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -468,6 +468,8 @@ type SessionLifetime struct { DefaultTokenDuration serpent.Duration `json:"default_token_lifetime,omitempty" typescript:",notnull"` MaximumTokenDuration serpent.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` + + MaximumAdminTokenDuration serpent.Duration `json:"max_admin_token_lifetime,omitempty" typescript:",notnull"` } type DERP struct { @@ -2340,6 +2342,17 @@ func (c *DeploymentValues) Options() serpent.OptionSet { YAML: "maxTokenLifetime", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, + { + Name: "Maximum Admin Token Lifetime", + Description: "The maximum lifetime duration administrators can specify when creating an API token.", + Flag: "max-admin-token-lifetime", + Env: "CODER_MAX_ADMIN_TOKEN_LIFETIME", + Default: (7 * 24 * time.Hour).String(), + Value: &c.Sessions.MaximumAdminTokenDuration, + Group: &deploymentGroupNetworkingHTTP, + YAML: "maxAdminTokenLifetime", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), + }, { Name: "Default Token Lifetime", Description: "The default lifetime duration for API tokens. This value is used when creating a token without specifying a duration, such as when authenticating the CLI or an IDE plugin.", @@ -3343,7 +3356,6 @@ 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. - ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace. 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. diff --git a/docker-compose.yaml b/docker-compose.yaml index d7d5c3ad6fbb1..5f1a1c8b4779e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -31,7 +31,7 @@ services: database: # Minimum supported version is 13. # More versions here: https://hub.docker.com/_/postgres - image: "postgres:16" + image: "postgres:17" ports: - "5432:5432" environment: diff --git a/docs/contributing/CODE_OF_CONDUCT.md b/docs/about/contributing/CODE_OF_CONDUCT.md similarity index 100% rename from docs/contributing/CODE_OF_CONDUCT.md rename to docs/about/contributing/CODE_OF_CONDUCT.md diff --git a/docs/CONTRIBUTING.md b/docs/about/contributing/CONTRIBUTING.md similarity index 98% rename from docs/CONTRIBUTING.md rename to docs/about/contributing/CONTRIBUTING.md index 3b0d14cb659f2..8f4eb518bae76 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/about/contributing/CONTRIBUTING.md @@ -143,9 +143,9 @@ channel. ## Styling -Visit our [documentation style guide](./contributing/documentation.md). +- [Documentation style guide](./documentation.md) -Frontend styling guide can be found [here](./contributing/frontend.md#styling). +- [Frontend styling guide](./frontend.md#styling) ## Reviews diff --git a/docs/about/contributing/SECURITY.md b/docs/about/contributing/SECURITY.md new file mode 100644 index 0000000000000..7d0f2673ae142 --- /dev/null +++ b/docs/about/contributing/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +Coder welcomes feedback from security researchers and the general public to help improve our security. +If you believe you have discovered a vulnerability, privacy issue, exposed data, or other security issues +in any of our assets, we want to hear from you. + +If you find a vulnerability, **DO NOT FILE AN ISSUE**. +Instead, send an email to +. + +Refer to the [Security policy](https://coder.com/security/policy) for more information. diff --git a/docs/contributing/backend.md b/docs/about/contributing/backend.md similarity index 100% rename from docs/contributing/backend.md rename to docs/about/contributing/backend.md diff --git a/docs/contributing/documentation.md b/docs/about/contributing/documentation.md similarity index 100% rename from docs/contributing/documentation.md rename to docs/about/contributing/documentation.md diff --git a/docs/contributing/frontend.md b/docs/about/contributing/frontend.md similarity index 99% rename from docs/contributing/frontend.md rename to docs/about/contributing/frontend.md index 62e86c9ad4ab9..b121b01a26c59 100644 --- a/docs/contributing/frontend.md +++ b/docs/about/contributing/frontend.md @@ -250,7 +250,7 @@ new conventions, but all new components should follow these guidelines. ## Styling -We use [Emotion](https://emotion.sh/) to handle css styles. +We use [Emotion](https://emotion.sh/) to handle CSS styles. ## Forms diff --git a/docs/start/screenshots.md b/docs/about/screenshots.md similarity index 100% rename from docs/start/screenshots.md rename to docs/about/screenshots.md diff --git a/docs/start/why-coder.md b/docs/about/why-coder.md similarity index 100% rename from docs/start/why-coder.md rename to docs/about/why-coder.md diff --git a/docs/admin/integrations/island.md b/docs/admin/integrations/island.md index d5159e9e28868..97de83af2b5e4 100644 --- a/docs/admin/integrations/island.md +++ b/docs/admin/integrations/island.md @@ -3,7 +3,6 @@ April 24, 2024 diff --git a/docs/admin/integrations/jfrog-xray.md b/docs/admin/integrations/jfrog-xray.md index e5e163559a381..194ea25bf8b6b 100644 --- a/docs/admin/integrations/jfrog-xray.md +++ b/docs/admin/integrations/jfrog-xray.md @@ -3,8 +3,6 @@ March 17, 2024 diff --git a/docs/admin/integrations/vault.md b/docs/admin/integrations/vault.md index 4894a7ebda0a1..74229bd6d8a79 100644 --- a/docs/admin/integrations/vault.md +++ b/docs/admin/integrations/vault.md @@ -3,8 +3,6 @@ August 05, 2024 diff --git a/docs/admin/monitoring/health-check.md b/docs/admin/monitoring/health-check.md index 456d52e0bce8b..3139697fec388 100644 --- a/docs/admin/monitoring/health-check.md +++ b/docs/admin/monitoring/health-check.md @@ -300,8 +300,7 @@ that they are able to successfully connect to Coder. Otherwise, ensure is set to a value greater than 0. > [!NOTE] -> This may be a transient issue if you are currently in the process of -updating your deployment. +> This may be a transient issue if you are currently in the process of updating your deployment. ### EPD02 @@ -316,8 +315,7 @@ of API incompatibility. version of Coder. > [!NOTE] -> This may be a transient issue if you are currently in the process of -updating your deployment. +> This may be a transient issue if you are currently in the process of updating your deployment. ### EPD03 @@ -332,8 +330,7 @@ connect to Coder. version of Coder. > [!NOTE] -> This may be a transient issue if you are currently in the process of -updating your deployment. +> This may be a transient issue if you are currently in the process of updating your deployment. ### EUNKNOWN diff --git a/docs/admin/security/index.md b/docs/admin/security/index.md index 84d89d0c34668..37028093f8c57 100644 --- a/docs/admin/security/index.md +++ b/docs/admin/security/index.md @@ -9,8 +9,7 @@ For other security tips, visit our guide to > [!CAUTION] > If you discover a vulnerability in Coder, please do not hesitate to report it -> to us by following the instructions -> [here](https://github.com/coder/coder/blob/main/SECURITY.md). +> to us by following the [security policy](https://github.com/coder/coder/blob/main/SECURITY.md). From time to time, Coder employees or other community members may discover vulnerabilities in the product. diff --git a/docs/admin/templates/extending-templates/docker-in-workspaces.md b/docs/admin/templates/extending-templates/docker-in-workspaces.md index 51b1634d20371..073049ba0ecdc 100644 --- a/docs/admin/templates/extending-templates/docker-in-workspaces.md +++ b/docs/admin/templates/extending-templates/docker-in-workspaces.md @@ -200,7 +200,7 @@ Before using Podman, please review the following documentation: - [Shortcomings of Rootless Podman](https://github.com/containers/podman/blob/main/rootless.md#shortcomings-of-rootless-podman) 1. Enable - [smart-device-manager](https://gitlab.com/arm-research/smarter/smarter-device-manager#enabling-access) + [smart-device-manager](https://github.com/smarter-project/smarter-device-manager#enabling-access) to securely expose a FUSE devices to pods. ```shell diff --git a/docs/admin/templates/extending-templates/modules.md b/docs/admin/templates/extending-templates/modules.md index 1f454bb26540c..d7ed472831662 100644 --- a/docs/admin/templates/extending-templates/modules.md +++ b/docs/admin/templates/extending-templates/modules.md @@ -54,14 +54,14 @@ For a full list of available modules please check ## Offline installations -In offline and restricted deploymnets, there are 2 ways to fetch modules. +In offline and restricted deployments, there are two ways to fetch modules. 1. Artifactory 2. Private git repository ### Artifactory -Air gapped users can clone the [coder/modules](https://github.com/coder/modules) +Air gapped users can clone the [coder/registry](https://github.com/coder/registry/) repo and publish a [local terraform module repository](https://jfrog.com/help/r/jfrog-artifactory-documentation/set-up-a-terraform-module/provider-registry) to resolve modules via [Artifactory](https://jfrog.com/artifactory/). @@ -71,8 +71,8 @@ to resolve modules via [Artifactory](https://jfrog.com/artifactory/). 3. Follow the below instructions to publish coder modules to Artifactory ```shell - git clone https://github.com/coder/modules - cd modules + git clone https://github.com/coder/registry + cd registry/coder/modules jf tfc jf tf p --namespace="coder" --provider="coder" --tag="1.0.0" ``` diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index 123a5944c0e08..47ee36bad65ac 100644 --- a/docs/admin/users/idp-sync.md +++ b/docs/admin/users/idp-sync.md @@ -595,3 +595,15 @@ user is granted the necessary permissions to obtain refresh tokens. By combining the `{"access_type":"offline"}` parameter in the OIDC Auth URL with the `offline_access` scope, you can achieve the desired behavior of obtaining refresh tokens for offline access to the user's resources. + +### Google + +To ensure Coder receives a refresh token when users authenticate with Google +directly, set the `prompt` to `consent` in the auth URL parameters. Without +this, users will be logged out after 1 hour. + +In your Coder configuration: + +```shell +CODER_OIDC_AUTH_URL_PARAMS='{"access_type": "offline", "prompt": "consent"}' +``` diff --git a/docs/ai-coder/custom-agents.md b/docs/ai-coder/custom-agents.md index 451c47689b6b0..3badc20cd8066 100644 --- a/docs/ai-coder/custom-agents.md +++ b/docs/ai-coder/custom-agents.md @@ -40,10 +40,10 @@ any-custom-agent configure-mcp --name "coder" --command "coder exp mcp server" This will start the MCP server and report activity back to the Coder control plane on behalf of the coder_app resource. -> See the [Goose module](https://github.com/coder/modules/blob/main/goose/main.tf) source code for a real world example. +> See the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf) source code for a real world example. ## Contributing We welcome contributions for various agents via the [Coder registry](https://registry.coder.com/modules?tag=agent)! -See our [contributing guide](https://github.com/coder/modules/blob/main/CONTRIBUTING.md) for more information. +See our [contributing guide](https://github.com/coder/registry/blob/main/CONTRIBUTING.md) for more information. diff --git a/docs/contributing/SECURITY.md b/docs/contributing/SECURITY.md deleted file mode 100644 index 7344f126449fe..0000000000000 --- a/docs/contributing/SECURITY.md +++ /dev/null @@ -1,4 +0,0 @@ -# Security Policy - -If you find a vulnerability, **DO NOT FILE AN ISSUE**. Instead, send an email to -. diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 92e97e3cf902c..1a920f96e1bca 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -133,7 +133,7 @@ We support two release channels: mainline and stable - read the helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.22.1 + --version 2.23.1 ``` - **Stable** Coder release: @@ -144,7 +144,7 @@ We support two release channels: mainline and stable - read the helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.19.0 + --version 2.22.1 ``` You can watch Coder start up by running `kubectl get pods -n coder`. Once Coder diff --git a/docs/install/releases/index.md b/docs/install/releases/index.md index 96c6c4f03120b..c23bbc25367ab 100644 --- a/docs/install/releases/index.md +++ b/docs/install/releases/index.md @@ -57,13 +57,13 @@ pages. | Release name | Release Date | Status | Latest Release | |------------------------------------------------|-------------------|------------------|----------------------------------------------------------------| -| [2.17](https://coder.com/changelog/coder-2-17) | November 04, 2024 | Not Supported | [v2.17.3](https://github.com/coder/coder/releases/tag/v2.17.3) | | [2.18](https://coder.com/changelog/coder-2-18) | December 03, 2024 | Not Supported | [v2.18.5](https://github.com/coder/coder/releases/tag/v2.18.5) | | [2.19](https://coder.com/changelog/coder-2-19) | February 04, 2025 | Not Supported | [v2.19.3](https://github.com/coder/coder/releases/tag/v2.19.3) | -| [2.20](https://coder.com/changelog/coder-2-20) | March 04, 2025 | Security Support | [v2.20.3](https://github.com/coder/coder/releases/tag/v2.20.3) | -| [2.21](https://coder.com/changelog/coder-2-21) | April 02, 2025 | Stable | [v2.21.3](https://github.com/coder/coder/releases/tag/v2.21.3) | -| [2.22](https://coder.com/changelog/coder-2-22) | May 16, 2025 | Mainline | [v2.22.0](https://github.com/coder/coder/releases/tag/v2.22.0) | -| 2.23 | | Not Released | N/A | +| [2.20](https://coder.com/changelog/coder-2-20) | March 04, 2025 | Not Supported | [v2.20.3](https://github.com/coder/coder/releases/tag/v2.20.3) | +| [2.21](https://coder.com/changelog/coder-2-21) | April 02, 2025 | Security Support | [v2.21.3](https://github.com/coder/coder/releases/tag/v2.21.3) | +| [2.22](https://coder.com/changelog/coder-2-22) | May 16, 2025 | Stable | [v2.22.1](https://github.com/coder/coder/releases/tag/v2.22.1) | +| [2.23](https://coder.com/changelog/coder-2-23) | June 03, 2025 | Mainline | [v2.23.1](https://github.com/coder/coder/releases/tag/v2.23.1) | +| 2.24 | | Not Released | N/A | > [!TIP] diff --git a/docs/manifest.json b/docs/manifest.json index 0133eb31c1c9a..e100a561aa40c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -7,15 +7,65 @@ "path": "./README.md", "icon_path": "./images/icons/home.svg", "children": [ + { + "title": "Screenshots", + "description": "View screenshots of the Coder platform", + "path": "./about/screenshots.md" + }, { "title": "Quickstart", "description": "Learn how to install and run Coder quickly", "path": "./tutorials/quickstart.md" }, { - "title": "Screenshots", - "description": "View screenshots of the Coder platform", - "path": "./start/screenshots.md" + "title": "Support", + "description": "How Coder supports your deployment and you", + "path": "./support/index.md", + "children": [ + { + "title": "Generate a Support Bundle", + "description": "Generate and upload a Support Bundle to Coder Support", + "path": "./support/support-bundle.md" + } + ] + }, + { + "title": "Contributing", + "description": "Learn how to contribute to Coder", + "path": "./about/contributing/CONTRIBUTING.md", + "icon_path": "./images/icons/contributing.svg", + "children": [ + { + "title": "Code of Conduct", + "description": "See the code of conduct for contributing to Coder", + "path": "./about/contributing/CODE_OF_CONDUCT.md", + "icon_path": "./images/icons/circle-dot.svg" + }, + { + "title": "Documentation", + "description": "Our style guide for use when authoring documentation", + "path": "./about/contributing/documentation.md", + "icon_path": "./images/icons/document.svg" + }, + { + "title": "Backend", + "description": "Our guide for backend development", + "path": "./about/contributing/backend.md", + "icon_path": "./images/icons/gear.svg" + }, + { + "title": "Frontend", + "description": "Our guide for frontend development", + "path": "./about/contributing/frontend.md", + "icon_path": "./images/icons/frontend.svg" + }, + { + "title": "Security", + "description": "Security vulnerability disclosure policy", + "path": "./about/contributing/SECURITY.md", + "icon_path": "./images/icons/lock.svg" + } + ] } ] }, @@ -810,44 +860,6 @@ } ] }, - { - "title": "Contributing", - "description": "Learn how to contribute to Coder", - "path": "./CONTRIBUTING.md", - "icon_path": "./images/icons/contributing.svg", - "children": [ - { - "title": "Code of Conduct", - "description": "See the code of conduct for contributing to Coder", - "path": "./contributing/CODE_OF_CONDUCT.md", - "icon_path": "./images/icons/circle-dot.svg" - }, - { - "title": "Documentation", - "description": "Our style guide for use when authoring documentation", - "path": "./contributing/documentation.md", - "icon_path": "./images/icons/document.svg" - }, - { - "title": "Backend", - "description": "Our guide for backend development", - "path": "./contributing/backend.md", - "icon_path": "./images/icons/gear.svg" - }, - { - "title": "Frontend", - "description": "Our guide for frontend development", - "path": "./contributing/frontend.md", - "icon_path": "./images/icons/frontend.svg" - }, - { - "title": "Security", - "description": "Our guide for security", - "path": "./contributing/SECURITY.md", - "icon_path": "./images/icons/lock.svg" - } - ] - }, { "title": "Tutorials", "description": "Coder knowledgebase for administrating your deployment", @@ -874,11 +886,6 @@ "description": "Learn about image management with Coder", "path": "./admin/templates/managing-templates/image-management.md" }, - { - "title": "Generate a Support Bundle", - "description": "Generate and upload a Support Bundle to Coder Support", - "path": "./tutorials/support-bundle.md" - }, { "title": "Configuring Okta", "description": "Custom claims/scopes with Okta for group/role sync", diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 12454145569bb..e0fb97a1513e0 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -454,6 +454,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6b0f8254a720c..a5b759e5dfb0c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2625,6 +2625,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", @@ -3124,6 +3125,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", @@ -3510,7 +3512,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `notifications` | | `workspace-usage` | | `web-push` | -| `dynamic-parameters` | | `workspace-prebuilds` | | `agentic-chat` | | `ai-tasks` | @@ -6767,18 +6768,20 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `default_duration` | integer | false | | Default duration is only for browser, workspace app and oauth sessions. | -| `default_token_lifetime` | integer | false | | | -| `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | -| `max_token_lifetime` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `default_duration` | integer | false | | Default duration is only for browser, workspace app and oauth sessions. | +| `default_token_lifetime` | integer | false | | | +| `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | +| `max_admin_token_lifetime` | integer | false | | | +| `max_token_lifetime` | integer | false | | | ## codersdk.SlimRole diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 1b4052e335e66..8b47ac00dbc7b 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -910,6 +910,17 @@ Periodically check for new releases of Coder and inform the owner. The check is The maximum lifetime duration users can specify when creating an API token. +### --max-admin-token-lifetime + +| | | +|-------------|----------------------------------------------------| +| Type | duration | +| Environment | $CODER_MAX_ADMIN_TOKEN_LIFETIME | +| YAML | networking.http.maxAdminTokenLifetime | +| Default | 168h0m0s | + +The maximum lifetime duration administrators can specify when creating an API token. + ### --default-token-lifetime | | | diff --git a/docs/support/index.md b/docs/support/index.md new file mode 100644 index 0000000000000..28787b364f3e1 --- /dev/null +++ b/docs/support/index.md @@ -0,0 +1,5 @@ +# Support + +If you have questions, encounter an issue or bug, or if you have a feature request, [open a GitHub issue](https://github.com/coder/coder/issues/new) or [join our Discord](https://discord.gg/coder). + + diff --git a/docs/tutorials/support-bundle.md b/docs/support/support-bundle.md similarity index 100% rename from docs/tutorials/support-bundle.md rename to docs/support/support-bundle.md diff --git a/docs/tutorials/azure-federation.md b/docs/tutorials/azure-federation.md index 18726af617bd8..0ac02495dbe5f 100644 --- a/docs/tutorials/azure-federation.md +++ b/docs/tutorials/azure-federation.md @@ -3,7 +3,6 @@ January 26, 2024 diff --git a/docs/tutorials/cloning-git-repositories.md b/docs/tutorials/cloning-git-repositories.md index 274476b5194b0..f67b8a97ca64f 100644 --- a/docs/tutorials/cloning-git-repositories.md +++ b/docs/tutorials/cloning-git-repositories.md @@ -4,7 +4,6 @@ Author: Bruno Quaresma - Bruno Quaresma August 06, 2024 diff --git a/docs/tutorials/configuring-okta.md b/docs/tutorials/configuring-okta.md index fa6e6c74c0601..349c1321b0693 100644 --- a/docs/tutorials/configuring-okta.md +++ b/docs/tutorials/configuring-okta.md @@ -4,7 +4,6 @@ Author: Steven Masley - Steven Masley December 13, 2023 diff --git a/docs/tutorials/example-guide.md b/docs/tutorials/example-guide.md index f287c265efc2f..71d5ff15cd321 100644 --- a/docs/tutorials/example-guide.md +++ b/docs/tutorials/example-guide.md @@ -3,7 +3,6 @@ December 13, 2023 diff --git a/docs/tutorials/gcp-to-aws.md b/docs/tutorials/gcp-to-aws.md index f1bde4616fd50..c1e767494ed80 100644 --- a/docs/tutorials/gcp-to-aws.md +++ b/docs/tutorials/gcp-to-aws.md @@ -3,7 +3,6 @@ January 4, 2024 diff --git a/docs/tutorials/image-pull-secret.md b/docs/tutorials/image-pull-secret.md index f100ada2b4e0e..a8802bf2f2c52 100644 --- a/docs/tutorials/image-pull-secret.md +++ b/docs/tutorials/image-pull-secret.md @@ -3,7 +3,6 @@ January 12, 2024 diff --git a/docs/tutorials/postgres-ssl.md b/docs/tutorials/postgres-ssl.md index 9160ef5d44459..5cb8ec620e04b 100644 --- a/docs/tutorials/postgres-ssl.md +++ b/docs/tutorials/postgres-ssl.md @@ -3,7 +3,6 @@ February 24, 2024 diff --git a/docs/tutorials/reverse-proxy-caddy.md b/docs/tutorials/reverse-proxy-caddy.md index 5f14745f4868c..d915687cad428 100644 --- a/docs/tutorials/reverse-proxy-caddy.md +++ b/docs/tutorials/reverse-proxy-caddy.md @@ -39,7 +39,7 @@ certificates, you'll need a domain name that resolves to your Caddy server. condition: service_healthy database: - image: "postgres:16" + image: "postgres:17" ports: - "5432:5432" environment: diff --git a/docs/tutorials/testing-templates.md b/docs/tutorials/testing-templates.md index 1ab617161d319..bcfa33a74e16f 100644 --- a/docs/tutorials/testing-templates.md +++ b/docs/tutorials/testing-templates.md @@ -3,8 +3,6 @@ November 15, 2024 diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index 76c1c77120487..1bf4d9d8c9927 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -140,7 +140,7 @@ Supported IDEs: Our [Module Registry](https://registry.coder.com/modules) also hosts a variety of tools for extending the capability of your workspace. If you have a request for a new IDE or tool, please file an issue in our -[Modules repo](https://github.com/coder/modules/issues). +[Modules repo](https://github.com/coder/registry/issues). ## Ports and Port forwarding diff --git a/dogfood/coder/Dockerfile b/dogfood/coder/Dockerfile index b02775af02fc8..1909722459a18 100644 --- a/dogfood/coder/Dockerfile +++ b/dogfood/coder/Dockerfile @@ -87,7 +87,7 @@ RUN apt-get update && \ rm -rf /tmp/go/src # alpine:3.18 -FROM gcr.io/coder-dev-1/alpine@sha256:25fad2a32ad1f6f510e528448ae1ec69a28ef81916a004d3629874104f8a7f70 AS proto +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 && \ diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index c983102eb9fca..af4417b78c04f 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "~> 2.0" + version = "~> 2.5" } docker = { source = "kreuzwerker/docker" @@ -267,21 +267,23 @@ module "personalize" { module "code-server" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.dev.id folder = local.repo_dir auto_install_extensions = true + group = "Web Editors" } module "vscode-web" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/vscode-web/coder" - version = "1.1.0" + version = "1.2.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 + group = "Web Editors" } module "jetbrains" { diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index d11304742d974..edacc0c43fc0b 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -333,6 +333,10 @@ NETWORKING / HTTP OPTIONS: The maximum lifetime duration users can specify when creating an API token. + --max-admin-token-lifetime duration, $CODER_MAX_ADMIN_TOKEN_LIFETIME (default: 168h0m0s) + The maximum lifetime duration administrators can specify when creating + an API token. + --proxy-health-interval duration, $CODER_PROXY_HEALTH_INTERVAL (default: 1m0s) The interval in which coderd should be checking the status of workspace proxies. diff --git a/enterprise/coderd/parameters_test.go b/enterprise/coderd/parameters_test.go index 605385430e779..5fc0eaa4aa369 100644 --- a/enterprise/coderd/parameters_test.go +++ b/enterprise/coderd/parameters_test.go @@ -21,8 +21,6 @@ import ( func TestDynamicParametersOwnerGroups(t *testing.T) { t.Parallel() - cfg := coderdtest.DeploymentValues(t) - cfg.Experiments = []string{string(codersdk.ExperimentDynamicParameters)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ LicenseOptions: &coderdenttest.LicenseOptions{ @@ -30,7 +28,7 @@ func TestDynamicParametersOwnerGroups(t *testing.T) { codersdk.FeatureTemplateRBAC: 1, }, }, - Options: &coderdtest.Options{IncludeProvisionerDaemon: true, DeploymentValues: cfg}, + Options: &coderdtest.Options{IncludeProvisionerDaemon: true}, }, ) templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 9039d2e97dbc5..30f4ddd66d91c 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -384,7 +384,9 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) }) // Log the request immediately instead of after it completes. - loggermw.RequestLoggerFromContext(ctx).WriteLog(ctx, http.StatusAccepted) + if rl := loggermw.RequestLoggerFromContext(ctx); rl != nil { + rl.WriteLog(ctx, http.StatusAccepted) + } err = server.Serve(ctx, session) srvCancel() diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 226232f37bf7f..ce86151f9b883 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -1698,7 +1698,7 @@ func TestWorkspaceTemplateParamsChange(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}) dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentDynamicParameters)} + client, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ Logger: &logger, @@ -1736,6 +1736,12 @@ func TestWorkspaceTemplateParamsChange(t *testing.T) { require.NoError(t, err, "failed to create template version") coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, tv.ID) tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, tv.ID) + + // Set to dynamic params + tpl, err = client.UpdateTemplateMeta(ctx, tpl.ID, codersdk.UpdateTemplateMeta{ + UseClassicParameterFlow: ptr.Ref(false), + }) + require.NoError(t, err, "failed to update template meta") require.False(t, tpl.UseClassicParameterFlow, "template to use dynamic parameters") // When: we create a workspace build using the above template but with diff --git a/go.mod b/go.mod index 584b7f08cc373..c42b8f5f23cdd 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ replace github.com/tcnksm/go-httpstat => github.com/coder/go-httpstat v0.0.0-202 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250422090654-5090e715905e +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c // This is replaced to include // 1. a fix for a data race: c.f. https://github.com/tailscale/wireguard-go/pull/25 @@ -170,7 +170,7 @@ require ( github.com/prometheus/common v0.63.0 github.com/quasilyte/go-ruleguard/dsl v0.3.22 github.com/robfig/cron/v3 v3.0.1 - github.com/shirou/gopsutil/v4 v4.25.2 + github.com/shirou/gopsutil/v4 v4.25.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/afero v1.14.0 github.com/spf13/pflag v1.0.6 @@ -207,9 +207,9 @@ require ( golang.org/x/tools v0.33.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da google.golang.org/api v0.231.0 - google.golang.org/grpc v1.72.1 + google.golang.org/grpc v1.73.0 google.golang.org/protobuf v1.36.6 - gopkg.in/DataDog/dd-trace-go.v1 v1.73.0 + gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc @@ -226,17 +226,17 @@ require ( dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/DataDog/appsec-internal-go v1.9.0 // indirect - github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.0-rc.1 // indirect - github.com/DataDog/datadog-agent/pkg/proto v0.64.0-rc.1 // indirect - github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.0-rc.1 // indirect - github.com/DataDog/datadog-agent/pkg/trace v0.64.0-rc.1 // indirect - github.com/DataDog/datadog-agent/pkg/util/log v0.64.0-rc.1 // indirect - github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.0-rc.1 // indirect + github.com/DataDog/appsec-internal-go v1.11.2 // indirect + github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.2 // indirect + github.com/DataDog/datadog-agent/pkg/proto v0.64.2 // indirect + github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.2 // indirect + github.com/DataDog/datadog-agent/pkg/trace v0.64.2 // indirect + github.com/DataDog/datadog-agent/pkg/util/log v0.64.2 // indirect + github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.2 // indirect github.com/DataDog/datadog-go/v5 v5.6.0 // indirect - github.com/DataDog/go-libddwaf/v3 v3.5.3 // indirect - github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20241206090539-a14610dc22b6 // indirect - github.com/DataDog/go-sqllexer v0.1.0 // indirect + github.com/DataDog/go-libddwaf/v3 v3.5.4 // indirect + github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20250319104955-81009b9bad14 // indirect + github.com/DataDog/go-sqllexer v0.1.3 // indirect github.com/DataDog/go-tuf v1.1.0-0.5.2 // indirect github.com/DataDog/gostackparse v0.7.0 // indirect github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0 // indirect @@ -291,7 +291,7 @@ require ( github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd // indirect github.com/dustin/go-humanize v1.0.1 github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect - github.com/ebitengine/purego v0.8.2 // indirect + github.com/ebitengine/purego v0.8.3 // indirect github.com/elastic/go-windows v1.0.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -323,7 +323,7 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/nftables v0.2.0 // indirect - github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect @@ -336,9 +336,6 @@ require ( github.com/hashicorp/go-cty v1.5.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect - github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect - github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/go-terraform-address v0.0.0-20240523040243-ccea9d309e0c github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect @@ -360,7 +357,7 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a // indirect + github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -404,7 +401,6 @@ require ( github.com/riandyrn/otelchi v0.5.1 // indirect github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/ryanuber/go-glob v1.0.0 // indirect github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -425,8 +421,8 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tinylib/msgp v1.2.5 // indirect - github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.8.0 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a // indirect github.com/vishvananda/netlink v1.2.1-beta.2 // indirect github.com/vishvananda/netns v0.0.4 // indirect @@ -445,10 +441,10 @@ require ( github.com/zclconf/go-cty v1.16.3 github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/collector/component v0.120.0 // indirect - go.opentelemetry.io/collector/pdata v1.26.0 // indirect - go.opentelemetry.io/collector/pdata/pprofile v0.120.0 // indirect - go.opentelemetry.io/collector/semconv v0.120.0 // indirect + go.opentelemetry.io/collector/component v1.27.0 // indirect + go.opentelemetry.io/collector/pdata v1.27.0 // indirect + go.opentelemetry.io/collector/pdata/pprofile v0.121.0 // indirect + go.opentelemetry.io/collector/semconv v0.123.0 // indirect go.opentelemetry.io/contrib v1.19.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect @@ -462,7 +458,7 @@ require ( golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -485,7 +481,7 @@ require ( require ( github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 - github.com/coder/preview v0.0.2-0.20250604144457-c9862a17f652 + github.com/coder/preview v0.0.2-0.20250611164554-2e5caa65a54a github.com/fsnotify/fsnotify v1.9.0 github.com/kylecarbs/aisdk-go v0.0.8 github.com/mark3labs/mcp-go v0.31.0 @@ -494,22 +490,24 @@ require ( ) require ( - cel.dev/expr v0.20.0 // indirect + cel.dev/expr v0.23.0 // indirect cloud.google.com/go v0.120.0 // indirect cloud.google.com/go/iam v1.4.1 // indirect cloud.google.com/go/monitoring v1.24.0 // indirect cloud.google.com/go/storage v1.50.0 // indirect - github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.0-rc.1 // indirect - github.com/DataDog/datadog-agent/pkg/version v0.64.0-rc.1 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 // indirect + github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2 // indirect + github.com/DataDog/datadog-agent/pkg/version v0.64.2 // indirect + github.com/DataDog/dd-trace-go/v2 v2.0.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect 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/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 github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect + github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect @@ -526,8 +524,8 @@ require ( github.com/ulikunitz/xz v0.5.12 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect + 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 - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect ) diff --git a/go.sum b/go.sum index c48ca26edd6ce..996f5de14158b 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb h1:4MKA8lBQLnCqj2myJCb5Lzoa65y0tABO4gHrxuMdsCQ= cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ= -cel.dev/expr v0.20.0 h1:OunBvVCfvpWlt4dN7zg3FM6TDkzOePe1+foGJ9AXeeI= -cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss= +cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -630,32 +630,34 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/DataDog/appsec-internal-go v1.9.0 h1:cGOneFsg0JTRzWl5U2+og5dbtyW3N8XaYwc5nXe39Vw= -github.com/DataDog/appsec-internal-go v1.9.0/go.mod h1:wW0cRfWBo4C044jHGwYiyh5moQV2x0AhnwqMuiX7O/g= -github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.0-rc.1 h1:XHITEDEb6NVc9n+myS8KJhdK0vKOvY0BTWSFrFynm4s= -github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.0-rc.1/go.mod h1:lzCtnMSGZm/3RMk5RBRW/6IuK1TNbDXx1ttHTxN5Ykc= -github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.0-rc.1 h1:63L66uiNazsZs1DCmb5aDv/YAkCqn6xKqc0aYeATkQ8= -github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.0-rc.1/go.mod h1:3BS4G7V1y7jhSgrbqPx2lGxBb/YomYwUP0wjwr+cBHc= -github.com/DataDog/datadog-agent/pkg/proto v0.64.0-rc.1 h1:8+4sv0i+na4QMjggZrQNFspbVHu7iaZU6VWeupPMdbA= -github.com/DataDog/datadog-agent/pkg/proto v0.64.0-rc.1/go.mod h1:q324yHcBN5hIeCU8eoinM7lP9c7MOA2FTj7oeWAl3Pc= -github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.0-rc.1 h1:MpUmwDTz+UQN/Pyng5GwvomH7LYjdcFhVVNMnxT4Rvc= -github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.0-rc.1/go.mod h1:QHiOw0sFriX2whwein+Puv69CqJcbOQnocUBo2IahNk= -github.com/DataDog/datadog-agent/pkg/trace v0.64.0-rc.1 h1:5PbiZw511B+qESc7PxxWY5ubiBtVnLFqC+UZKZAB3xo= -github.com/DataDog/datadog-agent/pkg/trace v0.64.0-rc.1/go.mod h1:AkapH6q9UZLoRQuhlOPiibRFqZtaKPMwtzZwYjjzgK0= -github.com/DataDog/datadog-agent/pkg/util/log v0.64.0-rc.1 h1:5UHDao4MdRwRsf4ZEvMSbgoujHY/2Aj+TQ768ZrPXq8= -github.com/DataDog/datadog-agent/pkg/util/log v0.64.0-rc.1/go.mod h1:ZEm+kWbgm3alAsoVbYFM10a+PIxEW5KoVhV3kwiCuxE= -github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.0-rc.1 h1:yqzXiCXrBXsQrbsFCTele7SgM6nK0bElDmBM0lsueIE= -github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.0-rc.1/go.mod h1:9ZfE6J8Ty8xkgRuoH1ip9kvtlq6UaHwPOqxe9NJbVUE= -github.com/DataDog/datadog-agent/pkg/version v0.64.0-rc.1 h1:eg+XW2CzOwFa//bjoXiw4xhNWWSdEJbMSC4TFcx6lVk= -github.com/DataDog/datadog-agent/pkg/version v0.64.0-rc.1/go.mod h1:DgOVsfSRaNV4GZNl/qgoZjG3hJjoYUNWPPhbfTfTqtY= +github.com/DataDog/appsec-internal-go v1.11.2 h1:Q00pPMQzqMIw7jT2ObaORIxBzSly+deS0Ely9OZ/Bj0= +github.com/DataDog/appsec-internal-go v1.11.2/go.mod h1:9YppRCpElfGX+emXOKruShFYsdPq7WEPq/Fen4tYYpk= +github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2 h1:wEW+nwoLKubvnLLaxMScYO+rEuHGXmvDsrSV9M3aWdU= +github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2/go.mod h1:lzCtnMSGZm/3RMk5RBRW/6IuK1TNbDXx1ttHTxN5Ykc= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.2 h1:xyKB0aTD0S0wp17Egqr8gNUL8btuaKC2WK08NT0pCFQ= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.2/go.mod h1:izbemZjqzBn9upkZj8SyT9igSGPMALaQYgswJ0408vY= +github.com/DataDog/datadog-agent/pkg/proto v0.64.2 h1:JGnb24mKLi+wEJg/bo5FPf1wli3ca2+owIkACl4mwl4= +github.com/DataDog/datadog-agent/pkg/proto v0.64.2/go.mod h1:q324yHcBN5hIeCU8eoinM7lP9c7MOA2FTj7oeWAl3Pc= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.2 h1:bCRz9YBvQTJNeE+eAPLEcuz4p/2aStxAO9lgf1HsivI= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.2/go.mod h1:1AAhFoEuoXs8jfpj7EiGW6lsqvCYgQc0B0pRpYAPEW4= +github.com/DataDog/datadog-agent/pkg/trace v0.64.2 h1:vuwxRGRVnlFYFUoSK5ZV0sHqskJwxknP5/lV+WfkSSw= +github.com/DataDog/datadog-agent/pkg/trace v0.64.2/go.mod h1:e0wLYMuXKwS/yorq1FqTDGR9WFj9RzwCMwUrli7mCAw= +github.com/DataDog/datadog-agent/pkg/util/log v0.64.2 h1:Sx+L6L2h/HN4UZwAFQMYt4eHkaLHe6THj6GUADLgkm0= +github.com/DataDog/datadog-agent/pkg/util/log v0.64.2/go.mod h1:XDJfRmc5FwFNLDFHtOKX8AW8W1N8Yk+V/wPwj98Zi6Q= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.2 h1:5jGvehYy2VVYJCMED3Dj6zIZds4g0O8PMf5uIMAwoAY= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.2/go.mod h1:uzxlZdxJ2yZZ9k+hDM4PyG3tYacoeneZuh+PVk+IVAw= +github.com/DataDog/datadog-agent/pkg/version v0.64.2 h1:clAPToUGyhFWJIfN6pBR808YigQsDP6hNcpEcu8qbtU= +github.com/DataDog/datadog-agent/pkg/version v0.64.2/go.mod h1:DgOVsfSRaNV4GZNl/qgoZjG3hJjoYUNWPPhbfTfTqtY= github.com/DataDog/datadog-go/v5 v5.6.0 h1:2oCLxjF/4htd55piM75baflj/KoE6VYS7alEUqFvRDw= github.com/DataDog/datadog-go/v5 v5.6.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= -github.com/DataDog/go-libddwaf/v3 v3.5.3 h1:UzIUhr/9SnRpDkxE18VeU6Fu4HiDv9yIR5R36N/LwVI= -github.com/DataDog/go-libddwaf/v3 v3.5.3/go.mod h1:HoLUHdj0NybsPBth/UppTcg8/DKA4g+AXuk8cZ6nuoo= -github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20241206090539-a14610dc22b6 h1:bpitH5JbjBhfcTG+H2RkkiUXpYa8xSuIPnyNtTaSPog= -github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20241206090539-a14610dc22b6/go.mod h1:quaQJ+wPN41xEC458FCpTwyROZm3MzmTZ8q8XOXQiPs= -github.com/DataDog/go-sqllexer v0.1.0 h1:QGBH68R4PFYGUbZjNjsT4ESHCIhO9Mmiz+SMKI7DzaY= -github.com/DataDog/go-sqllexer v0.1.0/go.mod h1:KwkYhpFEVIq+BfobkTC1vfqm4gTi65skV/DpDBXtexc= +github.com/DataDog/dd-trace-go/v2 v2.0.0 h1:cHMEzD0Wcgtu+Rec9d1GuVgpIN5f+4vCaNzuFHJ0v+Y= +github.com/DataDog/dd-trace-go/v2 v2.0.0/go.mod h1:WBtf7TA9bWr5uA8DjOyw1qlSKe3bw9gN5nc0Ta9dHFE= +github.com/DataDog/go-libddwaf/v3 v3.5.4 h1:cLV5lmGhrUBnHG50EUXdqPQAlJdVCp9n3aQ5bDWJEAg= +github.com/DataDog/go-libddwaf/v3 v3.5.4/go.mod h1:HoLUHdj0NybsPBth/UppTcg8/DKA4g+AXuk8cZ6nuoo= +github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20250319104955-81009b9bad14 h1:tc5aVw7OcMyfVmJnrY4IOeiV1RTSaBuJBqF14BXxzIo= +github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20250319104955-81009b9bad14/go.mod h1:quaQJ+wPN41xEC458FCpTwyROZm3MzmTZ8q8XOXQiPs= +github.com/DataDog/go-sqllexer v0.1.3 h1:Kl2T6QVndMEZqQSY8rkoltYP+LVNaA54N+EwAMc9N5w= +github.com/DataDog/go-sqllexer v0.1.3/go.mod h1:KwkYhpFEVIq+BfobkTC1vfqm4gTi65skV/DpDBXtexc= github.com/DataDog/go-tuf v1.1.0-0.5.2 h1:4CagiIekonLSfL8GMHRHcHudo1fQnxELS9g4tiAupQ4= github.com/DataDog/go-tuf v1.1.0-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0= github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= @@ -664,8 +666,8 @@ github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0 h1:GlvoS github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0/go.mod h1:mYQmU7mbHH6DrCaS8N6GZcxwPoeNfyuopUoLQltwSzs= github.com/DataDog/sketches-go v1.4.7 h1:eHs5/0i2Sdf20Zkj0udVFWuCrXGRFig2Dcfm5rtcTxc= github.com/DataDog/sketches-go v1.4.7/go.mod h1:eAmQ/EBmtSO+nQp7IZMZVRPT4BQTmIc5RZQ+deGlTPM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 h1:f2Qw/Ehhimh5uO1fayV0QIW7DShEQqhtUfhYc+cBPlw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0/go.mod h1:2bIszWvQRlJVmJLiuLhukLImRjKPcYdzzsx6darK02A= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 h1:5IT7xOdq17MtcdtL/vtl6mGfzhaq4m4vpollPRmlsBQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0/go.mod h1:ZV4VOm0/eHR06JLrXWe09068dHpr3TRpY9Uo7T+anuA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.50.0 h1:nNMpRpnkWDAaqcpxMJvxa/Ud98gjbYwayJY4/9bdjiU= @@ -675,9 +677,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= -github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -740,7 +741,6 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs= github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aslilac/afero v0.0.0-20250403163713-f06e86036696 h1:7hAl/81gNUjmSCqJYKe1aTIVY4myjapaSALdCko19tI= @@ -818,7 +818,6 @@ github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= @@ -892,8 +891,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= +github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= 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= @@ -911,8 +910,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.20250604144457-c9862a17f652 h1:GukgWbsop8A3vZXXwYtjJfLOIgLygvFw8I6BF0UuvNo= -github.com/coder/preview v0.0.2-0.20250604144457-c9862a17f652/go.mod h1:nXz3bBwbU8/9NYI4OISUsoLDFlEREtTozYhJq6FAE8E= +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/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= @@ -921,8 +920,8 @@ github.com/coder/serpent v0.10.0 h1:ofVk9FJXSek+SmL3yVE3GoArP83M+1tX+H7S4t8BSuM= github.com/coder/serpent v0.10.0/go.mod h1:cZFW6/fP+kE9nd/oRkEHJpG6sXCtQ+AX7WMMEHv0Y3Q= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= -github.com/coder/tailscale v1.1.1-0.20250422090654-5090e715905e h1:nope/SZfoLB9MCOB9wdCE6gW5+8l3PhFrDC5IWPL8bk= -github.com/coder/tailscale v1.1.1-0.20250422090654-5090e715905e/go.mod h1:1ggFFdHTRjPRu9Yc1yA7nVHBYB50w9Ce7VIXNqcW6Ko= +github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c h1:d/qBIi3Ez7KkopRgNtfdvTMqvqBg47d36qVfkd3C5EQ= +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.5.3 h1:EwqIIQKe/j8bsR4WyDJ3bD0dVdkfVqJ43TwClyGneUU= @@ -997,8 +996,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 h1:8EXxF+tCLqaVk8AOC29zl2mnhQjwyLxxOTuhUazWRsg= github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4/go.mod h1:I5sHm0Y0T1u5YjlyqC5GVArM7aNZRUYtTjmJ8mPJFds= -github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= -github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= +github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elastic/go-sysinfo v1.15.1 h1:zBmTnFEXxIQ3iwcQuk7MzaUotmKRp3OabbbWM8TdzIQ= github.com/elastic/go-sysinfo v1.15.1/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk= github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY= @@ -1288,8 +1287,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= @@ -1346,7 +1345,6 @@ github.com/hashicorp/go-getter v1.7.8 h1:mshVHx1Fto0/MydBekWan5zUipGq7jO0novchgM github.com/hashicorp/go-getter v1.7.8/go.mod h1:2c6CboOEb9jG6YvmC9xdD+tyAFsrUaJPedwXDGr0TM4= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= @@ -1357,13 +1355,6 @@ github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISH github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= -github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs= -github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= -github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= -github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= -github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= -github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= -github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-terraform-address v0.0.0-20240523040243-ccea9d309e0c h1:5v6L/m/HcAZYbrLGYBpPkcCVtDWwIgFxq2+FUmfPxPk= github.com/hashicorp/go-terraform-address v0.0.0-20240523040243-ccea9d309e0c/go.mod h1:xoy1vl2+4YvqSQEkKcFjNYxTk7cll+o1f1t2wxnHIX8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= @@ -1493,8 +1484,8 @@ github.com/liamg/memoryfs v1.6.0 h1:jAFec2HI1PgMTem5gR7UT8zi9u4BfG5jorCRlLH06W8= github.com/liamg/memoryfs v1.6.0/go.mod h1:z7mfqXFQS8eSeBBsFjYLlxYRMRyiPktytvYCYTb3BSk= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a h1:3Bm7EwfUQUvhNeKIkUct/gl9eod1TcXuj8stxvi/GoI= -github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= @@ -1514,7 +1505,6 @@ github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= @@ -1544,7 +1534,6 @@ github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -1553,10 +1542,8 @@ github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -1602,8 +1589,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek= @@ -1678,7 +1663,6 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= @@ -1701,8 +1685,6 @@ github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/riandyrn/otelchi v0.5.1 h1:0/45omeqpP7f/cvdL16GddQBfAEmZvUyl2QzLSE6uYo= github.com/riandyrn/otelchi v0.5.1/go.mod h1:ZxVxNEl+jQ9uHseRYIxKWRb3OY8YXFEu+EkNiiSNUEA= github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 h1:4+LEVOB87y175cLJC/mbsgKmoDOjrBldtXvioEy96WY= @@ -1721,9 +1703,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= -github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= -github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM= @@ -1732,8 +1711,8 @@ github.com/secure-systems-lab/go-securesystemslib v0.9.0 h1:rf1HIbL64nUpEIZnjLZ3 github.com/secure-systems-lab/go-securesystemslib v0.9.0/go.mod h1:DVHKMcZ+V4/woA/peqr+L0joiRXbPpQ042GgJckkFgw= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk= -github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA= +github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw= +github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -1823,10 +1802,10 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= -github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= -github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= -github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a h1:eg5FkNoQp76ZsswyGZ+TjYqA/rhKefxK8BW7XOlQsxo= github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a/go.mod h1:e/8TmrdreH0sZOw2DFKBaUV7bvDWRq6SeM9PzkuVM68= github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= @@ -1917,8 +1896,8 @@ go.nhat.io/otelsql v0.15.0 h1:e2lpIaFPe62Pa1fXZoOWXTvMzcN4SwHwHdCz1wDUG6c= go.nhat.io/otelsql v0.15.0/go.mod h1:IYUaWCLf7c883mzhfVpHXTBn0jxF4TRMkQjX6fqhXJ8= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/collector/component v0.120.0 h1:YHEQ6NuBI6FQHKW24OwrNg2IJ0EUIg4RIuwV5YQ6PSI= -go.opentelemetry.io/collector/component v0.120.0/go.mod h1:Ya5O+5NWG9XdhJPnOVhKtBrNXHN3hweQbB98HH4KPNU= +go.opentelemetry.io/collector/component v1.27.0 h1:6wk0K23YT9lSprX8BH9x5w8ssAORE109ekH/ix2S614= +go.opentelemetry.io/collector/component v1.27.0/go.mod h1:fIyBHoa7vDyZL3Pcidgy45cx24tBe7iHWne097blGgo= go.opentelemetry.io/collector/component/componentstatus v0.120.0 h1:hzKjI9+AIl8A/saAARb47JqabWsge0kMp8NSPNiCNOQ= go.opentelemetry.io/collector/component/componentstatus v0.120.0/go.mod h1:kbuAEddxvcyjGLXGmys3nckAj4jTGC0IqDIEXAOr3Ag= go.opentelemetry.io/collector/component/componenttest v0.120.0 h1:vKX85d3lpxj/RoiFQNvmIpX9lOS80FY5svzOYUyeYX0= @@ -1929,27 +1908,27 @@ go.opentelemetry.io/collector/consumer/consumertest v0.120.0 h1:iPFmXygDsDOjqwdQ go.opentelemetry.io/collector/consumer/consumertest v0.120.0/go.mod h1:HeSnmPfAEBnjsRR5UY1fDTLlSrYsMsUjufg1ihgnFJ0= go.opentelemetry.io/collector/consumer/xconsumer v0.120.0 h1:dzM/3KkFfMBIvad+NVXDV+mA+qUpHyu5c70TFOjDg68= go.opentelemetry.io/collector/consumer/xconsumer v0.120.0/go.mod h1:eOf7RX9CYC7bTZQFg0z2GHdATpQDxI0DP36F9gsvXOQ= -go.opentelemetry.io/collector/pdata v1.26.0 h1:o7nP0RTQOG0LXk55ZZjLrxwjX8x3wHF7Z7xPeOaskEA= -go.opentelemetry.io/collector/pdata v1.26.0/go.mod h1:18e8/xDZsqyj00h/5HM5GLdJgBzzG9Ei8g9SpNoiMtI= -go.opentelemetry.io/collector/pdata/pprofile v0.120.0 h1:lQl74z41MN9a0M+JFMZbJVesjndbwHXwUleVrVcTgc8= -go.opentelemetry.io/collector/pdata/pprofile v0.120.0/go.mod h1:4zwhklS0qhjptF5GUJTWoCZSTYE+2KkxYrQMuN4doVI= +go.opentelemetry.io/collector/pdata v1.27.0 h1:66yI7FYkUDia74h48Fd2/KG2Vk8DxZnGw54wRXykCEU= +go.opentelemetry.io/collector/pdata v1.27.0/go.mod h1:18e8/xDZsqyj00h/5HM5GLdJgBzzG9Ei8g9SpNoiMtI= +go.opentelemetry.io/collector/pdata/pprofile v0.121.0 h1:DFBelDRsZYxEaSoxSRtseAazsHJfqfC/Yl64uPicl2g= +go.opentelemetry.io/collector/pdata/pprofile v0.121.0/go.mod h1:j/fjrd7ybJp/PXkba92QLzx7hykUVmU8x/WJvI2JWSg= go.opentelemetry.io/collector/pdata/testdata v0.120.0 h1:Zp0LBOv3yzv/lbWHK1oht41OZ4WNbaXb70ENqRY7HnE= go.opentelemetry.io/collector/pdata/testdata v0.120.0/go.mod h1:PfezW5Rzd13CWwrElTZRrjRTSgMGUOOGLfHeBjj+LwY= -go.opentelemetry.io/collector/pipeline v0.120.0 h1:QQQbnLCYiuOqmxIRQ11cvFGt+SXq0rypK3fW8qMkzqQ= -go.opentelemetry.io/collector/pipeline v0.120.0/go.mod h1:TO02zju/K6E+oFIOdi372Wk0MXd+Szy72zcTsFQwXl4= +go.opentelemetry.io/collector/pipeline v0.123.0 h1:LDcuCrwhCTx2yROJZqhNmq2v0CFkCkUEvxvvcRW0+2c= +go.opentelemetry.io/collector/pipeline v0.123.0/go.mod h1:TO02zju/K6E+oFIOdi372Wk0MXd+Szy72zcTsFQwXl4= go.opentelemetry.io/collector/processor v0.120.0 h1:No+I65ybBLVy4jc7CxcsfduiBrm7Z6kGfTnekW3hx1A= go.opentelemetry.io/collector/processor v0.120.0/go.mod h1:4zaJGLZCK8XKChkwlGC/gn0Dj4Yke04gQCu4LGbJGro= go.opentelemetry.io/collector/processor/processortest v0.120.0 h1:R+VSVSU59W0/mPAcyt8/h1d0PfWN6JI2KY5KeMICXvo= go.opentelemetry.io/collector/processor/processortest v0.120.0/go.mod h1:me+IVxPsj4IgK99I0pgKLX34XnJtcLwqtgTuVLhhYDI= go.opentelemetry.io/collector/processor/xprocessor v0.120.0 h1:mBznj/1MtNqmu6UpcoXz6a63tU0931oWH2pVAt2+hzo= go.opentelemetry.io/collector/processor/xprocessor v0.120.0/go.mod h1:Nsp0sDR3gE+GAhi9d0KbN0RhOP+BK8CGjBRn8+9d/SY= -go.opentelemetry.io/collector/semconv v0.120.0 h1:iG9N78c2IZN4XOH7ZSdAQJBbaHDTuPnTlbQjKV9uIPY= -go.opentelemetry.io/collector/semconv v0.120.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxDiL45mwX0YAQQWRQ0Qr9U= +go.opentelemetry.io/collector/semconv v0.123.0 h1:hFjhLU1SSmsZ67pXVCVbIaejonkYf5XD/6u4qCQQPtc= +go.opentelemetry.io/collector/semconv v0.123.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxDiL45mwX0YAQQWRQ0Qr9U= go.opentelemetry.io/contrib v1.0.0/go.mod h1:EH4yDYeNoaTqn/8yCWQmfNB78VHfGX2Jt2bvnvzBlGM= go.opentelemetry.io/contrib v1.19.0 h1:rnYI7OEPMWFeM4QCqWQ3InMJ0arWMR1i0Cx9A5hcjYM= go.opentelemetry.io/contrib v1.19.0/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= @@ -2198,7 +2177,6 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -2641,8 +2619,8 @@ google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOl google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 h1:29cjnHVylHwTzH66WfFZqgSQgnxzvWE+jvBwpZCLRxY= google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -2686,8 +2664,8 @@ google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -2709,8 +2687,8 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/DataDog/dd-trace-go.v1 v1.73.0 h1:9s6iGFpUBbotQJtv4wHhgHoLrFFji3m/PPcuvZCFieE= -gopkg.in/DataDog/dd-trace-go.v1 v1.73.0/go.mod h1:MVHzDPBdS141gBKBwXvaa8VOLyfoO/vFTLW71OkGxug= +gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 h1:wScziU1ff6Bnyr8MEyxATPSLJdnLxKz3p6RsA8FUaek= +gopkg.in/DataDog/dd-trace-go.v1 v1.74.0/go.mod h1:ReNBsNfnsjVC7GsCe80zRcykL/n+nxvsNrg3NbjuleM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -2734,8 +2712,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc h1:DXLLFYv/k/xr0rWcwVEvWme1GR36Oc4kNMspg38JeiE= gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -2750,8 +2728,8 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= +k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= kernel.org/pub/linux/libs/security/libcap/cap v1.2.73 h1:Th2b8jljYqkyZKS3aD3N9VpYsQpHuXLgea+SZUIfODA= kernel.org/pub/linux/libs/security/libcap/cap v1.2.73/go.mod h1:hbeKwKcboEsxARYmcy/AdPVN11wmT/Wnpgv4k4ftyqY= kernel.org/pub/linux/libs/security/libcap/psx v1.2.73 h1:SEAEUiPVylTD4vqqi+vtGkSnXeP2FcRO3FoZB1MklMw= @@ -2776,23 +2754,15 @@ modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= -modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= -modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= -modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= diff --git a/provisioner/terraform/modules.go b/provisioner/terraform/modules.go index 363afe3f40fc0..e0da5f1578069 100644 --- a/provisioner/terraform/modules.go +++ b/provisioner/terraform/modules.go @@ -103,6 +103,13 @@ func GetModulesArchive(root fs.FS) ([]byte, error) { if !fileMode.IsRegular() && !fileMode.IsDir() { return nil } + + // .git directories are not needed in the archive and only cause + // hash differences for identical modules. + if fileMode.IsDir() && d.Name() == ".git" { + return fs.SkipDir + } + fileInfo, err := d.Info() if err != nil { return xerrors.Errorf("failed to archive module file %q: %w", filePath, err) diff --git a/provisionerd/proto/provisionerd.proto b/provisionerd/proto/provisionerd.proto index 0accc48f00a58..adab9653ab1ef 100644 --- a/provisionerd/proto/provisionerd.proto +++ b/provisionerd/proto/provisionerd.proto @@ -11,173 +11,173 @@ message Empty {} // AcquiredJob is returned when a provisioner daemon has a job locked. message AcquiredJob { - message WorkspaceBuild { - reserved 3; - - string workspace_build_id = 1; - string workspace_name = 2; - repeated provisioner.RichParameterValue rich_parameter_values = 4; - repeated provisioner.VariableValue variable_values = 5; - repeated provisioner.ExternalAuthProvider external_auth_providers = 6; - provisioner.Metadata metadata = 7; - bytes state = 8; - string log_level = 9; - // previous_parameter_values is used to pass the values of the previous - // workspace build. Omit these values if the workspace is being created - // for the first time. - repeated provisioner.RichParameterValue previous_parameter_values = 10; - } - message TemplateImport { - provisioner.Metadata metadata = 1; - repeated provisioner.VariableValue user_variable_values = 2; - } - message TemplateDryRun { - reserved 1; - - repeated provisioner.RichParameterValue rich_parameter_values = 2; - repeated provisioner.VariableValue variable_values = 3; - provisioner.Metadata metadata = 4; - } - - string job_id = 1; - int64 created_at = 2; - string provisioner = 3; - string user_name = 4; - bytes template_source_archive = 5; - oneof type { - WorkspaceBuild workspace_build = 6; - TemplateImport template_import = 7; - TemplateDryRun template_dry_run = 8; - } - // trace_metadata is currently used for tracing information only. It allows - // jobs to be tied to the request that created them. - map trace_metadata = 9; + message WorkspaceBuild { + reserved 3; + + string workspace_build_id = 1; + string workspace_name = 2; + repeated provisioner.RichParameterValue rich_parameter_values = 4; + repeated provisioner.VariableValue variable_values = 5; + repeated provisioner.ExternalAuthProvider external_auth_providers = 6; + provisioner.Metadata metadata = 7; + bytes state = 8; + string log_level = 9; + // previous_parameter_values is used to pass the values of the previous + // workspace build. Omit these values if the workspace is being created + // for the first time. + repeated provisioner.RichParameterValue previous_parameter_values = 10; + } + message TemplateImport { + provisioner.Metadata metadata = 1; + repeated provisioner.VariableValue user_variable_values = 2; + } + message TemplateDryRun { + reserved 1; + + repeated provisioner.RichParameterValue rich_parameter_values = 2; + repeated provisioner.VariableValue variable_values = 3; + provisioner.Metadata metadata = 4; + } + + string job_id = 1; + int64 created_at = 2; + string provisioner = 3; + string user_name = 4; + bytes template_source_archive = 5; + oneof type { + WorkspaceBuild workspace_build = 6; + TemplateImport template_import = 7; + TemplateDryRun template_dry_run = 8; + } + // trace_metadata is currently used for tracing information only. It allows + // jobs to be tied to the request that created them. + map trace_metadata = 9; } message FailedJob { - message WorkspaceBuild { - bytes state = 1; - repeated provisioner.Timing timings = 2; - } - message TemplateImport {} - message TemplateDryRun {} - - string job_id = 1; - string error = 2; - oneof type { - WorkspaceBuild workspace_build = 3; - TemplateImport template_import = 4; - TemplateDryRun template_dry_run = 5; - } - string error_code = 6; + message WorkspaceBuild { + bytes state = 1; + repeated provisioner.Timing timings = 2; + } + message TemplateImport {} + message TemplateDryRun {} + + string job_id = 1; + string error = 2; + oneof type { + WorkspaceBuild workspace_build = 3; + TemplateImport template_import = 4; + TemplateDryRun template_dry_run = 5; + } + string error_code = 6; } // CompletedJob is sent when the provisioner daemon completes a job. message CompletedJob { - message WorkspaceBuild { - bytes state = 1; - repeated provisioner.Resource resources = 2; - repeated provisioner.Timing timings = 3; - repeated provisioner.Module modules = 4; - repeated provisioner.ResourceReplacement resource_replacements = 5; - } - message TemplateImport { - repeated provisioner.Resource start_resources = 1; - repeated provisioner.Resource stop_resources = 2; - repeated provisioner.RichParameter rich_parameters = 3; - repeated string external_auth_providers_names = 4; - repeated provisioner.ExternalAuthProviderResource external_auth_providers = 5; - repeated provisioner.Module start_modules = 6; - repeated provisioner.Module stop_modules = 7; - repeated provisioner.Preset presets = 8; - bytes plan = 9; - bytes module_files = 10; - } - message TemplateDryRun { - repeated provisioner.Resource resources = 1; - repeated provisioner.Module modules = 2; - } - - string job_id = 1; - oneof type { - WorkspaceBuild workspace_build = 2; - TemplateImport template_import = 3; - TemplateDryRun template_dry_run = 4; - } + message WorkspaceBuild { + bytes state = 1; + repeated provisioner.Resource resources = 2; + repeated provisioner.Timing timings = 3; + repeated provisioner.Module modules = 4; + repeated provisioner.ResourceReplacement resource_replacements = 5; + } + message TemplateImport { + repeated provisioner.Resource start_resources = 1; + repeated provisioner.Resource stop_resources = 2; + repeated provisioner.RichParameter rich_parameters = 3; + repeated string external_auth_providers_names = 4; + repeated provisioner.ExternalAuthProviderResource external_auth_providers = 5; + repeated provisioner.Module start_modules = 6; + repeated provisioner.Module stop_modules = 7; + repeated provisioner.Preset presets = 8; + bytes plan = 9; + bytes module_files = 10; + } + message TemplateDryRun { + repeated provisioner.Resource resources = 1; + repeated provisioner.Module modules = 2; + } + + string job_id = 1; + oneof type { + WorkspaceBuild workspace_build = 2; + TemplateImport template_import = 3; + TemplateDryRun template_dry_run = 4; + } } // LogSource represents the sender of the log. enum LogSource { - PROVISIONER_DAEMON = 0; - PROVISIONER = 1; + PROVISIONER_DAEMON = 0; + PROVISIONER = 1; } // Log represents output from a job. message Log { - LogSource source = 1; - provisioner.LogLevel level = 2; - int64 created_at = 3; - string stage = 4; - string output = 5; + LogSource source = 1; + provisioner.LogLevel level = 2; + int64 created_at = 3; + string stage = 4; + string output = 5; } // This message should be sent periodically as a heartbeat. message UpdateJobRequest { - reserved 3; - - string job_id = 1; - repeated Log logs = 2; - repeated provisioner.TemplateVariable template_variables = 4; - repeated provisioner.VariableValue user_variable_values = 5; - bytes readme = 6; - map workspace_tags = 7; + reserved 3; + + string job_id = 1; + repeated Log logs = 2; + repeated provisioner.TemplateVariable template_variables = 4; + repeated provisioner.VariableValue user_variable_values = 5; + bytes readme = 6; + map workspace_tags = 7; } message UpdateJobResponse { - reserved 2; + reserved 2; - bool canceled = 1; - repeated provisioner.VariableValue variable_values = 3; + bool canceled = 1; + repeated provisioner.VariableValue variable_values = 3; } message CommitQuotaRequest { - string job_id = 1; - int32 daily_cost = 2; + string job_id = 1; + int32 daily_cost = 2; } message CommitQuotaResponse { - bool ok = 1; - int32 credits_consumed = 2; - int32 budget = 3; + bool ok = 1; + int32 credits_consumed = 2; + int32 budget = 3; } message CancelAcquire {} service ProvisionerDaemon { - // AcquireJob requests a job. Implementations should - // hold a lock on the job until CompleteJob() is - // called with the matching ID. - rpc AcquireJob(Empty) returns (AcquiredJob) { - option deprecated = true; - }; - // AcquireJobWithCancel requests a job, blocking until - // a job is available or the client sends CancelAcquire. - // Server will send exactly one AcquiredJob, which is - // empty if a cancel was successful. This RPC is a bidirectional - // stream since both messages are asynchronous with no implied - // ordering. - rpc AcquireJobWithCancel(stream CancelAcquire) returns (stream AcquiredJob); - - rpc CommitQuota(CommitQuotaRequest) returns (CommitQuotaResponse); - - // UpdateJob streams periodic updates for a job. - // Implementations should buffer logs so this stream - // is non-blocking. - rpc UpdateJob(UpdateJobRequest) returns (UpdateJobResponse); - - // FailJob indicates a job has failed. - rpc FailJob(FailedJob) returns (Empty); - - // CompleteJob indicates a job has been completed. - rpc CompleteJob(CompletedJob) returns (Empty); + // AcquireJob requests a job. Implementations should + // hold a lock on the job until CompleteJob() is + // called with the matching ID. + rpc AcquireJob(Empty) returns (AcquiredJob) { + option deprecated = true; + }; + // AcquireJobWithCancel requests a job, blocking until + // a job is available or the client sends CancelAcquire. + // Server will send exactly one AcquiredJob, which is + // empty if a cancel was successful. This RPC is a bidirectional + // stream since both messages are asynchronous with no implied + // ordering. + rpc AcquireJobWithCancel(stream CancelAcquire) returns (stream AcquiredJob); + + rpc CommitQuota(CommitQuotaRequest) returns (CommitQuotaResponse); + + // UpdateJob streams periodic updates for a job. + // Implementations should buffer logs so this stream + // is non-blocking. + rpc UpdateJob(UpdateJobRequest) returns (UpdateJobResponse); + + // FailJob indicates a job has failed. + rpc FailJob(FailedJob) returns (Empty); + + // CompleteJob indicates a job has been completed. + rpc CompleteJob(CompletedJob) returns (Empty); } diff --git a/provisionerd/runner/runner.go b/provisionerd/runner/runner.go index ed1f134556fba..2894dadb8ff0a 100644 --- a/provisionerd/runner/runner.go +++ b/provisionerd/runner/runner.go @@ -552,8 +552,9 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p CreatedAt: time.Now().UnixMilli(), }) startProvision, err := r.runTemplateImportProvision(ctx, updateResponse.VariableValues, &sdkproto.Metadata{ - CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, - WorkspaceTransition: sdkproto.WorkspaceTransition_START, + CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, + WorkspaceOwnerGroups: r.job.GetTemplateImport().Metadata.WorkspaceOwnerGroups, + WorkspaceTransition: sdkproto.WorkspaceTransition_START, }) if err != nil { return nil, r.failedJobf("template import provision for start: %s", err) @@ -567,8 +568,9 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p CreatedAt: time.Now().UnixMilli(), }) stopProvision, err := r.runTemplateImportProvision(ctx, updateResponse.VariableValues, &sdkproto.Metadata{ - CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, - WorkspaceTransition: sdkproto.WorkspaceTransition_STOP, + CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, + WorkspaceOwnerGroups: r.job.GetTemplateImport().Metadata.WorkspaceOwnerGroups, + WorkspaceTransition: sdkproto.WorkspaceTransition_STOP, }) if err != nil { return nil, r.failedJobf("template import provision for stop: %s", err) diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 4d75187aa42da..b305f5d494d8f 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -11,340 +11,340 @@ message Empty {} // TemplateVariable represents a Terraform variable. message TemplateVariable { - string name = 1; - string description = 2; - string type = 3; - string default_value = 4; - bool required = 5; - bool sensitive = 6; + string name = 1; + string description = 2; + string type = 3; + string default_value = 4; + bool required = 5; + bool sensitive = 6; } // RichParameterOption represents a singular option that a parameter may expose. message RichParameterOption { - string name = 1; - string description = 2; - string value = 3; - string icon = 4; + string name = 1; + string description = 2; + string value = 3; + string icon = 4; } enum ParameterFormType { - DEFAULT = 0; - FORM_ERROR = 1; - RADIO = 2; - DROPDOWN = 3; - INPUT = 4; - TEXTAREA = 5; - SLIDER = 6; - CHECKBOX = 7; - SWITCH = 8; - TAGSELECT = 9; - MULTISELECT = 10; + DEFAULT = 0; + FORM_ERROR = 1; + RADIO = 2; + DROPDOWN = 3; + INPUT = 4; + TEXTAREA = 5; + SLIDER = 6; + CHECKBOX = 7; + SWITCH = 8; + TAGSELECT = 9; + MULTISELECT = 10; } // RichParameter represents a variable that is exposed. message RichParameter { - reserved 14; - reserved "legacy_variable_name"; - - string name = 1; - string description = 2; - string type = 3; - bool mutable = 4; - string default_value = 5; - string icon = 6; - repeated RichParameterOption options = 7; - string validation_regex = 8; - string validation_error = 9; - optional int32 validation_min = 10; - optional int32 validation_max = 11; - string validation_monotonic = 12; - bool required = 13; - // legacy_variable_name was removed (= 14) - string display_name = 15; - int32 order = 16; - bool ephemeral = 17; - ParameterFormType form_type = 18; + reserved 14; + reserved "legacy_variable_name"; + + string name = 1; + string description = 2; + string type = 3; + bool mutable = 4; + string default_value = 5; + string icon = 6; + repeated RichParameterOption options = 7; + string validation_regex = 8; + string validation_error = 9; + optional int32 validation_min = 10; + optional int32 validation_max = 11; + string validation_monotonic = 12; + bool required = 13; + // legacy_variable_name was removed (= 14) + string display_name = 15; + int32 order = 16; + bool ephemeral = 17; + ParameterFormType form_type = 18; } // RichParameterValue holds the key/value mapping of a parameter. message RichParameterValue { - string name = 1; - string value = 2; + string name = 1; + string value = 2; } // ExpirationPolicy defines the policy for expiring unclaimed prebuilds. // If a prebuild remains unclaimed for longer than ttl seconds, it is deleted and // recreated to prevent staleness. message ExpirationPolicy { - int32 ttl = 1; + int32 ttl = 1; } message Prebuild { - int32 instances = 1; - ExpirationPolicy expiration_policy = 2; + int32 instances = 1; + ExpirationPolicy expiration_policy = 2; } // Preset represents a set of preset parameters for a template version. message Preset { - string name = 1; - repeated PresetParameter parameters = 2; - Prebuild prebuild = 3; + string name = 1; + repeated PresetParameter parameters = 2; + Prebuild prebuild = 3; } message PresetParameter { - string name = 1; - string value = 2; + string name = 1; + string value = 2; } message ResourceReplacement { - string resource = 1; - repeated string paths = 2; + string resource = 1; + repeated string paths = 2; } // VariableValue holds the key/value mapping of a Terraform variable. message VariableValue { - string name = 1; - string value = 2; - bool sensitive = 3; + string name = 1; + string value = 2; + bool sensitive = 3; } // LogLevel represents severity of the log. enum LogLevel { - TRACE = 0; - DEBUG = 1; - INFO = 2; - WARN = 3; - ERROR = 4; + TRACE = 0; + DEBUG = 1; + INFO = 2; + WARN = 3; + ERROR = 4; } // Log represents output from a request. message Log { - LogLevel level = 1; - string output = 2; + LogLevel level = 1; + string output = 2; } message InstanceIdentityAuth { - string instance_id = 1; + string instance_id = 1; } message ExternalAuthProviderResource { - string id = 1; - bool optional = 2; + string id = 1; + bool optional = 2; } message ExternalAuthProvider { - string id = 1; - string access_token = 2; + string id = 1; + string access_token = 2; } // Agent represents a running agent on the workspace. message Agent { - message Metadata { - string key = 1; - string display_name = 2; - string script = 3; - int64 interval = 4; - int64 timeout = 5; - int64 order = 6; - } - reserved 14; - reserved "login_before_ready"; - - string id = 1; - string name = 2; - map env = 3; - // Field 4 was startup_script, now removed. - string operating_system = 5; - string architecture = 6; - string directory = 7; - repeated App apps = 8; - oneof auth { - string token = 9; - string instance_id = 10; - } - int32 connection_timeout_seconds = 11; - string troubleshooting_url = 12; - string motd_file = 13; - // Field 14 was bool login_before_ready = 14, now removed. - // Field 15, 16, 17 were related to scripts, which are now removed. - repeated Metadata metadata = 18; - // Field 19 was startup_script_behavior, now removed. - DisplayApps display_apps = 20; - repeated Script scripts = 21; - repeated Env extra_envs = 22; - int64 order = 23; - ResourcesMonitoring resources_monitoring = 24; - repeated Devcontainer devcontainers = 25; - string api_key_scope = 26; + message Metadata { + string key = 1; + string display_name = 2; + string script = 3; + int64 interval = 4; + int64 timeout = 5; + int64 order = 6; + } + reserved 14; + reserved "login_before_ready"; + + string id = 1; + string name = 2; + map env = 3; + // Field 4 was startup_script, now removed. + string operating_system = 5; + string architecture = 6; + string directory = 7; + repeated App apps = 8; + oneof auth { + string token = 9; + string instance_id = 10; + } + int32 connection_timeout_seconds = 11; + string troubleshooting_url = 12; + string motd_file = 13; + // Field 14 was bool login_before_ready = 14, now removed. + // Field 15, 16, 17 were related to scripts, which are now removed. + repeated Metadata metadata = 18; + // Field 19 was startup_script_behavior, now removed. + DisplayApps display_apps = 20; + repeated Script scripts = 21; + repeated Env extra_envs = 22; + int64 order = 23; + ResourcesMonitoring resources_monitoring = 24; + repeated Devcontainer devcontainers = 25; + string api_key_scope = 26; } enum AppSharingLevel { - OWNER = 0; - AUTHENTICATED = 1; - PUBLIC = 2; + OWNER = 0; + AUTHENTICATED = 1; + PUBLIC = 2; } message ResourcesMonitoring { - MemoryResourceMonitor memory = 1; - repeated VolumeResourceMonitor volumes = 2; + MemoryResourceMonitor memory = 1; + repeated VolumeResourceMonitor volumes = 2; } message MemoryResourceMonitor { - bool enabled = 1; - int32 threshold = 2; + bool enabled = 1; + int32 threshold = 2; } message VolumeResourceMonitor { - string path = 1; - bool enabled = 2; - int32 threshold = 3; + string path = 1; + bool enabled = 2; + int32 threshold = 3; } message DisplayApps { - bool vscode = 1; - bool vscode_insiders = 2; - bool web_terminal = 3; - bool ssh_helper = 4; - bool port_forwarding_helper = 5; + bool vscode = 1; + bool vscode_insiders = 2; + bool web_terminal = 3; + bool ssh_helper = 4; + bool port_forwarding_helper = 5; } message Env { - string name = 1; - string value = 2; + string name = 1; + string value = 2; } // Script represents a script to be run on the workspace. message Script { - string display_name = 1; - string icon = 2; - string script = 3; - string cron = 4; - bool start_blocks_login = 5; - bool run_on_start = 6; - bool run_on_stop = 7; - int32 timeout_seconds = 8; - string log_path = 9; + string display_name = 1; + string icon = 2; + string script = 3; + string cron = 4; + bool start_blocks_login = 5; + bool run_on_start = 6; + bool run_on_stop = 7; + int32 timeout_seconds = 8; + string log_path = 9; } message Devcontainer { - string workspace_folder = 1; - string config_path = 2; - string name = 3; + string workspace_folder = 1; + string config_path = 2; + string name = 3; } enum AppOpenIn { - WINDOW = 0 [deprecated = true]; - SLIM_WINDOW = 1; - TAB = 2; + WINDOW = 0 [deprecated = true]; + SLIM_WINDOW = 1; + TAB = 2; } // App represents a dev-accessible application on the workspace. message App { - // slug is the unique identifier for the app, usually the name from the - // template. It must be URL-safe and hostname-safe. - string slug = 1; - string display_name = 2; - string command = 3; - string url = 4; - string icon = 5; - bool subdomain = 6; - Healthcheck healthcheck = 7; - AppSharingLevel sharing_level = 8; - bool external = 9; - int64 order = 10; - bool hidden = 11; - AppOpenIn open_in = 12; - string group = 13; + // slug is the unique identifier for the app, usually the name from the + // template. It must be URL-safe and hostname-safe. + string slug = 1; + string display_name = 2; + string command = 3; + string url = 4; + string icon = 5; + bool subdomain = 6; + Healthcheck healthcheck = 7; + AppSharingLevel sharing_level = 8; + bool external = 9; + int64 order = 10; + bool hidden = 11; + AppOpenIn open_in = 12; + string group = 13; } // Healthcheck represents configuration for checking for app readiness. message Healthcheck { - string url = 1; - int32 interval = 2; - int32 threshold = 3; + string url = 1; + int32 interval = 2; + int32 threshold = 3; } // Resource represents created infrastructure. message Resource { - string name = 1; - string type = 2; - repeated Agent agents = 3; - - message Metadata { - string key = 1; - string value = 2; - bool sensitive = 3; - bool is_null = 4; - } - repeated Metadata metadata = 4; - bool hide = 5; - string icon = 6; - string instance_type = 7; - int32 daily_cost = 8; - string module_path = 9; + string name = 1; + string type = 2; + repeated Agent agents = 3; + + message Metadata { + string key = 1; + string value = 2; + bool sensitive = 3; + bool is_null = 4; + } + repeated Metadata metadata = 4; + bool hide = 5; + string icon = 6; + string instance_type = 7; + int32 daily_cost = 8; + string module_path = 9; } message Module { - string source = 1; - string version = 2; - string key = 3; - string dir = 4; + string source = 1; + string version = 2; + string key = 3; + string dir = 4; } // WorkspaceTransition is the desired outcome of a build enum WorkspaceTransition { - START = 0; - STOP = 1; - DESTROY = 2; + START = 0; + STOP = 1; + DESTROY = 2; } message Role { - string name = 1; - string org_id = 2; + string name = 1; + string org_id = 2; } message RunningAgentAuthToken { - string agent_id = 1; - string token = 2; + string agent_id = 1; + string token = 2; } enum PrebuiltWorkspaceBuildStage { - NONE = 0; // Default value for builds unrelated to prebuilds. - CREATE = 1; // A prebuilt workspace is being provisioned. - CLAIM = 2; // A prebuilt workspace is being claimed. + NONE = 0; // Default value for builds unrelated to prebuilds. + CREATE = 1; // A prebuilt workspace is being provisioned. + CLAIM = 2; // A prebuilt workspace is being claimed. } // Metadata is information about a workspace used in the execution of a build message Metadata { - string coder_url = 1; - WorkspaceTransition workspace_transition = 2; - string workspace_name = 3; - string workspace_owner = 4; - string workspace_id = 5; - string workspace_owner_id = 6; - string workspace_owner_email = 7; - string template_name = 8; - string template_version = 9; - string workspace_owner_oidc_access_token = 10; - string workspace_owner_session_token = 11; - string template_id = 12; - string workspace_owner_name = 13; - repeated string workspace_owner_groups = 14; - string workspace_owner_ssh_public_key = 15; - string workspace_owner_ssh_private_key = 16; - string workspace_build_id = 17; - string workspace_owner_login_type = 18; - repeated Role workspace_owner_rbac_roles = 19; - PrebuiltWorkspaceBuildStage prebuilt_workspace_build_stage = 20; // Indicates that a prebuilt workspace is being built. - repeated RunningAgentAuthToken running_agent_auth_tokens = 21; + string coder_url = 1; + WorkspaceTransition workspace_transition = 2; + string workspace_name = 3; + string workspace_owner = 4; + string workspace_id = 5; + string workspace_owner_id = 6; + string workspace_owner_email = 7; + string template_name = 8; + string template_version = 9; + string workspace_owner_oidc_access_token = 10; + string workspace_owner_session_token = 11; + string template_id = 12; + string workspace_owner_name = 13; + repeated string workspace_owner_groups = 14; + string workspace_owner_ssh_public_key = 15; + string workspace_owner_ssh_private_key = 16; + string workspace_build_id = 17; + string workspace_owner_login_type = 18; + repeated Role workspace_owner_rbac_roles = 19; + PrebuiltWorkspaceBuildStage prebuilt_workspace_build_stage = 20; // Indicates that a prebuilt workspace is being built. + repeated RunningAgentAuthToken running_agent_auth_tokens = 21; } // Config represents execution configuration shared by all subsequent requests in the Session message Config { - // template_source_archive is a tar of the template source files - bytes template_source_archive = 1; - // state is the provisioner state (if any) - bytes state = 2; - string provisioner_log_level = 3; + // template_source_archive is a tar of the template source files + bytes template_source_archive = 1; + // state is the provisioner state (if any) + bytes state = 2; + string provisioner_log_level = 3; } // ParseRequest consumes source-code to produce inputs. @@ -353,99 +353,99 @@ message ParseRequest { // ParseComplete indicates a request to parse completed. message ParseComplete { - string error = 1; - repeated TemplateVariable template_variables = 2; - bytes readme = 3; - map workspace_tags = 4; + string error = 1; + repeated TemplateVariable template_variables = 2; + bytes readme = 3; + map workspace_tags = 4; } // PlanRequest asks the provisioner to plan what resources & parameters it will create message PlanRequest { - Metadata metadata = 1; - repeated RichParameterValue rich_parameter_values = 2; - repeated VariableValue variable_values = 3; - repeated ExternalAuthProvider external_auth_providers = 4; - repeated RichParameterValue previous_parameter_values = 5; + Metadata metadata = 1; + repeated RichParameterValue rich_parameter_values = 2; + repeated VariableValue variable_values = 3; + repeated ExternalAuthProvider external_auth_providers = 4; + repeated RichParameterValue previous_parameter_values = 5; } // PlanComplete indicates a request to plan completed. message PlanComplete { - string error = 1; - repeated Resource resources = 2; - repeated RichParameter parameters = 3; - repeated ExternalAuthProviderResource external_auth_providers = 4; - repeated Timing timings = 6; - repeated Module modules = 7; - repeated Preset presets = 8; - bytes plan = 9; - repeated ResourceReplacement resource_replacements = 10; - bytes module_files = 11; + string error = 1; + repeated Resource resources = 2; + repeated RichParameter parameters = 3; + repeated ExternalAuthProviderResource external_auth_providers = 4; + repeated Timing timings = 6; + repeated Module modules = 7; + repeated Preset presets = 8; + bytes plan = 9; + repeated ResourceReplacement resource_replacements = 10; + bytes module_files = 11; } // 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. message ApplyRequest { - Metadata metadata = 1; + Metadata metadata = 1; } // ApplyComplete indicates a request to apply completed. message ApplyComplete { - bytes state = 1; - string error = 2; - repeated Resource resources = 3; - repeated RichParameter parameters = 4; - repeated ExternalAuthProviderResource external_auth_providers = 5; - repeated Timing timings = 6; + bytes state = 1; + string error = 2; + repeated Resource resources = 3; + repeated RichParameter parameters = 4; + repeated ExternalAuthProviderResource external_auth_providers = 5; + repeated Timing timings = 6; } message Timing { - google.protobuf.Timestamp start = 1; - google.protobuf.Timestamp end = 2; - string action = 3; - string source = 4; - string resource = 5; - string stage = 6; - TimingState state = 7; + google.protobuf.Timestamp start = 1; + google.protobuf.Timestamp end = 2; + string action = 3; + string source = 4; + string resource = 5; + string stage = 6; + TimingState state = 7; } enum TimingState { - STARTED = 0; - COMPLETED = 1; - FAILED = 2; + STARTED = 0; + COMPLETED = 1; + FAILED = 2; } // CancelRequest requests that the previous request be canceled gracefully. message CancelRequest {} message Request { - oneof type { - Config config = 1; - ParseRequest parse = 2; - PlanRequest plan = 3; - ApplyRequest apply = 4; - CancelRequest cancel = 5; - } + oneof type { + Config config = 1; + ParseRequest parse = 2; + PlanRequest plan = 3; + ApplyRequest apply = 4; + CancelRequest cancel = 5; + } } message Response { - oneof type { - Log log = 1; - ParseComplete parse = 2; - PlanComplete plan = 3; - ApplyComplete apply = 4; - } + oneof type { + Log log = 1; + ParseComplete parse = 2; + PlanComplete plan = 3; + ApplyComplete apply = 4; + } } service Provisioner { - // Session represents provisioning a single template import or workspace. The daemon always sends Config followed - // by one of the requests (ParseRequest, PlanRequest, ApplyRequest). The provisioner should respond with a stream - // of zero or more Logs, followed by the corresponding complete message (ParseComplete, PlanComplete, - // ApplyComplete). The daemon may then send a new request. A request to apply MUST be preceded by a request plan, - // and the provisioner should store the plan data on the Session after a successful plan, so that the daemon may - // request an apply. If the daemon closes the Session without an apply, the plan data may be safely discarded. - // - // The daemon may send a CancelRequest, asynchronously to ask the provisioner to cancel the previous ParseRequest, - // PlanRequest, or ApplyRequest. The provisioner MUST reply with a complete message corresponding to the request - // that was canceled. If the provisioner has already completed the request, it may ignore the CancelRequest. - rpc Session(stream Request) returns (stream Response); + // Session represents provisioning a single template import or workspace. The daemon always sends Config followed + // by one of the requests (ParseRequest, PlanRequest, ApplyRequest). The provisioner should respond with a stream + // of zero or more Logs, followed by the corresponding complete message (ParseComplete, PlanComplete, + // ApplyComplete). The daemon may then send a new request. A request to apply MUST be preceded by a request plan, + // and the provisioner should store the plan data on the Session after a successful plan, so that the daemon may + // request an apply. If the daemon closes the Session without an apply, the plan data may be safely discarded. + // + // The daemon may send a CancelRequest, asynchronously to ask the provisioner to cancel the previous ParseRequest, + // PlanRequest, or ApplyRequest. The provisioner MUST reply with a complete message corresponding to the request + // that was canceled. If the provisioner has already completed the request, it may ignore the CancelRequest. + rpc Session(stream Request) returns (stream Response); } diff --git a/provisionersdk/session.go b/provisionersdk/session.go index 8c5b8cf40b70d..fe6e3e2ca1f97 100644 --- a/provisionersdk/session.go +++ b/provisionersdk/session.go @@ -100,7 +100,11 @@ func (s *Session) requestReader(done <-chan struct{}) <-chan *proto.Request { for { req, err := s.stream.Recv() if err != nil { - s.Logger.Info(s.Context(), "recv done on Session", slog.Error(err)) + if !xerrors.Is(err, io.EOF) { + s.Logger.Warn(s.Context(), "recv done on Session", slog.Error(err)) + } else { + s.Logger.Info(s.Context(), "recv done on Session") + } return } select { diff --git a/scaletest/templates/scaletest-runner/Dockerfile b/scaletest/templates/scaletest-runner/Dockerfile index 61409c1018654..37b5ddd3b3ca7 100644 --- a/scaletest/templates/scaletest-runner/Dockerfile +++ b/scaletest/templates/scaletest-runner/Dockerfile @@ -1,6 +1,6 @@ # This image is used to run scaletest jobs and, although it is inside # the template directory, it is built separately and pushed to -# gcr.io/coder-dev-1/scaletest-runner:latest. +# us-docker.pkg.dev/coder-v2-images-public/public/scaletest-runner:latest. # # Future improvements will include versioning and including the version # in the template push. diff --git a/scaletest/templates/scaletest-runner/main.tf b/scaletest/templates/scaletest-runner/main.tf index 450fab44dce6c..26d2d490f0a6b 100644 --- a/scaletest/templates/scaletest-runner/main.tf +++ b/scaletest/templates/scaletest-runner/main.tf @@ -822,7 +822,7 @@ resource "kubernetes_pod" "main" { container { name = "dev" - image = "gcr.io/coder-dev-1/scaletest-runner:latest" + image = "us-docker.pkg.dev/coder-v2-images-public/public/scaletest-runner:latest" image_pull_policy = "Always" command = ["sh", "-c", coder_agent.main.init_script] security_context { diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 52e9f5e820f23..0d6c10df500b0 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1011,6 +1011,8 @@ export const updateWorkspace = async ( await page.getByTestId("workspace-update-button").click(); await page.getByTestId("confirm-button").click(); + await page.waitForSelector('[data-testid="dialog"]', { state: "visible" }); + await fillParameters(page, richParameters, buildParameters); await page.getByRole("button", { name: /update parameters/i }).click(); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5463ad7a44dd6..28807bd547c2a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -24,7 +24,10 @@ import type dayjs from "dayjs"; import userAgentParser from "ua-parser-js"; import { OneWayWebSocket } from "../utils/OneWayWebSocket"; import { delay } from "../utils/delay"; -import type { PostWorkspaceUsageRequest } from "./typesGenerated"; +import type { + DynamicParametersRequest, + PostWorkspaceUsageRequest, +} from "./typesGenerated"; import * as TypesGen from "./typesGenerated"; const getMissingParameters = ( @@ -73,8 +76,10 @@ const getMissingParameters = ( if (templateParameter.options.length === 0) { continue; } - - // Check if there is a new value + // For multi-select, extra steps are necessary to JSON parse the value. + if (templateParameter.form_type === "multi-select") { + continue; + } let buildParameter = newBuildParameters.find( (p) => p.name === templateParameter.name, ); @@ -231,7 +236,7 @@ export const watchWorkspaceAgentLogs = ( /** * WebSocket compression in Safari (confirmed in 16.5) is broken when * the server sends large messages. The following error is seen: - * WebSocket connection to 'wss://...' failed: The operation couldn’t be completed. + * WebSocket connection to 'wss://...' failed: The operation couldn't be completed. */ if (userAgentParser(navigator.userAgent).browser.name === "Safari") { searchParams.set("no_compression", ""); @@ -990,6 +995,17 @@ class ApiMethods { return response.data; }; + getTemplateVersionDynamicParameters = async ( + versionId: string, + data: TypesGen.DynamicParametersRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/templateversions/${versionId}/dynamic-parameters/evaluate`, + data, + ); + return response.data; + }; + getTemplateVersionRichParameters = async ( versionId: string, ): Promise => { @@ -2132,6 +2148,38 @@ class ApiMethods { await this.axios.delete(`/api/v2/licenses/${licenseId}`); }; + getDynamicParameters = async ( + templateVersionId: string, + ownerId: string, + oldBuildParameters: TypesGen.WorkspaceBuildParameter[], + ) => { + const request: DynamicParametersRequest = { + id: 1, + owner_id: ownerId, + inputs: Object.fromEntries( + new Map(oldBuildParameters.map((param) => [param.name, param.value])), + ), + }; + + const dynamicParametersResponse = + await this.getTemplateVersionDynamicParameters( + templateVersionId, + request, + ); + + return dynamicParametersResponse.parameters.map((p) => ({ + ...p, + description_plaintext: p.description || "", + default_value: p.default_value?.valid ? p.default_value.value : "", + options: p.options + ? p.options.map((opt) => ({ + ...opt, + value: opt.value?.valid ? opt.value.value : "", + })) + : [], + })); + }; + /** Steps to change the workspace version * - Get the latest template to access the latest active version * - Get the current build parameters @@ -2145,11 +2193,23 @@ class ApiMethods { workspace: TypesGen.Workspace, templateVersionId: string, newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + isDynamicParametersEnabled = false, ): Promise => { - const [currentBuildParameters, templateParameters] = await Promise.all([ - this.getWorkspaceBuildParameters(workspace.latest_build.id), - this.getTemplateVersionRichParameters(templateVersionId), - ]); + const currentBuildParameters = await this.getWorkspaceBuildParameters( + workspace.latest_build.id, + ); + + let templateParameters: TypesGen.TemplateVersionParameter[] = []; + if (isDynamicParametersEnabled) { + templateParameters = await this.getDynamicParameters( + templateVersionId, + workspace.owner_id, + currentBuildParameters, + ); + } else { + templateParameters = + await this.getTemplateVersionRichParameters(templateVersionId); + } const missingParameters = getMissingParameters( currentBuildParameters, @@ -2180,6 +2240,7 @@ class ApiMethods { updateWorkspace = async ( workspace: TypesGen.Workspace, newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + isDynamicParametersEnabled = false, ): Promise => { const [template, oldBuildParameters] = await Promise.all([ this.getTemplate(workspace.template_id), @@ -2187,8 +2248,19 @@ class ApiMethods { ]); const activeVersionId = template.active_version_id; - const templateParameters = - await this.getTemplateVersionRichParameters(activeVersionId); + + let templateParameters: TypesGen.TemplateVersionParameter[] = []; + + if (isDynamicParametersEnabled) { + templateParameters = await this.getDynamicParameters( + activeVersionId, + workspace.owner_id, + oldBuildParameters, + ); + } else { + templateParameters = + await this.getTemplateVersionRichParameters(activeVersionId); + } const missingParameters = getMissingParameters( oldBuildParameters, diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 6c6a1aa19825c..5a4cdb46dd4e9 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -163,6 +163,7 @@ export const updateDeadline = ( export const changeVersion = ( workspace: Workspace, queryClient: QueryClient, + isDynamicParametersEnabled: boolean, ) => { return { mutationFn: ({ @@ -172,7 +173,12 @@ export const changeVersion = ( versionId: string; buildParameters?: WorkspaceBuildParameter[]; }) => { - return API.changeWorkspaceVersion(workspace, versionId, buildParameters); + return API.changeWorkspaceVersion( + workspace, + versionId, + buildParameters, + isDynamicParametersEnabled, + ); }, onSuccess: async (build: WorkspaceBuild) => { await updateWorkspaceBuild(build, queryClient); @@ -185,8 +191,18 @@ export const updateWorkspace = ( queryClient: QueryClient, ) => { return { - mutationFn: (buildParameters?: WorkspaceBuildParameter[]) => { - return API.updateWorkspace(workspace, buildParameters); + mutationFn: ({ + buildParameters, + isDynamicParametersEnabled, + }: { + buildParameters?: WorkspaceBuildParameter[]; + isDynamicParametersEnabled: boolean; + }) => { + return API.updateWorkspace( + workspace, + buildParameters, + isDynamicParametersEnabled, + ); }, onSuccess: async (build: WorkspaceBuild) => { await updateWorkspaceBuild(build, queryClient); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9fa6e45fa30da..a512305c489d3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -830,7 +830,6 @@ export type Experiment = | "ai-tasks" | "agentic-chat" | "auto-fill-parameters" - | "dynamic-parameters" | "example" | "notifications" | "web-push" @@ -2519,6 +2518,7 @@ export interface SessionLifetime { readonly default_duration: number; readonly default_token_lifetime?: number; readonly max_token_lifetime?: number; + readonly max_admin_token_lifetime?: number; } // From codersdk/client.go diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index 7dbd536204254..2ec8ab40781c7 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -45,7 +45,7 @@ export const DialogContent = forwardRef< > = ({ }) => (
(({ className, ...props }, ref) => ( )); diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx new file mode 100644 index 0000000000000..04bb92a5e79b2 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx @@ -0,0 +1,71 @@ +import type { TemplateVersionParameter } from "api/typesGenerated"; +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"; + +type UpdateBuildParametersDialogExperimentalProps = { + open: boolean; + onClose: () => void; + missedParameters: TemplateVersionParameter[]; + workspaceOwnerName: string; + workspaceName: string; + templateVersionId: string | undefined; +}; + +export const UpdateBuildParametersDialogExperimental: FC< + UpdateBuildParametersDialogExperimentalProps +> = ({ + missedParameters, + open, + onClose, + workspaceOwnerName, + workspaceName, + templateVersionId, +}) => { + const navigate = useNavigate(); + + const handleGoToParameters = () => { + onClose(); + navigate( + `/@${workspaceOwnerName}/${workspaceName}/settings/parameters?templateVersionId=${templateVersionId}`, + ); + }; + + return ( + !isOpen && onClose()}> + + + Update workspace parameters + + This template has{" "} + + {missedParameters.length} new parameter + {missedParameters.length === 1 ? "" : "s"} + {" "} + that must be configured to complete the update. + + + Would you like to go to the workspace parameters page to review and + update these parameters before continuing? + + + + + + + + + ); +}; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx index 22e9638ee7caa..d2d916f71e9e8 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx @@ -27,6 +27,7 @@ import { Link as RouterLink } from "react-router-dom"; import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog"; import { DownloadLogsDialog } from "./DownloadLogsDialog"; import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"; +import { UpdateBuildParametersDialogExperimental } from "./UpdateBuildParametersDialogExperimental"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; import { useWorkspaceDuplication } from "./useWorkspaceDuplication"; @@ -50,7 +51,11 @@ export const WorkspaceMoreActions: FC = ({ // Change version const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false); const changeVersionMutation = useMutation( - changeVersion(workspace, queryClient), + changeVersion( + workspace, + queryClient, + !workspace.template_use_classic_parameter_flow, + ), ); // Delete @@ -142,25 +147,46 @@ export const WorkspaceMoreActions: FC = ({ onClose={() => setIsDownloadDialogOpen(false)} /> - { - changeVersionMutation.reset(); - }} - onUpdate={(buildParameters) => { - if (changeVersionMutation.error instanceof MissingBuildParameters) { - changeVersionMutation.mutate({ - versionId: changeVersionMutation.error.versionId, - buildParameters, - }); + {workspace.template_use_classic_parameter_flow ? ( + + open={changeVersionMutation.error instanceof MissingBuildParameters} + onClose={() => { + changeVersionMutation.reset(); + }} + onUpdate={(buildParameters) => { + if (changeVersionMutation.error instanceof MissingBuildParameters) { + changeVersionMutation.mutate({ + versionId: changeVersionMutation.error.versionId, + buildParameters, + }); + } + }} + /> + ) : ( + { + changeVersionMutation.reset(); + }} + workspaceOwnerName={workspace.owner_name} + workspaceName={workspace.name} + templateVersionId={ + changeVersionMutation.error instanceof MissingBuildParameters + ? changeVersionMutation.error?.versionId + : undefined + } + /> + )} { - updateWorkspaceMutation.mutate(buildParameters); + updateWorkspaceMutation.mutate({ + buildParameters, + isDynamicParametersEnabled: + !workspace.template_use_classic_parameter_flow, + }); setIsConfirmingUpdate(false); }; @@ -67,6 +72,7 @@ export const useWorkspaceUpdate = ({ latestVersion, }, missingBuildParameters: { + workspace, error: updateWorkspaceMutation.error, onClose: () => { updateWorkspaceMutation.reset(); @@ -134,22 +140,37 @@ const UpdateConfirmationDialog: FC = ({ }; type MissingBuildParametersDialogProps = { + workspace: Workspace; error: unknown; onClose: () => void; onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void; }; const MissingBuildParametersDialog: FC = ({ + workspace, error, ...dialogProps }) => { - return ( + const missedParameters = + error instanceof MissingBuildParameters ? error.parameters : []; + const versionId = + error instanceof MissingBuildParameters ? error.versionId : undefined; + const isOpen = error instanceof MissingBuildParameters; + + return workspace.template_use_classic_parameter_flow ? ( + ) : ( + ); }; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx index db5d37e2b1007..a05cdd1843354 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx @@ -408,7 +408,7 @@ RUN apt-get update && \ rm -rf /tmp/go/src # alpine:3.18 -FROM gcr.io/coder-dev-1/alpine@sha256:25fad2a32ad1f6f510e528448ae1ec69a28ef81916a004d3629874104f8a7f70 AS proto +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 && \ diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx index 4f2d0a4e4f8f7..a0dd3dbf715c4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx @@ -1,81 +1,35 @@ import { templateByName } from "api/queries/templates"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; -import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; import { useQuery } from "react-query"; import { useParams } from "react-router-dom"; import CreateWorkspacePage from "./CreateWorkspacePage"; import CreateWorkspacePageExperimental from "./CreateWorkspacePageExperimental"; -import { ExperimentalFormContext } from "./ExperimentalFormContext"; const CreateWorkspaceExperimentRouter: FC = () => { - const { experiments } = useDashboard(); - const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); - const { organization: organizationName = "default", template: templateName } = useParams() as { organization?: string; template: string }; - const templateQuery = useQuery({ - ...templateByName(organizationName, templateName), - enabled: dynamicParametersEnabled, - }); - - const optOutQuery = useQuery({ - enabled: !!templateQuery.data, - queryKey: [organizationName, "template", templateQuery.data?.id, "optOut"], - queryFn: () => { - const templateId = templateQuery.data?.id; - const localStorageKey = optOutKey(templateId ?? ""); - const storedOptOutString = localStorage.getItem(localStorageKey); - - let optOutResult: boolean; - - if (storedOptOutString !== null) { - optOutResult = storedOptOutString === "true"; - } else { - optOutResult = !!templateQuery.data?.use_classic_parameter_flow; - } - - return { - templateId: templateId, - optedOut: optOutResult, - }; - }, - }); + const templateQuery = useQuery( + templateByName(organizationName, templateName), + ); - if (dynamicParametersEnabled) { - if (optOutQuery.isError) { - return ; - } - if (!optOutQuery.data) { - return ; - } - - const toggleOptedOut = () => { - const key = optOutKey(optOutQuery.data?.templateId ?? ""); - const storedValue = localStorage.getItem(key); - - const current = storedValue - ? storedValue === "true" - : Boolean(templateQuery.data?.use_classic_parameter_flow); - - localStorage.setItem(key, (!current).toString()); - optOutQuery.refetch(); - }; - return ( - - {optOutQuery.data.optedOut ? ( - - ) : ( - - )} - - ); + if (templateQuery.isError) { + return ; + } + if (!templateQuery.data) { + return ; } - return ; + return ( + <> + {templateQuery.data?.use_classic_parameter_flow ? ( + + ) : ( + + )} + + ); }; export default CreateWorkspaceExperimentRouter; - -const optOutKey = (id: string) => `parameters.${id}.optOut`; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 64ea110709cf4..d365a565afcdb 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -28,14 +28,7 @@ import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; -import { - type FC, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { getFormHelpers, nameValidator, @@ -51,7 +44,6 @@ import type { CreateWorkspaceMode, ExternalAuthPollingState, } from "./CreateWorkspacePage"; -import { ExperimentalFormContext } from "./ExperimentalFormContext"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; @@ -106,7 +98,6 @@ export const CreateWorkspacePageView: FC = ({ onSubmit, onCancel, }) => { - const experimentalFormContext = useContext(ExperimentalFormContext); const [owner, setOwner] = useState(defaultOwner); const [suggestedName, setSuggestedName] = useState(() => generateWorkspaceName(), @@ -220,20 +211,9 @@ export const CreateWorkspacePageView: FC = ({ - {experimentalFormContext && ( - - )} - - + } > diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 09056aa66af72..4fff4db92e21d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -26,7 +26,7 @@ import { } from "components/Tooltip/Tooltip"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; -import { ArrowLeft, CircleHelp, Undo2 } from "lucide-react"; +import { ArrowLeft, CircleHelp } from "lucide-react"; import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; import { Diagnostics } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { @@ -38,7 +38,6 @@ import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName" import { type FC, useCallback, - useContext, useEffect, useId, useRef, @@ -52,7 +51,6 @@ import type { CreateWorkspaceMode, ExternalAuthPollingState, } from "./CreateWorkspacePage"; -import { ExperimentalFormContext } from "./ExperimentalFormContext"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; @@ -112,7 +110,6 @@ export const CreateWorkspacePageViewExperimental: FC< owner, setOwner, }) => { - const experimentalFormContext = useContext(ExperimentalFormContext); const [suggestedName, setSuggestedName] = useState(() => generateWorkspaceName(), ); @@ -372,16 +369,6 @@ export const CreateWorkspacePageViewExperimental: FC< )} - {experimentalFormContext && ( - - )}

New workspace

@@ -608,7 +595,15 @@ export const CreateWorkspacePageViewExperimental: FC<
{parameters.map((parameter, index) => { - const parameterField = `rich_parameter_values.${index}`; + const currentParameterValueIndex = + form.values.rich_parameter_values?.findIndex( + (p) => p.name === parameter.name, + ) ?? -1; + const parameterFieldIndex = + currentParameterValueIndex !== -1 + ? currentParameterValueIndex + : index; + const parameterField = `rich_parameter_values.${parameterFieldIndex}`; const isPresetParameter = presetParameterNames.includes( parameter.name, ); @@ -620,13 +615,22 @@ export const CreateWorkspacePageViewExperimental: FC< creatingWorkspace || isPresetParameter; - // Hide preset parameters if showPresetParameters is false - if (!showPresetParameters && isPresetParameter) { + // Always show preset parameters if they have any diagnostics + if ( + !showPresetParameters && + isPresetParameter && + parameter.diagnostics.length === 0 + ) { return null; } + // Get the form value by parameter name to ensure correct value mapping const formValue = - form.values?.rich_parameter_values?.[index]?.value || ""; + currentParameterValueIndex !== -1 + ? form.values?.rich_parameter_values?.[ + currentParameterValueIndex + ]?.value || "" + : ""; return ( void } | undefined ->(undefined); diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 284267f4487e1..9282bd6bfd2b1 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -32,6 +32,7 @@ import { useFormik } from "formik"; import { Plus, Trash, TriangleAlert } from "lucide-react"; import { type FC, type KeyboardEventHandler, useId, useState } from "react"; import { docs } from "utils/docs"; +import { isEveryoneGroup } from "utils/groups"; import { isUUID } from "utils/uuid"; import * as Yup from "yup"; import { ExportPolicyButton } from "./ExportPolicyButton"; @@ -259,15 +260,17 @@ export const IdpGroupSyncForm: FC = ({ className="min-w-60 max-w-3xl" value={coderGroups} onChange={setCoderGroups} - options={groups.map((group) => ({ - label: group.display_name || group.name, - value: group.id, - }))} + options={groups + .filter((group) => !isEveryoneGroup(group)) + .map((group) => ({ + label: group.display_name || group.name, + value: group.id, + }))} hidePlaceholderWhenSelected placeholder="Select group" emptyIndicator={

- All groups selected + No more groups to select

} /> diff --git a/site/src/pages/TaskPage/TaskApps.tsx b/site/src/pages/TaskPage/TaskApps.tsx index 7954447853c1e..316d7b91dd633 100644 --- a/site/src/pages/TaskPage/TaskApps.tsx +++ b/site/src/pages/TaskPage/TaskApps.tsx @@ -32,10 +32,13 @@ export const TaskApps: FC = ({ task }) => { .flatMap((a) => a?.apps) .filter((a) => !!a && a.slug !== AI_APP_CHAT_SLUG); + const embeddedApps = apps.filter((app) => !app.external); + const externalApps = apps.filter((app) => app.external); + const [activeAppId, setActiveAppId] = useState(() => { - const appId = task.workspace.latest_app_status?.app_id; + const appId = embeddedApps[0]?.id; if (!appId) { - throw new Error("No active app found in task"); + throw new Error("No apps found in task"); } return appId; }); @@ -52,9 +55,6 @@ export const TaskApps: FC = ({ task }) => { throw new Error(`Agent for app ${activeAppId} not found in task workspace`); } - const embeddedApps = apps.filter((app) => !app.external); - const externalApps = apps.filter((app) => app.external); - return (
diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 287568852d209..1b90b7b775e07 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -12,6 +12,7 @@ import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { useParams } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom"; +import { ellipsizeText } from "utils/ellipsizeText"; import { pageTitle } from "utils/page"; import { TaskApps } from "./TaskApps"; import { TaskSidebar } from "./TaskSidebar"; @@ -92,10 +93,10 @@ const TaskPage = () => {

- Building the workspace + Starting your workspace

- Your task will run as soon as the workspace is ready + This should take a few minutes
@@ -163,7 +164,7 @@ const TaskPage = () => { return ( <> - {pageTitle(task.prompt)} + {pageTitle(ellipsizeText(task.prompt, 64)!)}
diff --git a/site/src/pages/TaskPage/TaskSidebar.tsx b/site/src/pages/TaskPage/TaskSidebar.tsx index 872d64a60cbca..9ed19c41fa4f1 100644 --- a/site/src/pages/TaskPage/TaskSidebar.tsx +++ b/site/src/pages/TaskPage/TaskSidebar.tsx @@ -40,6 +40,8 @@ export const TaskSidebar: FC = ({ task }) => { .flatMap((r) => r.agents) .flatMap((a) => a?.apps) .find((a) => a?.slug === AI_APP_CHAT_SLUG); + const showChatApp = + chatApp && (chatApp.health === "disabled" || chatApp.health === "healthy"); return (
-

{task.prompt}

+

+ {task.prompt} +

{task.workspace.latest_app_status?.uri && (
@@ -104,7 +108,7 @@ export const TaskSidebar: FC = ({ task }) => { )} - {chatApp ? ( + {showChatApp ? ( { const workspace = await API.createWorkspace(userId, { - name: `task-${new Date().getTime()}`, + name: `task-${generateWorkspaceName()}`, template_id: templateId, rich_parameter_values: [ { name: AI_PROMPT_PARAMETER_NAME, value: prompt }, diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 8ba0e7b948b8c..8dbe4dcab0290 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -63,7 +63,6 @@ export interface TemplateSettingsForm { accessControlEnabled: boolean; advancedSchedulingEnabled: boolean; portSharingControlsEnabled: boolean; - isDynamicParametersEnabled: boolean; } export const TemplateSettingsForm: FC = ({ @@ -76,7 +75,6 @@ export const TemplateSettingsForm: FC = ({ accessControlEnabled, advancedSchedulingEnabled, portSharingControlsEnabled, - isDynamicParametersEnabled, }) => { const form = useFormik({ initialValues: { @@ -226,37 +224,35 @@ export const TemplateSettingsForm: FC = ({ } /> - {isDynamicParametersEnabled && ( - - } - label={ - - Use classic workspace creation form - - - Show the original workspace creation form and workspace - parameters settings form without dynamic parameters or - live updates. Recommended if your provisioners aren't - updated or the new form causes issues.{" "} - - Users can always manually switch experiences in the - workspace creation form. - - - - - } - /> - )} + + } + label={ + + Use classic workspace creation form + + + Show the original workspace creation form and workspace + parameters settings form without dynamic parameters or live + updates. Recommended if your provisioners aren't updated or + the new form causes issues.{" "} + + Users can always manually switch experiences in the + workspace creation form. + + + + + } + /> diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 78114589691f8..1703ed5fea1d7 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -54,7 +54,7 @@ const validFormValues: FormValues = { require_active_version: false, disable_everyone_group_access: false, max_port_share_level: "owner", - use_classic_parameter_flow: false, + use_classic_parameter_flow: true, }; const renderTemplateSettingsPage = async () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx index e27f0b75c81e4..be5af252aec31 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx @@ -14,8 +14,6 @@ import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplateSettingsPageView } from "./TemplateSettingsPageView"; const TemplateSettingsPage: FC = () => { - const { experiments } = useDashboard(); - const isDynamicParametersEnabled = experiments.includes("dynamic-parameters"); const { template: templateName } = useParams() as { template: string }; const navigate = useNavigate(); const getLink = useLinks(); @@ -81,7 +79,6 @@ const TemplateSettingsPage: FC = () => { accessControlEnabled={accessControlEnabled} advancedSchedulingEnabled={advancedSchedulingEnabled} sharedPortControlsEnabled={sharedPortControlsEnabled} - isDynamicParametersEnabled={isDynamicParametersEnabled} /> ); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx index 059999d27bb74..e267d25ce572e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx @@ -15,7 +15,6 @@ interface TemplateSettingsPageViewProps { accessControlEnabled: boolean; advancedSchedulingEnabled: boolean; sharedPortControlsEnabled: boolean; - isDynamicParametersEnabled: boolean; } export const TemplateSettingsPageView: FC = ({ @@ -28,7 +27,6 @@ export const TemplateSettingsPageView: FC = ({ accessControlEnabled, advancedSchedulingEnabled, sharedPortControlsEnabled, - isDynamicParametersEnabled, }) => { return ( <> @@ -46,7 +44,6 @@ export const TemplateSettingsPageView: FC = ({ accessControlEnabled={accessControlEnabled} advancedSchedulingEnabled={advancedSchedulingEnabled} portSharingControlsEnabled={sharedPortControlsEnabled} - isDynamicParametersEnabled={isDynamicParametersEnabled} /> ); diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 3f217a86a3aad..67a1a460dcd45 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -305,16 +305,20 @@ describe("WorkspacePage", () => { // Check if the update was called using the values from the form await waitFor(() => { - expect(API.updateWorkspace).toBeCalledWith(MockOutdatedWorkspace, [ - { - name: MockTemplateVersionParameter1.name, - value: "some-value", - }, - { - name: MockTemplateVersionParameter2.name, - value: "2", - }, - ]); + expect(API.updateWorkspace).toHaveBeenCalledWith( + MockOutdatedWorkspace, + [ + { + name: MockTemplateVersionParameter1.name, + value: "some-value", + }, + { + name: MockTemplateVersionParameter2.name, + value: "2", + }, + ], + false, + ); }); }); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx index e7f57108f8e54..0a01c9907bd00 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx @@ -1,83 +1,20 @@ -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Loader } from "components/Loader/Loader"; -import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; -import { useQuery } from "react-query"; -import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import WorkspaceParametersPage from "./WorkspaceParametersPage"; import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental"; const WorkspaceParametersExperimentRouter: FC = () => { - const { experiments } = useDashboard(); const workspace = useWorkspaceSettings(); - const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); - const optOutQuery = useQuery({ - enabled: dynamicParametersEnabled, - queryKey: [ - "workspace", - workspace.id, - "template_id", - workspace.template_id, - "optOut", - ], - queryFn: () => { - const templateId = workspace.template_id; - const workspaceId = workspace.id; - const localStorageKey = optOutKey(templateId); - const storedOptOutString = localStorage.getItem(localStorageKey); - - let optOutResult: boolean; - - if (storedOptOutString !== null) { - optOutResult = storedOptOutString === "true"; - } else { - optOutResult = Boolean(workspace.template_use_classic_parameter_flow); - } - - return { - templateId, - workspaceId, - optedOut: optOutResult, - }; - }, - }); - - if (dynamicParametersEnabled) { - if (optOutQuery.isLoading) { - return ; - } - if (!optOutQuery.data) { - return ; - } - - const toggleOptedOut = () => { - const key = optOutKey(optOutQuery.data.templateId); - const storedValue = localStorage.getItem(key); - - const current = storedValue - ? storedValue === "true" - : Boolean(workspace.template_use_classic_parameter_flow); - - localStorage.setItem(key, (!current).toString()); - optOutQuery.refetch(); - }; - - return ( - - {optOutQuery.data.optedOut ? ( - - ) : ( - - )} - - ); - } - - return ; + return ( + <> + {workspace.template_use_classic_parameter_flow ? ( + + ) : ( + + )} + + ); }; export default WorkspaceParametersExperimentRouter; - -const optOutKey = (id: string) => `parameters.${id}.optOut`; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index 56720292957ff..50f2eedaeec26 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -4,11 +4,10 @@ import { isApiValidationError } from "api/errors"; import { checkAuthorization } from "api/queries/authCheck"; import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Button as ShadcnButton } from "components/Button/Button"; import { EmptyState } from "components/EmptyState/EmptyState"; import { Loader } from "components/Loader/Loader"; import { ExternalLinkIcon } from "lucide-react"; -import { type FC, useContext } from "react"; +import type { FC } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery } from "react-query"; import { useNavigate } from "react-router-dom"; @@ -18,7 +17,6 @@ import { type WorkspacePermissions, workspaceChecks, } from "../../../modules/workspaces/permissions"; -import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { WorkspaceParametersForm, @@ -113,21 +111,11 @@ export const WorkspaceParametersPageView: FC< isSubmitting, onCancel, }) => { - const experimentalFormContext = useContext(ExperimentalFormContext); return (

Workspace parameters

- {experimentalFormContext && ( - - Try out the new workspace parameters ✨ - - )}
diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx index 37baf9c3a1240..755291ec28629 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx @@ -7,7 +7,6 @@ import type { WorkspaceBuildParameter, } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Button } from "components/Button/Button"; import { EmptyState } from "components/EmptyState/EmptyState"; import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { Link } from "components/Link/Link"; @@ -19,12 +18,12 @@ import { TooltipTrigger, } from "components/Tooltip/Tooltip"; import { useEffectEvent } from "hooks/hookPolyfills"; -import { CircleHelp, Undo2 } from "lucide-react"; +import { CircleHelp } from "lucide-react"; import type { FC } from "react"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery } from "react-query"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; import type { AutofillBuildParameter } from "utils/richParameters"; @@ -32,14 +31,14 @@ import { type WorkspacePermissions, workspaceChecks, } from "../../../modules/workspaces/permissions"; -import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { WorkspaceParametersPageViewExperimental } from "./WorkspaceParametersPageViewExperimental"; const WorkspaceParametersPageExperimental: FC = () => { const workspace = useWorkspaceSettings(); const navigate = useNavigate(); - const experimentalFormContext = useContext(ExperimentalFormContext); + const [searchParams] = useSearchParams(); + const templateVersionId = searchParams.get("templateVersionId") ?? undefined; // autofill the form with the workspace build parameters from the latest build const { @@ -107,10 +106,11 @@ const WorkspaceParametersPageExperimental: FC = () => { }); useEffect(() => { - if (!workspace.latest_build.template_version_id) return; + if (!templateVersionId && !workspace.latest_build.template_version_id) + return; const socket = API.templateVersionDynamicParameters( - workspace.latest_build.template_version_id, + templateVersionId ?? workspace.latest_build.template_version_id, { onMessage, onError: (error) => { @@ -136,12 +136,17 @@ const WorkspaceParametersPageExperimental: FC = () => { return () => { socket.close(); }; - }, [workspace.latest_build.template_version_id, onMessage]); + }, [ + templateVersionId, + workspace.latest_build.template_version_id, + onMessage, + ]); const updateParameters = useMutation({ mutationFn: (buildParameters: WorkspaceBuildParameter[]) => API.postWorkspaceBuild(workspace.id, { transition: "start", + template_version_id: templateVersionId, rich_parameter_values: buildParameters, }), onSuccess: () => { @@ -228,16 +233,6 @@ const WorkspaceParametersPageExperimental: FC = () => { - {experimentalFormContext && ( - - )} { {sortedParams.length > 0 ? ( void; sendMessage: (formValues: Record) => void; + templateVersionId: string | undefined; }; export const WorkspaceParametersPageViewExperimental: FC< @@ -44,6 +46,7 @@ export const WorkspaceParametersPageViewExperimental: FC< onSubmit, sendMessage, onCancel, + templateVersionId, }) => { const autofillByName = Object.fromEntries( autofillParameters.map((param) => [param.name, param]), @@ -152,6 +155,15 @@ export const WorkspaceParametersPageViewExperimental: FC<
)} + {(templateVersionId || workspace.latest_build.template_version_id) && ( +
+ +

+ {templateVersionId ?? workspace.latest_build.template_version_id} +

+
+ )} +
{standardParameters.length > 0 && (
@@ -236,10 +248,21 @@ export const WorkspaceParametersPageViewExperimental: FC<
diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index c8577f191d47e..94c12f0372b59 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -123,8 +123,8 @@ describe("WorkspacesPage", () => { await waitFor(() => { expect(updateWorkspace).toHaveBeenCalledTimes(2); }); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false); }); it("warns about and updates running workspaces", async () => { @@ -160,9 +160,9 @@ describe("WorkspacesPage", () => { await waitFor(() => { expect(updateWorkspace).toHaveBeenCalledTimes(3); }); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false); }); it("warns about and ignores dormant workspaces", async () => { @@ -199,8 +199,8 @@ describe("WorkspacesPage", () => { await waitFor(() => { expect(updateWorkspace).toHaveBeenCalledTimes(2); }); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false); }); it("warns about running workspaces and then dormant workspaces", async () => { @@ -241,9 +241,9 @@ describe("WorkspacesPage", () => { await waitFor(() => { expect(updateWorkspace).toHaveBeenCalledTimes(3); }); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false); }); }); diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index b6309638e209d..22ba0d15f1f9a 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -162,7 +162,10 @@ const WorkspacesPage: FC = () => { checkedWorkspaces={checkedWorkspaces} open={confirmingBatchAction === "update"} onConfirm={async () => { - await batchActions.updateAll(checkedWorkspaces); + await batchActions.updateAll({ + workspaces: checkedWorkspaces, + isDynamicParametersEnabled: false, + }); setConfirmingBatchAction(null); }} onClose={() => { diff --git a/site/src/pages/WorkspacesPage/batchActions.tsx b/site/src/pages/WorkspacesPage/batchActions.tsx index cbec017eb8583..806c7a03afddb 100644 --- a/site/src/pages/WorkspacesPage/batchActions.tsx +++ b/site/src/pages/WorkspacesPage/batchActions.tsx @@ -45,11 +45,15 @@ export function useBatchActions(options: UseBatchActionsProps) { }); const updateAllMutation = useMutation({ - mutationFn: (workspaces: readonly Workspace[]) => { + mutationFn: (payload: { + workspaces: readonly Workspace[]; + isDynamicParametersEnabled: boolean; + }) => { + const { workspaces, isDynamicParametersEnabled } = payload; return Promise.all( workspaces .filter((w) => w.outdated && !w.dormant_at) - .map((w) => API.updateWorkspace(w)), + .map((w) => API.updateWorkspace(w, [], isDynamicParametersEnabled)), ); }, onSuccess, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 72ad6fa508a02..0201e4b563efc 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -824,7 +824,7 @@ export const MockTemplate: TypesGen.Template = { deprecated: false, deprecation_message: "", max_port_share_level: "public", - use_classic_parameter_flow: false, + use_classic_parameter_flow: true, }; const MockTemplateVersionFiles: TemplateVersionFiles = { @@ -1410,7 +1410,7 @@ export const MockWorkspace: TypesGen.Workspace = { MockTemplate.allow_user_cancel_workspace_jobs, template_active_version_id: MockTemplate.active_version_id, template_require_active_version: MockTemplate.require_active_version, - template_use_classic_parameter_flow: false, + template_use_classic_parameter_flow: true, outdated: false, owner_id: MockUserOwner.id, organization_id: MockOrganization.id, diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json index 4e162f38b6bb5..8474ab0ef15a3 100644 --- a/site/src/theme/icons.json +++ b/site/src/theme/icons.json @@ -106,6 +106,7 @@ "vsphere.svg", "webstorm.svg", "widgets.svg", + "windows.svg", "windsurf.svg", "zed.svg" ] diff --git a/site/static/icon/windows.svg b/site/static/icon/windows.svg new file mode 100644 index 0000000000000..8b774a501cdc1 --- /dev/null +++ b/site/static/icon/windows.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tailnet/derpmap.go b/tailnet/derpmap.go index e2722c1ff9ab4..6f284dad05991 100644 --- a/tailnet/derpmap.go +++ b/tailnet/derpmap.go @@ -8,8 +8,11 @@ import ( "net/http" "os" "strconv" + "strings" + "time" "golang.org/x/xerrors" + "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" ) @@ -152,6 +155,49 @@ regionLoop: return derpMap, nil } +func ExtractPreferredDERPName(pingResult *ipnstate.PingResult, node *Node, derpMap *tailcfg.DERPMap) string { + // Sometimes the preferred DERP doesn't match the one we're actually + // connected with. Perhaps because the agent prefers a different DERP and + // we're using that server instead. + preferredDerpID := node.PreferredDERP + if pingResult.DERPRegionID != 0 { + preferredDerpID = pingResult.DERPRegionID + } + preferredDerp, ok := derpMap.Regions[preferredDerpID] + preferredDerpName := fmt.Sprintf("Unnamed %d", preferredDerpID) + if ok { + preferredDerpName = preferredDerp.RegionName + } + + return preferredDerpName +} + +// ExtractDERPLatency extracts a map of derp region names to their latencies +func ExtractDERPLatency(node *Node, derpMap *tailcfg.DERPMap) map[string]time.Duration { + latencyMs := make(map[string]time.Duration) + + // Convert DERP region IDs to friendly names for display in the UI. + for rawRegion, latency := range node.DERPLatency { + regionParts := strings.SplitN(rawRegion, "-", 2) + regionID, err := strconv.Atoi(regionParts[0]) + if err != nil { + continue + } + region, found := derpMap.Regions[regionID] + if !found { + // It's possible that a workspace agent is using an old DERPMap + // and reports regions that do not exist. If that's the case, + // report the region as unknown! + region = &tailcfg.DERPRegion{ + RegionID: regionID, + RegionName: fmt.Sprintf("Unnamed %d", regionID), + } + } + latencyMs[region.RegionName] = time.Duration(latency * float64(time.Second)) + } + return latencyMs +} + // CompareDERPMaps returns true if the given DERPMaps are equivalent. Ordering // of slices is ignored. // diff --git a/tailnet/derpmap_test.go b/tailnet/derpmap_test.go index a91969bfeca09..c723437cad0d2 100644 --- a/tailnet/derpmap_test.go +++ b/tailnet/derpmap_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "github.com/coder/coder/v2/tailnet" @@ -162,3 +163,111 @@ func TestNewDERPMap(t *testing.T) { require.ErrorContains(t, err, "DERP map has no DERP nodes") }) } + +func TestExtractDERPLatency(t *testing.T) { + t.Parallel() + + derpMap := &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionName: "Region One", + Nodes: []*tailcfg.DERPNode{ + {Name: "node1", RegionID: 1}, + }, + }, + 2: { + RegionID: 2, + RegionName: "Region Two", + Nodes: []*tailcfg.DERPNode{ + {Name: "node2", RegionID: 2}, + }, + }, + }, + } + + t.Run("Basic", func(t *testing.T) { + t.Parallel() + node := &tailnet.Node{ + DERPLatency: map[string]float64{ + "1-node1": 0.05, + "2-node2": 0.1, + }, + } + latencyMs := tailnet.ExtractDERPLatency(node, derpMap) + require.EqualValues(t, 50, latencyMs["Region One"].Milliseconds()) + require.EqualValues(t, 100, latencyMs["Region Two"].Milliseconds()) + require.Len(t, latencyMs, 2) + }) + + t.Run("UnknownRegion", func(t *testing.T) { + t.Parallel() + node := &tailnet.Node{ + DERPLatency: map[string]float64{ + "999-node999": 0.2, + }, + } + latencyMs := tailnet.ExtractDERPLatency(node, derpMap) + require.EqualValues(t, 200, latencyMs["Unnamed 999"].Milliseconds()) + require.Len(t, latencyMs, 1) + }) + + t.Run("InvalidRegionFormat", func(t *testing.T) { + t.Parallel() + node := &tailnet.Node{ + DERPLatency: map[string]float64{ + "invalid": 0.3, + "1-node1": 0.05, + "abc-node": 0.15, + }, + } + latencyMs := tailnet.ExtractDERPLatency(node, derpMap) + require.EqualValues(t, 50, latencyMs["Region One"].Milliseconds()) + require.Len(t, latencyMs, 1) + require.NotContains(t, latencyMs, "invalid") + require.NotContains(t, latencyMs, "abc-node") + }) + + t.Run("EmptyInput", func(t *testing.T) { + t.Parallel() + node := &tailnet.Node{ + DERPLatency: map[string]float64{}, + } + latencyMs := tailnet.ExtractDERPLatency(node, derpMap) + require.Empty(t, latencyMs) + }) +} + +func TestExtractPreferredDERPName(t *testing.T) { + t.Parallel() + derpMap := &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: {RegionName: "New York"}, + 2: {RegionName: "London"}, + }, + } + + t.Run("UsesPingRegion", func(t *testing.T) { + t.Parallel() + pingResult := &ipnstate.PingResult{DERPRegionID: 2} + node := &tailnet.Node{PreferredDERP: 1} + result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + require.Equal(t, "London", result) + }) + + t.Run("UsesNodePreferred", func(t *testing.T) { + t.Parallel() + pingResult := &ipnstate.PingResult{DERPRegionID: 0} + node := &tailnet.Node{PreferredDERP: 1} + result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + require.Equal(t, "New York", result) + }) + + t.Run("UnknownRegion", func(t *testing.T) { + t.Parallel() + pingResult := &ipnstate.PingResult{DERPRegionID: 99} + node := &tailnet.Node{PreferredDERP: 1} + result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + require.Equal(t, "Unnamed 99", result) + }) +} diff --git a/tailnet/test/integration/integration.go b/tailnet/test/integration/integration.go index 1190a3aa98b0d..5ca1ed9ffd667 100644 --- a/tailnet/test/integration/integration.go +++ b/tailnet/test/integration/integration.go @@ -25,11 +25,14 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" "golang.org/x/xerrors" "tailscale.com/derp" "tailscale.com/derp/derphttp" + "tailscale.com/net/packet" "tailscale.com/tailcfg" "tailscale.com/types/key" + "tailscale.com/wgengine/capture" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/httpapi" @@ -54,6 +57,7 @@ type Client struct { ID uuid.UUID ListenPort uint16 ShouldRunTests bool + TunnelSrc bool } var Client1 = Client{ @@ -61,6 +65,7 @@ var Client1 = Client{ ID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), ListenPort: client1Port, ShouldRunTests: true, + TunnelSrc: true, } var Client2 = Client{ @@ -68,21 +73,20 @@ var Client2 = Client{ ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"), ListenPort: client2Port, ShouldRunTests: false, + TunnelSrc: false, } type TestTopology struct { Name string - // SetupNetworking creates interfaces and network namespaces for the test. - // The most simple implementation is NetworkSetupDefault, which only creates - // a network namespace shared for all tests. - SetupNetworking func(t *testing.T, logger slog.Logger) TestNetworking + + NetworkingProvider NetworkingProvider // Server is the server starter for the test. It is executed in the server // subprocess. Server ServerStarter - // StartClient gets called in each client subprocess. It's expected to + // ClientStarter.StartClient gets called in each client subprocess. It's expected to // create the tailnet.Conn and ensure connectivity to it's peer. - StartClient func(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me Client, peer Client) *tailnet.Conn + ClientStarter ClientStarter // RunTests is the main test function. It's called in each of the client // subprocesses. If tests can only run once, they should check the client ID @@ -97,6 +101,17 @@ type ServerStarter interface { StartServer(t *testing.T, logger slog.Logger, listenAddr string) } +type NetworkingProvider interface { + // SetupNetworking creates interfaces and network namespaces for the test. + // The most simple implementation is NetworkSetupDefault, which only creates + // a network namespace shared for all tests. + SetupNetworking(t *testing.T, logger slog.Logger) TestNetworking +} + +type ClientStarter interface { + StartClient(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me Client, peer Client) *tailnet.Conn +} + type SimpleServerOptions struct { // FailUpgradeDERP will make the DERP server fail to handle the initial DERP // upgrade in a way that causes the client to fallback to @@ -369,77 +384,117 @@ http { _, _ = ExecBackground(t, "server.nginx", nil, "nginx", []string{"-c", cfgPath}) } -// StartClientDERP creates a client connection to the server for coordination -// and creates a tailnet.Conn which will only use DERP to connect to the peer. -func StartClientDERP(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn { - return startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{ - Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)}, - DERPMap: derpMap, - BlockEndpoints: true, - Logger: logger, - DERPForceWebSockets: false, - ListenPort: me.ListenPort, - // These tests don't have internet connection, so we need to force - // magicsock to do anything. - ForceNetworkUp: true, - }) +type BasicClientStarter struct { + BlockEndpoints bool + DERPForceWebsockets bool + // WaitForConnection means wait for (any) peer connection before returning from StartClient + WaitForConnection bool + // WaitForConnection means wait for a direct peer connection before returning from StartClient + WaitForDirect bool + // Service is a network service (e.g. an echo server) to start on the client. If Wait* is set, the service is + // started prior to waiting. + Service NetworkService + LogPackets bool } -// StartClientDERPWebSockets does the same thing as StartClientDERP but will -// only use DERP WebSocket fallback. -func StartClientDERPWebSockets(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn { - return startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{ - Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)}, - DERPMap: derpMap, - BlockEndpoints: true, - Logger: logger, - DERPForceWebSockets: true, - ListenPort: me.ListenPort, - // These tests don't have internet connection, so we need to force - // magicsock to do anything. - ForceNetworkUp: true, - }) +type NetworkService interface { + StartService(t *testing.T, logger slog.Logger, conn *tailnet.Conn) } -// StartClientDirect does the same thing as StartClientDERP but disables -// BlockEndpoints (which enables Direct connections), and waits for a direct -// connection to be established between the two peers. -func StartClientDirect(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn { +func (b BasicClientStarter) StartClient(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn { + var hook capture.Callback + if b.LogPackets { + pktLogger := packetLogger{logger} + hook = pktLogger.LogPacket + } conn := startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{ Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)}, DERPMap: derpMap, - BlockEndpoints: false, + BlockEndpoints: b.BlockEndpoints, Logger: logger, - DERPForceWebSockets: true, + DERPForceWebSockets: b.DERPForceWebsockets, ListenPort: me.ListenPort, // These tests don't have internet connection, so we need to force // magicsock to do anything. ForceNetworkUp: true, + CaptureHook: hook, }) - // Wait for direct connection to be established. - peerIP := tailnet.TailscaleServicePrefix.AddrFromUUID(peer.ID) - require.Eventually(t, func() bool { - t.Log("attempting ping to peer to judge direct connection") - ctx := testutil.Context(t, testutil.WaitShort) - _, p2p, pong, err := conn.Ping(ctx, peerIP) - if err != nil { - t.Logf("ping failed: %v", err) - return false - } - if !p2p { - t.Log("ping succeeded, but not direct yet") - return false - } - t.Logf("ping succeeded, direct connection established via %s", pong.Endpoint) - return true - }, testutil.WaitLong, testutil.IntervalMedium) + if b.Service != nil { + b.Service.StartService(t, logger, conn) + } + + if b.WaitForConnection || b.WaitForDirect { + // Wait for connection to be established. + peerIP := tailnet.TailscaleServicePrefix.AddrFromUUID(peer.ID) + require.Eventually(t, func() bool { + t.Log("attempting ping to peer to judge direct connection") + ctx := testutil.Context(t, testutil.WaitShort) + _, p2p, pong, err := conn.Ping(ctx, peerIP) + if err != nil { + t.Logf("ping failed: %v", err) + return false + } + if !p2p && b.WaitForDirect { + t.Log("ping succeeded, but not direct yet") + return false + } + t.Logf("ping succeeded, p2p=%t, endpoint=%s", p2p, pong.Endpoint) + return true + }, testutil.WaitLong, testutil.IntervalMedium) + } return conn } -type ClientStarter struct { - Options *tailnet.Options +const EchoPort = 2381 + +type UDPEchoService struct{} + +func (UDPEchoService) StartService(t *testing.T, logger slog.Logger, _ *tailnet.Conn) { + // tailnet doesn't handle UDP connections "in-process" the way we do for TCP, so we need to listen in the OS, + // and tailnet will forward packets. + l, err := net.ListenUDP("udp", &net.UDPAddr{ + IP: net.IPv6zero, // all interfaces + Port: EchoPort, + }) + require.NoError(t, err) + + // set path MTU discovery so that we don't fragment the responses. + c, err := l.SyscallConn() + require.NoError(t, err) + var sockErr error + err = c.Control(func(fd uintptr) { + sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_MTU_DISCOVER, unix.IP_PMTUDISC_DO) + }) + require.NoError(t, err) + require.NoError(t, sockErr) + logger.Info(context.Background(), "started UDPEcho server") + t.Cleanup(func() { + lCloseErr := l.Close() + if lCloseErr != nil { + t.Logf("error closing UDPEcho listener: %v", lCloseErr) + } + }) + go func() { + buf := make([]byte, 1500) + for { + n, remote, readErr := l.ReadFromUDP(buf) + if readErr != nil { + logger.Info(context.Background(), "error reading UDPEcho listener", slog.Error(readErr)) + return + } + logger.Info(context.Background(), "received UDPEcho packet", + slog.F("len", n), slog.F("remote", remote)) + n, writeErr := l.WriteToUDP(buf[:n], remote) + if writeErr != nil { + logger.Info(context.Background(), "error writing UDPEcho listener", slog.Error(writeErr)) + return + } + logger.Info(context.Background(), "wrote UDPEcho packet", + slog.F("len", n), slog.F("remote", remote)) + } + }() } func startClientOptions(t *testing.T, logger slog.Logger, serverURL *url.URL, me, peer Client, options *tailnet.Options) *tailnet.Conn { @@ -467,9 +522,16 @@ func startClientOptions(t *testing.T, logger slog.Logger, serverURL *url.URL, me _ = conn.Close() }) - ctrl := tailnet.NewTunnelSrcCoordController(logger, conn) - ctrl.AddDestination(peer.ID) - coordination := ctrl.New(coord) + var coordination tailnet.CloserWaiter + if me.TunnelSrc { + ctrl := tailnet.NewTunnelSrcCoordController(logger, conn) + ctrl.AddDestination(peer.ID) + coordination = ctrl.New(coord) + } else { + // use the "Agent" controller so that we act as a tunnel destination and send "ReadyForHandshake" acks. + ctrl := tailnet.NewAgentCoordinationController(logger, conn) + coordination = ctrl.New(coord) + } t.Cleanup(func() { cctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() @@ -492,11 +554,17 @@ func basicDERPMap(serverURLStr string) (*tailcfg.DERPMap, error) { } hostname := serverURL.Hostname() - ipv4 := "" + ipv4 := "none" + ipv6 := "none" ip, err := netip.ParseAddr(hostname) if err == nil { hostname = "" - ipv4 = ip.String() + if ip.Is4() { + ipv4 = ip.String() + } + if ip.Is6() { + ipv6 = ip.String() + } } return &tailcfg.DERPMap{ @@ -511,7 +579,7 @@ func basicDERPMap(serverURLStr string) (*tailcfg.DERPMap, error) { RegionID: 1, HostName: hostname, IPv4: ipv4, - IPv6: "none", + IPv6: ipv6, DERPPort: port, STUNPort: -1, ForceHTTP: true, @@ -648,3 +716,35 @@ func (w *testWriter) Flush() { } w.capturedLines = nil } + +type packetLogger struct { + l slog.Logger +} + +func (p packetLogger) LogPacket(path capture.Path, when time.Time, pkt []byte, _ packet.CaptureMeta) { + q := new(packet.Parsed) + q.Decode(pkt) + p.l.Info(context.Background(), "Packet", + slog.F("path", pathString(path)), + slog.F("when", when), + slog.F("decode", q.String()), + slog.F("len", len(pkt)), + ) +} + +func pathString(path capture.Path) string { + switch path { + case capture.FromLocal: + return "Local" + case capture.FromPeer: + return "Peer" + case capture.SynthesizedToLocal: + return "SynthesizedToLocal" + case capture.SynthesizedToPeer: + return "SynthesizedToPeer" + case capture.PathDisco: + return "Disco" + default: + return "<>" + } +} diff --git a/tailnet/test/integration/integration_test.go b/tailnet/test/integration/integration_test.go index b2cfa900674f0..e10c2bea57075 100644 --- a/tailnet/test/integration/integration_test.go +++ b/tailnet/test/integration/integration_test.go @@ -76,70 +76,90 @@ func TestMain(m *testing.M) { var topologies = []integration.TestTopology{ { // Test that DERP over loopback works. - Name: "BasicLoopbackDERP", - SetupNetworking: integration.SetupNetworkingLoopback, - Server: integration.SimpleServerOptions{}, - StartClient: integration.StartClientDERP, - RunTests: integration.TestSuite, + Name: "BasicLoopbackDERP", + NetworkingProvider: integration.NetworkingLoopback{}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true}, + RunTests: integration.TestSuite, }, { // Test that DERP over "easy" NAT works. The server, client 1 and client // 2 are on different networks with their own routers, which are joined // by a bridge. - Name: "EasyNATDERP", - SetupNetworking: integration.SetupNetworkingEasyNAT, - Server: integration.SimpleServerOptions{}, - StartClient: integration.StartClientDERP, - RunTests: integration.TestSuite, + Name: "EasyNATDERP", + NetworkingProvider: integration.NetworkingNAT{StunCount: 0, Client1Hard: false, Client2Hard: false}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true}, + RunTests: integration.TestSuite, }, { // Test that direct over "easy" NAT works with IP/ports grabbed from // STUN. - Name: "EasyNATDirect", - SetupNetworking: integration.SetupNetworkingEasyNATWithSTUN, - Server: integration.SimpleServerOptions{}, - StartClient: integration.StartClientDirect, - RunTests: integration.TestSuite, + Name: "EasyNATDirect", + NetworkingProvider: integration.NetworkingNAT{StunCount: 1, Client1Hard: false, Client2Hard: false}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{WaitForDirect: true}, + RunTests: integration.TestSuite, }, { // Test that direct over hard NAT <=> easy NAT works. - Name: "HardNATEasyNATDirect", - SetupNetworking: integration.SetupNetworkingHardNATEasyNATDirect, - Server: integration.SimpleServerOptions{}, - StartClient: integration.StartClientDirect, - RunTests: integration.TestSuite, + Name: "HardNATEasyNATDirect", + NetworkingProvider: integration.NetworkingNAT{StunCount: 2, Client1Hard: true, Client2Hard: false}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{WaitForDirect: true}, + RunTests: integration.TestSuite, + }, + { + // Test that direct over normal MTU works. + Name: "DirectMTU1500", + NetworkingProvider: integration.TriangleNetwork{Client1MTU: 1500}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{ + WaitForDirect: true, + Service: integration.UDPEchoService{}, + LogPackets: true, + }, + RunTests: integration.TestBigUDP, + }, + { + // Test that small MTU works. + Name: "MTU1280", + NetworkingProvider: integration.TriangleNetwork{Client1MTU: 1280}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{Service: integration.UDPEchoService{}, LogPackets: true}, + RunTests: integration.TestBigUDP, }, { // Test that DERP over WebSocket (as well as DERPForceWebSockets works). // This does not test the actual DERP failure detection code and // automatic fallback. - Name: "DERPForceWebSockets", - SetupNetworking: integration.SetupNetworkingEasyNAT, + Name: "DERPForceWebSockets", + NetworkingProvider: integration.NetworkingNAT{StunCount: 0, Client1Hard: false, Client2Hard: false}, Server: integration.SimpleServerOptions{ FailUpgradeDERP: false, DERPWebsocketOnly: true, }, - StartClient: integration.StartClientDERPWebSockets, - RunTests: integration.TestSuite, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true, DERPForceWebsockets: true}, + RunTests: integration.TestSuite, }, { // Test that falling back to DERP over WebSocket works. - Name: "DERPFallbackWebSockets", - SetupNetworking: integration.SetupNetworkingEasyNAT, + Name: "DERPFallbackWebSockets", + NetworkingProvider: integration.NetworkingNAT{StunCount: 0, Client1Hard: false, Client2Hard: false}, Server: integration.SimpleServerOptions{ FailUpgradeDERP: true, DERPWebsocketOnly: false, }, // Use a basic client that will try `Upgrade: derp` first. - StartClient: integration.StartClientDERP, - RunTests: integration.TestSuite, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true}, + RunTests: integration.TestSuite, }, { - Name: "BasicLoopbackDERPNGINX", - SetupNetworking: integration.SetupNetworkingLoopback, - Server: integration.NGINXServerOptions{}, - StartClient: integration.StartClientDERP, - RunTests: integration.TestSuite, + Name: "BasicLoopbackDERPNGINX", + NetworkingProvider: integration.NetworkingLoopback{}, + Server: integration.NGINXServerOptions{}, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true}, + RunTests: integration.TestSuite, }, } @@ -151,7 +171,6 @@ func TestIntegration(t *testing.T) { } for _, topo := range topologies { - topo := topo t.Run(topo.Name, func(t *testing.T) { // These can run in parallel because every test should be in an // isolated NetNS. @@ -166,7 +185,11 @@ func TestIntegration(t *testing.T) { } log := testutil.Logger(t) - networking := topo.SetupNetworking(t, log) + networking := topo.NetworkingProvider.SetupNetworking(t, log) + + tempDir := t.TempDir() + // useful for debugging: + // networking.Client1.Process.CapturePackets(t, "client1", tempDir) // Useful for debugging network namespaces by avoiding cleanup. // t.Cleanup(func() { @@ -181,7 +204,6 @@ func TestIntegration(t *testing.T) { } // Write the DERP maps to a file. - tempDir := t.TempDir() client1DERPMapPath := filepath.Join(tempDir, "client1-derp-map.json") client1DERPMap, err := networking.Client1.ResolveDERPMap() require.NoError(t, err, "resolve client 1 DERP map") @@ -270,7 +292,7 @@ func handleTestSubprocess(t *testing.T) { waitForServerAvailable(t, serverURL) - conn := topo.StartClient(t, logger, serverURL, &derpMap, me, peer) + conn := topo.ClientStarter.StartClient(t, logger, serverURL, &derpMap, me, peer) if me.ShouldRunTests { // Wait for connectivity. diff --git a/tailnet/test/integration/network.go b/tailnet/test/integration/network.go index b496879fd1219..30a20ed1f71a3 100644 --- a/tailnet/test/integration/network.go +++ b/tailnet/test/integration/network.go @@ -5,9 +5,11 @@ package integration import ( "bytes" + "context" "fmt" "os" "os/exec" + "path" "testing" "github.com/stretchr/testify/require" @@ -71,11 +73,21 @@ type TestNetworkingProcess struct { NetNS *os.File } -// SetupNetworkingLoopback creates a network namespace with a loopback interface +func (p TestNetworkingProcess) CapturePackets(t *testing.T, name, dir string) { + dumpfile := path.Join(dir, name+".pcap") + _, _ = ExecBackground(t, name+".pcap", p.NetNS, "tcpdump", []string{ + "-i", "any", + "-w", dumpfile, + }) +} + +// NetworkingLoopback creates a network namespace with a loopback interface // for all tests to share. This is the simplest networking setup. The network // namespace only exists for isolation on the host and doesn't serve any routing // purpose. -func SetupNetworkingLoopback(t *testing.T, _ slog.Logger) TestNetworking { +type NetworkingLoopback struct{} + +func (NetworkingLoopback) SetupNetworking(t *testing.T, _ slog.Logger) TestNetworking { // Create a single network namespace for all tests so we can have an // isolated loopback interface. netNSFile := createNetNS(t, uniqNetName(t)) @@ -102,91 +114,25 @@ func SetupNetworkingLoopback(t *testing.T, _ slog.Logger) TestNetworking { } } -func easyNAT(t *testing.T) fakeInternet { - internet := createFakeInternet(t) - - _, err := commandInNetNS(internet.BridgeNetNS, "sysctl", []string{"-w", "net.ipv4.ip_forward=1"}).Output() - require.NoError(t, wrapExitErr(err), "enable IP forwarding in bridge NetNS") - - // Set up iptables masquerade rules to allow each router to NAT packets. - leaves := []struct { - fakeRouterLeaf - clientPort int - natPort int - }{ - {internet.Client1, client1Port, client1RouterPort}, - {internet.Client2, client2Port, client2RouterPort}, - } - for _, leaf := range leaves { - _, err := commandInNetNS(leaf.RouterNetNS, "sysctl", []string{"-w", "net.ipv4.ip_forward=1"}).Output() - require.NoError(t, wrapExitErr(err), "enable IP forwarding in router NetNS") - - // All non-UDP traffic should use regular masquerade e.g. for HTTP. - _, err = commandInNetNS(leaf.RouterNetNS, "iptables", []string{ - "-t", "nat", - "-A", "POSTROUTING", - // Every interface except loopback. - "!", "-o", "lo", - // Every protocol except UDP. - "!", "-p", "udp", - "-j", "MASQUERADE", - }).Output() - require.NoError(t, wrapExitErr(err), "add iptables non-UDP masquerade rule") - - // Outgoing traffic should get NATed to the router's IP. - _, err = commandInNetNS(leaf.RouterNetNS, "iptables", []string{ - "-t", "nat", - "-A", "POSTROUTING", - "-p", "udp", - "--sport", fmt.Sprint(leaf.clientPort), - "-j", "SNAT", - "--to-source", fmt.Sprintf("%s:%d", leaf.RouterIP, leaf.natPort), - }).Output() - require.NoError(t, wrapExitErr(err), "add iptables SNAT rule") - - // Incoming traffic should be forwarded to the client's IP. - _, err = commandInNetNS(leaf.RouterNetNS, "iptables", []string{ - "-t", "nat", - "-A", "PREROUTING", - "-p", "udp", - "--dport", fmt.Sprint(leaf.natPort), - "-j", "DNAT", - "--to-destination", fmt.Sprintf("%s:%d", leaf.ClientIP, leaf.clientPort), - }).Output() - require.NoError(t, wrapExitErr(err), "add iptables DNAT rule") - } - - return internet -} - -// SetupNetworkingEasyNAT creates a fake internet and sets up "easy NAT" -// forwarding rules. +// NetworkingNAT creates a fake internet and sets up "NAT" +// forwarding rules, either easy or hard. // See createFakeInternet. // NAT is achieved through a single iptables masquerade rule. -func SetupNetworkingEasyNAT(t *testing.T, _ slog.Logger) TestNetworking { - return easyNAT(t).Net +type NetworkingNAT struct { + StunCount int + Client1Hard bool + Client2Hard bool } -// SetupNetworkingEasyNATWithSTUN does the same as SetupNetworkingEasyNAT, but -// also creates a namespace and bridge address for a STUN server. -func SetupNetworkingEasyNATWithSTUN(t *testing.T, _ slog.Logger) TestNetworking { - internet := easyNAT(t) - internet.Net.STUNs = []TestNetworkingSTUN{ - prepareSTUNServer(t, &internet, 0), - } - - return internet.Net -} - -// hardNAT creates a fake internet with multiple STUN servers and sets up "hard -// NAT" forwarding rules. If bothHard is false, only the first client will have -// hard NAT rules, and the second client will have easy NAT rules. -// -//nolint:revive -func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { +// SetupNetworking creates a fake internet with multiple STUN servers and sets up +// NAT forwarding rules. Client NATs are controlled by the switches ClientXHard, which if true, sets up hard +// nat. +func (n NetworkingNAT) SetupNetworking(t *testing.T, l slog.Logger) TestNetworking { + logger := l.Named("setup-networking").Leveled(slog.LevelDebug) internet := createFakeInternet(t) - internet.Net.STUNs = make([]TestNetworkingSTUN, stunCount) - for i := 0; i < stunCount; i++ { + logger.Debug(context.Background(), "preparing STUN", slog.F("stun_count", n.StunCount)) + internet.Net.STUNs = make([]TestNetworkingSTUN, n.StunCount) + for i := 0; i < n.StunCount; i++ { internet.Net.STUNs[i] = prepareSTUNServer(t, &internet, i) } @@ -202,8 +148,14 @@ func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { natStartPortSTUN int }{ { - fakeRouterLeaf: internet.Client1, - peerIP: internet.Client2.RouterIP, + fakeRouterLeaf: internet.Client1, + // If peerIP is empty, we do easy NAT (even for STUN) + peerIP: func() string { + if n.Client1Hard { + return internet.Client2.RouterIP + } + return "" + }(), clientPort: client1Port, natPortPeer: client1RouterPort, natStartPortSTUN: client1RouterPortSTUN, @@ -212,7 +164,7 @@ func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { fakeRouterLeaf: internet.Client2, // If peerIP is empty, we do easy NAT (even for STUN) peerIP: func() string { - if bothHard { + if n.Client2Hard { return internet.Client1.RouterIP } return "" @@ -235,6 +187,9 @@ func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { // NAT from this client to each STUN server. Only do this if we're doing // hard NAT, as the rule above will also touch STUN traffic in easy NAT. if leaf.peerIP != "" { + logger.Debug(context.Background(), "creating NAT to STUN", + slog.F("client_ip", leaf.ClientIP), slog.F("peer_ip", leaf.peerIP), + ) for i, stun := range internet.Net.STUNs { natPort := leaf.natStartPortSTUN + i iptablesNAT(t, leaf.RouterNetNS, leaf.ClientIP, leaf.clientPort, leaf.RouterIP, natPort, stun.IP) @@ -242,11 +197,7 @@ func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { } } - return internet -} - -func SetupNetworkingHardNATEasyNATDirect(t *testing.T, _ slog.Logger) TestNetworking { - return hardNAT(t, 2, false).Net + return internet.Net } type vethPair struct { @@ -438,6 +389,162 @@ func createFakeInternet(t *testing.T) fakeInternet { return router } +type TriangleNetwork struct { + Client1MTU int +} + +type fakeTriangleNetwork struct { + NamePrefix string + ServerNetNS *os.File + Client1NetNS *os.File + Client2NetNS *os.File + RouterNetNS *os.File + ServerVethPair vethPair + Client1VethPair vethPair + Client2VethPair vethPair +} + +// SetupNetworking creates multiple namespaces with a central router in the following topology +// . +// . ┌──────────────┐ +// . │ │ +// . │ Server ├─────────────────────────────────────┐ +// . │ │fdac:38fa:ffff:3::2 │ +// . └──────────────┘ │ fdac:38fa:ffff:3::1 +// . ┌──────────────┐ ┌─────┴───────┐ +// . │ │ fdac:38fa:ffff:1::1│ │ +// . │ Client 1 ├───────────────────────────────┤ Router │ +// . │ │fdac:38fa:ffff:1::2 │ │ +// . └──────────────┘ └─────┬───────┘ +// . ┌──────────────┐ │ fdac:38fa:ffff:2::1 +// . │ │ │ +// . │ Client 2 ├─────────────────────────────────────┘ +// . │ │fdac:38fa:ffff:2::2 +// . └──────────────┘ +// The veth link between Client 1 and the router has a configurable MTU via Client1MTU. +func (n TriangleNetwork) SetupNetworking(t *testing.T, l slog.Logger) TestNetworking { + logger := l.Named("setup-networking").Leveled(slog.LevelDebug) + t.Helper() + var ( + namePrefix = uniqNetName(t) + "_" + network = fakeTriangleNetwork{ + NamePrefix: namePrefix, + } + // Unique Local Address prefix + ula = "fdac:38fa:ffff:" + ) + + // Create three network namespaces for server, client1, and client2 + network.ServerNetNS = createNetNS(t, namePrefix+"server") + network.Client1NetNS = createNetNS(t, namePrefix+"client1") + network.Client2NetNS = createNetNS(t, namePrefix+"client2") + network.RouterNetNS = createNetNS(t, namePrefix+"router") + + // Create veth pair between server and router + network.ServerVethPair = vethPair{ + Outer: namePrefix + "s-r", + Inner: namePrefix + "r-s", + } + err := createVethPair(network.ServerVethPair.Outer, network.ServerVethPair.Inner) + require.NoErrorf(t, err, "create veth pair %q <-> %q", + network.ServerVethPair.Outer, network.ServerVethPair.Inner) + + // Move server-router veth ends to their respective namespaces + err = setVethNetNS(network.ServerVethPair.Outer, int(network.ServerNetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to server NetNS", network.ServerVethPair.Outer) + err = setVethNetNS(network.ServerVethPair.Inner, int(network.RouterNetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to router NetNS", network.ServerVethPair.Inner) + + // Create veth pair between client1 and router + network.Client1VethPair = vethPair{ + Outer: namePrefix + "1-r", + Inner: namePrefix + "r-1", + } + logger.Debug(context.Background(), "creating client1 link", slog.F("mtu", n.Client1MTU)) + err = createVethPair(network.Client1VethPair.Outer, network.Client1VethPair.Inner, withMTU(n.Client1MTU)) + require.NoErrorf(t, err, "create veth pair %q <-> %q", + network.Client1VethPair.Outer, network.Client1VethPair.Inner) + + // Move client1-router veth ends to their respective namespaces + err = setVethNetNS(network.Client1VethPair.Outer, int(network.Client1NetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to server NetNS", network.Client1VethPair.Outer) + err = setVethNetNS(network.Client1VethPair.Inner, int(network.RouterNetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to client2 NetNS", network.Client1VethPair.Inner) + + // Create veth pair between client1 and client2 + network.Client2VethPair = vethPair{ + Outer: namePrefix + "2-r", + Inner: namePrefix + "r-2", + } + + err = createVethPair(network.Client2VethPair.Outer, network.Client2VethPair.Inner) + require.NoErrorf(t, err, "create veth pair %q <-> %q", + network.Client2VethPair.Outer, network.Client2VethPair.Inner) + + // Move client1-client2 veth ends to their respective namespaces + err = setVethNetNS(network.Client2VethPair.Outer, int(network.Client2NetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to client1 NetNS", network.Client2VethPair.Outer) + err = setVethNetNS(network.Client2VethPair.Inner, int(network.RouterNetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to client2 NetNS", network.Client2VethPair.Inner) + + // Set IP addresses according to the diagram: + err = setInterfaceIP6(network.ServerNetNS, network.ServerVethPair.Outer, ula+"3::2") + require.NoErrorf(t, err, "set IP on server interface") + err = setInterfaceIP6(network.Client1NetNS, network.Client1VethPair.Outer, ula+"1::2") + require.NoErrorf(t, err, "set IP on client1 interface") + err = setInterfaceIP6(network.Client2NetNS, network.Client2VethPair.Outer, ula+"2::2") + require.NoErrorf(t, err, "set IP on client2 interface") + + err = setInterfaceIP6(network.RouterNetNS, network.ServerVethPair.Inner, ula+"3::1") + require.NoErrorf(t, err, "set IP on router-server interface") + err = setInterfaceIP6(network.RouterNetNS, network.Client1VethPair.Inner, ula+"1::1") + require.NoErrorf(t, err, "set IP on router-client1 interface") + err = setInterfaceIP6(network.RouterNetNS, network.Client2VethPair.Inner, ula+"2::1") + require.NoErrorf(t, err, "set IP on router-client2 interface") + + // Bring up all interfaces + interfaces := []struct { + netNS *os.File + ifaceName string + defaultRoute string + }{ + {network.ServerNetNS, network.ServerVethPair.Outer, ula + "3::1"}, + {network.Client1NetNS, network.Client1VethPair.Outer, ula + "1::1"}, + {network.Client2NetNS, network.Client2VethPair.Outer, ula + "2::1"}, + {network.RouterNetNS, network.ServerVethPair.Inner, ""}, + {network.RouterNetNS, network.Client1VethPair.Inner, ""}, + {network.RouterNetNS, network.Client2VethPair.Inner, ""}, + } + for _, iface := range interfaces { + err = setInterfaceUp(iface.netNS, iface.ifaceName) + require.NoErrorf(t, err, "bring up interface %q", iface.ifaceName) + + if iface.defaultRoute != "" { + err = addRouteInNetNS(iface.netNS, []string{"default", "via", iface.defaultRoute, "dev", iface.ifaceName}) + require.NoErrorf(t, err, "add peer default route to %s", iface.defaultRoute) + } + } + + // enable IP forwarding in the router + _, err = commandInNetNS(network.RouterNetNS, "sysctl", []string{"-w", "net.ipv6.conf.all.forwarding=1"}).Output() + require.NoError(t, wrapExitErr(err), "enable IPv6 forwarding in router NetNS") + + return TestNetworking{ + Server: TestNetworkingServer{ + Process: TestNetworkingProcess{NetNS: network.ServerNetNS}, + ListenAddr: "[::]:8080", // Server listens on all IPs + }, + Client1: TestNetworkingClient{ + Process: TestNetworkingProcess{NetNS: network.Client1NetNS}, + ServerAccessURL: "http://[" + ula + "3::2]:8080", + }, + Client2: TestNetworkingClient{ + Process: TestNetworkingProcess{NetNS: network.Client2NetNS}, + ServerAccessURL: "http://[" + ula + "3::2]:8080", + }, + } +} + func uniqNetName(t *testing.T) string { t.Helper() netNSName := "cdr_" @@ -522,8 +629,8 @@ func createNetNS(t *testing.T, name string) *os.File { }) // Open /run/netns/$name to get a file descriptor to the network namespace. - path := fmt.Sprintf("/run/netns/%s", name) - file, err := os.OpenFile(path, os.O_RDONLY, 0) + netnsPath := fmt.Sprintf("/run/netns/%s", name) + file, err := os.OpenFile(netnsPath, os.O_RDONLY, 0) require.NoError(t, err, "open network namespace file") t.Cleanup(func() { _ = file.Close() @@ -568,10 +675,22 @@ func setInterfaceBridge(netNS *os.File, ifaceName, bridgeName string) error { return nil } +type linkOption func(attrs netlink.LinkAttrs) netlink.LinkAttrs + +func withMTU(mtu int) linkOption { + return func(attrs netlink.LinkAttrs) netlink.LinkAttrs { + attrs.MTU = mtu + return attrs + } +} + // createVethPair creates a veth pair with the given names. -func createVethPair(parentVethName, peerVethName string) error { +func createVethPair(parentVethName, peerVethName string, options ...linkOption) error { linkAttrs := netlink.NewLinkAttrs() linkAttrs.Name = parentVethName + for _, option := range options { + linkAttrs = option(linkAttrs) + } veth := &netlink.Veth{ LinkAttrs: linkAttrs, PeerName: peerVethName, @@ -611,6 +730,17 @@ func setInterfaceIP(netNS *os.File, ifaceName, ip string) error { return nil } +// setInterfaceIP6 sets the IPv6 address on the given interface. It automatically +// adds a /64 subnet mask. +func setInterfaceIP6(netNS *os.File, ifaceName, ip string) error { + _, err := commandInNetNS(netNS, "ip", []string{"addr", "add", ip + "/64", "dev", ifaceName}).Output() + if err != nil { + return xerrors.Errorf("set IP %q on interface %q in netns: %w", ip, ifaceName, wrapExitErr(err)) + } + + return nil +} + // setInterfaceUp brings the given interface up. func setInterfaceUp(netNS *os.File, ifaceName string) error { _, err := commandInNetNS(netNS, "ip", []string{"link", "set", ifaceName, "up"}).Output() @@ -703,7 +833,9 @@ func iptablesMasqueradeNonUDP(t *testing.T, netNS *os.File) { // iptablesNAT sets up iptables rules for NAT forwarding. If destIP is // specified, the forwarding rule will only apply to traffic to/from that IP // (mapvarydest). -func iptablesNAT(t *testing.T, netNS *os.File, clientIP string, clientPort int, routerIP string, routerPort int, destIP string) { +func iptablesNAT( + t *testing.T, netNS *os.File, clientIP string, clientPort int, routerIP string, routerPort int, destIP string, +) { t.Helper() snatArgs := []string{ diff --git a/tailnet/test/integration/suite.go b/tailnet/test/integration/suite.go index eefba0eaf2ce0..9e04de03de53a 100644 --- a/tailnet/test/integration/suite.go +++ b/tailnet/test/integration/suite.go @@ -5,6 +5,7 @@ package integration import ( "net/http" + "net/netip" "net/url" "testing" "time" @@ -80,3 +81,40 @@ func TestSuite(t *testing.T, _ slog.Logger, serverURL *url.URL, conn *tailnet.Co require.NoError(t, err, "ping peer after restart") }) } + +func TestBigUDP(t *testing.T, logger slog.Logger, _ *url.URL, conn *tailnet.Conn, _, peer Client) { + t.Run("UDPEcho", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + + peerIP := tailnet.TailscaleServicePrefix.AddrFromUUID(peer.ID) + udpConn, err := conn.DialContextUDP(ctx, netip.AddrPortFrom(peerIP, uint16(EchoPort))) + require.NoError(t, err) + defer udpConn.Close() + + // 1280 max tunnel packet size + // -40 + // -8 UDP header + // ---------------------------- + // 1232 data size + logger.Info(ctx, "sending UDP test packet") + packet := make([]byte, 1232) + for i := range packet { + packet[i] = byte(i % 256) + } + err = udpConn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + require.NoError(t, err) + n, err := udpConn.Write(packet) + require.NoError(t, err) + require.Equal(t, len(packet), n) + + // read the echo + logger.Info(ctx, "attempting to read UDP reply") + buf := make([]byte, 1280) + err = udpConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + require.NoError(t, err) + n, err = udpConn.Read(buf) + require.NoError(t, err) + require.Equal(t, len(packet), n) + require.Equal(t, packet, buf[:n]) + }) +} diff --git a/vpn/client.go b/vpn/client.go index da066bbcd62b3..e3f3e767fc477 100644 --- a/vpn/client.go +++ b/vpn/client.go @@ -5,11 +5,14 @@ import ( "net/http" "net/netip" "net/url" + "time" "golang.org/x/xerrors" + "tailscale.com/ipn/ipnstate" "tailscale.com/net/dns" "tailscale.com/net/netmon" + "tailscale.com/tailcfg" "tailscale.com/wgengine/router" "github.com/google/uuid" @@ -27,6 +30,9 @@ import ( type Conn interface { CurrentWorkspaceState() (tailnet.WorkspaceUpdate, error) GetPeerDiagnostics(peerID uuid.UUID) tailnet.PeerDiagnostics + Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error) + Node() *tailnet.Node + DERPMap() *tailcfg.DERPMap Close() error } @@ -38,6 +44,10 @@ type vpnConn struct { updatesCtrl *tailnet.TunnelAllWorkspaceUpdatesController } +func (c *vpnConn) Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error) { + return c.Conn.Ping(ctx, tailnet.TailscaleServicePrefix.AddrFromUUID(agentID)) +} + func (c *vpnConn) CurrentWorkspaceState() (tailnet.WorkspaceUpdate, error) { return c.updatesCtrl.CurrentState() } diff --git a/vpn/dylib/lib.go b/vpn/dylib/lib.go index de6f91042c7ef..3677aee369598 100644 --- a/vpn/dylib/lib.go +++ b/vpn/dylib/lib.go @@ -46,7 +46,10 @@ func OpenTunnel(cReadFD, cWriteFD int32) int32 { return ErrOpenPipe } - _, err = vpn.NewTunnel(ctx, slog.Make(), conn, vpn.NewClient(), + // We log everything, as filtering is done by whatever renders the OS + // logs. + _, err = vpn.NewTunnel(ctx, slog.Make().Leveled(slog.LevelDebug), conn, + vpn.NewClient(), vpn.UseOSNetworkingStack(), vpn.UseAsLogger(), ) diff --git a/vpn/speaker_internal_test.go b/vpn/speaker_internal_test.go index 2f3d131093382..433868851a5bc 100644 --- a/vpn/speaker_internal_test.go +++ b/vpn/speaker_internal_test.go @@ -23,6 +23,8 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain(m, testutil.GoleakOptions...) } +const expectedHandshake = "codervpn tunnel 1.2\n" + // TestSpeaker_RawPeer tests the speaker with a peer that we simulate by directly making reads and // writes to the other end of the pipe. There should be at least one test that does this, rather // than use 2 speakers so that we don't have a bug where we don't adhere to the stated protocol, but @@ -48,8 +50,6 @@ func TestSpeaker_RawPeer(t *testing.T) { errCh <- err }() - expectedHandshake := "codervpn tunnel 1.1\n" - b := make([]byte, 256) n, err := mp.Read(b) require.NoError(t, err) @@ -157,8 +157,6 @@ func TestSpeaker_OversizeHandshake(t *testing.T) { errCh <- err }() - expectedHandshake := "codervpn tunnel 1.1\n" - b := make([]byte, 256) n, err := mp.Read(b) require.NoError(t, err) @@ -210,7 +208,6 @@ func TestSpeaker_HandshakeInvalid(t *testing.T) { _, err = mp.Write([]byte(tc.handshake)) require.NoError(t, err) - expectedHandshake := "codervpn tunnel 1.1\n" b := make([]byte, 256) n, err := mp.Read(b) require.NoError(t, err) @@ -248,8 +245,6 @@ func TestSpeaker_CorruptMessage(t *testing.T) { errCh <- err }() - expectedHandshake := "codervpn tunnel 1.1\n" - b := make([]byte, 256) n, err := mp.Read(b) require.NoError(t, err) diff --git a/vpn/tunnel.go b/vpn/tunnel.go index 6c71aecaa0965..e4624ac1822b0 100644 --- a/vpn/tunnel.go +++ b/vpn/tunnel.go @@ -19,6 +19,7 @@ import ( "github.com/google/uuid" "github.com/tailscale/wireguard-go/tun" "golang.org/x/xerrors" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/net/dns" "tailscale.com/net/netmon" @@ -32,9 +33,9 @@ import ( "github.com/coder/coder/v2/tailnet" ) -// netStatusInterval is the interval at which the tunnel sends network status updates to the manager. -// This is currently only used to keep `last_handshake` up to date. -const netStatusInterval = 10 * time.Second +// netStatusInterval is the interval at which the tunnel records latencies, +// and sends network status updates to the manager. +const netStatusInterval = 5 * time.Second type Tunnel struct { speaker[*TunnelMessage, *ManagerMessage, ManagerMessage] @@ -45,9 +46,6 @@ type Tunnel struct { logger slog.Logger - logMu sync.Mutex - logs []*TunnelMessage - client Client // clientLogger is a separate logger than `logger` when the `UseAsLogger` @@ -86,8 +84,9 @@ func NewTunnel( ctx: uCtx, cancel: uCancel, netLoopDone: make(chan struct{}), + logger: logger, uSendCh: s.sendCh, - agents: map[uuid.UUID]tailnet.Agent{}, + agents: map[uuid.UUID]agentWithPing{}, workspaces: map[uuid.UUID]tailnet.Workspace{}, clock: quartz.NewReal(), }, @@ -298,29 +297,22 @@ func (t *Tunnel) stop(*StopRequest) error { var _ slog.Sink = &Tunnel{} func (t *Tunnel) LogEntry(_ context.Context, e slog.SinkEntry) { - t.logMu.Lock() - defer t.logMu.Unlock() - t.logs = append(t.logs, &TunnelMessage{ + msg := &TunnelMessage{ Msg: &TunnelMessage_Log{ Log: sinkEntryToPb(e), }, - }) -} - -func (t *Tunnel) Sync() { - t.logMu.Lock() - logs := t.logs - t.logs = nil - t.logMu.Unlock() - for _, msg := range logs { - select { - case <-t.ctx.Done(): - return - case t.sendCh <- msg: - } + } + select { + case <-t.updater.ctx.Done(): + return + case <-t.ctx.Done(): + return + case t.sendCh <- msg: } } +func (*Tunnel) Sync() {} + func sinkEntryToPb(e slog.SinkEntry) *Log { l := &Log{ // #nosec G115 - Safe conversion for log levels which are small positive integers @@ -344,10 +336,12 @@ type updater struct { cancel context.CancelFunc netLoopDone chan struct{} + logger slog.Logger + mu sync.Mutex uSendCh chan<- *TunnelMessage // agents contains the agents that are currently connected to the tunnel. - agents map[uuid.UUID]tailnet.Agent + agents map[uuid.UUID]agentWithPing // workspaces contains the workspaces to which agents are currently connected via the tunnel. workspaces map[uuid.UUID]tailnet.Workspace conn Conn @@ -355,6 +349,26 @@ type updater struct { clock quartz.Clock } +type agentWithPing struct { + tailnet.Agent + // non-nil if a successful ping has been made + lastPing *lastPing +} + +func (a *agentWithPing) Clone() *agentWithPing { + return &agentWithPing{ + Agent: a.Agent.Clone(), + lastPing: a.lastPing, + } +} + +type lastPing struct { + pingDur time.Duration + didP2p bool + preferredDerp string + preferredDerpLatency *time.Duration +} + // Update pushes a workspace update to the manager func (u *updater) Update(update tailnet.WorkspaceUpdate) error { u.mu.Lock() @@ -412,10 +426,21 @@ func (u *updater) createPeerUpdateLocked(update tailnet.WorkspaceUpdate) *PeerUp DeletedAgents: make([]*Agent, len(update.DeletedAgents)), } + var upsertedAgentsWithPing []*agentWithPing + // save the workspace update to the tunnel's state, such that it can // be used to populate automated peer updates. for _, agent := range update.UpsertedAgents { - u.agents[agent.ID] = agent.Clone() + var lastPing *lastPing + if existing, ok := u.agents[agent.ID]; ok { + lastPing = existing.lastPing + } + upsertedAgent := agentWithPing{ + Agent: agent.Clone(), + lastPing: lastPing, + } + u.agents[agent.ID] = upsertedAgent + upsertedAgentsWithPing = append(upsertedAgentsWithPing, &upsertedAgent) } for _, agent := range update.DeletedAgents { delete(u.agents, agent.ID) @@ -435,7 +460,7 @@ func (u *updater) createPeerUpdateLocked(update tailnet.WorkspaceUpdate) *PeerUp } } - upsertedAgents := u.convertAgentsLocked(update.UpsertedAgents) + upsertedAgents := u.convertAgentsLocked(upsertedAgentsWithPing) out.UpsertedAgents = upsertedAgents for i, ws := range update.DeletedWorkspaces { out.DeletedWorkspaces[i] = &Workspace{ @@ -466,7 +491,7 @@ func (u *updater) createPeerUpdateLocked(update tailnet.WorkspaceUpdate) *PeerUp // convertAgentsLocked takes a list of `tailnet.Agent` and converts them to proto agents. // If there is an active connection, the last handshake time is populated. -func (u *updater) convertAgentsLocked(agents []*tailnet.Agent) []*Agent { +func (u *updater) convertAgentsLocked(agents []*agentWithPing) []*Agent { out := make([]*Agent, 0, len(agents)) for _, agent := range agents { @@ -477,12 +502,26 @@ func (u *updater) convertAgentsLocked(agents []*tailnet.Agent) []*Agent { sort.Slice(fqdn, func(i, j int) bool { return len(fqdn[i]) < len(fqdn[j]) }) + var lastPing *LastPing + if agent.lastPing != nil { + var preferredDerpLatency *durationpb.Duration + if agent.lastPing.preferredDerpLatency != nil { + preferredDerpLatency = durationpb.New(*agent.lastPing.preferredDerpLatency) + } + lastPing = &LastPing{ + Latency: durationpb.New(agent.lastPing.pingDur), + DidP2P: agent.lastPing.didP2p, + PreferredDerp: agent.lastPing.preferredDerp, + PreferredDerpLatency: preferredDerpLatency, + } + } protoAgent := &Agent{ Id: tailnet.UUIDToByteSlice(agent.ID), Name: agent.Name, WorkspaceId: tailnet.UUIDToByteSlice(agent.WorkspaceID), Fqdn: fqdn, IpAddrs: hostsToIPStrings(agent.Hosts), + LastPing: lastPing, } if u.conn != nil { diags := u.conn.GetPeerDiagnostics(agent.ID) @@ -514,8 +553,8 @@ func (u *updater) stop() error { return nil } err := u.conn.Close() - u.conn = nil u.cancel() + u.conn = nil return err } @@ -525,7 +564,7 @@ func (u *updater) sendAgentUpdate() { u.mu.Lock() defer u.mu.Unlock() - agents := make([]*tailnet.Agent, 0, len(u.agents)) + agents := make([]*agentWithPing, 0, len(u.agents)) for _, agent := range u.agents { agents = append(agents, &agent) } @@ -534,6 +573,8 @@ func (u *updater) sendAgentUpdate() { return } + u.logger.Debug(u.ctx, "sending agent update") + msg := &TunnelMessage{ Msg: &TunnelMessage_PeerUpdate{ PeerUpdate: &PeerUpdate{ @@ -558,17 +599,85 @@ func (u *updater) netStatusLoop() { case <-u.ctx.Done(): return case <-ticker.C: + u.recordLatencies() u.sendAgentUpdate() } } } +func (u *updater) recordLatencies() { + var agentsIDsToPing []uuid.UUID + u.mu.Lock() + for _, agent := range u.agents { + agentsIDsToPing = append(agentsIDsToPing, agent.ID) + } + conn := u.conn + u.mu.Unlock() + + if conn == nil { + u.logger.Debug(u.ctx, "skipping pings as tunnel is not connected") + return + } + + go func() { + // We need a waitgroup to cancel the context after all pings are done. + var wg sync.WaitGroup + pingCtx, cancelFunc := context.WithTimeout(u.ctx, netStatusInterval) + defer cancelFunc() + for _, agentID := range agentsIDsToPing { + wg.Add(1) + go func() { + defer wg.Done() + + pingDur, didP2p, pingResult, err := conn.Ping(pingCtx, agentID) + if err != nil { + u.logger.Warn(u.ctx, "failed to ping agent", slog.F("agent_id", agentID), slog.Error(err)) + return + } + + // We fetch the Node and DERPMap after each ping, as it may have + // changed. + node := conn.Node() + derpMap := conn.DERPMap() + if node == nil || derpMap == nil { + u.logger.Warn(u.ctx, "failed to get DERP map or node after ping") + return + } + derpLatencies := tailnet.ExtractDERPLatency(node, derpMap) + preferredDerp := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + var preferredDerpLatency *time.Duration + if derpLatency, ok := derpLatencies[preferredDerp]; ok { + preferredDerpLatency = &derpLatency + } else { + u.logger.Debug(u.ctx, "preferred DERP not found in DERP latency map", slog.F("preferred_derp", preferredDerp)) + } + + // Write back results + u.mu.Lock() + defer u.mu.Unlock() + if agent, ok := u.agents[agentID]; ok { + agent.lastPing = &lastPing{ + pingDur: pingDur, + didP2p: didP2p, + preferredDerp: preferredDerp, + preferredDerpLatency: preferredDerpLatency, + } + u.agents[agentID] = agent + } else { + u.logger.Debug(u.ctx, "ignoring ping result for unknown agent", slog.F("agent_id", agentID)) + } + }() + } + wg.Wait() + }() +} + // processSnapshotUpdate handles the logic when a full state update is received. // When the tunnel is live, we only receive diffs, but the first packet on any given // reconnect to the tailnet API is a full state. // Without this logic we weren't processing deletes for any workspaces or agents deleted // while the client was disconnected while the computer was asleep. -func processSnapshotUpdate(update *tailnet.WorkspaceUpdate, agents map[uuid.UUID]tailnet.Agent, workspaces map[uuid.UUID]tailnet.Workspace) { +func processSnapshotUpdate(update *tailnet.WorkspaceUpdate, agents map[uuid.UUID]agentWithPing, workspaces map[uuid.UUID]tailnet.Workspace) { // ignoredWorkspaces is initially populated with the workspaces that are // in the current update. Later on we populate it with the deleted workspaces too // so that we don't send duplicate updates. Same applies to ignoredAgents. diff --git a/vpn/tunnel_internal_test.go b/vpn/tunnel_internal_test.go index 15eb9cf569f5e..5c4e6ec03d47f 100644 --- a/vpn/tunnel_internal_test.go +++ b/vpn/tunnel_internal_test.go @@ -15,10 +15,13 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" "tailscale.com/util/dnsname" "github.com/coder/quartz" + maputil "github.com/coder/coder/v2/coderd/util/maps" "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/tailnet/proto" "github.com/coder/coder/v2/testutil" @@ -57,15 +60,59 @@ func newFakeConn(state tailnet.WorkspaceUpdate, hsTime time.Time) *fakeConn { } } +func (f *fakeConn) withManualPings() *fakeConn { + f.returnPing = make(chan struct{}) + return f +} + type fakeConn struct { - state tailnet.WorkspaceUpdate - hsTime time.Time - closed chan struct{} - doClose sync.Once + state tailnet.WorkspaceUpdate + returnPing chan struct{} + hsTime time.Time + closed chan struct{} + doClose sync.Once +} + +func (*fakeConn) DERPMap() *tailcfg.DERPMap { + return &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 999: { + RegionID: 999, + RegionCode: "zzz", + RegionName: "Coder Region", + }, + }, + } +} + +func (*fakeConn) Node() *tailnet.Node { + return &tailnet.Node{ + PreferredDERP: 999, + DERPLatency: map[string]float64{ + "999": 0.1, + }, + } } var _ Conn = (*fakeConn)(nil) +func (f *fakeConn) Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error) { + if f.returnPing == nil { + return time.Millisecond * 100, true, &ipnstate.PingResult{ + DERPRegionID: 999, + }, nil + } + + select { + case <-ctx.Done(): + return 0, false, nil, ctx.Err() + case <-f.returnPing: + return time.Millisecond * 100, true, &ipnstate.PingResult{ + DERPRegionID: 999, + }, nil + } +} + func (f *fakeConn) CurrentWorkspaceState() (tailnet.WorkspaceUpdate, error) { return f.state, nil } @@ -292,7 +339,7 @@ func TestUpdater_createPeerUpdate(t *testing.T) { updater := updater{ ctx: ctx, netLoopDone: make(chan struct{}), - agents: map[uuid.UUID]tailnet.Agent{}, + agents: map[uuid.UUID]agentWithPing{}, workspaces: map[uuid.UUID]tailnet.Workspace{}, conn: newFakeConn(tailnet.WorkspaceUpdate{}, hsTime), } @@ -430,6 +477,22 @@ func TestTunnel_sendAgentUpdate(t *testing.T) { require.Equal(t, hsTime, req.msg.GetPeerUpdate().UpsertedAgents[0].LastHandshake.AsTime()) } + // Latency is gathered in the background, so it'll eventually be sent + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + mClock.AdvanceNext() + req = testutil.TryReceive(ctx, t, mgr.requests) + if len(req.msg.GetPeerUpdate().UpsertedAgents) == 0 { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing == nil { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.Latency.AsDuration().Milliseconds() != 100 { + return false + } + return req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.PreferredDerp == "Coder Region" + }, testutil.IntervalFast) + // Upsert a new agent err = tun.Update(tailnet.WorkspaceUpdate{ UpsertedWorkspaces: []*tailnet.Workspace{}, @@ -459,6 +522,10 @@ func TestTunnel_sendAgentUpdate(t *testing.T) { require.Equal(t, aID1[:], req.msg.GetPeerUpdate().UpsertedAgents[0].Id) require.Equal(t, hsTime, req.msg.GetPeerUpdate().UpsertedAgents[0].LastHandshake.AsTime()) + // The latency of the first agent is still set + require.NotNil(t, req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing) + require.EqualValues(t, 100, req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.Latency.AsDuration().Milliseconds()) + require.Equal(t, aID2[:], req.msg.GetPeerUpdate().UpsertedAgents[1].Id) require.Equal(t, hsTime, req.msg.GetPeerUpdate().UpsertedAgents[1].LastHandshake.AsTime()) @@ -486,6 +553,22 @@ func TestTunnel_sendAgentUpdate(t *testing.T) { require.Len(t, req.msg.GetPeerUpdate().UpsertedAgents, 1) require.Equal(t, aID2[:], req.msg.GetPeerUpdate().UpsertedAgents[0].Id) require.Equal(t, hsTime, req.msg.GetPeerUpdate().UpsertedAgents[0].LastHandshake.AsTime()) + + // Eventually the second agent's latency is set + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + mClock.AdvanceNext() + req = testutil.TryReceive(ctx, t, mgr.requests) + if len(req.msg.GetPeerUpdate().UpsertedAgents) == 0 { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing == nil { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.Latency.AsDuration().Milliseconds() != 100 { + return false + } + return req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.PreferredDerp == "Coder Region" + }, testutil.IntervalFast) } func TestTunnel_sendAgentUpdateReconnect(t *testing.T) { @@ -693,6 +776,178 @@ func TestTunnel_sendAgentUpdateWorkspaceReconnect(t *testing.T) { require.Equal(t, wID1[:], peerUpdate.DeletedWorkspaces[0].Id) } +func TestTunnel_slowPing(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + mClock := quartz.NewMock(t) + + wID1 := uuid.UUID{1} + aID1 := uuid.UUID{2} + hsTime := time.Now().Add(-time.Minute).UTC() + + client := newFakeClient(ctx, t) + conn := newFakeConn(tailnet.WorkspaceUpdate{}, hsTime).withManualPings() + + tun, mgr := setupTunnel(t, ctx, client, mClock) + errCh := make(chan error, 1) + var resp *TunnelMessage + go func() { + r, err := mgr.unaryRPC(ctx, &ManagerMessage{ + Msg: &ManagerMessage_Start{ + Start: &StartRequest{ + TunnelFileDescriptor: 2, + CoderUrl: "https://coder.example.com", + ApiToken: "fakeToken", + }, + }, + }) + resp = r + errCh <- err + }() + testutil.RequireSend(ctx, t, client.ch, conn) + err := testutil.TryReceive(ctx, t, errCh) + require.NoError(t, err) + _, ok := resp.Msg.(*TunnelMessage_Start) + require.True(t, ok) + + // Inform the tunnel of the initial state + err = tun.Update(tailnet.WorkspaceUpdate{ + UpsertedWorkspaces: []*tailnet.Workspace{ + { + ID: wID1, Name: "w1", Status: proto.Workspace_STARTING, + }, + }, + UpsertedAgents: []*tailnet.Agent{ + { + ID: aID1, + Name: "agent1", + WorkspaceID: wID1, + Hosts: map[dnsname.FQDN][]netip.Addr{ + "agent1.coder.": {netip.MustParseAddr("fd60:627a:a42b:0101::")}, + }, + }, + }, + }) + require.NoError(t, err) + req := testutil.TryReceive(ctx, t, mgr.requests) + require.Nil(t, req.msg.Rpc) + require.NotNil(t, req.msg.GetPeerUpdate()) + require.Len(t, req.msg.GetPeerUpdate().UpsertedAgents, 1) + require.Equal(t, aID1[:], req.msg.GetPeerUpdate().UpsertedAgents[0].Id) + + // We can't check that it *never* pings, so the best we can do is + // check it doesn't ping even with 5 goroutines attempting to, + // and that updates are received as normal + for range 5 { + mClock.AdvanceNext() + require.Nil(t, req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing) + } + + // Provided that it hasn't been 5 seconds since the last AdvanceNext call, + // there'll be a ping in-flight that will return with this message + testutil.RequireSend(ctx, t, conn.returnPing, struct{}{}) + // Which will mean we'll eventually receive a PeerUpdate with the ping + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + mClock.AdvanceNext() + req = testutil.TryReceive(ctx, t, mgr.requests) + if len(req.msg.GetPeerUpdate().UpsertedAgents) == 0 { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing == nil { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.Latency.AsDuration().Milliseconds() != 100 { + return false + } + return req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.PreferredDerp == "Coder Region" + }, testutil.IntervalFast) +} + +func TestTunnel_stopMidPing(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + mClock := quartz.NewMock(t) + + wID1 := uuid.UUID{1} + aID1 := uuid.UUID{2} + hsTime := time.Now().Add(-time.Minute).UTC() + + client := newFakeClient(ctx, t) + conn := newFakeConn(tailnet.WorkspaceUpdate{}, hsTime).withManualPings() + + tun, mgr := setupTunnel(t, ctx, client, mClock) + errCh := make(chan error, 1) + var resp *TunnelMessage + go func() { + r, err := mgr.unaryRPC(ctx, &ManagerMessage{ + Msg: &ManagerMessage_Start{ + Start: &StartRequest{ + TunnelFileDescriptor: 2, + CoderUrl: "https://coder.example.com", + ApiToken: "fakeToken", + }, + }, + }) + resp = r + errCh <- err + }() + testutil.RequireSend(ctx, t, client.ch, conn) + err := testutil.TryReceive(ctx, t, errCh) + require.NoError(t, err) + _, ok := resp.Msg.(*TunnelMessage_Start) + require.True(t, ok) + + // Inform the tunnel of the initial state + err = tun.Update(tailnet.WorkspaceUpdate{ + UpsertedWorkspaces: []*tailnet.Workspace{ + { + ID: wID1, Name: "w1", Status: proto.Workspace_STARTING, + }, + }, + UpsertedAgents: []*tailnet.Agent{ + { + ID: aID1, + Name: "agent1", + WorkspaceID: wID1, + Hosts: map[dnsname.FQDN][]netip.Addr{ + "agent1.coder.": {netip.MustParseAddr("fd60:627a:a42b:0101::")}, + }, + }, + }, + }) + require.NoError(t, err) + req := testutil.TryReceive(ctx, t, mgr.requests) + require.Nil(t, req.msg.Rpc) + require.NotNil(t, req.msg.GetPeerUpdate()) + require.Len(t, req.msg.GetPeerUpdate().UpsertedAgents, 1) + require.Equal(t, aID1[:], req.msg.GetPeerUpdate().UpsertedAgents[0].Id) + + // We'll have some pings in flight when we stop + for range 5 { + mClock.AdvanceNext() + req = testutil.TryReceive(ctx, t, mgr.requests) + require.Nil(t, req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing) + } + + // Stop the tunnel + go func() { + r, err := mgr.unaryRPC(ctx, &ManagerMessage{ + Msg: &ManagerMessage_Stop{}, + }) + resp = r + errCh <- err + }() + testutil.TryReceive(ctx, t, conn.closed) + err = testutil.TryReceive(ctx, t, errCh) + require.NoError(t, err) + _, ok = resp.Msg.(*TunnelMessage_Stop) + require.True(t, ok) +} + //nolint:revive // t takes precedence func setupTunnel(t *testing.T, ctx context.Context, client *fakeClient, mClock *quartz.Mock) (*Tunnel, *speaker[*ManagerMessage, *TunnelMessage, TunnelMessage]) { mp, tp := net.Pipe() @@ -902,11 +1157,13 @@ func TestProcessFreshState(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - agentsCopy := make(map[uuid.UUID]tailnet.Agent) - maps.Copy(agentsCopy, tt.initialAgents) - - workspaceCopy := make(map[uuid.UUID]tailnet.Workspace) - maps.Copy(workspaceCopy, tt.initialWorkspaces) + agentsCopy := maputil.Map(tt.initialAgents, func(a tailnet.Agent) agentWithPing { + return agentWithPing{ + Agent: a.Clone(), + lastPing: nil, + } + }) + workspaceCopy := maps.Clone(tt.initialWorkspaces) processSnapshotUpdate(tt.update, agentsCopy, workspaceCopy) diff --git a/vpn/version.go b/vpn/version.go index 91aac9175f748..2bf815e903e29 100644 --- a/vpn/version.go +++ b/vpn/version.go @@ -16,7 +16,14 @@ var CurrentSupportedVersions = RPCVersionList{ // - device_id: Coder Desktop device ID // - device_os: Coder Desktop OS information // - coder_desktop_version: Coder Desktop version - {Major: 1, Minor: 1}, + // 1.2 adds network related information to Agent: + // - last_ping: + // - latency: RTT of the most recently sent ping + // - did_p2p: Whether the last ping was sent over P2P + // - preferred_derp: The server that DERP relayed connections are + // using, if they're not using P2P. + // - preferred_derp_latency: The latency to the preferred DERP + {Major: 1, Minor: 2}, }, } diff --git a/vpn/vpn.pb.go b/vpn/vpn.pb.go index c89d3e51e6c92..bc5829d763dfd 100644 --- a/vpn/vpn.pb.go +++ b/vpn/vpn.pb.go @@ -9,6 +9,7 @@ package vpn import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" @@ -205,7 +206,7 @@ func (x Status_Lifecycle) Number() protoreflect.EnumNumber { // Deprecated: Use Status_Lifecycle.Descriptor instead. func (Status_Lifecycle) EnumDescriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{17, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{18, 0} } // RPC allows a very simple unary request/response RPC mechanism. The requester generates a unique @@ -986,6 +987,8 @@ type Agent struct { // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or // anything longer than 5 minutes ago means there is a problem. LastHandshake *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=last_handshake,json=lastHandshake,proto3" json:"last_handshake,omitempty"` + // If unset, a successful ping has not yet been made. + LastPing *LastPing `protobuf:"bytes,7,opt,name=last_ping,json=lastPing,proto3,oneof" json:"last_ping,omitempty"` } func (x *Agent) Reset() { @@ -1062,6 +1065,90 @@ func (x *Agent) GetLastHandshake() *timestamppb.Timestamp { return nil } +func (x *Agent) GetLastPing() *LastPing { + if x != nil { + return x.LastPing + } + return nil +} + +type LastPing struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // latency is the RTT of the ping to the agent. + Latency *durationpb.Duration `protobuf:"bytes,1,opt,name=latency,proto3" json:"latency,omitempty"` + // did_p2p indicates whether the ping was sent P2P, or over DERP. + DidP2P bool `protobuf:"varint,2,opt,name=did_p2p,json=didP2p,proto3" json:"did_p2p,omitempty"` + // preferred_derp is the human readable name of the preferred DERP region, + // or the region used for the last ping, if it was sent over DERP. + PreferredDerp string `protobuf:"bytes,3,opt,name=preferred_derp,json=preferredDerp,proto3" json:"preferred_derp,omitempty"` + // preferred_derp_latency is the last known latency to the preferred DERP + // region. Unset if the region does not appear in the DERP map. + PreferredDerpLatency *durationpb.Duration `protobuf:"bytes,4,opt,name=preferred_derp_latency,json=preferredDerpLatency,proto3,oneof" json:"preferred_derp_latency,omitempty"` +} + +func (x *LastPing) Reset() { + *x = LastPing{} + if protoimpl.UnsafeEnabled { + mi := &file_vpn_vpn_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LastPing) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LastPing) ProtoMessage() {} + +func (x *LastPing) ProtoReflect() protoreflect.Message { + mi := &file_vpn_vpn_proto_msgTypes[10] + 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 LastPing.ProtoReflect.Descriptor instead. +func (*LastPing) Descriptor() ([]byte, []int) { + return file_vpn_vpn_proto_rawDescGZIP(), []int{10} +} + +func (x *LastPing) GetLatency() *durationpb.Duration { + if x != nil { + return x.Latency + } + return nil +} + +func (x *LastPing) GetDidP2P() bool { + if x != nil { + return x.DidP2P + } + return false +} + +func (x *LastPing) GetPreferredDerp() string { + if x != nil { + return x.PreferredDerp + } + return "" +} + +func (x *LastPing) GetPreferredDerpLatency() *durationpb.Duration { + if x != nil { + return x.PreferredDerpLatency + } + return nil +} + // NetworkSettingsRequest is based on // https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings for // macOS. It is a request/response message with response NetworkSettingsResponse @@ -1081,7 +1168,7 @@ type NetworkSettingsRequest struct { func (x *NetworkSettingsRequest) Reset() { *x = NetworkSettingsRequest{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[10] + mi := &file_vpn_vpn_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1094,7 +1181,7 @@ func (x *NetworkSettingsRequest) String() string { func (*NetworkSettingsRequest) ProtoMessage() {} func (x *NetworkSettingsRequest) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[10] + mi := &file_vpn_vpn_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1107,7 +1194,7 @@ func (x *NetworkSettingsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkSettingsRequest.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11} } func (x *NetworkSettingsRequest) GetTunnelOverheadBytes() uint32 { @@ -1166,7 +1253,7 @@ type NetworkSettingsResponse struct { func (x *NetworkSettingsResponse) Reset() { *x = NetworkSettingsResponse{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[11] + mi := &file_vpn_vpn_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1179,7 +1266,7 @@ func (x *NetworkSettingsResponse) String() string { func (*NetworkSettingsResponse) ProtoMessage() {} func (x *NetworkSettingsResponse) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[11] + mi := &file_vpn_vpn_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1192,7 +1279,7 @@ func (x *NetworkSettingsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkSettingsResponse.ProtoReflect.Descriptor instead. func (*NetworkSettingsResponse) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{11} + return file_vpn_vpn_proto_rawDescGZIP(), []int{12} } func (x *NetworkSettingsResponse) GetSuccess() bool { @@ -1231,7 +1318,7 @@ type StartRequest struct { func (x *StartRequest) Reset() { *x = StartRequest{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[12] + mi := &file_vpn_vpn_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1244,7 +1331,7 @@ func (x *StartRequest) String() string { func (*StartRequest) ProtoMessage() {} func (x *StartRequest) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[12] + mi := &file_vpn_vpn_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1257,7 +1344,7 @@ func (x *StartRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. func (*StartRequest) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{12} + return file_vpn_vpn_proto_rawDescGZIP(), []int{13} } func (x *StartRequest) GetTunnelFileDescriptor() int32 { @@ -1321,7 +1408,7 @@ type StartResponse struct { func (x *StartResponse) Reset() { *x = StartResponse{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[13] + mi := &file_vpn_vpn_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1334,7 +1421,7 @@ func (x *StartResponse) String() string { func (*StartResponse) ProtoMessage() {} func (x *StartResponse) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[13] + mi := &file_vpn_vpn_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1347,7 +1434,7 @@ func (x *StartResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. func (*StartResponse) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{13} + return file_vpn_vpn_proto_rawDescGZIP(), []int{14} } func (x *StartResponse) GetSuccess() bool { @@ -1375,7 +1462,7 @@ type StopRequest struct { func (x *StopRequest) Reset() { *x = StopRequest{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[14] + mi := &file_vpn_vpn_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1388,7 +1475,7 @@ func (x *StopRequest) String() string { func (*StopRequest) ProtoMessage() {} func (x *StopRequest) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[14] + mi := &file_vpn_vpn_proto_msgTypes[15] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1401,7 +1488,7 @@ func (x *StopRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopRequest.ProtoReflect.Descriptor instead. func (*StopRequest) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{14} + return file_vpn_vpn_proto_rawDescGZIP(), []int{15} } // StopResponse is a response to stopping the tunnel. After sending this response, the tunnel closes @@ -1418,7 +1505,7 @@ type StopResponse struct { func (x *StopResponse) Reset() { *x = StopResponse{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[15] + mi := &file_vpn_vpn_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1431,7 +1518,7 @@ func (x *StopResponse) String() string { func (*StopResponse) ProtoMessage() {} func (x *StopResponse) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[15] + mi := &file_vpn_vpn_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1444,7 +1531,7 @@ func (x *StopResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopResponse.ProtoReflect.Descriptor instead. func (*StopResponse) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{15} + return file_vpn_vpn_proto_rawDescGZIP(), []int{16} } func (x *StopResponse) GetSuccess() bool { @@ -1472,7 +1559,7 @@ type StatusRequest struct { func (x *StatusRequest) Reset() { *x = StatusRequest{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[16] + mi := &file_vpn_vpn_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1485,7 +1572,7 @@ func (x *StatusRequest) String() string { func (*StatusRequest) ProtoMessage() {} func (x *StatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[16] + mi := &file_vpn_vpn_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1498,7 +1585,7 @@ func (x *StatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead. func (*StatusRequest) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{16} + return file_vpn_vpn_proto_rawDescGZIP(), []int{17} } // Status is sent in response to a StatusRequest or broadcasted to all clients @@ -1519,7 +1606,7 @@ type Status struct { func (x *Status) Reset() { *x = Status{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[17] + mi := &file_vpn_vpn_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1532,7 +1619,7 @@ func (x *Status) String() string { func (*Status) ProtoMessage() {} func (x *Status) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[17] + mi := &file_vpn_vpn_proto_msgTypes[18] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1545,7 +1632,7 @@ func (x *Status) ProtoReflect() protoreflect.Message { // Deprecated: Use Status.ProtoReflect.Descriptor instead. func (*Status) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{17} + return file_vpn_vpn_proto_rawDescGZIP(), []int{18} } func (x *Status) GetLifecycle() Status_Lifecycle { @@ -1581,7 +1668,7 @@ type Log_Field struct { func (x *Log_Field) Reset() { *x = Log_Field{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[18] + mi := &file_vpn_vpn_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1594,7 +1681,7 @@ func (x *Log_Field) String() string { func (*Log_Field) ProtoMessage() {} func (x *Log_Field) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[18] + mi := &file_vpn_vpn_proto_msgTypes[19] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1642,7 +1729,7 @@ type NetworkSettingsRequest_DNSSettings struct { func (x *NetworkSettingsRequest_DNSSettings) Reset() { *x = NetworkSettingsRequest_DNSSettings{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[19] + mi := &file_vpn_vpn_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1655,7 +1742,7 @@ func (x *NetworkSettingsRequest_DNSSettings) String() string { func (*NetworkSettingsRequest_DNSSettings) ProtoMessage() {} func (x *NetworkSettingsRequest_DNSSettings) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[19] + mi := &file_vpn_vpn_proto_msgTypes[20] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1668,7 +1755,7 @@ func (x *NetworkSettingsRequest_DNSSettings) ProtoReflect() protoreflect.Message // Deprecated: Use NetworkSettingsRequest_DNSSettings.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_DNSSettings) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 0} } func (x *NetworkSettingsRequest_DNSSettings) GetServers() []string { @@ -1722,7 +1809,7 @@ type NetworkSettingsRequest_IPv4Settings struct { func (x *NetworkSettingsRequest_IPv4Settings) Reset() { *x = NetworkSettingsRequest_IPv4Settings{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[20] + mi := &file_vpn_vpn_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1735,7 +1822,7 @@ func (x *NetworkSettingsRequest_IPv4Settings) String() string { func (*NetworkSettingsRequest_IPv4Settings) ProtoMessage() {} func (x *NetworkSettingsRequest_IPv4Settings) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[20] + mi := &file_vpn_vpn_proto_msgTypes[21] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1748,7 +1835,7 @@ func (x *NetworkSettingsRequest_IPv4Settings) ProtoReflect() protoreflect.Messag // Deprecated: Use NetworkSettingsRequest_IPv4Settings.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_IPv4Settings) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 1} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 1} } func (x *NetworkSettingsRequest_IPv4Settings) GetAddrs() []string { @@ -1800,7 +1887,7 @@ type NetworkSettingsRequest_IPv6Settings struct { func (x *NetworkSettingsRequest_IPv6Settings) Reset() { *x = NetworkSettingsRequest_IPv6Settings{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[21] + mi := &file_vpn_vpn_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1813,7 +1900,7 @@ func (x *NetworkSettingsRequest_IPv6Settings) String() string { func (*NetworkSettingsRequest_IPv6Settings) ProtoMessage() {} func (x *NetworkSettingsRequest_IPv6Settings) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[21] + mi := &file_vpn_vpn_proto_msgTypes[22] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1826,7 +1913,7 @@ func (x *NetworkSettingsRequest_IPv6Settings) ProtoReflect() protoreflect.Messag // Deprecated: Use NetworkSettingsRequest_IPv6Settings.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_IPv6Settings) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 2} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 2} } func (x *NetworkSettingsRequest_IPv6Settings) GetAddrs() []string { @@ -1871,7 +1958,7 @@ type NetworkSettingsRequest_IPv4Settings_IPv4Route struct { func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) Reset() { *x = NetworkSettingsRequest_IPv4Settings_IPv4Route{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[22] + mi := &file_vpn_vpn_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1884,7 +1971,7 @@ func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) String() string { func (*NetworkSettingsRequest_IPv4Settings_IPv4Route) ProtoMessage() {} func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[22] + mi := &file_vpn_vpn_proto_msgTypes[23] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1897,7 +1984,7 @@ func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) ProtoReflect() protorefl // Deprecated: Use NetworkSettingsRequest_IPv4Settings_IPv4Route.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_IPv4Settings_IPv4Route) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 1, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 1, 0} } func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) GetDestination() string { @@ -1935,7 +2022,7 @@ type NetworkSettingsRequest_IPv6Settings_IPv6Route struct { func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) Reset() { *x = NetworkSettingsRequest_IPv6Settings_IPv6Route{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[23] + mi := &file_vpn_vpn_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1948,7 +2035,7 @@ func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) String() string { func (*NetworkSettingsRequest_IPv6Settings_IPv6Route) ProtoMessage() {} func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[23] + mi := &file_vpn_vpn_proto_msgTypes[24] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1961,7 +2048,7 @@ func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) ProtoReflect() protorefl // Deprecated: Use NetworkSettingsRequest_IPv6Settings_IPv6Route.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_IPv6Settings_IPv6Route) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 2, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 2, 0} } func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) GetDestination() string { @@ -1998,7 +2085,7 @@ type StartRequest_Header struct { func (x *StartRequest_Header) Reset() { *x = StartRequest_Header{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[24] + mi := &file_vpn_vpn_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2011,7 +2098,7 @@ func (x *StartRequest_Header) String() string { func (*StartRequest_Header) ProtoMessage() {} func (x *StartRequest_Header) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[24] + mi := &file_vpn_vpn_proto_msgTypes[25] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2024,7 +2111,7 @@ func (x *StartRequest_Header) ProtoReflect() protoreflect.Message { // Deprecated: Use StartRequest_Header.ProtoReflect.Descriptor instead. func (*StartRequest_Header) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{12, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{13, 0} } func (x *StartRequest_Header) GetName() string { @@ -2047,6 +2134,8 @@ var file_vpn_vpn_proto_rawDesc = []byte{ 0x0a, 0x0d, 0x76, 0x70, 0x6e, 0x2f, 0x76, 0x70, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x76, 0x70, 0x6e, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x3d, 0x0a, 0x03, 0x52, 0x50, 0x43, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, @@ -2158,7 +2247,7 @@ var file_vpn_vpn_proto_rawDesc = []byte{ 0x49, 0x4c, 0x45, 0x44, 0x10, 0x06, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x49, 0x4e, 0x47, 0x10, 0x07, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x45, 0x44, 0x10, 0x08, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, - 0x09, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x0a, 0x22, 0xc0, + 0x09, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x0a, 0x22, 0xff, 0x01, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 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, 0x21, 0x0a, 0x0c, @@ -2171,148 +2260,167 @@ var file_vpn_vpn_proto_rawDesc = []byte{ 0x18, 0x06, 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, 0x0d, 0x6c, 0x61, 0x73, 0x74, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, - 0x65, 0x22, 0xb5, 0x0a, 0x0a, 0x16, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, - 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x15, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x6f, 0x76, 0x65, 0x72, 0x68, 0x65, 0x61, 0x64, 0x5f, - 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x13, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x4f, 0x76, 0x65, 0x72, 0x68, 0x65, 0x61, 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, - 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x6d, - 0x74, 0x75, 0x12, 0x4a, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, + 0x65, 0x12, 0x2f, 0x0a, 0x09, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4c, 0x61, 0x73, 0x74, 0x50, + 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x61, 0x73, 0x74, 0x50, 0x69, 0x6e, 0x67, 0x88, + 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x70, 0x69, 0x6e, 0x67, + 0x22, 0xf0, 0x01, 0x0a, 0x08, 0x4c, 0x61, 0x73, 0x74, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x33, 0x0a, + 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, + 0x63, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x69, 0x64, 0x5f, 0x70, 0x32, 0x70, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x69, 0x64, 0x50, 0x32, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, 0x64, 0x65, 0x72, 0x70, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x44, 0x65, + 0x72, 0x70, 0x12, 0x54, 0x0a, 0x16, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, + 0x64, 0x65, 0x72, 0x70, 0x5f, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, + 0x14, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x44, 0x65, 0x72, 0x70, 0x4c, 0x61, + 0x74, 0x65, 0x6e, 0x63, 0x79, 0x88, 0x01, 0x01, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x70, 0x72, 0x65, + 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, 0x64, 0x65, 0x72, 0x70, 0x5f, 0x6c, 0x61, 0x74, 0x65, + 0x6e, 0x63, 0x79, 0x22, 0xb5, 0x0a, 0x0a, 0x16, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, + 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, + 0x0a, 0x15, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x6f, 0x76, 0x65, 0x72, 0x68, 0x65, 0x61, + 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x13, 0x74, + 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x4f, 0x76, 0x65, 0x72, 0x68, 0x65, 0x61, 0x64, 0x42, 0x79, 0x74, + 0x65, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x03, 0x6d, 0x74, 0x75, 0x12, 0x4a, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, + 0x69, 0x6e, 0x67, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x76, 0x70, 0x6e, + 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, + 0x6e, 0x67, 0x73, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, + 0x12, 0x32, 0x0a, 0x15, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x74, + 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x13, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x41, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x73, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x76, 0x70, + 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x69, 0x70, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, + 0x6e, 0x67, 0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x73, 0x65, 0x74, 0x74, + 0x69, 0x6e, 0x67, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x76, 0x70, 0x6e, + 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, + 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x69, 0x70, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, + 0x67, 0x73, 0x1a, 0xcb, 0x01, 0x0a, 0x0b, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, + 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x61, 0x74, + 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x74, + 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x6e, 0x6f, 0x5f, 0x73, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x6d, 0x61, 0x74, 0x63, + 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x4e, 0x6f, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x1a, 0xf4, 0x02, 0x0a, 0x0c, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6e, 0x65, + 0x74, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x73, + 0x75, 0x62, 0x6e, 0x65, 0x74, 0x4d, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, + 0x65, 0x72, 0x12, 0x5b, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, + 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, + 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, + 0x5b, 0x0a, 0x0f, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x75, 0x74, + 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, - 0x73, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x32, - 0x0a, 0x15, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, - 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x74, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x76, 0x70, 0x6e, 0x2e, - 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x69, 0x70, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, - 0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, + 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x0e, 0x65, 0x78, + 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, 0x59, 0x0a, 0x09, + 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, + 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6d, + 0x61, 0x73, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x61, 0x73, 0x6b, 0x12, + 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x1a, 0xf1, 0x02, 0x0a, 0x0c, 0x49, 0x50, 0x76, 0x36, + 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x12, 0x25, + 0x0a, 0x0e, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, + 0x6e, 0x67, 0x74, 0x68, 0x73, 0x12, 0x5b, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, + 0x64, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, + 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, + 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, + 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x52, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x73, 0x12, 0x5b, 0x0a, 0x0f, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, + 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, + 0x0e, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, + 0x6a, 0x0a, 0x09, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x0b, + 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x23, + 0x0a, 0x0d, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, 0x6e, + 0x67, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x22, 0x58, 0x0a, 0x17, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x0c, 0x69, 0x70, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x1a, 0xcb, 0x01, 0x0a, 0x0b, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0d, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x61, 0x74, 0x63, 0x68, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x74, 0x63, 0x68, - 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x6e, 0x6f, 0x5f, 0x73, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x44, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x4e, 0x6f, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x1a, 0xf4, - 0x02, 0x0a, 0x0c, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, - 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, - 0x61, 0x64, 0x64, 0x72, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, - 0x6d, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, - 0x6e, 0x65, 0x74, 0x4d, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, - 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, - 0x12, 0x5b, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, 0x6e, 0x2e, - 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x0e, 0x69, - 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x5b, 0x0a, - 0x0f, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, - 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, 0x65, 0x74, - 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x2e, 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x0e, 0x65, 0x78, 0x63, 0x6c, - 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, 0x59, 0x0a, 0x09, 0x49, 0x50, - 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, - 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, - 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x61, 0x73, - 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x61, 0x73, 0x6b, 0x12, 0x16, 0x0a, - 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, - 0x6f, 0x75, 0x74, 0x65, 0x72, 0x1a, 0xf1, 0x02, 0x0a, 0x0c, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, - 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, 0x6e, 0x67, - 0x74, 0x68, 0x73, 0x12, 0x5b, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, - 0x70, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x52, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, - 0x12, 0x5b, 0x0a, 0x0f, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, 0x6e, 0x2e, - 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x0e, 0x65, - 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, 0x6a, 0x0a, - 0x09, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, - 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, - 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, 0x6e, 0x67, 0x74, - 0x68, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x22, 0x58, 0x0a, 0x17, 0x4e, 0x65, 0x74, - 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, - 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x22, 0xd4, 0x02, 0x0a, 0x0c, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x16, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x66, - 0x69, 0x6c, 0x65, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x14, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x46, 0x69, 0x6c, 0x65, - 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x70, 0x69, 0x5f, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, - 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x72, - 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x52, - 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, - 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, - 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, - 0x6f, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, - 0x4f, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x64, 0x65, 0x73, 0x6b, - 0x74, 0x6f, 0x70, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x13, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f, 0x70, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x32, 0x0a, 0x06, 0x48, 0x65, 0x61, 0x64, 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, 0x4e, 0x0a, 0x0d, 0x53, 0x74, - 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xd4, 0x02, 0x0a, 0x0c, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x16, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x14, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x46, 0x69, + 0x6c, 0x65, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x1b, 0x0a, 0x09, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x70, 0x69, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, + 0x69, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x53, 0x74, + 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, + 0x72, 0x52, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, + 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, + 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x5f, 0x6f, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x4f, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x64, 0x65, + 0x73, 0x6b, 0x74, 0x6f, 0x70, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x13, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f, + 0x70, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x32, 0x0a, 0x06, 0x48, 0x65, 0x61, 0x64, + 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, 0x4e, 0x0a, 0x0d, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0d, 0x0a, 0x0b, + 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x4d, 0x0a, 0x0c, 0x53, + 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0d, 0x0a, 0x0b, 0x53, 0x74, - 0x6f, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x4d, 0x0a, 0x0c, 0x53, 0x74, 0x6f, - 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, - 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0f, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xe4, 0x01, 0x0a, 0x06, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x09, - 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x30, - 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x22, 0x4e, 0x0a, 0x09, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x0b, 0x0a, - 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, - 0x41, 0x52, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x41, 0x52, - 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, - 0x47, 0x10, 0x03, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, 0x10, 0x04, - 0x42, 0x39, 0x5a, 0x1d, 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, 0x76, 0x70, - 0x6e, 0xaa, 0x02, 0x17, 0x43, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f, - 0x70, 0x2e, 0x56, 0x70, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0f, 0x0a, 0x0d, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xe4, 0x01, 0x0a, 0x06, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, + 0x63, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x76, 0x70, 0x6e, 0x2e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, + 0x52, 0x09, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x30, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x50, 0x65, 0x65, 0x72, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x22, 0x4e, 0x0a, 0x09, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, + 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, + 0x53, 0x54, 0x41, 0x52, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, + 0x41, 0x52, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x4f, 0x50, 0x50, + 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, + 0x10, 0x04, 0x42, 0x39, 0x5a, 0x1d, 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, + 0x76, 0x70, 0x6e, 0xaa, 0x02, 0x17, 0x43, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x44, 0x65, 0x73, 0x6b, + 0x74, 0x6f, 0x70, 0x2e, 0x56, 0x70, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2328,7 +2436,7 @@ func file_vpn_vpn_proto_rawDescGZIP() []byte { } var file_vpn_vpn_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_vpn_vpn_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_vpn_vpn_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_vpn_vpn_proto_goTypes = []interface{}{ (Log_Level)(0), // 0: vpn.Log.Level (Workspace_Status)(0), // 1: vpn.Workspace.Status @@ -2343,66 +2451,71 @@ var file_vpn_vpn_proto_goTypes = []interface{}{ (*PeerUpdate)(nil), // 10: vpn.PeerUpdate (*Workspace)(nil), // 11: vpn.Workspace (*Agent)(nil), // 12: vpn.Agent - (*NetworkSettingsRequest)(nil), // 13: vpn.NetworkSettingsRequest - (*NetworkSettingsResponse)(nil), // 14: vpn.NetworkSettingsResponse - (*StartRequest)(nil), // 15: vpn.StartRequest - (*StartResponse)(nil), // 16: vpn.StartResponse - (*StopRequest)(nil), // 17: vpn.StopRequest - (*StopResponse)(nil), // 18: vpn.StopResponse - (*StatusRequest)(nil), // 19: vpn.StatusRequest - (*Status)(nil), // 20: vpn.Status - (*Log_Field)(nil), // 21: vpn.Log.Field - (*NetworkSettingsRequest_DNSSettings)(nil), // 22: vpn.NetworkSettingsRequest.DNSSettings - (*NetworkSettingsRequest_IPv4Settings)(nil), // 23: vpn.NetworkSettingsRequest.IPv4Settings - (*NetworkSettingsRequest_IPv6Settings)(nil), // 24: vpn.NetworkSettingsRequest.IPv6Settings - (*NetworkSettingsRequest_IPv4Settings_IPv4Route)(nil), // 25: vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route - (*NetworkSettingsRequest_IPv6Settings_IPv6Route)(nil), // 26: vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route - (*StartRequest_Header)(nil), // 27: vpn.StartRequest.Header - (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp + (*LastPing)(nil), // 13: vpn.LastPing + (*NetworkSettingsRequest)(nil), // 14: vpn.NetworkSettingsRequest + (*NetworkSettingsResponse)(nil), // 15: vpn.NetworkSettingsResponse + (*StartRequest)(nil), // 16: vpn.StartRequest + (*StartResponse)(nil), // 17: vpn.StartResponse + (*StopRequest)(nil), // 18: vpn.StopRequest + (*StopResponse)(nil), // 19: vpn.StopResponse + (*StatusRequest)(nil), // 20: vpn.StatusRequest + (*Status)(nil), // 21: vpn.Status + (*Log_Field)(nil), // 22: vpn.Log.Field + (*NetworkSettingsRequest_DNSSettings)(nil), // 23: vpn.NetworkSettingsRequest.DNSSettings + (*NetworkSettingsRequest_IPv4Settings)(nil), // 24: vpn.NetworkSettingsRequest.IPv4Settings + (*NetworkSettingsRequest_IPv6Settings)(nil), // 25: vpn.NetworkSettingsRequest.IPv6Settings + (*NetworkSettingsRequest_IPv4Settings_IPv4Route)(nil), // 26: vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route + (*NetworkSettingsRequest_IPv6Settings_IPv6Route)(nil), // 27: vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route + (*StartRequest_Header)(nil), // 28: vpn.StartRequest.Header + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 30: google.protobuf.Duration } var file_vpn_vpn_proto_depIdxs = []int32{ 3, // 0: vpn.ManagerMessage.rpc:type_name -> vpn.RPC 9, // 1: vpn.ManagerMessage.get_peer_update:type_name -> vpn.GetPeerUpdate - 14, // 2: vpn.ManagerMessage.network_settings:type_name -> vpn.NetworkSettingsResponse - 15, // 3: vpn.ManagerMessage.start:type_name -> vpn.StartRequest - 17, // 4: vpn.ManagerMessage.stop:type_name -> vpn.StopRequest + 15, // 2: vpn.ManagerMessage.network_settings:type_name -> vpn.NetworkSettingsResponse + 16, // 3: vpn.ManagerMessage.start:type_name -> vpn.StartRequest + 18, // 4: vpn.ManagerMessage.stop:type_name -> vpn.StopRequest 3, // 5: vpn.TunnelMessage.rpc:type_name -> vpn.RPC 8, // 6: vpn.TunnelMessage.log:type_name -> vpn.Log 10, // 7: vpn.TunnelMessage.peer_update:type_name -> vpn.PeerUpdate - 13, // 8: vpn.TunnelMessage.network_settings:type_name -> vpn.NetworkSettingsRequest - 16, // 9: vpn.TunnelMessage.start:type_name -> vpn.StartResponse - 18, // 10: vpn.TunnelMessage.stop:type_name -> vpn.StopResponse + 14, // 8: vpn.TunnelMessage.network_settings:type_name -> vpn.NetworkSettingsRequest + 17, // 9: vpn.TunnelMessage.start:type_name -> vpn.StartResponse + 19, // 10: vpn.TunnelMessage.stop:type_name -> vpn.StopResponse 3, // 11: vpn.ClientMessage.rpc:type_name -> vpn.RPC - 15, // 12: vpn.ClientMessage.start:type_name -> vpn.StartRequest - 17, // 13: vpn.ClientMessage.stop:type_name -> vpn.StopRequest - 19, // 14: vpn.ClientMessage.status:type_name -> vpn.StatusRequest + 16, // 12: vpn.ClientMessage.start:type_name -> vpn.StartRequest + 18, // 13: vpn.ClientMessage.stop:type_name -> vpn.StopRequest + 20, // 14: vpn.ClientMessage.status:type_name -> vpn.StatusRequest 3, // 15: vpn.ServiceMessage.rpc:type_name -> vpn.RPC - 16, // 16: vpn.ServiceMessage.start:type_name -> vpn.StartResponse - 18, // 17: vpn.ServiceMessage.stop:type_name -> vpn.StopResponse - 20, // 18: vpn.ServiceMessage.status:type_name -> vpn.Status + 17, // 16: vpn.ServiceMessage.start:type_name -> vpn.StartResponse + 19, // 17: vpn.ServiceMessage.stop:type_name -> vpn.StopResponse + 21, // 18: vpn.ServiceMessage.status:type_name -> vpn.Status 0, // 19: vpn.Log.level:type_name -> vpn.Log.Level - 21, // 20: vpn.Log.fields:type_name -> vpn.Log.Field + 22, // 20: vpn.Log.fields:type_name -> vpn.Log.Field 11, // 21: vpn.PeerUpdate.upserted_workspaces:type_name -> vpn.Workspace 12, // 22: vpn.PeerUpdate.upserted_agents:type_name -> vpn.Agent 11, // 23: vpn.PeerUpdate.deleted_workspaces:type_name -> vpn.Workspace 12, // 24: vpn.PeerUpdate.deleted_agents:type_name -> vpn.Agent 1, // 25: vpn.Workspace.status:type_name -> vpn.Workspace.Status - 28, // 26: vpn.Agent.last_handshake:type_name -> google.protobuf.Timestamp - 22, // 27: vpn.NetworkSettingsRequest.dns_settings:type_name -> vpn.NetworkSettingsRequest.DNSSettings - 23, // 28: vpn.NetworkSettingsRequest.ipv4_settings:type_name -> vpn.NetworkSettingsRequest.IPv4Settings - 24, // 29: vpn.NetworkSettingsRequest.ipv6_settings:type_name -> vpn.NetworkSettingsRequest.IPv6Settings - 27, // 30: vpn.StartRequest.headers:type_name -> vpn.StartRequest.Header - 2, // 31: vpn.Status.lifecycle:type_name -> vpn.Status.Lifecycle - 10, // 32: vpn.Status.peer_update:type_name -> vpn.PeerUpdate - 25, // 33: vpn.NetworkSettingsRequest.IPv4Settings.included_routes:type_name -> vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route - 25, // 34: vpn.NetworkSettingsRequest.IPv4Settings.excluded_routes:type_name -> vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route - 26, // 35: vpn.NetworkSettingsRequest.IPv6Settings.included_routes:type_name -> vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route - 26, // 36: vpn.NetworkSettingsRequest.IPv6Settings.excluded_routes:type_name -> vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route - 37, // [37:37] is the sub-list for method output_type - 37, // [37:37] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 29, // 26: vpn.Agent.last_handshake:type_name -> google.protobuf.Timestamp + 13, // 27: vpn.Agent.last_ping:type_name -> vpn.LastPing + 30, // 28: vpn.LastPing.latency:type_name -> google.protobuf.Duration + 30, // 29: vpn.LastPing.preferred_derp_latency:type_name -> google.protobuf.Duration + 23, // 30: vpn.NetworkSettingsRequest.dns_settings:type_name -> vpn.NetworkSettingsRequest.DNSSettings + 24, // 31: vpn.NetworkSettingsRequest.ipv4_settings:type_name -> vpn.NetworkSettingsRequest.IPv4Settings + 25, // 32: vpn.NetworkSettingsRequest.ipv6_settings:type_name -> vpn.NetworkSettingsRequest.IPv6Settings + 28, // 33: vpn.StartRequest.headers:type_name -> vpn.StartRequest.Header + 2, // 34: vpn.Status.lifecycle:type_name -> vpn.Status.Lifecycle + 10, // 35: vpn.Status.peer_update:type_name -> vpn.PeerUpdate + 26, // 36: vpn.NetworkSettingsRequest.IPv4Settings.included_routes:type_name -> vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route + 26, // 37: vpn.NetworkSettingsRequest.IPv4Settings.excluded_routes:type_name -> vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route + 27, // 38: vpn.NetworkSettingsRequest.IPv6Settings.included_routes:type_name -> vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route + 27, // 39: vpn.NetworkSettingsRequest.IPv6Settings.excluded_routes:type_name -> vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route + 40, // [40:40] is the sub-list for method output_type + 40, // [40:40] is the sub-list for method input_type + 40, // [40:40] is the sub-list for extension type_name + 40, // [40:40] is the sub-list for extension extendee + 0, // [0:40] is the sub-list for field type_name } func init() { file_vpn_vpn_proto_init() } @@ -2532,7 +2645,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest); i { + switch v := v.(*LastPing); i { case 0: return &v.state case 1: @@ -2544,7 +2657,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsResponse); i { + switch v := v.(*NetworkSettingsRequest); i { case 0: return &v.state case 1: @@ -2556,7 +2669,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StartRequest); i { + switch v := v.(*NetworkSettingsResponse); i { case 0: return &v.state case 1: @@ -2568,7 +2681,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StartResponse); i { + switch v := v.(*StartRequest); i { case 0: return &v.state case 1: @@ -2580,7 +2693,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopRequest); i { + switch v := v.(*StartResponse); i { case 0: return &v.state case 1: @@ -2592,7 +2705,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopResponse); i { + switch v := v.(*StopRequest); i { case 0: return &v.state case 1: @@ -2604,7 +2717,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StatusRequest); i { + switch v := v.(*StopResponse); i { case 0: return &v.state case 1: @@ -2616,7 +2729,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Status); i { + switch v := v.(*StatusRequest); i { case 0: return &v.state case 1: @@ -2628,7 +2741,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Log_Field); i { + switch v := v.(*Status); i { case 0: return &v.state case 1: @@ -2640,7 +2753,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_DNSSettings); i { + switch v := v.(*Log_Field); i { case 0: return &v.state case 1: @@ -2652,7 +2765,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_IPv4Settings); i { + switch v := v.(*NetworkSettingsRequest_DNSSettings); i { case 0: return &v.state case 1: @@ -2664,7 +2777,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_IPv6Settings); i { + switch v := v.(*NetworkSettingsRequest_IPv4Settings); i { case 0: return &v.state case 1: @@ -2676,7 +2789,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_IPv4Settings_IPv4Route); i { + switch v := v.(*NetworkSettingsRequest_IPv6Settings); i { case 0: return &v.state case 1: @@ -2688,7 +2801,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_IPv6Settings_IPv6Route); i { + switch v := v.(*NetworkSettingsRequest_IPv4Settings_IPv4Route); i { case 0: return &v.state case 1: @@ -2700,6 +2813,18 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkSettingsRequest_IPv6Settings_IPv6Route); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_vpn_vpn_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StartRequest_Header); i { case 0: return &v.state @@ -2735,13 +2860,15 @@ func file_vpn_vpn_proto_init() { (*ServiceMessage_Stop)(nil), (*ServiceMessage_Status)(nil), } + file_vpn_vpn_proto_msgTypes[9].OneofWrappers = []interface{}{} + file_vpn_vpn_proto_msgTypes[10].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_vpn_vpn_proto_rawDesc, NumEnums: 3, - NumMessages: 25, + NumMessages: 26, NumExtensions: 0, NumServices: 0, }, diff --git a/vpn/vpn.proto b/vpn/vpn.proto index 963098c60a648..44383fa80e0cb 100644 --- a/vpn/vpn.proto +++ b/vpn/vpn.proto @@ -3,6 +3,7 @@ option go_package = "github.com/coder/coder/v2/vpn"; option csharp_namespace = "Coder.Desktop.Vpn.Proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; package vpn; @@ -130,6 +131,21 @@ message Agent { // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or // anything longer than 5 minutes ago means there is a problem. google.protobuf.Timestamp last_handshake = 6; + // If unset, a successful ping has not yet been made. + optional LastPing last_ping = 7; +} + +message LastPing { + // latency is the RTT of the ping to the agent. + google.protobuf.Duration latency = 1; + // did_p2p indicates whether the ping was sent P2P, or over DERP. + bool did_p2p = 2; + // preferred_derp is the human readable name of the preferred DERP region, + // or the region used for the last ping, if it was sent over DERP. + string preferred_derp = 3; + // preferred_derp_latency is the last known latency to the preferred DERP + // region. Unset if the region does not appear in the DERP map. + optional google.protobuf.Duration preferred_derp_latency = 4; } // NetworkSettingsRequest is based on