From ca4fa81570e9ca6a7f95edfd6912fdc89d055505 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 31 Mar 2023 15:26:19 -0500 Subject: [PATCH] feat: add agent metadata (#6614) --- agent/agent.go | 237 +++++- agent/agent_test.go | 214 ++++-- coderd/apidoc/docs.go | 131 +++- coderd/apidoc/swagger.json | 125 ++- coderd/coderd.go | 8 +- coderd/coderdtest/swaggerparser.go | 5 + coderd/database/dbauthz/querier.go | 38 + coderd/database/dbfake/databasefake.go | 55 ++ coderd/database/dump.sql | 18 + .../000111_workspace_agent_metadata.down.sql | 1 + .../000111_workspace_agent_metadata.up.sql | 16 + .../000111_workspace_agent_metadata.up.sql | 18 + coderd/database/models.go | 12 + coderd/database/querier.go | 3 + coderd/database/queries.sql.go | 108 +++ coderd/database/queries/workspaceagents.sql | 32 + .../provisionerdserver/provisionerdserver.go | 15 + coderd/workspaceagents.go | 251 +++++- coderd/workspaceagents_test.go | 174 ++++- coderd/workspaceapps_test.go | 4 +- coderd/wsconncache/wsconncache_test.go | 26 +- codersdk/agentsdk/agentsdk.go | 61 +- codersdk/workspaceagents.go | 95 +++ codersdk/workspaceagents_test.go | 6 +- docs/api/agents.md | 17 +- docs/api/schemas.md | 82 +- docs/images/agent-metadata.png | Bin 0 -> 49803 bytes docs/manifest.json | 7 + docs/templates/agent-metadata.md | 90 +++ docs/templates/resource-metadata.md | 22 + provisioner/terraform/resources.go | 37 +- provisioner/terraform/resources_test.go | 17 + .../calling-module/calling-module.tfplan.json | 2 +- .../calling-module.tfstate.json | 10 +- .../chaining-resources.tfplan.json | 2 +- .../chaining-resources.tfstate.json | 10 +- .../conflicting-resources.tfplan.json | 2 +- .../conflicting-resources.tfstate.json | 10 +- .../git-auth-providers.tfstate.json | 6 +- .../instance-id/instance-id.tfplan.json | 2 +- .../instance-id/instance-id.tfstate.json | 12 +- .../mapped-apps/mapped-apps.tfstate.json | 14 +- .../multiple-agents.tfstate.json | 14 +- .../multiple-apps/multiple-apps.tfplan.json | 2 +- .../multiple-apps/multiple-apps.tfstate.json | 20 +- .../resource-metadata/resource-metadata.tf | 15 +- .../resource-metadata.tfplan.dot | 3 +- .../resource-metadata.tfplan.json | 68 +- .../resource-metadata.tfstate.dot | 3 +- .../resource-metadata.tfstate.json | 38 +- .../rich-parameters.tfplan.json | 8 +- .../rich-parameters.tfstate.json | 62 +- provisionersdk/proto/provisioner.pb.go | 712 ++++++++++-------- provisionersdk/proto/provisioner.proto | 8 + site/package.json | 2 + site/src/api/api.ts | 12 + site/src/api/typesGenerated.ts | 23 + .../Resources/AgentMetadata.stories.tsx | 107 +++ .../components/Resources/AgentMetadata.tsx | 319 ++++++++ site/src/components/Resources/AgentRow.tsx | 325 ++++---- .../Workspace/Workspace.stories.tsx | 12 + site/yarn.lock | 118 ++- 62 files changed, 3139 insertions(+), 727 deletions(-) create mode 100644 coderd/database/migrations/000111_workspace_agent_metadata.down.sql create mode 100644 coderd/database/migrations/000111_workspace_agent_metadata.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000111_workspace_agent_metadata.up.sql create mode 100644 docs/images/agent-metadata.png create mode 100644 docs/templates/agent-metadata.md create mode 100644 site/src/components/Resources/AgentMetadata.stories.tsx create mode 100644 site/src/components/Resources/AgentMetadata.tsx diff --git a/agent/agent.go b/agent/agent.go index f73d004ed6..356338b14b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -2,12 +2,14 @@ package agent import ( "bufio" + "bytes" "context" "crypto/rand" "crypto/rsa" "encoding/binary" "encoding/json" "errors" + "flag" "fmt" "io" "net" @@ -33,6 +35,7 @@ import ( "go.uber.org/atomic" gossh "golang.org/x/crypto/ssh" "golang.org/x/exp/slices" + "golang.org/x/sync/singleflight" "golang.org/x/xerrors" "tailscale.com/net/speedtest" "tailscale.com/tailcfg" @@ -83,12 +86,13 @@ type Options struct { } type Client interface { - Metadata(ctx context.Context) (agentsdk.Metadata, error) + Manifest(ctx context.Context) (agentsdk.Manifest, error) Listen(ctx context.Context) (net.Conn, error) ReportStats(ctx context.Context, log slog.Logger, statsChan <-chan *agentsdk.Stats, setInterval func(time.Duration)) (io.Closer, error) PostLifecycle(ctx context.Context, state agentsdk.PostLifecycleRequest) error PostAppHealth(ctx context.Context, req agentsdk.PostAppHealthsRequest) error PostStartup(ctx context.Context, req agentsdk.PostStartupRequest) error + PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequest) error PatchStartupLogs(ctx context.Context, req agentsdk.PatchStartupLogs) error } @@ -156,8 +160,8 @@ type agent struct { closed chan struct{} envVars map[string]string - // metadata is atomic because values can change after reconnection. - metadata atomic.Value + // manifest is atomic because values can change after reconnection. + manifest atomic.Pointer[agentsdk.Manifest] sessionToken atomic.Pointer[string] sshServer *ssh.Server sshMaxTimeout time.Duration @@ -183,6 +187,7 @@ type agent struct { // failure, you'll want the agent to reconnect. func (a *agent) runLoop(ctx context.Context) { go a.reportLifecycleLoop(ctx) + go a.reportMetadataLoop(ctx) for retrier := retry.New(100*time.Millisecond, 10*time.Second); retrier.Wait(ctx); { a.logger.Info(ctx, "connecting to coderd") @@ -205,6 +210,168 @@ func (a *agent) runLoop(ctx context.Context) { } } +func (a *agent) collectMetadata(ctx context.Context, md codersdk.WorkspaceAgentMetadataDescription) *codersdk.WorkspaceAgentMetadataResult { + var out bytes.Buffer + result := &codersdk.WorkspaceAgentMetadataResult{ + // CollectedAt is set here for testing purposes and overrode by + // the server to the time the server received the result to protect + // against clock skew. + // + // In the future, the server may accept the timestamp from the agent + // if it is certain the clocks are in sync. + CollectedAt: time.Now(), + } + cmd, err := a.createCommand(ctx, md.Script, nil) + if err != nil { + result.Error = err.Error() + return result + } + + cmd.Stdout = &out + cmd.Stderr = &out + + // The error isn't mutually exclusive with useful output. + err = cmd.Run() + + const bufLimit = 10 << 10 + if out.Len() > bufLimit { + err = errors.Join( + err, + xerrors.Errorf("output truncated from %v to %v bytes", out.Len(), bufLimit), + ) + out.Truncate(bufLimit) + } + + if err != nil { + result.Error = err.Error() + } + result.Value = out.String() + return result +} + +func adjustIntervalForTests(i int64) time.Duration { + // In tests we want to set shorter intervals because engineers are + // impatient. + base := time.Second + if flag.Lookup("test.v") != nil { + base = time.Millisecond * 100 + } + return time.Duration(i) * base +} + +type metadataResultAndKey struct { + result *codersdk.WorkspaceAgentMetadataResult + key string +} + +func (a *agent) reportMetadataLoop(ctx context.Context) { + baseInterval := adjustIntervalForTests(1) + + const metadataLimit = 128 + + var ( + baseTicker = time.NewTicker(baseInterval) + lastCollectedAts = make(map[string]time.Time) + metadataResults = make(chan metadataResultAndKey, metadataLimit) + ) + defer baseTicker.Stop() + + var flight singleflight.Group + + for { + select { + case <-ctx.Done(): + return + case mr := <-metadataResults: + lastCollectedAts[mr.key] = mr.result.CollectedAt + err := a.client.PostMetadata(ctx, mr.key, *mr.result) + if err != nil { + a.logger.Error(ctx, "report metadata", slog.Error(err)) + } + case <-baseTicker.C: + } + + if len(metadataResults) > 0 { + // The inner collection loop expects the channel is empty before spinning up + // all the collection goroutines. + a.logger.Debug( + ctx, "metadata collection backpressured", + slog.F("queue_len", len(metadataResults)), + ) + continue + } + + manifest := a.manifest.Load() + if manifest == nil { + continue + } + + if len(manifest.Metadata) > metadataLimit { + a.logger.Error( + ctx, "metadata limit exceeded", + slog.F("limit", metadataLimit), slog.F("got", len(manifest.Metadata)), + ) + continue + } + + // If the manifest changes (e.g. on agent reconnect) we need to + // purge old cache values to prevent lastCollectedAt from growing + // boundlessly. + for key := range lastCollectedAts { + if slices.IndexFunc(manifest.Metadata, func(md codersdk.WorkspaceAgentMetadataDescription) bool { + return md.Key == key + }) < 0 { + delete(lastCollectedAts, key) + } + } + + // Spawn a goroutine for each metadata collection, and use a + // channel to synchronize the results and avoid both messy + // mutex logic and overloading the API. + for _, md := range manifest.Metadata { + collectedAt, ok := lastCollectedAts[md.Key] + if ok { + // If the interval is zero, we assume the user just wants + // a single collection at startup, not a spinning loop. + if md.Interval == 0 { + continue + } + // The last collected value isn't quite stale yet, so we skip it. + if collectedAt.Add( + adjustIntervalForTests(md.Interval), + ).After(time.Now()) { + continue + } + } + + md := md + // We send the result to the channel in the goroutine to avoid + // sending the same result multiple times. So, we don't care about + // the return values. + flight.DoChan(md.Key, func() (interface{}, error) { + timeout := md.Timeout + if timeout == 0 { + timeout = md.Interval + } + ctx, cancel := context.WithTimeout(ctx, + time.Duration(timeout)*time.Second, + ) + defer cancel() + + select { + case <-ctx.Done(): + return 0, nil + case metadataResults <- metadataResultAndKey{ + key: md.Key, + result: a.collectMetadata(ctx, md), + }: + } + return 0, nil + }) + } + } +} + // reportLifecycleLoop reports the current lifecycle state once. // Only the latest state is reported, intermediate states may be // lost if the agent can't communicate with the API. @@ -279,40 +446,40 @@ func (a *agent) run(ctx context.Context) error { } a.sessionToken.Store(&sessionToken) - metadata, err := a.client.Metadata(ctx) + manifest, err := a.client.Manifest(ctx) if err != nil { return xerrors.Errorf("fetch metadata: %w", err) } - a.logger.Info(ctx, "fetched metadata", slog.F("metadata", metadata)) + a.logger.Info(ctx, "fetched manifest", slog.F("manifest", manifest)) // Expand the directory and send it back to coderd so external // applications that rely on the directory can use it. // // An example is VS Code Remote, which must know the directory // before initializing a connection. - metadata.Directory, err = expandDirectory(metadata.Directory) + manifest.Directory, err = expandDirectory(manifest.Directory) if err != nil { return xerrors.Errorf("expand directory: %w", err) } err = a.client.PostStartup(ctx, agentsdk.PostStartupRequest{ Version: buildinfo.Version(), - ExpandedDirectory: metadata.Directory, + ExpandedDirectory: manifest.Directory, }) if err != nil { return xerrors.Errorf("update workspace agent version: %w", err) } - oldMetadata := a.metadata.Swap(metadata) + oldManifest := a.manifest.Swap(&manifest) // The startup script should only execute on the first run! - if oldMetadata == nil { + if oldManifest == nil { a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStarting) // Perform overrides early so that Git auth can work even if users // connect to a workspace that is not yet ready. We don't run this // concurrently with the startup script to avoid conflicts between // them. - if metadata.GitAuthConfigs > 0 { + if manifest.GitAuthConfigs > 0 { // If this fails, we should consider surfacing the error in the // startup log and setting the lifecycle state to be "start_error" // (after startup script completion), but for now we'll just log it. @@ -327,7 +494,7 @@ func (a *agent) run(ctx context.Context) error { scriptStart := time.Now() err = a.trackConnGoroutine(func() { defer close(scriptDone) - scriptDone <- a.runStartupScript(ctx, metadata.StartupScript) + scriptDone <- a.runStartupScript(ctx, manifest.StartupScript) }) if err != nil { return xerrors.Errorf("track startup script: %w", err) @@ -336,8 +503,8 @@ func (a *agent) run(ctx context.Context) error { var timeout <-chan time.Time // If timeout is zero, an older version of the coder // provider was used. Otherwise a timeout is always > 0. - if metadata.StartupScriptTimeout > 0 { - t := time.NewTimer(metadata.StartupScriptTimeout) + if manifest.StartupScriptTimeout > 0 { + t := time.NewTimer(manifest.StartupScriptTimeout) defer t.Stop() timeout = t.C } @@ -354,7 +521,7 @@ func (a *agent) run(ctx context.Context) error { return } // Only log if there was a startup script. - if metadata.StartupScript != "" { + if manifest.StartupScript != "" { execTime := time.Since(scriptStart) if err != nil { a.logger.Warn(ctx, "startup script failed", slog.F("execution_time", execTime), slog.Error(err)) @@ -371,13 +538,13 @@ func (a *agent) run(ctx context.Context) error { appReporterCtx, appReporterCtxCancel := context.WithCancel(ctx) defer appReporterCtxCancel() go NewWorkspaceAppHealthReporter( - a.logger, metadata.Apps, a.client.PostAppHealth)(appReporterCtx) + a.logger, manifest.Apps, a.client.PostAppHealth)(appReporterCtx) a.closeMutex.Lock() network := a.network a.closeMutex.Unlock() if network == nil { - network, err = a.createTailnet(ctx, metadata.DERPMap) + network, err = a.createTailnet(ctx, manifest.DERPMap) if err != nil { return xerrors.Errorf("create tailnet: %w", err) } @@ -396,7 +563,7 @@ func (a *agent) run(ctx context.Context) error { a.startReportingConnectionStats(ctx) } else { // Update the DERP map! - network.SetDERPMap(metadata.DERPMap) + network.SetDERPMap(manifest.DERPMap) } a.logger.Debug(ctx, "running tailnet connection coordinator") @@ -926,9 +1093,9 @@ func (a *agent) init(ctx context.Context) { } // createCommand processes raw command input with OpenSSH-like behavior. -// If the rawCommand provided is empty, it will default to the users shell. +// If the script provided is empty, it will default to the users shell. // This injects environment variables specified by the user at launch too. -func (a *agent) createCommand(ctx context.Context, rawCommand string, env []string) (*exec.Cmd, error) { +func (a *agent) createCommand(ctx context.Context, script string, env []string) (*exec.Cmd, error) { currentUser, err := user.Current() if err != nil { return nil, xerrors.Errorf("get current user: %w", err) @@ -940,14 +1107,10 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri return nil, xerrors.Errorf("get user shell: %w", err) } - rawMetadata := a.metadata.Load() - if rawMetadata == nil { + manifest := a.manifest.Load() + if manifest == nil { return nil, xerrors.Errorf("no metadata was provided") } - metadata, valid := rawMetadata.(agentsdk.Metadata) - if !valid { - return nil, xerrors.Errorf("metadata is the wrong type: %T", metadata) - } // OpenSSH executes all commands with the users current shell. // We replicate that behavior for IDE support. @@ -955,11 +1118,11 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri if runtime.GOOS == "windows" { caller = "/c" } - args := []string{caller, rawCommand} + args := []string{caller, script} // gliderlabs/ssh returns a command slice of zero // when a shell is requested. - if len(rawCommand) == 0 { + if len(script) == 0 { args = []string{} if runtime.GOOS != "windows" { // On Linux and macOS, we should start a login @@ -969,7 +1132,7 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri } cmd := exec.CommandContext(ctx, shell, args...) - cmd.Dir = metadata.Directory + cmd.Dir = manifest.Directory // If the metadata directory doesn't exist, we run the command // in the users home directory. @@ -1010,14 +1173,14 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri // This adds the ports dialog to code-server that enables // proxying a port dynamically. - cmd.Env = append(cmd.Env, fmt.Sprintf("VSCODE_PROXY_URI=%s", metadata.VSCodePortProxyURI)) + cmd.Env = append(cmd.Env, fmt.Sprintf("VSCODE_PROXY_URI=%s", manifest.VSCodePortProxyURI)) // Hide Coder message on code-server's "Getting Started" page cmd.Env = append(cmd.Env, "CS_DISABLE_GETTING_STARTED_OVERRIDE=true") // Load environment variables passed via the agent. // These should override all variables we manually specify. - for envKey, value := range metadata.EnvironmentVariables { + for envKey, value := range manifest.EnvironmentVariables { // Expanding environment variables allows for customization // of the $PATH, among other variables. Customers can prepend // or append to the $PATH, so allowing expand is required! @@ -1080,9 +1243,9 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) { session.DisablePTYEmulation() if !isQuietLogin(session.RawCommand()) { - metadata, ok := a.metadata.Load().(agentsdk.Metadata) - if ok { - err = showMOTD(session, metadata.MOTDFile) + manifest := a.manifest.Load() + if manifest != nil { + err = showMOTD(session, manifest.MOTDFile) if err != nil { a.logger.Error(ctx, "show MOTD", slog.Error(err)) } @@ -1512,19 +1675,19 @@ func (a *agent) Close() error { a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleShuttingDown) lifecycleState := codersdk.WorkspaceAgentLifecycleOff - if metadata, ok := a.metadata.Load().(agentsdk.Metadata); ok && metadata.ShutdownScript != "" { + if manifest := a.manifest.Load(); manifest != nil && manifest.ShutdownScript != "" { scriptDone := make(chan error, 1) scriptStart := time.Now() go func() { defer close(scriptDone) - scriptDone <- a.runShutdownScript(ctx, metadata.ShutdownScript) + scriptDone <- a.runShutdownScript(ctx, manifest.ShutdownScript) }() var timeout <-chan time.Time // If timeout is zero, an older version of the coder // provider was used. Otherwise a timeout is always > 0. - if metadata.ShutdownScriptTimeout > 0 { - t := time.NewTimer(metadata.ShutdownScriptTimeout) + if manifest.ShutdownScriptTimeout > 0 { + t := time.NewTimer(manifest.ShutdownScriptTimeout) defer t.Stop() timeout = t.C } diff --git a/agent/agent_test.go b/agent/agent_test.go index a147d27815..ec76aa1b0b 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -33,6 +33,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" "golang.org/x/crypto/ssh" + "golang.org/x/exp/maps" "golang.org/x/xerrors" "tailscale.com/net/speedtest" "tailscale.com/tailcfg" @@ -61,7 +62,7 @@ func TestAgent_Stats_SSH(t *testing.T) { defer cancel() //nolint:dogsled - conn, _, stats, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) sshClient, err := conn.SSHClient(ctx) require.NoError(t, err) @@ -94,7 +95,7 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) { defer cancel() //nolint:dogsled - conn, _, stats, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) ptyConn, err := conn.ReconnectingPTY(ctx, uuid.New(), 128, 128, "/bin/bash") require.NoError(t, err) @@ -124,7 +125,7 @@ func TestAgent_Stats_Magic(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) sshClient, err := conn.SSHClient(ctx) require.NoError(t, err) defer sshClient.Close() @@ -151,7 +152,7 @@ func TestAgent_Stats_Magic(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() //nolint:dogsled - conn, _, stats, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) sshClient, err := conn.SSHClient(ctx) require.NoError(t, err) defer sshClient.Close() @@ -186,7 +187,7 @@ func TestAgent_Stats_Magic(t *testing.T) { func TestAgent_SessionExec(t *testing.T) { t.Parallel() - session := setupSSHSession(t, agentsdk.Metadata{}) + session := setupSSHSession(t, agentsdk.Manifest{}) command := "echo test" if runtime.GOOS == "windows" { @@ -199,7 +200,7 @@ func TestAgent_SessionExec(t *testing.T) { func TestAgent_GitSSH(t *testing.T) { t.Parallel() - session := setupSSHSession(t, agentsdk.Metadata{}) + session := setupSSHSession(t, agentsdk.Manifest{}) command := "sh -c 'echo $GIT_SSH_COMMAND'" if runtime.GOOS == "windows" { command = "cmd.exe /c echo %GIT_SSH_COMMAND%" @@ -219,7 +220,7 @@ func TestAgent_SessionTTYShell(t *testing.T) { // it seems like it could be either. t.Skip("ConPTY appears to be inconsistent on Windows.") } - session := setupSSHSession(t, agentsdk.Metadata{}) + session := setupSSHSession(t, agentsdk.Manifest{}) command := "sh" if runtime.GOOS == "windows" { command = "cmd.exe" @@ -242,7 +243,7 @@ func TestAgent_SessionTTYShell(t *testing.T) { func TestAgent_SessionTTYExitCode(t *testing.T) { t.Parallel() - session := setupSSHSession(t, agentsdk.Metadata{}) + session := setupSSHSession(t, agentsdk.Manifest{}) command := "areallynotrealcommand" err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{}) require.NoError(t, err) @@ -281,7 +282,7 @@ func TestAgent_Session_TTY_MOTD(t *testing.T) { // Set HOME so we can ensure no ~/.hushlogin is present. t.Setenv("HOME", tmpdir) - session := setupSSHSession(t, agentsdk.Metadata{ + session := setupSSHSession(t, agentsdk.Manifest{ MOTDFile: name, }) err = session.RequestPty("xterm", 128, 128, ssh.TerminalModes{}) @@ -327,7 +328,7 @@ func TestAgent_Session_TTY_Hushlogin(t *testing.T) { // Set HOME so we can ensure ~/.hushlogin is present. t.Setenv("HOME", tmpdir) - session := setupSSHSession(t, agentsdk.Metadata{ + session := setupSSHSession(t, agentsdk.Manifest{ MOTDFile: name, }) err = session.RequestPty("xterm", 128, 128, ssh.TerminalModes{}) @@ -357,7 +358,7 @@ func TestAgent_Session_TTY_FastCommandHasOutput(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) sshClient, err := conn.SSHClient(ctx) require.NoError(t, err) defer sshClient.Close() @@ -407,7 +408,7 @@ func TestAgent_Session_TTY_HugeOutputIsNotLost(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) sshClient, err := conn.SSHClient(ctx) require.NoError(t, err) defer sshClient.Close() @@ -706,7 +707,7 @@ func TestAgent_SFTP(t *testing.T) { home = "/" + strings.ReplaceAll(home, "\\", "/") } //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) sshClient, err := conn.SSHClient(ctx) require.NoError(t, err) defer sshClient.Close() @@ -738,7 +739,7 @@ func TestAgent_SCP(t *testing.T) { defer cancel() //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) sshClient, err := conn.SSHClient(ctx) require.NoError(t, err) defer sshClient.Close() @@ -757,7 +758,7 @@ func TestAgent_EnvironmentVariables(t *testing.T) { t.Parallel() key := "EXAMPLE" value := "value" - session := setupSSHSession(t, agentsdk.Metadata{ + session := setupSSHSession(t, agentsdk.Manifest{ EnvironmentVariables: map[string]string{ key: value, }, @@ -774,7 +775,7 @@ func TestAgent_EnvironmentVariables(t *testing.T) { func TestAgent_EnvironmentVariableExpansion(t *testing.T) { t.Parallel() key := "EXAMPLE" - session := setupSSHSession(t, agentsdk.Metadata{ + session := setupSSHSession(t, agentsdk.Manifest{ EnvironmentVariables: map[string]string{ key: "$SOMETHINGNOTSET", }, @@ -801,7 +802,7 @@ func TestAgent_CoderEnvVars(t *testing.T) { t.Run(key, func(t *testing.T) { t.Parallel() - session := setupSSHSession(t, agentsdk.Metadata{}) + session := setupSSHSession(t, agentsdk.Manifest{}) command := "sh -c 'echo $" + key + "'" if runtime.GOOS == "windows" { command = "cmd.exe /c echo %" + key + "%" @@ -824,7 +825,7 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) { t.Run(key, func(t *testing.T) { t.Parallel() - session := setupSSHSession(t, agentsdk.Metadata{}) + session := setupSSHSession(t, agentsdk.Manifest{}) command := "sh -c 'echo $" + key + "'" if runtime.GOOS == "windows" { command = "cmd.exe /c echo %" + key + "%" @@ -848,7 +849,7 @@ func TestAgent_StartupScript(t *testing.T) { client := &client{ t: t, agentID: uuid.New(), - metadata: agentsdk.Metadata{ + manifest: agentsdk.Manifest{ StartupScript: command, DERPMap: &tailcfg.DERPMap{}, }, @@ -879,7 +880,7 @@ func TestAgent_StartupScript(t *testing.T) { client := &client{ t: t, agentID: uuid.New(), - metadata: agentsdk.Metadata{ + manifest: agentsdk.Manifest{ StartupScript: command, DERPMap: &tailcfg.DERPMap{}, }, @@ -912,13 +913,125 @@ func TestAgent_StartupScript(t *testing.T) { }) } +func TestAgent_Metadata(t *testing.T) { + t.Parallel() + + t.Run("Once", func(t *testing.T) { + t.Parallel() + script := "echo -n hello" + if runtime.GOOS == "windows" { + script = "powershell " + script + } + //nolint:dogsled + _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ + Metadata: []codersdk.WorkspaceAgentMetadataDescription{ + { + Key: "greeting", + Interval: 0, + Script: script, + }, + }, + }, 0) + + var gotMd map[string]agentsdk.PostMetadataRequest + require.Eventually(t, func() bool { + gotMd = client.getMetadata() + return len(gotMd) == 1 + }, testutil.WaitShort, testutil.IntervalMedium) + + collectedAt := gotMd["greeting"].CollectedAt + + require.Never(t, func() bool { + gotMd = client.getMetadata() + if len(gotMd) != 1 { + panic("unexpected number of metadata") + } + return !gotMd["greeting"].CollectedAt.Equal(collectedAt) + }, testutil.WaitShort, testutil.IntervalMedium) + }) + + t.Run("Many", func(t *testing.T) { + if runtime.GOOS == "windows" { + // Shell scripting in Windows is a pain, and we have already tested + // that the OS logic works in the simpler "Once" test above. + t.Skip() + } + t.Parallel() + + dir := t.TempDir() + + const reportInterval = 2 + const intervalUnit = 100 * time.Millisecond + var ( + greetingPath = filepath.Join(dir, "greeting") + script = "echo hello | tee -a " + greetingPath + ) + _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ + Metadata: []codersdk.WorkspaceAgentMetadataDescription{ + { + Key: "greeting", + Interval: reportInterval, + Script: script, + }, + { + Key: "bad", + Interval: reportInterval, + Script: "exit 1", + }, + }, + }, 0) + + require.Eventually(t, func() bool { + return len(client.getMetadata()) == 2 + }, testutil.WaitShort, testutil.IntervalMedium) + + for start := time.Now(); time.Since(start) < testutil.WaitMedium; time.Sleep(testutil.IntervalMedium) { + md := client.getMetadata() + if len(md) != 2 { + panic("unexpected number of metadata entries") + } + + require.Equal(t, "hello\n", md["greeting"].Value) + require.Equal(t, "exit status 1", md["bad"].Error) + + greetingByt, err := os.ReadFile(greetingPath) + require.NoError(t, err) + + var ( + numGreetings = bytes.Count(greetingByt, []byte("hello")) + idealNumGreetings = time.Since(start) / (reportInterval * intervalUnit) + // We allow a 50% error margin because the report loop may backlog + // in CI and other toasters. In production, there is no hard + // guarantee on timing either, and the frontend gives similar + // wiggle room to the staleness of the value. + upperBound = int(idealNumGreetings) + 1 + lowerBound = (int(idealNumGreetings) / 2) + ) + + if idealNumGreetings < 50 { + // There is an insufficient sample size. + continue + } + + t.Logf("numGreetings: %d, idealNumGreetings: %d", numGreetings, idealNumGreetings) + // The report loop may slow down on load, but it should never, ever + // speed up. + if numGreetings > upperBound { + t.Fatalf("too many greetings: %d > %d in %v", numGreetings, upperBound, time.Since(start)) + } else if numGreetings < lowerBound { + t.Fatalf("too few greetings: %d < %d", numGreetings, lowerBound) + } + } + }) +} + func TestAgent_Lifecycle(t *testing.T) { t.Parallel() t.Run("StartTimeout", func(t *testing.T) { t.Parallel() - _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ StartupScript: "sleep 5", StartupScriptTimeout: time.Nanosecond, }, 0) @@ -947,7 +1060,7 @@ func TestAgent_Lifecycle(t *testing.T) { t.Run("StartError", func(t *testing.T) { t.Parallel() - _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ StartupScript: "false", StartupScriptTimeout: 30 * time.Second, }, 0) @@ -976,7 +1089,7 @@ func TestAgent_Lifecycle(t *testing.T) { t.Run("Ready", func(t *testing.T) { t.Parallel() - _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ StartupScript: "true", StartupScriptTimeout: 30 * time.Second, }, 0) @@ -1005,7 +1118,7 @@ func TestAgent_Lifecycle(t *testing.T) { t.Run("ShuttingDown", func(t *testing.T) { t.Parallel() - _, client, _, _, closer := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, closer := setupAgent(t, agentsdk.Manifest{ ShutdownScript: "sleep 5", StartupScriptTimeout: 30 * time.Second, }, 0) @@ -1043,7 +1156,7 @@ func TestAgent_Lifecycle(t *testing.T) { t.Run("ShutdownTimeout", func(t *testing.T) { t.Parallel() - _, client, _, _, closer := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, closer := setupAgent(t, agentsdk.Manifest{ ShutdownScript: "sleep 5", ShutdownScriptTimeout: time.Nanosecond, }, 0) @@ -1090,7 +1203,7 @@ func TestAgent_Lifecycle(t *testing.T) { t.Run("ShutdownError", func(t *testing.T) { t.Parallel() - _, client, _, _, closer := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, closer := setupAgent(t, agentsdk.Manifest{ ShutdownScript: "false", ShutdownScriptTimeout: 30 * time.Second, }, 0) @@ -1141,7 +1254,7 @@ func TestAgent_Lifecycle(t *testing.T) { client := &client{ t: t, agentID: uuid.New(), - metadata: agentsdk.Metadata{ + manifest: agentsdk.Manifest{ DERPMap: tailnettest.RunDERPAndSTUN(t), StartupScript: "echo 1", ShutdownScript: "echo " + expected, @@ -1194,7 +1307,7 @@ func TestAgent_Startup(t *testing.T) { t.Run("EmptyDirectory", func(t *testing.T) { t.Parallel() - _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ StartupScript: "true", StartupScriptTimeout: 30 * time.Second, Directory: "", @@ -1208,7 +1321,7 @@ func TestAgent_Startup(t *testing.T) { t.Run("HomeDirectory", func(t *testing.T) { t.Parallel() - _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ StartupScript: "true", StartupScriptTimeout: 30 * time.Second, Directory: "~", @@ -1224,7 +1337,7 @@ func TestAgent_Startup(t *testing.T) { t.Run("HomeEnvironmentVariable", func(t *testing.T) { t.Parallel() - _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ StartupScript: "true", StartupScriptTimeout: 30 * time.Second, Directory: "$HOME", @@ -1251,7 +1364,7 @@ func TestAgent_ReconnectingPTY(t *testing.T) { defer cancel() //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) id := uuid.New() netConn, err := conn.ReconnectingPTY(ctx, id, 100, 100, "/bin/bash") require.NoError(t, err) @@ -1353,7 +1466,7 @@ func TestAgent_Dial(t *testing.T) { }() //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) require.True(t, conn.AwaitReachable(context.Background())) conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String()) require.NoError(t, err) @@ -1375,7 +1488,7 @@ func TestAgent_Speedtest(t *testing.T) { defer cancel() derpMap := tailnettest.RunDERPAndSTUN(t) //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{ + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{ DERPMap: derpMap, }, 0) defer conn.Close() @@ -1397,7 +1510,7 @@ func TestAgent_Reconnect(t *testing.T) { client := &client{ t: t, agentID: agentID, - metadata: agentsdk.Metadata{ + manifest: agentsdk.Manifest{ DERPMap: derpMap, }, statsChan: statsCh, @@ -1432,7 +1545,7 @@ func TestAgent_WriteVSCodeConfigs(t *testing.T) { client := &client{ t: t, agentID: uuid.New(), - metadata: agentsdk.Metadata{ + manifest: agentsdk.Manifest{ GitAuthConfigs: 1, DERPMap: &tailcfg.DERPMap{}, }, @@ -1461,7 +1574,7 @@ func TestAgent_WriteVSCodeConfigs(t *testing.T) { func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { //nolint:dogsled - agentConn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + agentConn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) waitGroup := sync.WaitGroup{} @@ -1504,7 +1617,7 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe return exec.Command("ssh", args...) } -func setupSSHSession(t *testing.T, options agentsdk.Metadata) *ssh.Session { +func setupSSHSession(t *testing.T, options agentsdk.Manifest) *ssh.Session { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() //nolint:dogsled @@ -1528,7 +1641,7 @@ func (c closeFunc) Close() error { return c() } -func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Duration) ( +func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Duration) ( *codersdk.WorkspaceAgentConn, *client, <-chan *agentsdk.Stats, @@ -1548,7 +1661,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Durati c := &client{ t: t, agentID: agentID, - metadata: metadata, + manifest: metadata, statsChan: statsCh, coordinator: coordinator, } @@ -1631,7 +1744,8 @@ func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { type client struct { t *testing.T agentID uuid.UUID - metadata agentsdk.Metadata + manifest agentsdk.Manifest + metadata map[string]agentsdk.PostMetadataRequest statsChan chan *agentsdk.Stats coordinator tailnet.Coordinator lastWorkspaceAgent func() @@ -1643,8 +1757,8 @@ type client struct { logs []agentsdk.StartupLog } -func (c *client) Metadata(_ context.Context) (agentsdk.Metadata, error) { - return c.metadata, nil +func (c *client) Manifest(_ context.Context) (agentsdk.Manifest, error) { + return c.manifest, nil } func (c *client) Listen(_ context.Context) (net.Conn, error) { @@ -1718,6 +1832,22 @@ func (c *client) getStartup() agentsdk.PostStartupRequest { return c.startup } +func (c *client) getMetadata() map[string]agentsdk.PostMetadataRequest { + c.mu.Lock() + defer c.mu.Unlock() + return maps.Clone(c.metadata) +} + +func (c *client) PostMetadata(_ context.Context, key string, req agentsdk.PostMetadataRequest) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.metadata == nil { + c.metadata = make(map[string]agentsdk.PostMetadataRequest) + } + c.metadata[key] = req + return nil +} + func (c *client) PostStartup(_ context.Context, startup agentsdk.PostStartupRequest) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9561e0b511..e18f37d534 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4263,7 +4263,7 @@ const docTemplate = `{ } } }, - "/workspaceagents/me/metadata": { + "/workspaceagents/me/manifest": { "get": { "security": [ { @@ -4276,18 +4276,62 @@ const docTemplate = `{ "tags": [ "Agents" ], - "summary": "Get authorized workspace agent metadata", - "operationId": "get-authorized-workspace-agent-metadata", + "summary": "Get authorized workspace agent manifest", + "operationId": "get-authorized-workspace-agent-manifest", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/agentsdk.Metadata" + "$ref": "#/definitions/agentsdk.Manifest" } } } } }, + "/workspaceagents/me/metadata/{key}": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Submit workspace agent metadata", + "operationId": "submit-workspace-agent-metadata", + "parameters": [ + { + "description": "Workspace agent metadata request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PostMetadataRequest" + } + }, + { + "type": "string", + "format": "string", + "description": "metadata key", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Success" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspaceagents/me/report-lifecycle": { "post": { "security": [ @@ -4663,6 +4707,38 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/watch-metadata": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Agents" + ], + "summary": "Watch for workspace agent metadata updates", + "operationId": "watch-for-workspace-agent-metadata-updates", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspacebuilds/{workspacebuild}": { "get": { "security": [ @@ -5397,7 +5473,7 @@ const docTemplate = `{ } } }, - "agentsdk.Metadata": { + "agentsdk.Manifest": { "type": "object", "properties": { "apps": { @@ -5422,6 +5498,12 @@ const docTemplate = `{ "description": "GitAuthConfigs stores the number of Git configurations\nthe Coder deployment has. If this number is \u003e0, we\nset up special configuration in the workspace.", "type": "integer" }, + "metadata": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentMetadataDescription" + } + }, "motd_file": { "type": "string" }, @@ -5473,6 +5555,25 @@ const docTemplate = `{ } } }, + "agentsdk.PostMetadataRequest": { + "type": "object", + "properties": { + "age": { + "description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.", + "type": "integer" + }, + "collected_at": { + "type": "string", + "format": "date-time" + }, + "error": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "agentsdk.PostStartupRequest": { "type": "object", "properties": { @@ -8915,6 +9016,26 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceAgentMetadataDescription": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "interval": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "script": { + "type": "string" + }, + "timeout": { + "type": "integer" + } + } + }, "codersdk.WorkspaceAgentStartupLog": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fd9c8a7e8a..da71289983 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3747,7 +3747,7 @@ } } }, - "/workspaceagents/me/metadata": { + "/workspaceagents/me/manifest": { "get": { "security": [ { @@ -3756,18 +3756,58 @@ ], "produces": ["application/json"], "tags": ["Agents"], - "summary": "Get authorized workspace agent metadata", - "operationId": "get-authorized-workspace-agent-metadata", + "summary": "Get authorized workspace agent manifest", + "operationId": "get-authorized-workspace-agent-manifest", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/agentsdk.Metadata" + "$ref": "#/definitions/agentsdk.Manifest" } } } } }, + "/workspaceagents/me/metadata/{key}": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Agents"], + "summary": "Submit workspace agent metadata", + "operationId": "submit-workspace-agent-metadata", + "parameters": [ + { + "description": "Workspace agent metadata request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PostMetadataRequest" + } + }, + { + "type": "string", + "format": "string", + "description": "metadata key", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Success" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspaceagents/me/report-lifecycle": { "post": { "security": [ @@ -4101,6 +4141,36 @@ } } }, + "/workspaceagents/{workspaceagent}/watch-metadata": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Agents"], + "summary": "Watch for workspace agent metadata updates", + "operationId": "watch-for-workspace-agent-metadata-updates", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspacebuilds/{workspacebuild}": { "get": { "security": [ @@ -4758,7 +4828,7 @@ } } }, - "agentsdk.Metadata": { + "agentsdk.Manifest": { "type": "object", "properties": { "apps": { @@ -4783,6 +4853,12 @@ "description": "GitAuthConfigs stores the number of Git configurations\nthe Coder deployment has. If this number is \u003e0, we\nset up special configuration in the workspace.", "type": "integer" }, + "metadata": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentMetadataDescription" + } + }, "motd_file": { "type": "string" }, @@ -4834,6 +4910,25 @@ } } }, + "agentsdk.PostMetadataRequest": { + "type": "object", + "properties": { + "age": { + "description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.", + "type": "integer" + }, + "collected_at": { + "type": "string", + "format": "date-time" + }, + "error": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "agentsdk.PostStartupRequest": { "type": "object", "properties": { @@ -8043,6 +8138,26 @@ } } }, + "codersdk.WorkspaceAgentMetadataDescription": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "interval": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "script": { + "type": "string" + }, + "timeout": { + "type": "integer" + } + } + }, "codersdk.WorkspaceAgentStartupLog": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 8fd68ba47e..9573d9b3cf 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -608,7 +608,10 @@ func New(options *Options) *API { r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity) r.Route("/me", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) - r.Get("/metadata", api.workspaceAgentMetadata) + r.Get("/manifest", api.workspaceAgentManifest) + // This route is deprecated and will be removed in a future release. + // New agents will use /me/manifest instead. + r.Get("/metadata", api.workspaceAgentManifest) r.Post("/startup", api.postWorkspaceAgentStartup) r.Patch("/startup-logs", api.patchWorkspaceAgentStartupLogs) r.Post("/app-health", api.postWorkspaceAppHealth) @@ -617,6 +620,7 @@ func New(options *Options) *API { r.Get("/coordinate", api.workspaceAgentCoordinate) r.Post("/report-stats", api.workspaceAgentReportStats) r.Post("/report-lifecycle", api.workspaceAgentReportLifecycle) + r.Post("/metadata/{key}", api.workspaceAgentPostMetadata) }) // No middleware on the PTY endpoint since it uses workspace // application auth and tickets. @@ -628,6 +632,8 @@ func New(options *Options) *API { httpmw.ExtractWorkspaceParam(options.Database), ) r.Get("/", api.workspaceAgent) + r.Get("/watch-metadata", api.watchWorkspaceAgentMetadata) + r.Get("/pty", api.workspaceAgentPTY) r.Get("/startup-logs", api.workspaceAgentStartupLogs) r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index dda80d2f40..be70b0379d 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -160,6 +160,11 @@ func VerifySwaggerDefinitions(t *testing.T, router chi.Router, swaggerComments [ t.Run(method+" "+route, func(t *testing.T) { t.Parallel() + // This route is for compatibility purposes and is not documented. + if route == "/workspaceagents/me/metadata" { + return + } + c := findSwaggerCommentByMethodAndRoute(swaggerComments, method, route) assert.NotNil(t, c, "Missing @Router annotation") if c == nil { diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index a55ed6ba95..13699e28f7 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -1564,6 +1564,44 @@ func (q *querier) InsertWorkspaceAgentStat(ctx context.Context, arg database.Ins return q.db.InsertWorkspaceAgentStat(ctx, arg) } +func (q *querier) InsertWorkspaceAgentMetadata(ctx context.Context, arg database.InsertWorkspaceAgentMetadataParams) error { + // We don't check for workspace ownership here since the agent metadata may + // be associated with an orphaned agent used by a dry run build. + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { + return err + } + + return q.db.InsertWorkspaceAgentMetadata(ctx, arg) +} + +func (q *querier) UpdateWorkspaceAgentMetadata(ctx context.Context, arg database.UpdateWorkspaceAgentMetadataParams) error { + workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.WorkspaceAgentID) + if err != nil { + return err + } + + err = q.authorizeContext(ctx, rbac.ActionUpdate, workspace) + if err != nil { + return err + } + + return q.db.UpdateWorkspaceAgentMetadata(ctx, arg) +} + +func (q *querier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentMetadatum, error) { + workspace, err := q.db.GetWorkspaceByAgentID(ctx, workspaceAgentID) + if err != nil { + return nil, err + } + + err = q.authorizeContext(ctx, rbac.ActionRead, workspace) + if err != nil { + return nil, err + } + + return q.db.GetWorkspaceAgentMetadata(ctx, workspaceAgentID) +} + func (q *querier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { // TODO: This is a workspace agent operation. Should users be able to query this? workspace, err := q.db.GetWorkspaceByWorkspaceAppID(ctx, arg.ID) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 351707748b..5822b15a72 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -124,6 +124,7 @@ type data struct { templateVersionVariables []database.TemplateVersionVariable templates []database.Template workspaceAgents []database.WorkspaceAgent + workspaceAgentMetadata []database.WorkspaceAgentMetadatum workspaceAgentLogs []database.WorkspaceAgentStartupLog workspaceApps []database.WorkspaceApp workspaceBuilds []database.WorkspaceBuild @@ -2741,6 +2742,60 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP return key, nil } +func (q *fakeQuerier) UpdateWorkspaceAgentMetadata(_ context.Context, arg database.UpdateWorkspaceAgentMetadataParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + //nolint:gosimple + updated := database.WorkspaceAgentMetadatum{ + WorkspaceAgentID: arg.WorkspaceAgentID, + Key: arg.Key, + Value: arg.Value, + Error: arg.Error, + CollectedAt: arg.CollectedAt, + } + + for i, m := range q.workspaceAgentMetadata { + if m.WorkspaceAgentID == arg.WorkspaceAgentID && m.Key == arg.Key { + q.workspaceAgentMetadata[i] = updated + return nil + } + } + + return nil +} + +func (q *fakeQuerier) InsertWorkspaceAgentMetadata(_ context.Context, arg database.InsertWorkspaceAgentMetadataParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + //nolint:gosimple + metadatum := database.WorkspaceAgentMetadatum{ + WorkspaceAgentID: arg.WorkspaceAgentID, + Script: arg.Script, + DisplayName: arg.DisplayName, + Key: arg.Key, + Timeout: arg.Timeout, + Interval: arg.Interval, + } + + q.workspaceAgentMetadata = append(q.workspaceAgentMetadata, metadatum) + return nil +} + +func (q *fakeQuerier) GetWorkspaceAgentMetadata(_ context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentMetadatum, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + metadata := make([]database.WorkspaceAgentMetadatum, 0) + for _, m := range q.workspaceAgentMetadata { + if m.WorkspaceAgentID == workspaceAgentID { + metadata = append(metadata, m) + } + } + return metadata, nil +} + func (q *fakeQuerier) InsertFile(_ context.Context, arg database.InsertFileParams) (database.File, error) { if err := validateDatabaseType(arg); err != nil { return database.File{}, err diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2f99cf2a07..ae2343f249 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -475,6 +475,18 @@ CREATE TABLE users ( last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL ); +CREATE UNLOGGED TABLE workspace_agent_metadata ( + workspace_agent_id uuid NOT NULL, + display_name character varying(127) NOT NULL, + key character varying(127) NOT NULL, + script character varying(65535) NOT NULL, + value character varying(65535) DEFAULT ''::character varying NOT NULL, + error character varying(65535) DEFAULT ''::character varying NOT NULL, + timeout bigint NOT NULL, + "interval" bigint NOT NULL, + collected_at timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL +); + CREATE TABLE workspace_agent_startup_logs ( agent_id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -756,6 +768,9 @@ ALTER TABLE ONLY user_links ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +ALTER TABLE ONLY workspace_agent_metadata + ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); + ALTER TABLE ONLY workspace_agent_startup_logs ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); @@ -894,6 +909,9 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY workspace_agent_metadata + ADD CONSTRAINT workspace_agent_metadata_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ALTER TABLE ONLY workspace_agent_startup_logs ADD CONSTRAINT workspace_agent_startup_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000111_workspace_agent_metadata.down.sql b/coderd/database/migrations/000111_workspace_agent_metadata.down.sql new file mode 100644 index 0000000000..e59fb23844 --- /dev/null +++ b/coderd/database/migrations/000111_workspace_agent_metadata.down.sql @@ -0,0 +1 @@ +DROP TABLE workspace_agent_metadata; diff --git a/coderd/database/migrations/000111_workspace_agent_metadata.up.sql b/coderd/database/migrations/000111_workspace_agent_metadata.up.sql new file mode 100644 index 0000000000..426b4d69a1 --- /dev/null +++ b/coderd/database/migrations/000111_workspace_agent_metadata.up.sql @@ -0,0 +1,16 @@ +-- This table is UNLOGGED because it is very update-heavy and the the data +-- is not valuable enough to justify the overhead of WAL logging. This should +-- give us a ~70% improvement in write throughput. +CREATE UNLOGGED TABLE workspace_agent_metadata ( + workspace_agent_id uuid NOT NULL, + display_name varchar(127) NOT NULL, + key varchar(127) NOT NULL, + script varchar(65535) NOT NULL, + value varchar(65535) NOT NULL DEFAULT '', + error varchar(65535) NOT NULL DEFAULT '', + timeout bigint NOT NULL, + interval bigint NOT NULL, + collected_at timestamp with time zone NOT NULL DEFAULT '0001-01-01 00:00:00+00', + PRIMARY KEY (workspace_agent_id, key), + FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE +); diff --git a/coderd/database/migrations/testdata/fixtures/000111_workspace_agent_metadata.up.sql b/coderd/database/migrations/testdata/fixtures/000111_workspace_agent_metadata.up.sql new file mode 100644 index 0000000000..cfa7476742 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000111_workspace_agent_metadata.up.sql @@ -0,0 +1,18 @@ +INSERT INTO + workspace_agent_metadata ( + workspace_agent_id, + display_name, + key, + script, + timeout, + interval + ) +VALUES + ( + '45e89705-e09d-4850-bcec-f9a937f5d78d', + 'a h e m', + 'ahem', + 'rm -rf', + 3, + 1 + ); diff --git a/coderd/database/models.go b/coderd/database/models.go index 0a929ae070..639de9475a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1575,6 +1575,18 @@ type WorkspaceAgent struct { StartupLogsOverflowed bool `db:"startup_logs_overflowed" json:"startup_logs_overflowed"` } +type WorkspaceAgentMetadatum struct { + WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` + DisplayName string `db:"display_name" json:"display_name"` + Key string `db:"key" json:"key"` + Script string `db:"script" json:"script"` + Value string `db:"value" json:"value"` + Error string `db:"error" json:"error"` + Timeout int64 `db:"timeout" json:"timeout"` + Interval int64 `db:"interval" json:"interval"` + CollectedAt time.Time `db:"collected_at" json:"collected_at"` +} + type WorkspaceAgentStartupLog struct { AgentID uuid.UUID `db:"agent_id" json:"agent_id"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index bdd370acca..cdf1f14fcd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -126,6 +126,7 @@ type sqlcQuerier interface { GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) + GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentMetadatum, error) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg GetWorkspaceAgentStartupLogsAfterParams) ([]WorkspaceAgentStartupLog, error) GetWorkspaceAgentStats(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsRow, error) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) @@ -185,6 +186,7 @@ type sqlcQuerier interface { InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error) InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error) + InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error InsertWorkspaceAgentStartupLogs(ctx context.Context, arg InsertWorkspaceAgentStartupLogsParams) ([]WorkspaceAgentStartupLog, error) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWorkspaceAgentStatParams) (WorkspaceAgentStat, error) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) @@ -229,6 +231,7 @@ type sqlcQuerier interface { UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error + UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error UpdateWorkspaceAgentStartupLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentStartupLogOverflowByIDParams) error UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 49e6a95489..45dbbe75c5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5297,6 +5297,48 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst return i, err } +const getWorkspaceAgentMetadata = `-- name: GetWorkspaceAgentMetadata :many +SELECT + workspace_agent_id, display_name, key, script, value, error, timeout, interval, collected_at +FROM + workspace_agent_metadata +WHERE + workspace_agent_id = $1 +` + +func (q *sqlQuerier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentMetadatum, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentMetadata, workspaceAgentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentMetadatum + for rows.Next() { + var i WorkspaceAgentMetadatum + if err := rows.Scan( + &i.WorkspaceAgentID, + &i.DisplayName, + &i.Key, + &i.Script, + &i.Value, + &i.Error, + &i.Timeout, + &i.Interval, + &i.CollectedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceAgentStartupLogsAfter = `-- name: GetWorkspaceAgentStartupLogsAfter :many SELECT agent_id, created_at, output, id @@ -5651,6 +5693,41 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa return i, err } +const insertWorkspaceAgentMetadata = `-- name: InsertWorkspaceAgentMetadata :exec +INSERT INTO + workspace_agent_metadata ( + workspace_agent_id, + display_name, + key, + script, + timeout, + interval + ) +VALUES + ($1, $2, $3, $4, $5, $6) +` + +type InsertWorkspaceAgentMetadataParams struct { + WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` + DisplayName string `db:"display_name" json:"display_name"` + Key string `db:"key" json:"key"` + Script string `db:"script" json:"script"` + Timeout int64 `db:"timeout" json:"timeout"` + Interval int64 `db:"interval" json:"interval"` +} + +func (q *sqlQuerier) InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error { + _, err := q.db.ExecContext(ctx, insertWorkspaceAgentMetadata, + arg.WorkspaceAgentID, + arg.DisplayName, + arg.Key, + arg.Script, + arg.Timeout, + arg.Interval, + ) + return err +} + const insertWorkspaceAgentStartupLogs = `-- name: InsertWorkspaceAgentStartupLogs :many WITH new_length AS ( UPDATE workspace_agents SET @@ -5758,6 +5835,37 @@ func (q *sqlQuerier) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, return err } +const updateWorkspaceAgentMetadata = `-- name: UpdateWorkspaceAgentMetadata :exec +UPDATE + workspace_agent_metadata +SET + value = $3, + error = $4, + collected_at = $5 +WHERE + workspace_agent_id = $1 + AND key = $2 +` + +type UpdateWorkspaceAgentMetadataParams struct { + WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` + Error string `db:"error" json:"error"` + CollectedAt time.Time `db:"collected_at" json:"collected_at"` +} + +func (q *sqlQuerier) UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAgentMetadata, + arg.WorkspaceAgentID, + arg.Key, + arg.Value, + arg.Error, + arg.CollectedAt, + ) + return err +} + const updateWorkspaceAgentStartupByID = `-- name: UpdateWorkspaceAgentStartupByID :exec UPDATE workspace_agents diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 143be63c07..36c0ab31e1 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -94,6 +94,38 @@ SET WHERE id = $1; +-- name: InsertWorkspaceAgentMetadata :exec +INSERT INTO + workspace_agent_metadata ( + workspace_agent_id, + display_name, + key, + script, + timeout, + interval + ) +VALUES + ($1, $2, $3, $4, $5, $6); + +-- name: UpdateWorkspaceAgentMetadata :exec +UPDATE + workspace_agent_metadata +SET + value = $3, + error = $4, + collected_at = $5 +WHERE + workspace_agent_id = $1 + AND key = $2; + +-- name: GetWorkspaceAgentMetadata :many +SELECT + * +FROM + workspace_agent_metadata +WHERE + workspace_agent_id = $1; + -- name: UpdateWorkspaceAgentStartupLogOverflowByID :exec UPDATE workspace_agents diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 5f989f784d..0450e7aba6 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1277,6 +1277,21 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. } snapshot.WorkspaceAgents = append(snapshot.WorkspaceAgents, telemetry.ConvertWorkspaceAgent(dbAgent)) + for _, md := range prAgent.Metadata { + p := database.InsertWorkspaceAgentMetadataParams{ + WorkspaceAgentID: agentID, + DisplayName: md.DisplayName, + Script: md.Script, + Key: md.Key, + Timeout: md.Timeout, + Interval: md.Interval, + } + err := db.InsertWorkspaceAgentMetadata(ctx, p) + if err != nil { + return xerrors.Errorf("insert agent metadata: %w, params: %+v", err, p) + } + } + for _, app := range prAgent.Apps { slug := app.Slug if slug == "" { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 74e2818a6b..49885bb9e5 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -21,6 +21,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "go.opentelemetry.io/otel/trace" + "golang.org/x/exp/slices" "golang.org/x/mod/semver" "golang.org/x/xerrors" "nhooyr.io/websocket" @@ -76,14 +77,14 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, apiAgent) } -// @Summary Get authorized workspace agent metadata -// @ID get-authorized-workspace-agent-metadata +// @Summary Get authorized workspace agent manifest +// @ID get-authorized-workspace-agent-manifest // @Security CoderSessionToken // @Produce json // @Tags Agents -// @Success 200 {object} agentsdk.Metadata -// @Router /workspaceagents/me/metadata [get] -func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) { +// @Success 200 {object} agentsdk.Manifest +// @Router /workspaceagents/me/manifest [get] +func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgent(r) apiAgent, err := convertWorkspaceAgent( @@ -105,6 +106,16 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) }) return } + + metadata, err := api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace agent metadata.", + Detail: err.Error(), + }) + return + } + resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -149,7 +160,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) vscodeProxyURI += fmt.Sprintf(":%s", api.AccessURL.Port()) } - httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Metadata{ + httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Manifest{ Apps: convertApps(dbApps), DERPMap: api.DERPMap, GitAuthConfigs: len(api.GitAuthConfigs), @@ -161,6 +172,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) StartupScriptTimeout: time.Duration(apiAgent.StartupScriptTimeoutSeconds) * time.Second, ShutdownScript: apiAgent.ShutdownScript, ShutdownScriptTimeout: time.Duration(apiAgent.ShutdownScriptTimeoutSeconds) * time.Second, + Metadata: convertWorkspaceAgentMetadataDesc(metadata), }) } @@ -1133,6 +1145,20 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { return apps } +func convertWorkspaceAgentMetadataDesc(mds []database.WorkspaceAgentMetadatum) []codersdk.WorkspaceAgentMetadataDescription { + metadata := make([]codersdk.WorkspaceAgentMetadataDescription, 0) + for _, datum := range mds { + metadata = append(metadata, codersdk.WorkspaceAgentMetadataDescription{ + DisplayName: datum.DisplayName, + Key: datum.Key, + Script: datum.Script, + Interval: datum.Interval, + Timeout: datum.Timeout, + }) + } + return metadata +} + func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration, agentFallbackTroubleshootingURL string) (codersdk.WorkspaceAgent, error) { var envs map[string]string if dbAgent.EnvironmentVariables.Valid { @@ -1298,6 +1324,219 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques }) } +// @Summary Submit workspace agent metadata +// @ID submit-workspace-agent-metadata +// @Security CoderSessionToken +// @Accept json +// @Tags Agents +// @Param request body agentsdk.PostMetadataRequest true "Workspace agent metadata request" +// @Param key path string true "metadata key" format(string) +// @Success 204 "Success" +// @Router /workspaceagents/me/metadata/{key} [post] +// @x-apidocgen {"skip": true} +func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req agentsdk.PostMetadataRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + workspaceAgent := httpmw.WorkspaceAgent(r) + + workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get workspace.", + Detail: err.Error(), + }) + return + } + + key := chi.URLParam(r, "key") + + const ( + maxValueLen = 32 << 10 + maxErrorLen = maxValueLen + ) + + metadataError := req.Error + + // We overwrite the error if the provided payload is too long. + if len(req.Value) > maxValueLen { + metadataError = fmt.Sprintf("value of %d bytes exceeded %d bytes", len(req.Value), maxValueLen) + req.Value = req.Value[:maxValueLen] + } + + if len(req.Error) > maxErrorLen { + metadataError = fmt.Sprintf("error of %d bytes exceeded %d bytes", len(req.Error), maxErrorLen) + req.Error = req.Error[:maxErrorLen] + } + + datum := database.UpdateWorkspaceAgentMetadataParams{ + WorkspaceAgentID: workspaceAgent.ID, + // We don't want a misconfigured agent to fill the database. + Key: key, + Value: req.Value, + Error: metadataError, + // We ignore the CollectedAt from the agent to avoid bugs caused by + // clock skew. + CollectedAt: time.Now(), + } + + err = api.Database.UpdateWorkspaceAgentMetadata(ctx, datum) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + api.Logger.Debug( + ctx, "accepted metadata report", + slog.F("agent", workspaceAgent.ID), + slog.F("workspace", workspace.ID), + slog.F("collected_at", datum.CollectedAt), + slog.F("key", datum.Key), + ) + + err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), []byte(datum.Key)) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + +// @Summary Watch for workspace agent metadata updates +// @ID watch-for-workspace-agent-metadata-updates +// @Security CoderSessionToken +// @Tags Agents +// @Success 200 "Success" +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Router /workspaceagents/{workspaceagent}/watch-metadata [get] +// @x-apidocgen {"skip": true} +func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspaceAgent = httpmw.WorkspaceAgentParam(r) + ) + + sendEvent, senderClosed, err := httpapi.ServerSentEventSender(rw, r) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error setting up server-sent events.", + Detail: err.Error(), + }) + return + } + // Prevent handler from returning until the sender is closed. + defer func() { + <-senderClosed + }() + + // We don't want this intentionally long request to skew our tracing + // reports. + ctx = trace.ContextWithSpan(ctx, tracing.NoopSpan) + + const refreshInterval = time.Second * 5 + refreshTicker := time.NewTicker(refreshInterval) + defer refreshTicker.Stop() + + var ( + lastDBMetaMu sync.Mutex + lastDBMeta []database.WorkspaceAgentMetadatum + ) + + sendMetadata := func(pull bool) { + lastDBMetaMu.Lock() + defer lastDBMetaMu.Unlock() + + var err error + if pull { + // We always use the original Request context because it contains + // the RBAC actor. + lastDBMeta, err = api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID) + if err != nil { + _ = sendEvent(ctx, codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeError, + Data: codersdk.Response{ + Message: "Internal error getting metadata.", + Detail: err.Error(), + }, + }) + return + } + slices.SortFunc(lastDBMeta, func(i, j database.WorkspaceAgentMetadatum) bool { + return i.Key < j.Key + }) + + // Avoid sending refresh if the client is about to get a + // fresh update. + refreshTicker.Reset(refreshInterval) + } + + _ = sendEvent(ctx, codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeData, + Data: convertWorkspaceAgentMetadata(lastDBMeta), + }) + } + + // Send initial metadata. + sendMetadata(true) + + // Send metadata on updates. + cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, _ []byte) { + sendMetadata(true) + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + defer cancelSub() + + for { + select { + case <-senderClosed: + return + case <-refreshTicker.C: + break + } + + // Avoid spamming the DB with reads we know there are no updates. We want + // to continue sending updates to the frontend so that "Result.Age" + // is always accurate. This way, the frontend doesn't need to + // sync its own clock with the backend. + sendMetadata(false) + } +} + +func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []codersdk.WorkspaceAgentMetadata { + // An empty array is easier for clients to handle than a null. + result := []codersdk.WorkspaceAgentMetadata{} + for _, datum := range db { + result = append(result, codersdk.WorkspaceAgentMetadata{ + Result: codersdk.WorkspaceAgentMetadataResult{ + Value: datum.Value, + Error: datum.Error, + CollectedAt: datum.CollectedAt, + Age: int64(time.Since(datum.CollectedAt).Seconds()), + }, + Description: codersdk.WorkspaceAgentMetadataDescription{ + DisplayName: datum.DisplayName, + Key: datum.Key, + Script: datum.Script, + Interval: datum.Interval, + Timeout: datum.Timeout, + }, + }) + } + return result +} + +func watchWorkspaceAgentMetadataChannel(id uuid.UUID) string { + return "workspace_agent_metadata:" + id.String() +} + // @Summary Submit workspace agent lifecycle state // @ID submit-workspace-agent-lifecycle-state // @Security CoderSessionToken diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index fe37b07542..1da40dc737 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -831,10 +831,10 @@ func TestWorkspaceAgentAppHealth(t *testing.T) { agentClient := agentsdk.New(client.URL) agentClient.SetSessionToken(authToken) - metadata, err := agentClient.Metadata(ctx) + manifest, err := agentClient.Manifest(ctx) require.NoError(t, err) - require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, metadata.Apps[0].Health) - require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, metadata.Apps[1].Health) + require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, manifest.Apps[0].Health) + require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, manifest.Apps[1].Health) err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{}) require.Error(t, err) // empty @@ -843,37 +843,37 @@ func TestWorkspaceAgentAppHealth(t *testing.T) { // healthcheck disabled err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{ Healths: map[uuid.UUID]codersdk.WorkspaceAppHealth{ - metadata.Apps[0].ID: codersdk.WorkspaceAppHealthInitializing, + manifest.Apps[0].ID: codersdk.WorkspaceAppHealthInitializing, }, }) require.Error(t, err) // invalid value err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{ Healths: map[uuid.UUID]codersdk.WorkspaceAppHealth{ - metadata.Apps[1].ID: codersdk.WorkspaceAppHealth("bad-value"), + manifest.Apps[1].ID: codersdk.WorkspaceAppHealth("bad-value"), }, }) require.Error(t, err) // update to healthy err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{ Healths: map[uuid.UUID]codersdk.WorkspaceAppHealth{ - metadata.Apps[1].ID: codersdk.WorkspaceAppHealthHealthy, + manifest.Apps[1].ID: codersdk.WorkspaceAppHealthHealthy, }, }) require.NoError(t, err) - metadata, err = agentClient.Metadata(ctx) + manifest, err = agentClient.Manifest(ctx) require.NoError(t, err) - require.EqualValues(t, codersdk.WorkspaceAppHealthHealthy, metadata.Apps[1].Health) + require.EqualValues(t, codersdk.WorkspaceAppHealthHealthy, manifest.Apps[1].Health) // update to unhealthy err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{ Healths: map[uuid.UUID]codersdk.WorkspaceAppHealth{ - metadata.Apps[1].ID: codersdk.WorkspaceAppHealthUnhealthy, + manifest.Apps[1].ID: codersdk.WorkspaceAppHealthUnhealthy, }, }) require.NoError(t, err) - metadata, err = agentClient.Metadata(ctx) + manifest, err = agentClient.Manifest(ctx) require.NoError(t, err) - require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, metadata.Apps[1].Health) + require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, manifest.Apps[1].Health) } // nolint:bodyclose @@ -1262,3 +1262,155 @@ func TestWorkspaceAgent_LifecycleState(t *testing.T) { } }) } + +func TestWorkspaceAgent_Metadata(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Metadata: []*proto.Agent_Metadata{ + { + DisplayName: "First Meta", + Key: "foo1", + Script: "echo hi", + Interval: 10, + Timeout: 3, + }, + { + DisplayName: "Second Meta", + Key: "foo2", + Script: "echo howdy", + Interval: 10, + Timeout: 3, + }, + { + DisplayName: "TooLong", + Key: "foo3", + Script: "echo howdy", + Interval: 10, + Timeout: 3, + }, + }, + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + for _, res := range workspace.LatestBuild.Resources { + for _, a := range res.Agents { + require.Equal(t, codersdk.WorkspaceAgentLifecycleCreated, a.LifecycleState) + } + } + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + + ctx := testutil.Context(t, testutil.WaitMedium) + + manifest, err := agentClient.Manifest(ctx) + require.NoError(t, err) + + // Verify manifest API response. + require.Equal(t, "First Meta", manifest.Metadata[0].DisplayName) + require.Equal(t, "foo1", manifest.Metadata[0].Key) + require.Equal(t, "echo hi", manifest.Metadata[0].Script) + require.EqualValues(t, 10, manifest.Metadata[0].Interval) + require.EqualValues(t, 3, manifest.Metadata[0].Timeout) + + post := func(key string, mr codersdk.WorkspaceAgentMetadataResult) { + err := agentClient.PostMetadata(ctx, key, mr) + require.NoError(t, err, "post metadata", t) + } + + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "get workspace") + + agentID := workspace.LatestBuild.Resources[0].Agents[0].ID + + var update []codersdk.WorkspaceAgentMetadata + + check := func(want codersdk.WorkspaceAgentMetadataResult, got codersdk.WorkspaceAgentMetadata) { + require.WithinDuration(t, want.CollectedAt, got.Result.CollectedAt, time.Second) + require.WithinDuration( + t, time.Now(), got.Result.CollectedAt.Add(time.Duration(got.Result.Age)*time.Second), time.Millisecond*500, + ) + require.Equal(t, want.Value, got.Result.Value) + require.Equal(t, want.Error, got.Result.Error) + } + + wantMetadata1 := codersdk.WorkspaceAgentMetadataResult{ + CollectedAt: time.Now(), + Value: "bar", + } + + // Initial post must come before the Watch is established. + post("foo1", wantMetadata1) + + updates, errors := client.WatchWorkspaceAgentMetadata(ctx, agentID) + + recvUpdate := func() []codersdk.WorkspaceAgentMetadata { + select { + case err := <-errors: + t.Fatalf("error watching metadata: %v", err) + return nil + case update := <-updates: + return update + } + } + + update = recvUpdate() + require.Len(t, update, 3) + check(wantMetadata1, update[0]) + // The second metadata result is not yet posted. + require.Zero(t, update[1].Result.CollectedAt) + + wantMetadata2 := wantMetadata1 + post("foo2", wantMetadata2) + update = recvUpdate() + require.Len(t, update, 3) + check(wantMetadata1, update[0]) + check(wantMetadata2, update[1]) + + wantMetadata1.Error = "error" + post("foo1", wantMetadata1) + update = recvUpdate() + require.Len(t, update, 3) + check(wantMetadata1, update[0]) + + const maxValueLen = 32 << 10 + tooLongValueMetadata := wantMetadata1 + tooLongValueMetadata.Value = strings.Repeat("a", maxValueLen*2) + tooLongValueMetadata.Error = "" + tooLongValueMetadata.CollectedAt = time.Now() + post("foo3", tooLongValueMetadata) + got := recvUpdate()[2] + require.Len(t, got.Result.Value, maxValueLen) + require.NotEmpty(t, got.Result.Error) + + unknownKeyMetadata := wantMetadata1 + err = agentClient.PostMetadata(ctx, "unknown", unknownKeyMetadata) + require.NoError(t, err) +} diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 24e4529a82..dff7033d89 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -273,7 +273,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U agentClient := agentsdk.New(client.URL) agentClient.SetSessionToken(authToken) if appHost != "" { - metadata, err := agentClient.Metadata(context.Background()) + manifest, err := agentClient.Manifest(context.Background()) require.NoError(t, err) proxyURL := fmt.Sprintf( "http://{{port}}--%s--%s--%s%s", @@ -285,7 +285,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U if client.URL.Port() != "" { proxyURL += fmt.Sprintf(":%s", client.URL.Port()) } - require.Equal(t, proxyURL, metadata.VSCodePortProxyURI) + require.Equal(t, proxyURL, manifest.VSCodePortProxyURI) } agentCloser := agent.New(agent.Options{ Client: agentClient, diff --git a/coderd/wsconncache/wsconncache_test.go b/coderd/wsconncache/wsconncache_test.go index da5df152f0..24f0f241a1 100644 --- a/coderd/wsconncache/wsconncache_test.go +++ b/coderd/wsconncache/wsconncache_test.go @@ -41,7 +41,7 @@ func TestCache(t *testing.T) { t.Run("Same", func(t *testing.T) { t.Parallel() cache := wsconncache.New(func(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) { - return setupAgent(t, agentsdk.Metadata{}, 0), nil + return setupAgent(t, agentsdk.Manifest{}, 0), nil }, 0) defer func() { _ = cache.Close() @@ -57,7 +57,7 @@ func TestCache(t *testing.T) { called := atomic.NewInt32(0) cache := wsconncache.New(func(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) { called.Add(1) - return setupAgent(t, agentsdk.Metadata{}, 0), nil + return setupAgent(t, agentsdk.Manifest{}, 0), nil }, time.Microsecond) defer func() { _ = cache.Close() @@ -75,7 +75,7 @@ func TestCache(t *testing.T) { t.Run("NoExpireWhenLocked", func(t *testing.T) { t.Parallel() cache := wsconncache.New(func(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) { - return setupAgent(t, agentsdk.Metadata{}, 0), nil + return setupAgent(t, agentsdk.Manifest{}, 0), nil }, time.Microsecond) defer func() { _ = cache.Close() @@ -108,7 +108,7 @@ func TestCache(t *testing.T) { go server.Serve(random) cache := wsconncache.New(func(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) { - return setupAgent(t, agentsdk.Metadata{}, 0), nil + return setupAgent(t, agentsdk.Manifest{}, 0), nil }, time.Microsecond) defer func() { _ = cache.Close() @@ -154,10 +154,10 @@ func TestCache(t *testing.T) { }) } -func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Duration) *codersdk.WorkspaceAgentConn { +func setupAgent(t *testing.T, manifest agentsdk.Manifest, ptyTimeout time.Duration) *codersdk.WorkspaceAgentConn { t.Helper() - metadata.DERPMap = tailnettest.RunDERPAndSTUN(t) + manifest.DERPMap = tailnettest.RunDERPAndSTUN(t) coordinator := tailnet.NewCoordinator() t.Cleanup(func() { @@ -168,7 +168,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Durati Client: &client{ t: t, agentID: agentID, - metadata: metadata, + manifest: manifest, coordinator: coordinator, }, Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelInfo), @@ -179,7 +179,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Durati }) conn, err := tailnet.NewConn(&tailnet.Options{ Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, - DERPMap: metadata.DERPMap, + DERPMap: manifest.DERPMap, Logger: slogtest.Make(t, nil).Named("tailnet").Leveled(slog.LevelDebug), }) require.NoError(t, err) @@ -211,12 +211,12 @@ func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Durati type client struct { t *testing.T agentID uuid.UUID - metadata agentsdk.Metadata + manifest agentsdk.Manifest coordinator tailnet.Coordinator } -func (c *client) Metadata(_ context.Context) (agentsdk.Metadata, error) { - return c.metadata, nil +func (c *client) Manifest(_ context.Context) (agentsdk.Manifest, error) { + return c.manifest, nil } func (c *client) Listen(_ context.Context) (net.Conn, error) { @@ -246,6 +246,10 @@ func (*client) PostAppHealth(_ context.Context, _ agentsdk.PostAppHealthsRequest return nil } +func (*client) PostMetadata(_ context.Context, _ string, _ agentsdk.PostMetadataRequest) error { + return nil +} + func (*client) PostStartup(_ context.Context, _ agentsdk.PostStartupRequest) error { return nil } diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 1796baea60..db6c839c5a 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -65,37 +65,56 @@ func (c *Client) GitSSHKey(ctx context.Context) (GitSSHKey, error) { return gitSSHKey, json.NewDecoder(res.Body).Decode(&gitSSHKey) } -type Metadata struct { +// In the future, we may want to support sending back multiple values for +// performance. +type PostMetadataRequest = codersdk.WorkspaceAgentMetadataResult + +func (c *Client) PostMetadata(ctx context.Context, key string, req PostMetadataRequest) error { + res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/metadata/"+key, req) + if err != nil { + return xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return codersdk.ReadBodyAsError(res) + } + + return nil +} + +type Manifest struct { // GitAuthConfigs stores the number of Git configurations // the Coder deployment has. If this number is >0, we // set up special configuration in the workspace. - GitAuthConfigs int `json:"git_auth_configs"` - VSCodePortProxyURI string `json:"vscode_port_proxy_uri"` - Apps []codersdk.WorkspaceApp `json:"apps"` - DERPMap *tailcfg.DERPMap `json:"derpmap"` - EnvironmentVariables map[string]string `json:"environment_variables"` - StartupScript string `json:"startup_script"` - StartupScriptTimeout time.Duration `json:"startup_script_timeout"` - Directory string `json:"directory"` - MOTDFile string `json:"motd_file"` - ShutdownScript string `json:"shutdown_script"` - ShutdownScriptTimeout time.Duration `json:"shutdown_script_timeout"` + GitAuthConfigs int `json:"git_auth_configs"` + VSCodePortProxyURI string `json:"vscode_port_proxy_uri"` + Apps []codersdk.WorkspaceApp `json:"apps"` + DERPMap *tailcfg.DERPMap `json:"derpmap"` + EnvironmentVariables map[string]string `json:"environment_variables"` + StartupScript string `json:"startup_script"` + StartupScriptTimeout time.Duration `json:"startup_script_timeout"` + Directory string `json:"directory"` + MOTDFile string `json:"motd_file"` + ShutdownScript string `json:"shutdown_script"` + ShutdownScriptTimeout time.Duration `json:"shutdown_script_timeout"` + Metadata []codersdk.WorkspaceAgentMetadataDescription `json:"metadata"` } -// Metadata fetches metadata for the currently authenticated workspace agent. -func (c *Client) Metadata(ctx context.Context) (Metadata, error) { - res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil) +// Manifest fetches manifest for the currently authenticated workspace agent. +func (c *Client) Manifest(ctx context.Context) (Manifest, error) { + res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/manifest", nil) if err != nil { - return Metadata{}, err + return Manifest{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return Metadata{}, codersdk.ReadBodyAsError(res) + return Manifest{}, codersdk.ReadBodyAsError(res) } - var agentMeta Metadata + var agentMeta Manifest err = json.NewDecoder(res.Body).Decode(&agentMeta) if err != nil { - return Metadata{}, err + return Manifest{}, err } accessingPort := c.SDK.URL.Port() if accessingPort == "" { @@ -106,14 +125,14 @@ func (c *Client) Metadata(ctx context.Context) (Metadata, error) { } accessPort, err := strconv.Atoi(accessingPort) if err != nil { - return Metadata{}, xerrors.Errorf("convert accessing port %q: %w", accessingPort, err) + return Manifest{}, xerrors.Errorf("convert accessing port %q: %w", accessingPort, err) } // Agents can provide an arbitrary access URL that may be different // that the globally configured one. This breaks the built-in DERP, // which would continue to reference the global access URL. // // This converts all built-in DERPs to use the access URL that the - // metadata request was performed with. + // manifest request was performed with. for _, region := range agentMeta.DERPMap.Regions { if !region.EmbeddedRelay { continue diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index cf2d1687f3..706ea1ec6a 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -19,6 +19,7 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" + "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/tailnet" "github.com/coder/retry" ) @@ -75,6 +76,31 @@ var WorkspaceAgentLifecycleOrder = []WorkspaceAgentLifecycle{ WorkspaceAgentLifecycleOff, } +type WorkspaceAgentMetadataResult struct { + CollectedAt time.Time `json:"collected_at" format:"date-time"` + // Age is the number of seconds since the metadata was collected. + // It is provided in addition to CollectedAt to protect against clock skew. + Age int64 `json:"age"` + Value string `json:"value"` + Error string `json:"error"` +} + +// WorkspaceAgentMetadataDescription is a description of dynamic metadata the agent should report +// back to coderd. It is provided via the `metadata` list in the `coder_agent` +// block. +type WorkspaceAgentMetadataDescription struct { + DisplayName string `json:"display_name"` + Key string `json:"key"` + Script string `json:"script"` + Interval int64 `json:"interval"` + Timeout int64 `json:"timeout"` +} + +type WorkspaceAgentMetadata struct { + Result WorkspaceAgentMetadataResult `json:"result"` + Description WorkspaceAgentMetadataDescription `json:"description"` +} + type WorkspaceAgent struct { ID uuid.UUID `json:"id" format:"uuid"` CreatedAt time.Time `json:"created_at" format:"date-time"` @@ -258,6 +284,75 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti return agentConn, nil } +// WatchWorkspaceAgentMetadata watches the metadata of a workspace agent. +// The returned channel will be closed when the context is canceled. Exactly +// one error will be sent on the error channel. The metadata channel is never closed. +func (c *Client) WatchWorkspaceAgentMetadata(ctx context.Context, id uuid.UUID) (<-chan []WorkspaceAgentMetadata, <-chan error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + metadataChan := make(chan []WorkspaceAgentMetadata, 256) + + watch := func() error { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/watch-metadata", id), nil) + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + + nextEvent := ServerSentEventReader(ctx, res.Body) + defer res.Body.Close() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + break + } + + sse, err := nextEvent() + if err != nil { + return err + } + + b, ok := sse.Data.([]byte) + if !ok { + return xerrors.Errorf("unexpected data type: %T", sse.Data) + } + + switch sse.Type { + case ServerSentEventTypeData: + var met []WorkspaceAgentMetadata + err = json.Unmarshal(b, &met) + if err != nil { + return xerrors.Errorf("unmarshal metadata: %w", err) + } + metadataChan <- met + case ServerSentEventTypeError: + var r Response + err = json.Unmarshal(b, &r) + if err != nil { + return xerrors.Errorf("unmarshal error: %w", err) + } + return xerrors.Errorf("%+v", r) + default: + return xerrors.Errorf("unexpected event type: %s", sse.Type) + } + } + } + + errorChan := make(chan error, 1) + go func() { + defer close(errorChan) + errorChan <- watch() + }() + + return metadataChan, errorChan +} + // WorkspaceAgent returns an agent by ID. func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s", id), nil) diff --git a/codersdk/workspaceagents_test.go b/codersdk/workspaceagents_test.go index a5b20d886a..6373160c29 100644 --- a/codersdk/workspaceagents_test.go +++ b/codersdk/workspaceagents_test.go @@ -25,7 +25,7 @@ func TestWorkspaceAgentMetadata(t *testing.T) { // This test ensures that the DERP map returned properly // mutates built-in DERPs with the client access URL. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.Metadata{ + httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.Manifest{ DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: { @@ -43,9 +43,9 @@ func TestWorkspaceAgentMetadata(t *testing.T) { parsed, err := url.Parse(srv.URL) require.NoError(t, err) client := agentsdk.New(parsed) - metadata, err := client.Metadata(context.Background()) + manifest, err := client.Manifest(context.Background()) require.NoError(t, err) - region := metadata.DERPMap.Regions[1] + region := manifest.DERPMap.Regions[1] require.True(t, region.EmbeddedRelay) require.Len(t, region.Nodes, 1) node := region.Nodes[0] diff --git a/docs/api/agents.md b/docs/api/agents.md index 7757a89c89..79ad8a5029 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -273,18 +273,18 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/gitsshkey \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get authorized workspace agent metadata +## Get authorized workspace agent manifest ### Code samples ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/metadata \ +curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /workspaceagents/me/metadata` +`GET /workspaceagents/me/manifest` ### Example responses @@ -368,6 +368,15 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/metadata \ "property2": "string" }, "git_auth_configs": 0, + "metadata": [ + { + "display_name": "string", + "interval": 0, + "key": "string", + "script": "string", + "timeout": 0 + } + ], "motd_file": "string", "shutdown_script": "string", "shutdown_script_timeout": 0, @@ -381,7 +390,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/metadata \ | Status | Meaning | Description | Schema | | ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.Metadata](schemas.md#agentsdkmetadata) | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.Manifest](schemas.md#agentsdkmanifest) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 2f76aef88b..0cde075705 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -94,7 +94,7 @@ | ---------------- | ------ | -------- | ------------ | ----------- | | `json_web_token` | string | true | | | -## agentsdk.Metadata +## agentsdk.Manifest ```json { @@ -174,6 +174,15 @@ "property2": "string" }, "git_auth_configs": 0, + "metadata": [ + { + "display_name": "string", + "interval": 0, + "key": "string", + "script": "string", + "timeout": 0 + } + ], "motd_file": "string", "shutdown_script": "string", "shutdown_script_timeout": 0, @@ -185,20 +194,21 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------------- | ------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `apps` | array of [codersdk.WorkspaceApp](#codersdkworkspaceapp) | false | | | -| `derpmap` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | -| `directory` | string | false | | | -| `environment_variables` | object | false | | | -| » `[any property]` | string | false | | | -| `git_auth_configs` | integer | false | | Git auth configs stores the number of Git configurations the Coder deployment has. If this number is >0, we set up special configuration in the workspace. | -| `motd_file` | string | false | | | -| `shutdown_script` | string | false | | | -| `shutdown_script_timeout` | integer | false | | | -| `startup_script` | string | false | | | -| `startup_script_timeout` | integer | false | | | -| `vscode_port_proxy_uri` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps` | array of [codersdk.WorkspaceApp](#codersdkworkspaceapp) | false | | | +| `derpmap` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | +| `directory` | string | false | | | +| `environment_variables` | object | false | | | +| » `[any property]` | string | false | | | +| `git_auth_configs` | integer | false | | Git auth configs stores the number of Git configurations the Coder deployment has. If this number is >0, we set up special configuration in the workspace. | +| `metadata` | array of [codersdk.WorkspaceAgentMetadataDescription](#codersdkworkspaceagentmetadatadescription) | false | | | +| `motd_file` | string | false | | | +| `shutdown_script` | string | false | | | +| `shutdown_script_timeout` | integer | false | | | +| `startup_script` | string | false | | | +| `startup_script_timeout` | integer | false | | | +| `vscode_port_proxy_uri` | string | false | | | ## agentsdk.PatchStartupLogs @@ -251,6 +261,26 @@ | ------- | -------------------------------------------------------------------- | -------- | ------------ | ----------- | | `state` | [codersdk.WorkspaceAgentLifecycle](#codersdkworkspaceagentlifecycle) | false | | | +## agentsdk.PostMetadataRequest + +```json +{ + "age": 0, + "collected_at": "2019-08-24T14:15:22Z", + "error": "string", + "value": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `age` | integer | false | | Age is the number of seconds since the metadata was collected. It is provided in addition to CollectedAt to protect against clock skew. | +| `collected_at` | string | false | | | +| `error` | string | false | | | +| `value` | string | false | | | + ## agentsdk.PostStartupRequest ```json @@ -4680,6 +4710,28 @@ Parameter represents a set value for the scope. | ------- | ------------------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ports` | array of [codersdk.WorkspaceAgentListeningPort](#codersdkworkspaceagentlisteningport) | false | | If there are no ports in the list, nothing should be displayed in the UI. There must not be a "no ports available" message or anything similar, as there will always be no ports displayed on platforms where our port detection logic is unsupported. | +## codersdk.WorkspaceAgentMetadataDescription + +```json +{ + "display_name": "string", + "interval": 0, + "key": "string", + "script": "string", + "timeout": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------- | -------- | ------------ | ----------- | +| `display_name` | string | false | | | +| `interval` | integer | false | | | +| `key` | string | false | | | +| `script` | string | false | | | +| `timeout` | integer | false | | | + ## codersdk.WorkspaceAgentStartupLog ```json diff --git a/docs/images/agent-metadata.png b/docs/images/agent-metadata.png new file mode 100644 index 0000000000000000000000000000000000000000..666ee2b0b9ef33c7db9dcedf758c1a45d1a751a0 GIT binary patch literal 49803 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

`Co0#=L^!eEO2S5R#- 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 +Agent metadata is in an alpha state and may break or disappear at any time. + + +![agent-metadata](../images/agent-metadata.png) + +With Agent Metadata, template admin can expose operational metrics from +their workspaces to their users. It is a sibling of [Resource Metadata](./resource-metadata.md). + +See the [Terraform reference](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/agent#metadata). + +## Examples + +All of these examples use [heredoc strings](https://developer.hashicorp.com/terraform/language/expressions/strings#heredoc-strings) for the script declaration. With heredoc strings you +can script without messy escape codes, just as if you were working in your terminal. + +Here are useful agent metadata snippets for Linux agents: + +```hcl +resource "coder_agent" "main" { + os = "linux" + ... + metadata { + display_name = "CPU Usage" + key = "cpu" + # calculates CPU usage by summing the "us", "sy" and "id" columns of + # vmstat. + script = <> + interval = 1 + timeout = 1 + } +} +``` + +## Utilities + +[vmstat](https://linux.die.net/man/8/vmstat) is available in most Linux +distributions and contains virtual memory, CPU and IO statistics. Running `vmstat` +produces output that looks like: + +``` +procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- +r b swpd free buff cache si so bi bo in cs us sy id wa st +0 0 19580 4781680 12133692 217646944 0 2 4 32 1 0 1 1 98 0 0 +``` + +[dstat](https://linux.die.net/man/1/dstat) is considerably more parseable +than `vmstat` but often not included in base images. It is easily installed by +most package managers under the name `dstat`. The output of running `dstat 1 1` looks +like: + +``` +--total-cpu-usage-- -dsk/total- -net/total- ---paging-- ---system-- +usr sys idl wai stl| read writ| recv send| in out | int csw +1 1 98 0 0|3422k 25M| 0 0 | 153k 904k| 123k 174k +``` diff --git a/docs/templates/resource-metadata.md b/docs/templates/resource-metadata.md index a5870a9925..ebfb360c9e 100644 --- a/docs/templates/resource-metadata.md +++ b/docs/templates/resource-metadata.md @@ -97,6 +97,28 @@ To make easier for you to customize your resource we added some built-in icons: We also have other icons related to the IDEs. You can see all the icons [here](https://github.com/coder/coder/tree/main/site/static/icon). +## Agent Metadata + +In cases where you want to present automatically updating, dynamic values. You +can use the `metadata` block in the `coder_agent` resource. For example: + +```hcl +resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + dir = "/workspace" + metadata { + name = "Process Count" + script = "ps aux | wc -l" + interval = 1 + timeout = 3 + } +} +``` + +Read more [here](./agent-metadata.md). + ## Up next - Learn about [secrets](../secrets.md) +- Learn about [Agent Metadata](../agent-metadata.md) diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 5cb07eefdf..0a0a3aaac5 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -14,6 +14,14 @@ import ( "github.com/coder/coder/provisionersdk/proto" ) +type agentMetadata struct { + Key string `mapstructure:"key"` + DisplayName string `mapstructure:"display_name"` + Script string `mapstructure:"script"` + Interval int64 `mapstructure:"interval"` + Timeout int64 `mapstructure:"timeout"` +} + // A mapping of attributes on the "coder_agent" resource. type agentAttributes struct { Auth string `mapstructure:"auth"` @@ -31,6 +39,7 @@ type agentAttributes struct { StartupScriptTimeoutSeconds int32 `mapstructure:"startup_script_timeout"` ShutdownScript string `mapstructure:"shutdown_script"` ShutdownScriptTimeoutSeconds int32 `mapstructure:"shutdown_script_timeout"` + Metadata []agentMetadata `mapstructure:"metadata"` } // A mapping of attributes on the "coder_app" resource. @@ -59,15 +68,15 @@ type appHealthcheckAttributes struct { } // A mapping of attributes on the "coder_metadata" resource. -type metadataAttributes struct { - ResourceID string `mapstructure:"resource_id"` - Hide bool `mapstructure:"hide"` - Icon string `mapstructure:"icon"` - DailyCost int32 `mapstructure:"daily_cost"` - Items []metadataItem `mapstructure:"item"` +type resourceMetadataAttributes struct { + ResourceID string `mapstructure:"resource_id"` + Hide bool `mapstructure:"hide"` + Icon string `mapstructure:"icon"` + DailyCost int32 `mapstructure:"daily_cost"` + Items []resourceMetadataItem `mapstructure:"item"` } -type metadataItem struct { +type resourceMetadataItem struct { Key string `mapstructure:"key"` Value string `mapstructure:"value"` Sensitive bool `mapstructure:"sensitive"` @@ -148,6 +157,17 @@ func ConvertState(modules []*tfjson.StateModule, rawGraph string, rawParameterNa loginBeforeReady = attrs.LoginBeforeReady } + var metadata []*proto.Agent_Metadata + for _, item := range attrs.Metadata { + metadata = append(metadata, &proto.Agent_Metadata{ + Key: item.Key, + DisplayName: item.DisplayName, + Script: item.Script, + Interval: item.Interval, + Timeout: item.Timeout, + }) + } + agent := &proto.Agent{ Name: tfResource.Name, Id: attrs.ID, @@ -163,6 +183,7 @@ func ConvertState(modules []*tfjson.StateModule, rawGraph string, rawParameterNa StartupScriptTimeoutSeconds: attrs.StartupScriptTimeoutSeconds, ShutdownScript: attrs.ShutdownScript, ShutdownScriptTimeoutSeconds: attrs.ShutdownScriptTimeoutSeconds, + Metadata: metadata, } switch attrs.Auth { case "token": @@ -356,7 +377,7 @@ func ConvertState(modules []*tfjson.StateModule, rawGraph string, rawParameterNa continue } - var attrs metadataAttributes + var attrs resourceMetadataAttributes err = mapstructure.Decode(resource.AttributeValues, &attrs) if err != nil { return nil, xerrors.Errorf("decode metadata attributes: %w", err) diff --git a/provisioner/terraform/resources_test.go b/provisioner/terraform/resources_test.go index 9e81de3b25..34ab787b89 100644 --- a/provisioner/terraform/resources_test.go +++ b/provisioner/terraform/resources_test.go @@ -221,6 +221,23 @@ func TestConvertResources(t *testing.T) { Value: "squirrel", Sensitive: true, }}, + Agents: []*proto.Agent{{ + Name: "main", + Auth: &proto.Agent_Token{}, + OperatingSystem: "linux", + Architecture: "amd64", + Metadata: []*proto.Agent_Metadata{{ + Key: "process_count", + DisplayName: "Process Count", + Script: "ps -ef | wc -l", + Interval: 5, + Timeout: 1, + }}, + ShutdownScriptTimeoutSeconds: 300, + StartupScriptTimeoutSeconds: 300, + LoginBeforeReady: true, + ConnectionTimeoutSeconds: 120, + }}, }}, }, // Tests that resources with the same id correctly get metadata applied diff --git a/provisioner/terraform/testdata/calling-module/calling-module.tfplan.json b/provisioner/terraform/testdata/calling-module/calling-module.tfplan.json index 363b4f52fd..be7a0aaaca 100644 --- a/provisioner/terraform/testdata/calling-module/calling-module.tfplan.json +++ b/provisioner/terraform/testdata/calling-module/calling-module.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.1", - "terraform_version": "1.3.6", + "terraform_version": "1.3.7", "planned_values": { "root_module": { "resources": [ diff --git a/provisioner/terraform/testdata/calling-module/calling-module.tfstate.json b/provisioner/terraform/testdata/calling-module/calling-module.tfstate.json index fc8751b6e6..eb44278efa 100644 --- a/provisioner/terraform/testdata/calling-module/calling-module.tfstate.json +++ b/provisioner/terraform/testdata/calling-module/calling-module.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.3.6", + "terraform_version": "1.3.7", "values": { "root_module": { "resources": [ @@ -17,11 +17,11 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "411bdd93-0ea4-4376-a032-52b1fbf44ca5", + "id": "2d84db09-c3c3-4272-8119-1847e68e2dbe", "init_script": "", "os": "linux", "startup_script": null, - "token": "eeac85aa-19f9-4a50-8002-dfd11556081b", + "token": "d5d46d6a-5e4b-493f-9b68-dd8c78727cac", "troubleshooting_url": null }, "sensitive_values": {} @@ -46,7 +46,7 @@ "outputs": { "script": "" }, - "random": "5816533441722838433" + "random": "6336115403873146745" }, "sensitive_values": { "inputs": {}, @@ -61,7 +61,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "5594550025354402054", + "id": "3671991160538447687", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfplan.json b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfplan.json index e3e1fe4440..9097d4a673 100644 --- a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfplan.json +++ b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.1", - "terraform_version": "1.3.6", + "terraform_version": "1.3.7", "planned_values": { "root_module": { "resources": [ diff --git a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfstate.json b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfstate.json index a55aa267bf..75f44e5266 100644 --- a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfstate.json +++ b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.3.6", + "terraform_version": "1.3.7", "values": { "root_module": { "resources": [ @@ -17,11 +17,11 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "4dc52ff5-b270-47a2-8b6a-695b4872f07b", + "id": "36c5629c-8ac4-4e6b-ae6e-94b8f1bbb0bc", "init_script": "", "os": "linux", "startup_script": null, - "token": "c5c8378e-66df-4f3f-94a2-84bff1dc6fc9", + "token": "9ed706cc-8b11-4b05-b6c3-ea943af2c60c", "troubleshooting_url": null }, "sensitive_values": {} @@ -34,7 +34,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "7372487656283423086", + "id": "4876594853562919335", "triggers": null }, "sensitive_values": {}, @@ -51,7 +51,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "2553224683756509362", + "id": "3012307200839131913", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfplan.json b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfplan.json index 89793191c8..ff45f741cf 100644 --- a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfplan.json +++ b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.1", - "terraform_version": "1.3.6", + "terraform_version": "1.3.7", "planned_values": { "root_module": { "resources": [ diff --git a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfstate.json b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfstate.json index e696c33fea..ef172db9b6 100644 --- a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfstate.json +++ b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.3.6", + "terraform_version": "1.3.7", "values": { "root_module": { "resources": [ @@ -17,11 +17,11 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "3cd9cbba-31f7-482c-a8a0-bf39dfe42dc2", + "id": "a79d53f2-f616-4be2-b1bf-6f6ce7409e23", "init_script": "", "os": "linux", "startup_script": null, - "token": "8b063f22-9e66-4dbf-9f13-7b09ac2a470f", + "token": "bb333041-5225-490d-86a0-69d1b5afd0db", "troubleshooting_url": null }, "sensitive_values": {} @@ -34,7 +34,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "3370347998754925285", + "id": "3651093174168180448", "triggers": null }, "sensitive_values": {}, @@ -50,7 +50,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "4707694957868093590", + "id": "3820948743929828676", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfstate.json b/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfstate.json index 268ed84b45..6116c9d093 100644 --- a/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfstate.json +++ b/provisioner/terraform/testdata/git-auth-providers/git-auth-providers.tfstate.json @@ -17,7 +17,7 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "78b29f93-097d-403b-ab56-0bc943d427cc", + "id": "7060319c-a8bf-44b7-8fb9-5a4dc3bd35fe", "init_script": "", "login_before_ready": true, "motd_file": null, @@ -26,7 +26,7 @@ "shutdown_script_timeout": 300, "startup_script": null, "startup_script_timeout": 300, - "token": "a57838e5-355c-471a-9a85-f81314fbaec6", + "token": "9ea68982-fd51-4510-9c23-9e9ad1ce1ef7", "troubleshooting_url": null }, "sensitive_values": {} @@ -65,7 +65,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "1416347524569828366", + "id": "4918728983070449527", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/instance-id/instance-id.tfplan.json b/provisioner/terraform/testdata/instance-id/instance-id.tfplan.json index 6bafb713b3..f54a0d003e 100644 --- a/provisioner/terraform/testdata/instance-id/instance-id.tfplan.json +++ b/provisioner/terraform/testdata/instance-id/instance-id.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.1", - "terraform_version": "1.3.6", + "terraform_version": "1.3.7", "planned_values": { "root_module": { "resources": [ diff --git a/provisioner/terraform/testdata/instance-id/instance-id.tfstate.json b/provisioner/terraform/testdata/instance-id/instance-id.tfstate.json index 0344e88948..cc8870100a 100644 --- a/provisioner/terraform/testdata/instance-id/instance-id.tfstate.json +++ b/provisioner/terraform/testdata/instance-id/instance-id.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.3.6", + "terraform_version": "1.3.7", "values": { "root_module": { "resources": [ @@ -17,11 +17,11 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "36189f12-6eed-4094-9179-6584a8659219", + "id": "facfb2ad-417d-412c-8304-ce50ff8a886e", "init_script": "", "os": "linux", "startup_script": null, - "token": "907fa482-fd3b-44be-8cfb-4515e3122e78", + "token": "f1b3312a-af1c-45a8-9695-23d92c4cb68d", "troubleshooting_url": null }, "sensitive_values": {} @@ -34,8 +34,8 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "36189f12-6eed-4094-9179-6584a8659219", - "id": "c9bd849e-ac37-440b-9c5b-a288344be41c", + "agent_id": "facfb2ad-417d-412c-8304-ce50ff8a886e", + "id": "6ce4c6f4-1b93-4b22-9b17-1dcb50a36e77", "instance_id": "example" }, "sensitive_values": {}, @@ -51,7 +51,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "4399071137990404376", + "id": "2761080386857837319", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfstate.json b/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfstate.json index a43e792388..91f08d1e25 100644 --- a/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfstate.json +++ b/provisioner/terraform/testdata/mapped-apps/mapped-apps.tfstate.json @@ -17,11 +17,11 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "5cb3c105-1a70-4c60-bd32-b75d9a60a98d", + "id": "441cfb02-3044-4d2b-ac2c-1ac150ec8c58", "init_script": "", "os": "linux", "startup_script": null, - "token": "b477690f-0a2d-4d9b-818e-7b60c845c44f", + "token": "75ee44b6-4cbb-4615-acb2-19c53f82dc58", "troubleshooting_url": null }, "sensitive_values": {} @@ -35,12 +35,12 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "5cb3c105-1a70-4c60-bd32-b75d9a60a98d", + "agent_id": "441cfb02-3044-4d2b-ac2c-1ac150ec8c58", "command": null, "display_name": "app1", "healthcheck": [], "icon": null, - "id": "72d41f36-d775-424c-9a05-b633da43cd58", + "id": "abbc4449-5dd1-4ee0-ac58-3055a6da206b", "name": null, "relative_path": null, "share": "owner", @@ -64,12 +64,12 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "5cb3c105-1a70-4c60-bd32-b75d9a60a98d", + "agent_id": "441cfb02-3044-4d2b-ac2c-1ac150ec8c58", "command": null, "display_name": "app2", "healthcheck": [], "icon": null, - "id": "810dbeda-3041-403d-86e8-146354ad2657", + "id": "ff87411e-4edb-48ce-b62f-6f792d02b25c", "name": null, "relative_path": null, "share": "owner", @@ -92,7 +92,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "1955786418433284816", + "id": "3464468981645058362", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfstate.json b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfstate.json index f4b5ff036e..ad6eb7118c 100644 --- a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfstate.json +++ b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tfstate.json @@ -17,7 +17,7 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "6b912abe-50d4-48b2-be7c-1464ca69b5b9", + "id": "7fd8bb3f-704e-4d85-aaaf-1928a9a4df83", "init_script": "", "login_before_ready": true, "motd_file": null, @@ -26,7 +26,7 @@ "shutdown_script_timeout": 300, "startup_script": null, "startup_script_timeout": 300, - "token": "d296a9cd-6f7c-4c6b-b2f3-7a647512efe8", + "token": "ebdd904c-a277-49ec-97cc-e29c7326b475", "troubleshooting_url": null }, "sensitive_values": {} @@ -44,7 +44,7 @@ "connection_timeout": 1, "dir": null, "env": null, - "id": "8a2956f7-d37b-441e-bf62-bd9a45316f6a", + "id": "cd35b6c2-3f81-4857-ac8c-9cc0d1d0f0ee", "init_script": "", "login_before_ready": true, "motd_file": "/etc/motd", @@ -53,7 +53,7 @@ "shutdown_script_timeout": 30, "startup_script": null, "startup_script_timeout": 30, - "token": "b1e0fba4-5bba-439f-b3ea-3f6a8ba4d301", + "token": "bbfc3bb1-31c8-42a2-bc5a-3f4ee95eea7b", "troubleshooting_url": null }, "sensitive_values": {} @@ -71,7 +71,7 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "819b1b19-a709-463e-9aeb-5e1321b7af23", + "id": "7407c159-30e7-4c7c-8187-3bf0f6805515", "init_script": "", "login_before_ready": false, "motd_file": null, @@ -80,7 +80,7 @@ "shutdown_script_timeout": 300, "startup_script": null, "startup_script_timeout": 300, - "token": "238ff017-12ae-403f-b3f8-4dea4dc87a7d", + "token": "080070d7-cb08-4634-aa05-7ee07a193441", "troubleshooting_url": "https://coder.com/troubleshoot" }, "sensitive_values": {} @@ -93,7 +93,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "5288433022262248914", + "id": "235602507221507275", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfplan.json b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfplan.json index 04c77276e3..9b10a6a2d5 100644 --- a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfplan.json +++ b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.1", - "terraform_version": "1.3.3", + "terraform_version": "1.3.7", "planned_values": { "root_module": { "resources": [ diff --git a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfstate.json b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfstate.json index 07c2f8ce17..1f27d05493 100644 --- a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfstate.json +++ b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.3.3", + "terraform_version": "1.3.7", "values": { "root_module": { "resources": [ @@ -17,11 +17,11 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "f911bd98-54fc-476a-aec1-df6e525630a9", + "id": "54519a12-e34b-4c4f-aef9-7dfac5f4949b", "init_script": "", "os": "linux", "startup_script": null, - "token": "fa05ad9c-2062-4707-a27f-12364c89641e", + "token": "bf339e89-0594-4f44-83f0-fc7cde9ceb0c", "troubleshooting_url": null }, "sensitive_values": {} @@ -34,12 +34,12 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "f911bd98-54fc-476a-aec1-df6e525630a9", + "agent_id": "54519a12-e34b-4c4f-aef9-7dfac5f4949b", "command": null, "display_name": null, "healthcheck": [], "icon": null, - "id": "038d0f6c-90b7-465b-915a-8a9f0cf21757", + "id": "13101247-bdf1-409e-81e2-51a4ff45576b", "name": null, "relative_path": null, "share": "owner", @@ -62,7 +62,7 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "f911bd98-54fc-476a-aec1-df6e525630a9", + "agent_id": "54519a12-e34b-4c4f-aef9-7dfac5f4949b", "command": null, "display_name": null, "healthcheck": [ @@ -73,7 +73,7 @@ } ], "icon": null, - "id": "c00ec121-a167-4418-8c4e-2ccae0a0cd6e", + "id": "ef508497-0437-43eb-b773-c0622582ab5d", "name": null, "relative_path": null, "share": "owner", @@ -98,12 +98,12 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 0, "values": { - "agent_id": "f911bd98-54fc-476a-aec1-df6e525630a9", + "agent_id": "54519a12-e34b-4c4f-aef9-7dfac5f4949b", "command": null, "display_name": null, "healthcheck": [], "icon": null, - "id": "e9226aa6-a1a6-42a7-8557-64620cbf3dc2", + "id": "2c187306-80cc-46ba-a75c-42d4648ff94a", "name": null, "relative_path": null, "share": "owner", @@ -126,7 +126,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "5577006791947779410", + "id": "1264552698255765246", "triggers": null }, "sensitive_values": {}, diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf index 07dcdffdac..1b8a4abea6 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.6.3" + version = "0.7.0" } } } @@ -10,9 +10,20 @@ terraform { resource "coder_agent" "main" { os = "linux" arch = "amd64" + metadata { + key = "process_count" + display_name = "Process Count" + script = "ps -ef | wc -l" + interval = 5 + timeout = 1 + } } -resource "null_resource" "about" {} +resource "null_resource" "about" { + depends_on = [ + coder_agent.main, + ] +} resource "coder_metadata" "about_info" { resource_id = null_resource.about.id diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.dot b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.dot index 4bac498b35..041734ac4b 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.dot +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.dot @@ -9,9 +9,8 @@ digraph { "[root] provider[\"registry.terraform.io/hashicorp/null\"]" [label = "provider[\"registry.terraform.io/hashicorp/null\"]", shape = "diamond"] "[root] coder_agent.main (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" "[root] coder_metadata.about_info (expand)" -> "[root] null_resource.about (expand)" - "[root] coder_metadata.about_info (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] null_resource.about (expand)" -> "[root] coder_agent.main (expand)" "[root] null_resource.about (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"]" - "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_agent.main (expand)" "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_metadata.about_info (expand)" "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" -> "[root] null_resource.about (expand)" "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.json b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.json index 6fc6591b82..9fae1dcf31 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.json +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.1", - "terraform_version": "1.3.3", + "terraform_version": "1.3.7", "planned_values": { "root_module": { "resources": [ @@ -17,11 +17,29 @@ "connection_timeout": 120, "dir": null, "env": null, + "login_before_ready": true, + "metadata": [ + { + "display_name": "Process Count", + "interval": 5, + "key": "process_count", + "script": "ps -ef | wc -l", + "timeout": 1 + } + ], + "motd_file": null, "os": "linux", + "shutdown_script": null, + "shutdown_script_timeout": 300, "startup_script": null, + "startup_script_timeout": 300, "troubleshooting_url": null }, - "sensitive_values": {} + "sensitive_values": { + "metadata": [ + {} + ] + } }, { "address": "coder_metadata.about_info", @@ -99,17 +117,37 @@ "connection_timeout": 120, "dir": null, "env": null, + "login_before_ready": true, + "metadata": [ + { + "display_name": "Process Count", + "interval": 5, + "key": "process_count", + "script": "ps -ef | wc -l", + "timeout": 1 + } + ], + "motd_file": null, "os": "linux", + "shutdown_script": null, + "shutdown_script_timeout": 300, "startup_script": null, + "startup_script_timeout": 300, "troubleshooting_url": null }, "after_unknown": { "id": true, "init_script": true, + "metadata": [ + {} + ], "token": true }, "before_sensitive": false, "after_sensitive": { + "metadata": [ + {} + ], "token": true } } @@ -208,7 +246,7 @@ "coder": { "name": "coder", "full_name": "registry.terraform.io/coder/coder", - "version_constraint": "0.6.3" + "version_constraint": "0.7.0-rc0" }, "null": { "name": "null", @@ -227,6 +265,25 @@ "arch": { "constant_value": "amd64" }, + "metadata": [ + { + "display_name": { + "constant_value": "Process Count" + }, + "interval": { + "constant_value": 5 + }, + "key": { + "constant_value": "process_count" + }, + "script": { + "constant_value": "ps -ef | wc -l" + }, + "timeout": { + "constant_value": 1 + } + } + ], "os": { "constant_value": "linux" } @@ -298,7 +355,10 @@ "type": "null_resource", "name": "about", "provider_config_key": "null", - "schema_version": 0 + "schema_version": 0, + "depends_on": [ + "coder_agent.main" + ] } ] } diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.dot b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.dot index 4bac498b35..041734ac4b 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.dot +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.dot @@ -9,9 +9,8 @@ digraph { "[root] provider[\"registry.terraform.io/hashicorp/null\"]" [label = "provider[\"registry.terraform.io/hashicorp/null\"]", shape = "diamond"] "[root] coder_agent.main (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" "[root] coder_metadata.about_info (expand)" -> "[root] null_resource.about (expand)" - "[root] coder_metadata.about_info (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] null_resource.about (expand)" -> "[root] coder_agent.main (expand)" "[root] null_resource.about (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"]" - "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_agent.main (expand)" "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_metadata.about_info (expand)" "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" -> "[root] null_resource.about (expand)" "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.json b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.json index 0b0e51e528..efcf3b98e1 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.json +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tfstate.json @@ -1,6 +1,6 @@ { "format_version": "1.0", - "terraform_version": "1.3.3", + "terraform_version": "1.3.7", "values": { "root_module": { "resources": [ @@ -17,14 +17,32 @@ "connection_timeout": 120, "dir": null, "env": null, - "id": "7766b2a9-c00f-4cde-9acc-1fc05651dbdf", + "id": "2090d6c6-c4c1-4219-b8f8-9df6db6d5864", "init_script": "", + "login_before_ready": true, + "metadata": [ + { + "display_name": "Process Count", + "interval": 5, + "key": "process_count", + "script": "ps -ef | wc -l", + "timeout": 1 + } + ], + "motd_file": null, "os": "linux", + "shutdown_script": null, + "shutdown_script_timeout": 300, "startup_script": null, - "token": "5e54c173-a813-4df0-b87d-0617082769dc", + "startup_script_timeout": 300, + "token": "a984d85d-eff6-4366-9658-9719fb3dd82e", "troubleshooting_url": null }, - "sensitive_values": {} + "sensitive_values": { + "metadata": [ + {} + ] + } }, { "address": "coder_metadata.about_info", @@ -37,7 +55,7 @@ "daily_cost": 29, "hide": true, "icon": "/icon/server.svg", - "id": "e43f1cd6-5dbb-4d6b-8942-37f914b37be5", + "id": "9e239cb2-e381-423a-bf16-74ece8254eff", "item": [ { "is_null": false, @@ -64,7 +82,7 @@ "value": "squirrel" } ], - "resource_id": "5577006791947779410" + "resource_id": "3104684855633455084" }, "sensitive_values": { "item": [ @@ -75,6 +93,7 @@ ] }, "depends_on": [ + "coder_agent.main", "null_resource.about" ] }, @@ -86,10 +105,13 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "5577006791947779410", + "id": "3104684855633455084", "triggers": null }, - "sensitive_values": {} + "sensitive_values": {}, + "depends_on": [ + "coder_agent.main" + ] } ] } diff --git a/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfplan.json b/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfplan.json index 899e1617f7..71b701abde 100644 --- a/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfplan.json +++ b/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfplan.json @@ -1,6 +1,6 @@ { "format_version": "1.1", - "terraform_version": "1.4.0", + "terraform_version": "1.3.7", "planned_values": { "root_module": { "resources": [ @@ -105,7 +105,7 @@ ], "prior_state": { "format_version": "1.0", - "terraform_version": "1.4.0", + "terraform_version": "1.3.7", "values": { "root_module": { "resources": [ @@ -120,7 +120,7 @@ "default": null, "description": null, "icon": null, - "id": "5820e575-2637-4830-b6a3-75f4c664b447", + "id": "857b1591-ee42-4ded-9804-783ffd1eb180", "legacy_variable": null, "legacy_variable_name": null, "mutable": false, @@ -162,7 +162,7 @@ "default": "ok", "description": "blah blah", "icon": null, - "id": "9cac2300-0618-45f6-97d9-2f0395b1a0b4", + "id": "1477c44d-b36a-48cd-9942-0b532f1791db", "legacy_variable": null, "legacy_variable_name": null, "mutable": false, diff --git a/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfstate.json b/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfstate.json index 38a1aed88f..268a678601 100644 --- a/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfstate.json +++ b/provisioner/terraform/testdata/rich-parameters/rich-parameters.tfstate.json @@ -1,9 +1,36 @@ { "format_version": "1.0", - "terraform_version": "1.4.0", + "terraform_version": "1.3.7", "values": { "root_module": { "resources": [ + { + "address": "coder_agent.dev", + "mode": "managed", + "type": "coder_agent", + "name": "dev", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "arch": "arm64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "id": "c2221717-e813-49f0-a655-4cb7aa5265e2", + "init_script": "", + "login_before_ready": true, + "motd_file": null, + "os": "windows", + "shutdown_script": null, + "shutdown_script_timeout": 300, + "startup_script": null, + "startup_script_timeout": 300, + "token": "fdb94db8-fca1-4a13-bbcb-73bfaec95b77", + "troubleshooting_url": null + }, + "sensitive_values": {} + }, { "address": "data.coder_parameter.example", "mode": "data", @@ -15,7 +42,7 @@ "default": null, "description": null, "icon": null, - "id": "30b8b963-684d-4c11-9230-a06b81473f6f", + "id": "f5f644c9-cb0c-47b1-8e02-d9f6fa99b935", "legacy_variable": null, "legacy_variable_name": null, "mutable": false, @@ -57,7 +84,7 @@ "default": "ok", "description": "blah blah", "icon": null, - "id": "c40e87d2-7694-40f7-8b7d-30dbf14dd0c0", + "id": "e2944252-1c30-43c8-9ce3-53a9755030dc", "legacy_variable": null, "legacy_variable_name": null, "mutable": false, @@ -70,33 +97,6 @@ }, "sensitive_values": {} }, - { - "address": "coder_agent.dev", - "mode": "managed", - "type": "coder_agent", - "name": "dev", - "provider_name": "registry.terraform.io/coder/coder", - "schema_version": 0, - "values": { - "arch": "arm64", - "auth": "token", - "connection_timeout": 120, - "dir": null, - "env": null, - "id": "775b9977-421e-4d4d-8c02-dc38958259e3", - "init_script": "", - "login_before_ready": true, - "motd_file": null, - "os": "windows", - "shutdown_script": null, - "shutdown_script_timeout": 300, - "startup_script": null, - "startup_script_timeout": 300, - "token": "927e1872-90d0-43a2-9a55-a8438ead0ad3", - "troubleshooting_url": null - }, - "sensitive_values": {} - }, { "address": "null_resource.dev", "mode": "managed", @@ -105,7 +105,7 @@ "provider_name": "registry.terraform.io/hashicorp/null", "schema_version": 0, "values": { - "id": "3727779938861599093", + "id": "5032149403215603103", "triggers": null }, "sensitive_values": {}, diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index 013f5fa054..e8061dc426 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -1252,14 +1252,15 @@ type Agent struct { // // *Agent_Token // *Agent_InstanceId - Auth isAgent_Auth `protobuf_oneof:"auth"` - ConnectionTimeoutSeconds int32 `protobuf:"varint,11,opt,name=connection_timeout_seconds,json=connectionTimeoutSeconds,proto3" json:"connection_timeout_seconds,omitempty"` - TroubleshootingUrl string `protobuf:"bytes,12,opt,name=troubleshooting_url,json=troubleshootingUrl,proto3" json:"troubleshooting_url,omitempty"` - MotdFile string `protobuf:"bytes,13,opt,name=motd_file,json=motdFile,proto3" json:"motd_file,omitempty"` - LoginBeforeReady bool `protobuf:"varint,14,opt,name=login_before_ready,json=loginBeforeReady,proto3" json:"login_before_ready,omitempty"` - StartupScriptTimeoutSeconds int32 `protobuf:"varint,15,opt,name=startup_script_timeout_seconds,json=startupScriptTimeoutSeconds,proto3" json:"startup_script_timeout_seconds,omitempty"` - ShutdownScript string `protobuf:"bytes,16,opt,name=shutdown_script,json=shutdownScript,proto3" json:"shutdown_script,omitempty"` - ShutdownScriptTimeoutSeconds int32 `protobuf:"varint,17,opt,name=shutdown_script_timeout_seconds,json=shutdownScriptTimeoutSeconds,proto3" json:"shutdown_script_timeout_seconds,omitempty"` + Auth isAgent_Auth `protobuf_oneof:"auth"` + ConnectionTimeoutSeconds int32 `protobuf:"varint,11,opt,name=connection_timeout_seconds,json=connectionTimeoutSeconds,proto3" json:"connection_timeout_seconds,omitempty"` + TroubleshootingUrl string `protobuf:"bytes,12,opt,name=troubleshooting_url,json=troubleshootingUrl,proto3" json:"troubleshooting_url,omitempty"` + MotdFile string `protobuf:"bytes,13,opt,name=motd_file,json=motdFile,proto3" json:"motd_file,omitempty"` + LoginBeforeReady bool `protobuf:"varint,14,opt,name=login_before_ready,json=loginBeforeReady,proto3" json:"login_before_ready,omitempty"` + StartupScriptTimeoutSeconds int32 `protobuf:"varint,15,opt,name=startup_script_timeout_seconds,json=startupScriptTimeoutSeconds,proto3" json:"startup_script_timeout_seconds,omitempty"` + ShutdownScript string `protobuf:"bytes,16,opt,name=shutdown_script,json=shutdownScript,proto3" json:"shutdown_script,omitempty"` + ShutdownScriptTimeoutSeconds int32 `protobuf:"varint,17,opt,name=shutdown_script_timeout_seconds,json=shutdownScriptTimeoutSeconds,proto3" json:"shutdown_script_timeout_seconds,omitempty"` + Metadata []*Agent_Metadata `protobuf:"bytes,18,rep,name=metadata,proto3" json:"metadata,omitempty"` } func (x *Agent) Reset() { @@ -1420,6 +1421,13 @@ func (x *Agent) GetShutdownScriptTimeoutSeconds() int32 { return 0 } +func (x *Agent) GetMetadata() []*Agent_Metadata { + if x != nil { + return x.Metadata + } + return nil +} + type isAgent_Auth interface { isAgent_Auth() } @@ -1797,6 +1805,85 @@ func (*Provision) Descriptor() ([]byte, []int) { return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{18} } +type Agent_Metadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + Script string `protobuf:"bytes,3,opt,name=script,proto3" json:"script,omitempty"` + Interval int64 `protobuf:"varint,4,opt,name=interval,proto3" json:"interval,omitempty"` + Timeout int64 `protobuf:"varint,5,opt,name=timeout,proto3" json:"timeout,omitempty"` +} + +func (x *Agent_Metadata) Reset() { + *x = Agent_Metadata{} + if protoimpl.UnsafeEnabled { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Agent_Metadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Agent_Metadata) ProtoMessage() {} + +func (x *Agent_Metadata) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[19] + 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 Agent_Metadata.ProtoReflect.Descriptor instead. +func (*Agent_Metadata) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{13, 0} +} + +func (x *Agent_Metadata) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *Agent_Metadata) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *Agent_Metadata) GetScript() string { + if x != nil { + return x.Script + } + return "" +} + +func (x *Agent_Metadata) GetInterval() int64 { + if x != nil { + return x.Interval + } + return 0 +} + +func (x *Agent_Metadata) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + type Resource_Metadata struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1811,7 +1898,7 @@ type Resource_Metadata struct { func (x *Resource_Metadata) Reset() { *x = Resource_Metadata{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[20] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1824,7 +1911,7 @@ func (x *Resource_Metadata) String() string { func (*Resource_Metadata) ProtoMessage() {} func (x *Resource_Metadata) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[20] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[21] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1879,7 +1966,7 @@ type Parse_Request struct { func (x *Parse_Request) Reset() { *x = Parse_Request{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[21] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1892,7 +1979,7 @@ func (x *Parse_Request) String() string { func (*Parse_Request) ProtoMessage() {} func (x *Parse_Request) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[21] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[22] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1927,7 +2014,7 @@ type Parse_Complete struct { func (x *Parse_Complete) Reset() { *x = Parse_Complete{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[22] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1940,7 +2027,7 @@ func (x *Parse_Complete) String() string { func (*Parse_Complete) ProtoMessage() {} func (x *Parse_Complete) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[22] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[23] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1985,7 +2072,7 @@ type Parse_Response struct { func (x *Parse_Response) Reset() { *x = Parse_Response{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[23] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1998,7 +2085,7 @@ func (x *Parse_Response) String() string { func (*Parse_Response) ProtoMessage() {} func (x *Parse_Response) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[23] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[24] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2071,7 +2158,7 @@ type Provision_Metadata struct { func (x *Provision_Metadata) Reset() { *x = Provision_Metadata{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[24] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2084,7 +2171,7 @@ func (x *Provision_Metadata) String() string { func (*Provision_Metadata) ProtoMessage() {} func (x *Provision_Metadata) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[24] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[25] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2186,7 +2273,7 @@ type Provision_Config struct { func (x *Provision_Config) Reset() { *x = Provision_Config{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[25] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2199,7 +2286,7 @@ func (x *Provision_Config) String() string { func (*Provision_Config) ProtoMessage() {} func (x *Provision_Config) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[25] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[26] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2258,7 +2345,7 @@ type Provision_Plan struct { func (x *Provision_Plan) Reset() { *x = Provision_Plan{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[26] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2271,7 +2358,7 @@ func (x *Provision_Plan) String() string { func (*Provision_Plan) ProtoMessage() {} func (x *Provision_Plan) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[26] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[27] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2334,7 +2421,7 @@ type Provision_Apply struct { func (x *Provision_Apply) Reset() { *x = Provision_Apply{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[27] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2347,7 +2434,7 @@ func (x *Provision_Apply) String() string { func (*Provision_Apply) ProtoMessage() {} func (x *Provision_Apply) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[27] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[28] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2386,7 +2473,7 @@ type Provision_Cancel struct { func (x *Provision_Cancel) Reset() { *x = Provision_Cancel{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[28] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2399,7 +2486,7 @@ func (x *Provision_Cancel) String() string { func (*Provision_Cancel) ProtoMessage() {} func (x *Provision_Cancel) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[28] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[29] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2431,7 +2518,7 @@ type Provision_Request struct { func (x *Provision_Request) Reset() { *x = Provision_Request{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[29] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2444,7 +2531,7 @@ func (x *Provision_Request) String() string { func (*Provision_Request) ProtoMessage() {} func (x *Provision_Request) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[29] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[30] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2526,7 +2613,7 @@ type Provision_Complete struct { func (x *Provision_Complete) Reset() { *x = Provision_Complete{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[30] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2539,7 +2626,7 @@ func (x *Provision_Complete) String() string { func (*Provision_Complete) ProtoMessage() {} func (x *Provision_Complete) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[30] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[31] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2612,7 +2699,7 @@ type Provision_Response struct { func (x *Provision_Response) Reset() { *x = Provision_Response{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[31] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2625,7 +2712,7 @@ func (x *Provision_Response) String() string { func (*Provision_Response) ProtoMessage() {} func (x *Provision_Response) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[31] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[32] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2827,7 +2914,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x22, 0xfe, 0x05, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x22, 0xc7, 0x07, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2d, 0x0a, 0x03, 0x65, 0x6e, 0x76, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, @@ -2871,210 +2958,223 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x05, 0x52, 0x1c, 0x73, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, - 0x73, 0x1a, 0x36, 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x61, 0x75, 0x74, - 0x68, 0x22, 0xb5, 0x02, 0x0a, 0x03, 0x41, 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, - 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x21, 0x0a, - 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, - 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, - 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, - 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x3a, - 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x0b, 0x68, - 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x41, 0x0a, 0x0d, 0x73, 0x68, - 0x61, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, - 0x0c, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x0a, - 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x22, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, - 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, - 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e, - 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, - 0x6f, 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, - 0x68, 0x6f, 0x6c, 0x64, 0x22, 0xf1, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x69, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x04, 0x68, 0x69, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0c, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1d, - 0x0a, 0x0a, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x09, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x1a, 0x69, 0x0a, - 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12, - 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, 0xcb, 0x02, 0x0a, 0x05, 0x50, 0x61, 0x72, - 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, - 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x1a, 0xa3, 0x01, 0x0a, 0x08, - 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x4c, 0x0a, 0x12, 0x74, 0x65, 0x6d, 0x70, - 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, - 0x62, 0x6c, 0x65, 0x52, 0x11, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, - 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x12, 0x49, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, - 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, - 0x73, 0x1a, 0x73, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, - 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, - 0x6c, 0x6f, 0x67, 0x12, 0x39, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, - 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, - 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x90, 0x0d, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xeb, 0x03, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, - 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, - 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, - 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, - 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, - 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, - 0x65, 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, - 0x6e, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x65, 0x6d, 0x70, - 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x29, 0x0a, - 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, - 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x48, 0x0a, 0x21, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6f, 0x69, 0x64, 0x63, - 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0a, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x1d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, - 0x6e, 0x65, 0x72, 0x4f, 0x69, 0x64, 0x63, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x1a, 0xad, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1c, 0x0a, - 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, - 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, - 0x65, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x32, - 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x5f, 0x6c, 0x6f, - 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, - 0x65, 0x6c, 0x1a, 0xeb, 0x02, 0x0a, 0x04, 0x50, 0x6c, 0x61, 0x6e, 0x12, 0x35, 0x0a, 0x06, 0x63, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, - 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, - 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x53, 0x0a, 0x15, 0x72, 0x69, - 0x63, 0x68, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, - 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x13, 0x72, 0x69, 0x63, 0x68, - 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, - 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, - 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x73, 0x12, 0x4a, 0x0a, 0x12, 0x67, 0x69, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, - 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x47, - 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x10, - 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, - 0x1a, 0x52, 0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, - 0x70, 0x6c, 0x61, 0x6e, 0x1a, 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0xb3, - 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x70, 0x6c, - 0x61, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x34, 0x0a, - 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, + 0x73, 0x12, 0x37, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x12, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x8d, 0x01, 0x0a, 0x08, 0x4d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, + 0x12, 0x18, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x1a, 0x36, 0x0a, 0x08, 0x45, 0x6e, + 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, + 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x22, 0xb5, 0x02, 0x0a, 0x03, 0x41, + 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, + 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, + 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x75, 0x62, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x75, + 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x3a, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, + 0x65, 0x63, 0x6b, 0x12, 0x41, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x6c, + 0x65, 0x76, 0x65, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, + 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x0c, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x22, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, + 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, + 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0xf1, 0x02, + 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x3a, + 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x69, + 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x68, 0x69, 0x64, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, + 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x69, 0x6e, 0x73, 0x74, 0x61, + 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x61, 0x69, 0x6c, 0x79, + 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x64, 0x61, 0x69, + 0x6c, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x1a, 0x69, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, + 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, + 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6e, + 0x75, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, + 0x6c, 0x22, 0xcb, 0x02, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x79, 0x1a, 0xa3, 0x01, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, + 0x65, 0x12, 0x4c, 0x0a, 0x12, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x61, + 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, 0x74, 0x65, + 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x12, + 0x49, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, + 0x65, 0x6d, 0x61, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, + 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, + 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x1a, 0x73, 0x0a, 0x08, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x39, 0x0a, 0x08, + 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, + 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, + 0x73, 0x65, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, + 0x90, 0x0d, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xeb, 0x03, + 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, + 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, + 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, + 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, + 0x15, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, + 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, + 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, + 0x74, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x48, 0x0a, 0x21, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, + 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6f, 0x69, 0x64, 0x63, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1d, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x4f, 0x69, 0x64, 0x63, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x1a, 0xad, 0x01, 0x0a, 0x06, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, - 0x70, 0x6c, 0x79, 0x12, 0x37, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, - 0x65, 0x6c, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x42, 0x06, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x1a, 0xe9, 0x01, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, - 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x73, 0x12, 0x3a, 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, - 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, - 0x65, 0x72, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2c, - 0x0a, 0x12, 0x67, 0x69, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, - 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x12, 0x0a, 0x04, - 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, - 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, - 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, - 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, - 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a, 0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, - 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x00, - 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, - 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, - 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, 0x0f, 0x41, 0x70, - 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, - 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, - 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, - 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x2a, 0x37, 0x0a, 0x13, 0x57, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x09, - 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x54, 0x4f, - 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10, 0x02, - 0x32, 0xa3, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x12, 0x42, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x30, 0x01, 0x12, 0x50, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x73, 0x64, 0x6b, 0x2f, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x1a, 0xeb, 0x02, 0x0a, 0x04, + 0x50, 0x6c, 0x61, 0x6e, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x46, 0x0a, 0x10, 0x70, + 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x12, 0x53, 0x0a, 0x15, 0x72, 0x69, 0x63, 0x68, 0x5f, 0x70, 0x61, 0x72, 0x61, + 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x13, 0x72, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, + 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, + 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, + 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x4a, 0x0a, + 0x12, 0x67, 0x69, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x47, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x1a, 0x52, 0x0a, 0x05, 0x41, 0x70, 0x70, + 0x6c, 0x79, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6c, 0x61, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x1a, 0x08, 0x0a, + 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0xb3, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x48, 0x00, + 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x34, 0x0a, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x41, 0x70, + 0x70, 0x6c, 0x79, 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x37, 0x0a, 0x06, + 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x48, 0x00, 0x52, 0x06, 0x63, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x1a, 0xe9, 0x01, + 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x3a, 0x0a, 0x0a, 0x70, + 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, + 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0a, 0x70, 0x61, 0x72, + 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x67, 0x69, 0x74, 0x5f, 0x61, + 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, + 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x2a, 0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, + 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, + 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, + 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, + 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, 0x0f, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, + 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, + 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, + 0x2a, 0x37, 0x0a, 0x13, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, + 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, + 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x54, 0x4f, 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, + 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10, 0x02, 0x32, 0xa3, 0x01, 0x0a, 0x0b, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x05, 0x50, 0x61, 0x72, + 0x73, 0x65, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, + 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, + 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x50, 0x0a, + 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, + 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -3090,7 +3190,7 @@ func file_provisionersdk_proto_provisioner_proto_rawDescGZIP() []byte { } var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 6) -var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ (LogLevel)(0), // 0: provisioner.LogLevel (AppSharingLevel)(0), // 1: provisioner.AppSharingLevel @@ -3117,19 +3217,20 @@ var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ (*Resource)(nil), // 22: provisioner.Resource (*Parse)(nil), // 23: provisioner.Parse (*Provision)(nil), // 24: provisioner.Provision - nil, // 25: provisioner.Agent.EnvEntry - (*Resource_Metadata)(nil), // 26: provisioner.Resource.Metadata - (*Parse_Request)(nil), // 27: provisioner.Parse.Request - (*Parse_Complete)(nil), // 28: provisioner.Parse.Complete - (*Parse_Response)(nil), // 29: provisioner.Parse.Response - (*Provision_Metadata)(nil), // 30: provisioner.Provision.Metadata - (*Provision_Config)(nil), // 31: provisioner.Provision.Config - (*Provision_Plan)(nil), // 32: provisioner.Provision.Plan - (*Provision_Apply)(nil), // 33: provisioner.Provision.Apply - (*Provision_Cancel)(nil), // 34: provisioner.Provision.Cancel - (*Provision_Request)(nil), // 35: provisioner.Provision.Request - (*Provision_Complete)(nil), // 36: provisioner.Provision.Complete - (*Provision_Response)(nil), // 37: provisioner.Provision.Response + (*Agent_Metadata)(nil), // 25: provisioner.Agent.Metadata + nil, // 26: provisioner.Agent.EnvEntry + (*Resource_Metadata)(nil), // 27: provisioner.Resource.Metadata + (*Parse_Request)(nil), // 28: provisioner.Parse.Request + (*Parse_Complete)(nil), // 29: provisioner.Parse.Complete + (*Parse_Response)(nil), // 30: provisioner.Parse.Response + (*Provision_Metadata)(nil), // 31: provisioner.Provision.Metadata + (*Provision_Config)(nil), // 32: provisioner.Provision.Config + (*Provision_Plan)(nil), // 33: provisioner.Provision.Plan + (*Provision_Apply)(nil), // 34: provisioner.Provision.Apply + (*Provision_Cancel)(nil), // 35: provisioner.Provision.Cancel + (*Provision_Request)(nil), // 36: provisioner.Provision.Request + (*Provision_Complete)(nil), // 37: provisioner.Provision.Complete + (*Provision_Response)(nil), // 38: provisioner.Provision.Response } var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 3, // 0: provisioner.ParameterSource.scheme:type_name -> provisioner.ParameterSource.Scheme @@ -3140,40 +3241,41 @@ var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 5, // 5: provisioner.ParameterSchema.validation_type_system:type_name -> provisioner.ParameterSchema.TypeSystem 12, // 6: provisioner.RichParameter.options:type_name -> provisioner.RichParameterOption 0, // 7: provisioner.Log.level:type_name -> provisioner.LogLevel - 25, // 8: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry + 26, // 8: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry 20, // 9: provisioner.Agent.apps:type_name -> provisioner.App - 21, // 10: provisioner.App.healthcheck:type_name -> provisioner.Healthcheck - 1, // 11: provisioner.App.sharing_level:type_name -> provisioner.AppSharingLevel - 19, // 12: provisioner.Resource.agents:type_name -> provisioner.Agent - 26, // 13: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata - 11, // 14: provisioner.Parse.Complete.template_variables:type_name -> provisioner.TemplateVariable - 10, // 15: provisioner.Parse.Complete.parameter_schemas:type_name -> provisioner.ParameterSchema - 16, // 16: provisioner.Parse.Response.log:type_name -> provisioner.Log - 28, // 17: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete - 2, // 18: provisioner.Provision.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition - 30, // 19: provisioner.Provision.Config.metadata:type_name -> provisioner.Provision.Metadata - 31, // 20: provisioner.Provision.Plan.config:type_name -> provisioner.Provision.Config - 9, // 21: provisioner.Provision.Plan.parameter_values:type_name -> provisioner.ParameterValue - 14, // 22: provisioner.Provision.Plan.rich_parameter_values:type_name -> provisioner.RichParameterValue - 15, // 23: provisioner.Provision.Plan.variable_values:type_name -> provisioner.VariableValue - 18, // 24: provisioner.Provision.Plan.git_auth_providers:type_name -> provisioner.GitAuthProvider - 31, // 25: provisioner.Provision.Apply.config:type_name -> provisioner.Provision.Config - 32, // 26: provisioner.Provision.Request.plan:type_name -> provisioner.Provision.Plan - 33, // 27: provisioner.Provision.Request.apply:type_name -> provisioner.Provision.Apply - 34, // 28: provisioner.Provision.Request.cancel:type_name -> provisioner.Provision.Cancel - 22, // 29: provisioner.Provision.Complete.resources:type_name -> provisioner.Resource - 13, // 30: provisioner.Provision.Complete.parameters:type_name -> provisioner.RichParameter - 16, // 31: provisioner.Provision.Response.log:type_name -> provisioner.Log - 36, // 32: provisioner.Provision.Response.complete:type_name -> provisioner.Provision.Complete - 27, // 33: provisioner.Provisioner.Parse:input_type -> provisioner.Parse.Request - 35, // 34: provisioner.Provisioner.Provision:input_type -> provisioner.Provision.Request - 29, // 35: provisioner.Provisioner.Parse:output_type -> provisioner.Parse.Response - 37, // 36: provisioner.Provisioner.Provision:output_type -> provisioner.Provision.Response - 35, // [35:37] is the sub-list for method output_type - 33, // [33:35] is the sub-list for method input_type - 33, // [33:33] is the sub-list for extension type_name - 33, // [33:33] is the sub-list for extension extendee - 0, // [0:33] is the sub-list for field type_name + 25, // 10: provisioner.Agent.metadata:type_name -> provisioner.Agent.Metadata + 21, // 11: provisioner.App.healthcheck:type_name -> provisioner.Healthcheck + 1, // 12: provisioner.App.sharing_level:type_name -> provisioner.AppSharingLevel + 19, // 13: provisioner.Resource.agents:type_name -> provisioner.Agent + 27, // 14: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata + 11, // 15: provisioner.Parse.Complete.template_variables:type_name -> provisioner.TemplateVariable + 10, // 16: provisioner.Parse.Complete.parameter_schemas:type_name -> provisioner.ParameterSchema + 16, // 17: provisioner.Parse.Response.log:type_name -> provisioner.Log + 29, // 18: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete + 2, // 19: provisioner.Provision.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition + 31, // 20: provisioner.Provision.Config.metadata:type_name -> provisioner.Provision.Metadata + 32, // 21: provisioner.Provision.Plan.config:type_name -> provisioner.Provision.Config + 9, // 22: provisioner.Provision.Plan.parameter_values:type_name -> provisioner.ParameterValue + 14, // 23: provisioner.Provision.Plan.rich_parameter_values:type_name -> provisioner.RichParameterValue + 15, // 24: provisioner.Provision.Plan.variable_values:type_name -> provisioner.VariableValue + 18, // 25: provisioner.Provision.Plan.git_auth_providers:type_name -> provisioner.GitAuthProvider + 32, // 26: provisioner.Provision.Apply.config:type_name -> provisioner.Provision.Config + 33, // 27: provisioner.Provision.Request.plan:type_name -> provisioner.Provision.Plan + 34, // 28: provisioner.Provision.Request.apply:type_name -> provisioner.Provision.Apply + 35, // 29: provisioner.Provision.Request.cancel:type_name -> provisioner.Provision.Cancel + 22, // 30: provisioner.Provision.Complete.resources:type_name -> provisioner.Resource + 13, // 31: provisioner.Provision.Complete.parameters:type_name -> provisioner.RichParameter + 16, // 32: provisioner.Provision.Response.log:type_name -> provisioner.Log + 37, // 33: provisioner.Provision.Response.complete:type_name -> provisioner.Provision.Complete + 28, // 34: provisioner.Provisioner.Parse:input_type -> provisioner.Parse.Request + 36, // 35: provisioner.Provisioner.Provision:input_type -> provisioner.Provision.Request + 30, // 36: provisioner.Provisioner.Parse:output_type -> provisioner.Parse.Response + 38, // 37: provisioner.Provisioner.Provision:output_type -> provisioner.Provision.Response + 36, // [36:38] is the sub-list for method output_type + 34, // [34:36] is the sub-list for method input_type + 34, // [34:34] is the sub-list for extension type_name + 34, // [34:34] is the sub-list for extension extendee + 0, // [0:34] is the sub-list for field type_name } func init() { file_provisionersdk_proto_provisioner_proto_init() } @@ -3410,8 +3512,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Resource_Metadata); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Agent_Metadata); i { case 0: return &v.state case 1: @@ -3423,7 +3525,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Parse_Request); i { + switch v := v.(*Resource_Metadata); i { case 0: return &v.state case 1: @@ -3435,7 +3537,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Parse_Complete); i { + switch v := v.(*Parse_Request); i { case 0: return &v.state case 1: @@ -3447,7 +3549,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Parse_Response); i { + switch v := v.(*Parse_Complete); i { case 0: return &v.state case 1: @@ -3459,7 +3561,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Metadata); i { + switch v := v.(*Parse_Response); i { case 0: return &v.state case 1: @@ -3471,7 +3573,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Config); i { + switch v := v.(*Provision_Metadata); i { case 0: return &v.state case 1: @@ -3483,7 +3585,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Plan); i { + switch v := v.(*Provision_Config); i { case 0: return &v.state case 1: @@ -3495,7 +3597,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Apply); i { + switch v := v.(*Provision_Plan); i { case 0: return &v.state case 1: @@ -3507,7 +3609,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Cancel); i { + switch v := v.(*Provision_Apply); i { case 0: return &v.state case 1: @@ -3519,7 +3621,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Request); i { + switch v := v.(*Provision_Cancel); i { case 0: return &v.state case 1: @@ -3531,7 +3633,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Complete); i { + switch v := v.(*Provision_Request); i { case 0: return &v.state case 1: @@ -3543,6 +3645,18 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Provision_Complete); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_provisionersdk_proto_provisioner_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Provision_Response); i { case 0: return &v.state @@ -3559,16 +3673,16 @@ func file_provisionersdk_proto_provisioner_proto_init() { (*Agent_Token)(nil), (*Agent_InstanceId)(nil), } - file_provisionersdk_proto_provisioner_proto_msgTypes[23].OneofWrappers = []interface{}{ + file_provisionersdk_proto_provisioner_proto_msgTypes[24].OneofWrappers = []interface{}{ (*Parse_Response_Log)(nil), (*Parse_Response_Complete)(nil), } - file_provisionersdk_proto_provisioner_proto_msgTypes[29].OneofWrappers = []interface{}{ + file_provisionersdk_proto_provisioner_proto_msgTypes[30].OneofWrappers = []interface{}{ (*Provision_Request_Plan)(nil), (*Provision_Request_Apply)(nil), (*Provision_Request_Cancel)(nil), } - file_provisionersdk_proto_provisioner_proto_msgTypes[31].OneofWrappers = []interface{}{ + file_provisionersdk_proto_provisioner_proto_msgTypes[32].OneofWrappers = []interface{}{ (*Provision_Response_Log)(nil), (*Provision_Response_Complete)(nil), } @@ -3578,7 +3692,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_provisionersdk_proto_provisioner_proto_rawDesc, NumEnums: 6, - NumMessages: 32, + NumMessages: 33, NumExtensions: 0, NumServices: 1, }, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 135c616d35..5737ade95b 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -127,6 +127,13 @@ message GitAuthProvider { // 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; + } string id = 1; string name = 2; map env = 3; @@ -146,6 +153,7 @@ message Agent { int32 startup_script_timeout_seconds = 15; string shutdown_script = 16; int32 shutdown_script_timeout_seconds = 17; + repeated Metadata metadata = 18; } enum AppSharingLevel { diff --git a/site/package.json b/site/package.json index 3e9e4b7df7..a47bd32efa 100644 --- a/site/package.json +++ b/site/package.json @@ -138,6 +138,8 @@ "prettier": "2.8.1", "resize-observer": "1.0.4", "semver": "7.3.7", + "storybook-addon-mock": "^3.2.0", + "storybook-react-context": "^0.6.0", "typescript": "4.8.2" }, "browserslist": [ diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 190ec4baae..b686b29365 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1046,6 +1046,18 @@ const getMissingParameters = ( return missingParameters } +/** + * + * @param agentId + * @returns An EventSource that emits agent metadata event objects (ServerSentEvent) + */ +export const watchAgentMetadata = (agentId: string): EventSource => { + return new EventSource( + `${location.protocol}//${location.host}/api/v2/workspaceagents/${agentId}/watch-metadata`, + { withCredentials: true }, + ) +} + export const watchBuildLogs = ( versionId: string, onMessage: (log: TypesGen.ProvisionerJobLog) => void, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f1b40dddc6..1bb3a82354 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1086,6 +1086,29 @@ export interface WorkspaceAgentListeningPortsResponse { readonly ports: WorkspaceAgentListeningPort[] } +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadata { + readonly result: WorkspaceAgentMetadataResult + readonly description: WorkspaceAgentMetadataDescription +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadataDescription { + readonly display_name: string + readonly key: string + readonly script: string + readonly interval: number + readonly timeout: number +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadataResult { + readonly collected_at: string + readonly age: number + readonly value: string + readonly error: string +} + // From codersdk/workspaceagents.go export interface WorkspaceAgentStartupLog { readonly id: number diff --git a/site/src/components/Resources/AgentMetadata.stories.tsx b/site/src/components/Resources/AgentMetadata.stories.tsx new file mode 100644 index 0000000000..8b6fc80a9f --- /dev/null +++ b/site/src/components/Resources/AgentMetadata.stories.tsx @@ -0,0 +1,107 @@ +import { Story } from "@storybook/react" +import { + WorkspaceAgentMetadataDescription, + WorkspaceAgentMetadataResult, +} from "api/typesGenerated" +import { AgentMetadataView, AgentMetadataViewProps } from "./AgentMetadata" + +export default { + title: "components/AgentMetadata", + component: AgentMetadataView, +} + +const Template: Story = (args) => ( + +) + +const resultDefaults: WorkspaceAgentMetadataResult = { + collected_at: "2021-05-05T00:00:00Z", + error: "", + value: "defvalue", + age: 5, +} + +const descriptionDefaults: WorkspaceAgentMetadataDescription = { + display_name: "DisPlay", + key: "defkey", + interval: 10, + timeout: 10, + script: "some command", +} + +export const Example = Template.bind({}) +Example.args = { + metadata: [ + { + result: { + ...resultDefaults, + value: "110%", + }, + description: { + ...descriptionDefaults, + display_name: "CPU", + key: "CPU", + }, + }, + { + result: { + ...resultDefaults, + value: "50GB", + }, + description: { + ...descriptionDefaults, + display_name: "Memory", + key: "Memory", + }, + }, + { + result: { + ...resultDefaults, + value: "cant see it", + age: 300, + }, + description: { + ...descriptionDefaults, + interval: 5, + display_name: "Stale", + key: "stale", + }, + }, + { + result: { + ...resultDefaults, + value: "oops", + error: "fatal error", + }, + description: { + ...descriptionDefaults, + display_name: "Error", + }, + }, + { + result: { + ...resultDefaults, + value: "oops", + error: "fatal error", + }, + description: { + ...descriptionDefaults, + display_name: "Error", + key: "stale", + }, + }, + { + result: { + ...resultDefaults, + value: "", + collected_at: "0001-01-01T00:00:00Z", + age: 1000000, + }, + description: { + ...descriptionDefaults, + display_name: "Never loads", + key: "nloads", + }, + }, + ], +} diff --git a/site/src/components/Resources/AgentMetadata.tsx b/site/src/components/Resources/AgentMetadata.tsx new file mode 100644 index 0000000000..df70543a30 --- /dev/null +++ b/site/src/components/Resources/AgentMetadata.tsx @@ -0,0 +1,319 @@ +import Popover from "@material-ui/core/Popover" +import CircularProgress from "@material-ui/core/CircularProgress" +import makeStyles from "@material-ui/core/styles/makeStyles" +import { watchAgentMetadata } from "api/api" +import { WorkspaceAgent, WorkspaceAgentMetadata } from "api/typesGenerated" +import { CodeExample } from "components/CodeExample/CodeExample" +import { Stack } from "components/Stack/Stack" +import { + HelpTooltipText, + HelpTooltipTitle, +} from "components/Tooltips/HelpTooltip" +import dayjs from "dayjs" +import { + createContext, + FC, + PropsWithChildren, + useContext, + useEffect, + useRef, + useState, +} from "react" + +export const WatchAgentMetadataContext = createContext(watchAgentMetadata) + +const MetadataItemValue: FC< + PropsWithChildren<{ item: WorkspaceAgentMetadata }> +> = ({ item, children }) => { + const [isOpen, setIsOpen] = useState(false) + const anchorRef = useRef(null) + const styles = useStyles() + return ( + <> +

setIsOpen(true)} + role="presentation" + > + {children} +
+ setIsOpen(false)} + PaperProps={{ + onMouseEnter: () => setIsOpen(true), + onMouseLeave: () => setIsOpen(false), + }} + classes={{ paper: styles.metadataPopover }} + > + {item.description.display_name} + {item.result.value.length > 0 && ( + <> + Last result: + + + + + )} + {item.result.error.length > 0 && ( + <> + Last error: + + + + + )} + + + ) +} + +const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => { + const styles = useStyles() + + const [isOpen, setIsOpen] = useState(false) + + const labelAnchorRef = useRef(null) + + if (item.result === undefined) { + throw new Error("Metadata item result is undefined") + } + if (item.description === undefined) { + throw new Error("Metadata item description is undefined") + } + + const staleThreshold = Math.max( + item.description.interval + item.description.timeout * 2, + 5, + ) + + const status: "stale" | "valid" | "loading" = (() => { + const year = dayjs(item.result.collected_at).year() + if (year <= 1970 || isNaN(year)) { + return "loading" + } + if (item.result.age > staleThreshold) { + return "stale" + } + return "valid" + })() + + // Stale data is as good as no data. Plus, we want to build confidence in our + // users that what's shown is real. If times aren't correctly synced this + // could be buggy. But, how common is that anyways? + const value = + status === "stale" || status === "loading" ? ( + + ) : ( +
+ {item.result.value} +
+ ) + + const updatesInSeconds = -(item.description.interval - item.result.age) + + return ( + <> +
+
setIsOpen(true)} + // onMouseLeave={() => setIsOpen(false)} + role="presentation" + ref={labelAnchorRef} + > + {item.description.display_name} +
+ {value} +
+ setIsOpen(false)} + PaperProps={{ + onMouseEnter: () => setIsOpen(true), + onMouseLeave: () => setIsOpen(false), + }} + classes={{ paper: styles.metadataPopover }} + > + {item.description.display_name} + {status === "stale" ? ( + + This item is now stale because the agent hasn{"'"}t reported a new + value in {dayjs.duration(item.result.age, "s").humanize()}. + + ) : ( + <> + )} + {status === "valid" ? ( + + The agent collected this value{" "} + {dayjs.duration(item.result.age, "s").humanize()} ago and will + update it in{" "} + {dayjs.duration(Math.min(updatesInSeconds, 0), "s").humanize()}. + + ) : ( + <> + )} + {status === "loading" ? ( + + This value is loading for the first time... + + ) : ( + <> + )} + + This value is produced by the following script: + + + + + + + ) +} + +export interface AgentMetadataViewProps { + metadata: WorkspaceAgentMetadata[] +} + +export const AgentMetadataView: FC = ({ metadata }) => { + const styles = useStyles() + if (metadata.length === 0) { + return <> + } + return ( + +
+ {metadata.map((m) => { + if (m.description === undefined) { + throw new Error("Metadata item description is undefined") + } + return + })} +
+
+ ) +} + +export const AgentMetadata: FC<{ + agent: WorkspaceAgent +}> = ({ agent }) => { + const [metadata, setMetadata] = useState< + WorkspaceAgentMetadata[] | undefined + >(undefined) + + const watchAgentMetadata = useContext(WatchAgentMetadataContext) + + useEffect(() => { + const source = watchAgentMetadata(agent.id) + + source.onerror = (e) => { + console.error("received error in watch stream", e) + } + source.addEventListener("data", (e) => { + const data = JSON.parse(e.data) + setMetadata(data) + }) + return () => { + source.close() + } + }, [agent.id, watchAgentMetadata]) + + if (metadata === undefined) { + return + } + + return +} + +// These are more or less copied from +// site/src/components/Resources/ResourceCard.tsx +const useStyles = makeStyles((theme) => ({ + metadataStack: { + border: `2px dashed ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + width: "100%", + }, + metadataHeader: { + padding: "8px", + display: "grid", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gap: theme.spacing(5), + rowGap: theme.spacing(3), + }, + + metadata: { + fontSize: 16, + }, + + metadataLabel: { + fontSize: 12, + color: theme.palette.text.secondary, + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + fontWeight: "bold", + }, + + metadataValue: { + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + }, + + metadataValueSuccess: { + color: theme.palette.success.light, + }, + metadataValueError: { + color: theme.palette.error.main, + }, + + metadataPopover: { + marginTop: theme.spacing(0.5), + padding: theme.spacing(2.5), + color: theme.palette.text.secondary, + pointerEvents: "auto", + maxWidth: "480px", + + "& .MuiButton-root": { + padding: theme.spacing(1, 2), + borderRadius: 0, + border: 0, + + "&:hover": { + background: theme.palette.action.hover, + }, + }, + }, +})) diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index 00d84793fb..c00ca2a179 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -35,6 +35,7 @@ import { SSHButton } from "../SSHButton/SSHButton" import { Stack } from "../Stack/Stack" import { TerminalLink } from "../TerminalLink/TerminalLink" import { AgentLatency } from "./AgentLatency" +import { AgentMetadata } from "./AgentMetadata" import { AgentStatus } from "./AgentStatus" import { AgentVersion } from "./AgentVersion" @@ -169,178 +170,202 @@ export const AgentRow: FC = ({ - -
- -
-
-
{agent.name}
+
+ +
+ + + +
+
{agent.name}
+ + + {agent.operating_system} + + + + + + + + + + + + + + + {t("unableToConnect")} + + +
+
+ + + {showApps && agent.status === "connected" && ( + <> + {agent.apps.map((app) => ( + + ))} + + + {!hideSSHButton && ( + + )} + {!hideVSCodeDesktopButton && ( + + )} + {applicationsHost !== undefined && + applicationsHost !== "" && ( + + )} + + )} + {showApps && agent.status === "connecting" && ( + <> + + + + )} + +
+ + {hasStartupFeatures && ( - {agent.operating_system} - - - - - - - - - - - - - - {t("unableToConnect")} - - - - {hasStartupFeatures && ( - { + setShowStartupLogs(!showStartupLogs) + }} > + {showStartupLogs ? ( + + ) : ( + + )} + {showStartupLogs ? "Hide" : "Show"} Startup Logs + + + {agent.startup_script && ( { - setShowStartupLogs(!showStartupLogs) + setStartupScriptOpen(!startupScriptOpen) }} > - {showStartupLogs ? ( - - ) : ( - - )} - {showStartupLogs ? "Hide" : "Show"} Startup Logs + + View Startup Script + )} - {agent.startup_script && ( - { - setStartupScriptOpen(!startupScriptOpen) + setStartupScriptOpen(false)} + anchorEl={startupScriptAnchorRef.current} + anchorOrigin={{ + vertical: "bottom", + horizontal: "left", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + > +
+ - - View Startup Script - - )} - - setStartupScriptOpen(false)} - anchorEl={startupScriptAnchorRef.current} - anchorOrigin={{ - vertical: "bottom", - horizontal: "left", - }} - transformOrigin={{ - vertical: "top", - horizontal: "left", - }} - > -
- - {agent.startup_script || ""} - -
-
- - )} -
-
- - - {showApps && agent.status === "connected" && ( - <> - {agent.apps.map((app) => ( - - ))} - - - {!hideSSHButton && ( - - )} - {!hideVSCodeDesktopButton && ( - - )} - {applicationsHost !== undefined && applicationsHost !== "" && ( - - )} - - )} - {showApps && agent.status === "connecting" && ( - <> - - - + {agent.startup_script || ""} + +
+ +
)}
- {showStartupLogs && ( {({ width }) => ( diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index a6fb225a20..30bf79507f 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -1,13 +1,25 @@ import { action } from "@storybook/addon-actions" import { Story } from "@storybook/react" +import { WatchAgentMetadataContext } from "components/Resources/AgentMetadata" import { ProvisionerJobLog } from "api/typesGenerated" import * as Mocks from "../../testHelpers/entities" import { Workspace, WorkspaceErrors, WorkspaceProps } from "./Workspace" +import { withReactContext } from "storybook-react-context" +import EventSource from "eventsourcemock" export default { title: "components/Workspace", component: Workspace, argTypes: {}, + decorators: [ + withReactContext({ + Context: WatchAgentMetadataContext, + initialState: (_: string): EventSource => { + // Need Bruno's help here. + return new EventSource() + }, + }), + ], } const Template: Story = (args) => diff --git a/site/yarn.lock b/site/yarn.lock index 6e81f10bab..58e74b6699 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -2219,6 +2219,23 @@ global "^4.4.0" regenerator-runtime "^0.13.7" +"@storybook/addons@^6.3.6": + version "6.5.16" + resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.5.16.tgz#07e8f2205f86fa4c9dada719e3e096cb468e3cdd" + integrity sha512-p3DqQi+8QRL5k7jXhXmJZLsE/GqHqyY6PcoA1oNTJr0try48uhTGUOYkgzmqtDaa/qPFO5LP+xCPzZXckGtquQ== + dependencies: + "@storybook/api" "6.5.16" + "@storybook/channels" "6.5.16" + "@storybook/client-logger" "6.5.16" + "@storybook/core-events" "6.5.16" + "@storybook/csf" "0.0.2--canary.4566f4d.1" + "@storybook/router" "6.5.16" + "@storybook/theming" "6.5.16" + "@types/webpack-env" "^1.16.0" + core-js "^3.8.2" + global "^4.4.0" + regenerator-runtime "^0.13.7" + "@storybook/api@6.5.12": version "6.5.12" resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.5.12.tgz#7cc82087fc9298be03f15bf4ab9c4aab294b3bac" @@ -2242,6 +2259,29 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/api@6.5.16": + version "6.5.16" + resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.5.16.tgz#897915b76de05587fd702951d5d836f708043662" + integrity sha512-HOsuT8iomqeTMQJrRx5U8nsC7lJTwRr1DhdD0SzlqL4c80S/7uuCy4IZvOt4sYQjOzW5fOo/kamcoBXyLproTA== + dependencies: + "@storybook/channels" "6.5.16" + "@storybook/client-logger" "6.5.16" + "@storybook/core-events" "6.5.16" + "@storybook/csf" "0.0.2--canary.4566f4d.1" + "@storybook/router" "6.5.16" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.5.16" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.21" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + store2 "^2.12.0" + telejson "^6.0.8" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/api@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.5.9.tgz#303733214c9de0422d162f7c54ae05d088b89bf9" @@ -2351,6 +2391,15 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/channels@6.5.16": + version "6.5.16" + resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.5.16.tgz#3fb9a3b5666ecb951a2d0cf8b0699b084ef2d3c6" + integrity sha512-VylzaWQZaMozEwZPJdyJoz+0jpDa8GRyaqu9TGG6QGv+KU5POoZaGLDkRE7TzWkyyP0KQLo80K99MssZCpgSeg== + dependencies: + core-js "^3.8.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/channels@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.5.9.tgz#abfab89a6587a2688e9926d4aafeb11c9d8b2e79" @@ -2394,6 +2443,14 @@ core-js "^3.8.2" global "^4.4.0" +"@storybook/client-logger@6.5.16": + version "6.5.16" + resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.5.16.tgz#955cc46b389e7151c9eb1585a75e6a0605af61a1" + integrity sha512-pxcNaCj3ItDdicPTXTtmYJE3YC1SjxFrBmHcyrN+nffeNyiMuViJdOOZzzzucTUG0wcOOX8jaSyak+nnHg5H1Q== + dependencies: + core-js "^3.8.2" + global "^4.4.0" + "@storybook/client-logger@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.5.9.tgz#dc1669abe8c45af1cc38f74c6f4b15ff33e63014" @@ -2521,6 +2578,13 @@ dependencies: core-js "^3.8.2" +"@storybook/core-events@6.5.16": + version "6.5.16" + resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.5.16.tgz#b1c265dac755007dae172d9d4b72656c9e5d7bb3" + integrity sha512-qMZQwmvzpH5F2uwNUllTPg6eZXr2OaYZQRRN8VZJiuorZzDNdAFmiVWMWdkThwmyLEJuQKXxqCL8lMj/7PPM+g== + dependencies: + core-js "^3.8.2" + "@storybook/core-events@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.5.9.tgz#5b0783c7d22a586c0f5e927a61fe1b1223e19637" @@ -2790,6 +2854,17 @@ qs "^6.10.0" regenerator-runtime "^0.13.7" +"@storybook/router@6.5.16": + version "6.5.16" + resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.5.16.tgz#28fb4d34e8219351a40bee1fc94dcacda6e1bd8b" + integrity sha512-ZgeP8a5YV/iuKbv31V8DjPxlV4AzorRiR8OuSt/KqaiYXNXlOoQDz/qMmiNcrshrfLpmkzoq7fSo4T8lWo2UwQ== + dependencies: + "@storybook/client-logger" "6.5.16" + core-js "^3.8.2" + memoizerific "^1.11.3" + qs "^6.10.0" + regenerator-runtime "^0.13.7" + "@storybook/router@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.5.9.tgz#4740248f8517425b2056273fb366ace8a17c65e8" @@ -2874,6 +2949,16 @@ memoizerific "^1.11.3" regenerator-runtime "^0.13.7" +"@storybook/theming@6.5.16": + version "6.5.16" + resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.5.16.tgz#b999bdb98945b605b93b9dfdf7408535b701e2aa" + integrity sha512-hNLctkjaYLRdk1+xYTkC1mg4dYz2wSv6SqbLpcKMbkPHTE0ElhddGPHQqB362md/w9emYXNkt1LSMD8Xk9JzVQ== + dependencies: + "@storybook/client-logger" "6.5.16" + core-js "^3.8.2" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + "@storybook/theming@6.5.9": version "6.5.9" resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.5.9.tgz#13f60a3a3cd73ceb5caf9f188e1627e79f1891aa" @@ -8853,7 +8938,7 @@ is-plain-obj@^4.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== -is-plain-object@5.0.0: +is-plain-object@5.0.0, is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== @@ -11043,6 +11128,11 @@ mock-socket@^9.1.0: resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.2.1.tgz#cc9c0810aa4d0afe02d721dcb2b7e657c00e2282" integrity sha512-aw9F9T9G2zpGipLLhSNh6ZpgUyUl4frcVmRN08uE1NWPWg43Wx6+sGPDbQ7E5iFZZDJW5b5bypMeAEHqTbIFag== +mock-xmlhttprequest@^7.0.3: + version "7.0.4" + resolved "https://registry.yarnpkg.com/mock-xmlhttprequest/-/mock-xmlhttprequest-7.0.4.tgz#5e188da009cf46900e522f690cbea8d26274a872" + integrity sha512-hA0fIHy/74p5DE0rdmrpU0sV1U+gnWTcgShWequGRLy0L1eT+zY0ozFukawpLaxMwIA+orRcqFRElYwT+5p81A== + monaco-editor@0.34.1: version "0.34.1" resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.34.1.tgz#1b75c4ad6bc4c1f9da656d740d98e0b850a22f87" @@ -12590,6 +12680,14 @@ react@18.2.0: dependencies: loose-envify "^1.1.0" +react@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + reactcss@^1.2.0: version "1.2.3" resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd" @@ -13638,6 +13736,24 @@ store2@^2.12.0: resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068" integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w== +storybook-addon-mock@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/storybook-addon-mock/-/storybook-addon-mock-3.2.0.tgz#5832b1e49ff39ffab7a0ae8ec7de8bfdb8ddea45" + integrity sha512-LaggsF/6Lt0AyHiotIEVQpwKfIiZ3KsNqtdXKVnIdOetjaD7GaOQeX0jIZiZUFX/i6QLmMuNoXFngqqkdVtfSg== + dependencies: + mock-xmlhttprequest "^7.0.3" + path-to-regexp "^6.2.0" + polished "^4.2.2" + +storybook-react-context@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/storybook-react-context/-/storybook-react-context-0.6.0.tgz#06c7b48dc95f4619cf12e59429305fbd6f2b1373" + integrity sha512-6IOUbSoC1WW68x8zQBEh8tZsVXjEvOBSJSOhkaD9o8IF9caIg/o1jnwuGibdyAd47ARN6g95O0N0vFBjXcB7pA== + dependencies: + "@storybook/addons" "^6.3.6" + is-plain-object "^5.0.0" + react "^17.0.2" + stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"