From 85745abbd9f480fe68de9aa114e96d4e20a16fa8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 8 Apr 2022 14:12:57 +0000 Subject: [PATCH 01/17] Improve CLI documentation --- cli/cliui/cliui.go | 2 ++ cli/cliui/resources.go | 55 ++++++++++++++++++++++++++++++++++++++++++ cli/cliui/select.go | 5 ++++ cli/root.go | 7 +++--- cli/start.go | 5 ++-- cli/templatecreate.go | 2 -- cli/templateinit.go | 8 +++--- cli/templates.go | 2 ++ cmd/cliui/main.go | 17 +++++++++++++ cmd/coder/main.go | 7 ++++++ 10 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 cli/cliui/resources.go diff --git a/cli/cliui/cliui.go b/cli/cliui/cliui.go index e69280cdf4119..f4093c7b54335 100644 --- a/cli/cliui/cliui.go +++ b/cli/cliui/cliui.go @@ -26,6 +26,7 @@ var Styles = struct { Checkmark, Code, Crossmark, + Error, Field, Keyword, Paragraph, @@ -41,6 +42,7 @@ var Styles = struct { Checkmark: defaultStyles.Checkmark, Code: defaultStyles.Code, Crossmark: defaultStyles.Error.Copy().SetString("✘"), + Error: defaultStyles.Error, Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}), Keyword: defaultStyles.Keyword, Paragraph: defaultStyles.Paragraph, diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go new file mode 100644 index 0000000000000..3f73bb2bac077 --- /dev/null +++ b/cli/cliui/resources.go @@ -0,0 +1,55 @@ +package cliui + +import ( + "fmt" + "sort" + "text/tabwriter" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" + "github.com/spf13/cobra" +) + +func WorkspaceResources(cmd *cobra.Command, resources []codersdk.WorkspaceResource) error { + sort.Slice(resources, func(i, j int) bool { + return fmt.Sprintf("%s.%s", resources[i].Type, resources[i].Name) < fmt.Sprintf("%s.%s", resources[j].Type, resources[j].Name) + }) + + addressOnStop := map[string]codersdk.WorkspaceResource{} + for _, resource := range resources { + if resource.Transition != database.WorkspaceTransitionStop { + continue + } + addressOnStop[resource.Address] = resource + } + + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0) + _, _ = fmt.Fprintf(writer, "Type\tName\tGood\n") + displayed := map[string]struct{}{} + for _, resource := range resources { + if resource.Type == "random_string" { + // Hide resources that aren't substantial to a user! + continue + } + _, alreadyShown := displayed[resource.Address] + if alreadyShown { + continue + } + displayed[resource.Address] = struct{}{} + + _, _ = fmt.Fprintf(writer, "%s\t%s\tMacOS\n", resource.Type, resource.Name) + + // _, _ = fmt.Fprintln(cmd.OutOrStdout(), resource.Type+"."+resource.Name) + _, existsOnStop := addressOnStop[resource.Address] + if existsOnStop { + // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Warn.Render("~ persistent")) + } else { + // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Keyword.Render("+ start")+Styles.Placeholder.Render(" (deletes on stop)")) + } + if resource.Agent != nil { + // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Fuschia.Render("▲ allows ssh")) + } + // _, _ = fmt.Fprintln(cmd.OutOrStdout()) + } + return writer.Flush() +} diff --git a/cli/cliui/select.go b/cli/cliui/select.go index fa101414aca40..ed7c3b87b275c 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -1,11 +1,13 @@ package cliui import ( + "errors" "flag" "io" "os" "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/spf13/cobra" ) @@ -63,6 +65,9 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) { }, fileReadWriter{ Writer: cmd.OutOrStdout(), }, cmd.OutOrStdout())) + if errors.Is(err, terminal.InterruptErr) { + return value, Canceled + } return value, err } diff --git a/cli/root.go b/cli/root.go index 9f44fefbb5686..b6960f837fb99 100644 --- a/cli/root.go +++ b/cli/root.go @@ -29,9 +29,10 @@ const ( func Root() *cobra.Command { cmd := &cobra.Command{ - Use: "coder", - Version: buildinfo.Version(), - SilenceUsage: true, + Use: "coder", + Version: buildinfo.Version(), + SilenceErrors: true, + SilenceUsage: true, Long: ` ▄█▀ ▀█▄ ▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█ ▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀ diff --git a/cli/start.go b/cli/start.go index 5b438db4a334f..9726c9a1a8d1e 100644 --- a/cli/start.go +++ b/cli/start.go @@ -213,11 +213,10 @@ func start() *cobra.Command { return xerrors.Errorf("create first user: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+ - cliui.Styles.Field.Render("dev")+` mode. All data is in-memory! Do not use in production. Press `+cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`))+ + _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Wrap.Copy().Margin(0, 0, 0, 2).Render(cliui.Styles.Wrap.Render(`Started in dev mode. All data is in-memory! `+cliui.Styles.Bold.Render("Do not use in production")+`. Press `+cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`))+ ` `+ - cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Run `+cliui.Styles.Code.Render("coder templates init")+" in a new terminal to get started.\n"))+` + cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(`Run `+cliui.Styles.Code.Render("coder templates init")+" in a new terminal to start creating development workspaces.\n"))+` `) } else { // This is helpful for tests, but can be silently ignored. diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 5d9ece4c3e698..5b35d12086224 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -192,8 +192,6 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org return nil, nil, xerrors.New(version.Job.Error) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Checkmark.String()+" Successfully imported template source!\n") - resources, err := client.TemplateVersionResources(cmd.Context(), version.ID) if err != nil { return nil, nil, err diff --git a/cli/templateinit.go b/cli/templateinit.go index 603bcd87ac26d..166e761e4140e 100644 --- a/cli/templateinit.go +++ b/cli/templateinit.go @@ -28,7 +28,7 @@ func templateInit() *cobra.Command { exampleByName[example.Name] = example } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Templates contain Infrastructure as Code that works with Coder to provision development workspaces. Get started by selecting an example:\n")) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("A template defines infrastructure as code to be provisioned for individual developer workspaces. Select an example to get started:\n")) option, err := cliui.Select(cmd, cliui.SelectOptions{ Options: exampleNames, }) @@ -56,7 +56,7 @@ func templateInit() *cobra.Command { } else { relPath = "./" + relPath } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%sExtracting %s to %s...\n", cliui.Styles.Prompt, cliui.Styles.Field.Render(selectedTemplate.ID), cliui.Styles.Keyword.Render(relPath)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Extracting %s to %s...\n", cliui.Styles.Field.Render(selectedTemplate.ID), relPath) err = os.MkdirAll(directory, 0700) if err != nil { return err @@ -65,8 +65,8 @@ func templateInit() *cobra.Command { if err != nil { return err } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Inside that directory, get started by running:") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("coder templates create"))+"\n") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Create your template by running:") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("cd "+relPath+"\ncoder templates create"))+"\n") return nil }, } diff --git a/cli/templates.go b/cli/templates.go index 340d000d03e68..d8abc9f8b1d92 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -43,6 +43,8 @@ func templates() *cobra.Command { } func displayTemplateVersionInfo(cmd *cobra.Command, resources []codersdk.WorkspaceResource) error { + fmt.Printf("Previewing template!\n") + sort.Slice(resources, func(i, j int) bool { return fmt.Sprintf("%s.%s", resources[i].Type, resources[i].Name) < fmt.Sprintf("%s.%s", resources[j].Type, resources[j].Name) }) diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index c633a6b6d2e0f..b9cd453c55e1e 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -187,6 +187,23 @@ func main() { }, }) + root.AddCommand(&cobra.Command{ + Use: "resources", + RunE: func(cmd *cobra.Command, args []string) error { + return cliui.WorkspaceResources(cmd, []codersdk.WorkspaceResource{{ + Address: "another", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_instance", + Name: "dev", + }, { + Address: "something", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_instance", + Name: "something", + }}) + }, + }) + err := root.Execute() if err != nil { _, _ = fmt.Println(err.Error()) diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 3daffadbb921d..55cc8ca794e57 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -1,14 +1,21 @@ package main import ( + "errors" + "fmt" "os" "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliui" ) func main() { err := cli.Root().Execute() if err != nil { + if errors.Is(err, cliui.Canceled) { + os.Exit(1) + } + fmt.Fprintln(os.Stderr, cliui.Styles.Error.Render(err.Error())) os.Exit(1) } } From e8258ea75359f16dce71c1c57a1ba5db4b39540b Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 8 Apr 2022 19:34:40 +0000 Subject: [PATCH 02/17] feat: Allow workspace resources to attach multiple agents This enables a "kubernetes_pod" to attach multiple agents that could be for multiple services. Each agent is required to have a unique name, so SSH syntax is: `coder ssh .` A resource can have zero agents too, they aren't required. --- .vscode/settings.json | 2 +- Makefile | 5 +- cli/cliui/agent.go | 16 +- cli/cliui/agent_test.go | 12 +- cli/configssh.go | 34 +- cli/gitssh_test.go | 6 +- cli/ssh.go | 50 ++- cli/ssh_test.go | 8 +- cli/templates.go | 2 +- cli/workspaceagent_test.go | 12 +- cli/workspaceshow.go | 23 -- cmd/cliui/main.go | 14 +- cmd/templater/main.go | 17 +- coderd/coderd.go | 27 +- coderd/coderdtest/coderdtest.go | 9 +- coderd/database/databasefake/databasefake.go | 33 +- coderd/database/dump.sql | 6 +- coderd/database/migrations/000004_jobs.up.sql | 4 +- coderd/database/models.go | 4 +- coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 112 ++++-- coderd/database/queries/workspaceagents.sql | 17 +- .../database/queries/workspaceresources.sql | 5 +- coderd/gitsshkey_test.go | 73 ++-- coderd/httpmw/workspaceagentparam.go | 94 +++++ coderd/httpmw/workspaceagentparam_test.go | 153 ++++++++ coderd/provisionerdaemons.go | 27 +- coderd/provisionerjobs.go | 47 ++- coderd/templateversions_test.go | 10 +- coderd/workspaceagents.go | 257 +++++++++++++ coderd/workspaceagents_test.go | 108 ++++++ coderd/workspacebuilds.go | 4 +- coderd/workspacebuilds_test.go | 10 +- coderd/workspaceresourceauth_test.go | 8 +- coderd/workspaceresources.go | 269 +------------- coderd/workspaceresources_test.go | 62 +--- codersdk/gitsshkey.go | 2 +- ...paceresourceauth.go => workspaceagents.go} | 121 +++++- codersdk/workspaceresources.go | 108 +----- examples/aws-linux/main.tf | 17 +- examples/aws-windows/main.tf | 15 +- examples/gcp-linux/main.tf | 34 +- examples/gcp-windows/main.tf | 30 +- provisioner/terraform/provision.go | 178 ++++++--- provisioner/terraform/provision_test.go | 148 ++++---- provisionersdk/proto/provisioner.pb.go | 346 ++++++++++-------- provisionersdk/proto/provisioner.proto | 15 +- 47 files changed, 1531 insertions(+), 1026 deletions(-) create mode 100644 coderd/httpmw/workspaceagentparam.go create mode 100644 coderd/httpmw/workspaceagentparam_test.go create mode 100644 coderd/workspaceagents.go create mode 100644 coderd/workspaceagents_test.go rename codersdk/{workspaceresourceauth.go => workspaceagents.go} (51%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 38a091b323f59..2988daa9f0d75 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -62,7 +62,7 @@ "emeraldwalk.runonsave": { "commands": [ { - "match": "database/query.sql", + "match": "database/queries/*.sql", "cmd": "make gen" } ] diff --git a/Makefile b/Makefile index 91baebe6cc1ee..681d565a22c25 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,10 @@ fmt/sql: $(wildcard coderd/database/queries/*.sql) sed -i 's/@ /@/g' ./coderd/database/queries/*.sql -fmt: fmt/prettier fmt/sql +fmt/terraform: $(wildcard *.tf) + terraform fmt -recursive + +fmt: fmt/prettier fmt/sql fmt/terraform .PHONY: fmt gen: coderd/database/generate peerbroker/proto provisionersdk/proto provisionerd/proto diff --git a/cli/cliui/agent.go b/cli/cliui/agent.go index ca32fd3270106..3770d07d5aecf 100644 --- a/cli/cliui/agent.go +++ b/cli/cliui/agent.go @@ -15,7 +15,7 @@ import ( type AgentOptions struct { WorkspaceName string - Fetch func(context.Context) (codersdk.WorkspaceResource, error) + Fetch func(context.Context) (codersdk.WorkspaceAgent, error) FetchInterval time.Duration WarnInterval time.Duration } @@ -29,20 +29,20 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error { opts.WarnInterval = 30 * time.Second } var resourceMutex sync.Mutex - resource, err := opts.Fetch(ctx) + agent, err := opts.Fetch(ctx) if err != nil { return xerrors.Errorf("fetch: %w", err) } - if resource.Agent.Status == codersdk.WorkspaceAgentConnected { + if agent.Status == codersdk.WorkspaceAgentConnected { return nil } - if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { + if agent.Status == codersdk.WorkspaceAgentDisconnected { opts.WarnInterval = 0 } spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen")) spin.Writer = writer spin.ForceOutput = true - spin.Suffix = " Waiting for connection from " + Styles.Field.Render(resource.Type+"."+resource.Name) + "..." + spin.Suffix = " Waiting for connection from " + Styles.Field.Render(agent.Name) + "..." spin.Start() defer spin.Stop() @@ -59,7 +59,7 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error { resourceMutex.Lock() defer resourceMutex.Unlock() message := "Don't panic, your workspace is booting up!" - if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { + if agent.Status == codersdk.WorkspaceAgentDisconnected { message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder workspaces rebuild "+opts.WorkspaceName) } // This saves the cursor position, then defers clearing from the cursor @@ -74,11 +74,11 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error { case <-ticker.C: } resourceMutex.Lock() - resource, err = opts.Fetch(ctx) + agent, err = opts.Fetch(ctx) if err != nil { return xerrors.Errorf("fetch: %w", err) } - if resource.Agent.Status != codersdk.WorkspaceAgentConnected { + if agent.Status != codersdk.WorkspaceAgentConnected { resourceMutex.Unlock() continue } diff --git a/cli/cliui/agent_test.go b/cli/cliui/agent_test.go index 87323c17a6ded..6f717541edd46 100644 --- a/cli/cliui/agent_test.go +++ b/cli/cliui/agent_test.go @@ -22,16 +22,14 @@ func TestAgent(t *testing.T) { RunE: func(cmd *cobra.Command, args []string) error { err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{ WorkspaceName: "example", - Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { - resource := codersdk.WorkspaceResource{ - Agent: &codersdk.WorkspaceAgent{ - Status: codersdk.WorkspaceAgentDisconnected, - }, + Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) { + agent := codersdk.WorkspaceAgent{ + Status: codersdk.WorkspaceAgentDisconnected, } if disconnected.Load() { - resource.Agent.Status = codersdk.WorkspaceAgentConnected + agent.Status = codersdk.WorkspaceAgentConnected } - return resource, nil + return agent, nil }, FetchInterval: time.Millisecond, WarnInterval: 10 * time.Millisecond, diff --git a/cli/configssh.go b/cli/configssh.go index d5c2dca40ff75..6bcd117372f09 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" ) @@ -70,29 +71,30 @@ func configSSH() *cobra.Command { for _, workspace := range workspaces { workspace := workspace errGroup.Go(func() error { - resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) + resources, err := client.TemplateVersionResources(cmd.Context(), workspace.LatestBuild.TemplateVersionID) if err != nil { return err } - resourcesWithAgents := make([]codersdk.WorkspaceResource, 0) for _, resource := range resources { - if resource.Agent == nil { + if resource.Transition != database.WorkspaceTransitionStart { continue } - resourcesWithAgents = append(resourcesWithAgents, resource) - } - sshConfigContentMutex.Lock() - defer sshConfigContentMutex.Unlock() - if len(resourcesWithAgents) == 1 { - sshConfigContent += strings.Join([]string{ - "Host coder." + workspace.Name, - "\tHostName coder." + workspace.Name, - fmt.Sprintf("\tProxyCommand %q ssh --stdio %s", binPath, workspace.Name), - "\tConnectTimeout=0", - "\tStrictHostKeyChecking=no", - }, "\n") + "\n" + for _, agent := range resource.Agents { + sshConfigContentMutex.Lock() + hostname := workspace.Name + if len(resource.Agents) > 1 { + hostname += "." + agent.Name + } + sshConfigContent += strings.Join([]string{ + "Host coder." + hostname, + "\tHostName coder." + hostname, + fmt.Sprintf("\tProxyCommand %q ssh --stdio %s", binPath, hostname), + "\tConnectTimeout=0", + "\tStrictHostKeyChecking=no", + }, "\n") + "\n" + sshConfigContentMutex.Unlock() + } } - return nil }) } diff --git a/cli/gitssh_test.go b/cli/gitssh_test.go index d9b36d103f20d..4ad6f6daea62b 100644 --- a/cli/gitssh_test.go +++ b/cli/gitssh_test.go @@ -49,11 +49,11 @@ func TestGitSSH(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, @@ -81,7 +81,7 @@ func TestGitSSH(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) - dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil, nil) require.NoError(t, err) defer dialer.Close() _, err = dialer.Ping() diff --git a/cli/ssh.go b/cli/ssh.go index e8310c53a2302..6cdda4423880e 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -7,6 +7,7 @@ import ( "os" "time" + "github.com/google/uuid" "github.com/mattn/go-isatty" "github.com/pion/webrtc/v3" "github.com/spf13/cobra" @@ -25,7 +26,7 @@ func ssh() *cobra.Command { stdio bool ) cmd := &cobra.Command{ - Use: "ssh [resource]", + Use: "ssh [agent]", RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { @@ -57,50 +58,45 @@ func ssh() *cobra.Command { return err } - resourceByAddress := make(map[string]codersdk.WorkspaceResource) + agents := make([]codersdk.WorkspaceAgent, 0) for _, resource := range resources { - if resource.Agent == nil { - continue - } - resourceByAddress[resource.Address] = resource + agents = append(agents, resource.Agents...) } - - var resourceAddress string + if len(agents) == 0 { + return xerrors.New("workspace has no agents") + } + var agent codersdk.WorkspaceAgent if len(args) >= 2 { - resourceAddress = args[1] - } else { - // No resource name was provided! - if len(resourceByAddress) > 1 { - // List available resources to connect into? - return xerrors.Errorf("multiple agents") - } - for _, resource := range resourceByAddress { - resourceAddress = resource.Address + for _, otherAgent := range agents { + if otherAgent.Name != args[1] { + continue + } + agent = otherAgent break } + if agent.ID == uuid.Nil { + return xerrors.Errorf("agent not found by name %q", args[1]) + } } - - resource, exists := resourceByAddress[resourceAddress] - if !exists { - resourceKeys := make([]string, 0) - for resourceKey := range resourceByAddress { - resourceKeys = append(resourceKeys, resourceKey) + if agent.ID == uuid.Nil { + if len(agents) > 1 { + return xerrors.New("you must specify the name of an agent") } - return xerrors.Errorf("no sshable agent with address %q: %+v", resourceAddress, resourceKeys) + agent = agents[0] } // OpenSSH passes stderr directly to the calling TTY. // This is required in "stdio" mode so a connecting indicator can be displayed. err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{ WorkspaceName: workspace.Name, - Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { - return client.WorkspaceResource(ctx, resource.ID) + Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) { + return client.WorkspaceAgent(ctx, agent.ID) }, }) if err != nil { return xerrors.Errorf("await agent: %w", err) } - conn, err := client.DialWorkspaceAgent(cmd.Context(), resource.ID, []webrtc.ICEServer{{ + conn, err := client.DialWorkspaceAgent(cmd.Context(), agent.ID, []webrtc.ICEServer{{ URLs: []string{"stun:stun.l.google.com:19302"}, }}, nil) if err != nil { diff --git a/cli/ssh_test.go b/cli/ssh_test.go index cc192150fd028..208d8379d0b24 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -40,12 +40,12 @@ func TestSSH(t *testing.T) { Resources: []*proto.Resource{{ Name: "dev", Type: "google_compute_instance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: uuid.NewString(), Auth: &proto.Agent_Token{ Token: agentToken, }, - }, + }}, }}, }, }, @@ -98,12 +98,12 @@ func TestSSH(t *testing.T) { Resources: []*proto.Resource{{ Name: "dev", Type: "google_compute_instance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: uuid.NewString(), Auth: &proto.Agent_Token{ Token: agentToken, }, - }, + }}, }}, }, }, diff --git a/cli/templates.go b/cli/templates.go index 340d000d03e68..e1f65ca4b1adc 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -74,7 +74,7 @@ func displayTemplateVersionInfo(cmd *cobra.Command, resources []codersdk.Workspa } else { _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Keyword.Render("+ start")+cliui.Styles.Placeholder.Render(" (deletes on stop)")) } - if resource.Agent != nil { + if len(resource.Agents) > 0 { _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Fuschia.Render("▲ allows ssh")) } _, _ = fmt.Fprintln(cmd.OutOrStdout()) diff --git a/cli/workspaceagent_test.go b/cli/workspaceagent_test.go index abad44d13a735..c672c7bb7946c 100644 --- a/cli/workspaceagent_test.go +++ b/cli/workspaceagent_test.go @@ -32,11 +32,11 @@ func TestWorkspaceAgent(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, @@ -61,7 +61,7 @@ func TestWorkspaceAgent(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) - dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil, nil) require.NoError(t, err) defer dialer.Close() _, err = dialer.Ping() @@ -86,11 +86,11 @@ func TestWorkspaceAgent(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, @@ -115,7 +115,7 @@ func TestWorkspaceAgent(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) - dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil, nil) require.NoError(t, err) defer dialer.Close() _, err = dialer.Ping() diff --git a/cli/workspaceshow.go b/cli/workspaceshow.go index 80a2fcc3ca052..36a7b5040432d 100644 --- a/cli/workspaceshow.go +++ b/cli/workspaceshow.go @@ -1,36 +1,13 @@ package cli import ( - "fmt" - "github.com/spf13/cobra" - - "github.com/coder/coder/codersdk" ) func workspaceShow() *cobra.Command { return &cobra.Command{ Use: "show", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) - if err != nil { - return err - } - workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) - if err != nil { - return err - } - resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } - for _, resource := range resources { - if resource.Agent == nil { - continue - } - - _, _ = fmt.Printf("Agent: %+v\n", resource.Agent) - } return nil }, } diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index c633a6b6d2e0f..391e76a35c002 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -161,21 +161,17 @@ func main() { root.AddCommand(&cobra.Command{ Use: "agent", RunE: func(cmd *cobra.Command, args []string) error { - resource := codersdk.WorkspaceResource{ - Type: "google_compute_instance", - Name: "dev", - Agent: &codersdk.WorkspaceAgent{ - Status: codersdk.WorkspaceAgentDisconnected, - }, + agent := codersdk.WorkspaceAgent{ + Status: codersdk.WorkspaceAgentDisconnected, } go func() { time.Sleep(3 * time.Second) - resource.Agent.Status = codersdk.WorkspaceAgentConnected + agent.Status = codersdk.WorkspaceAgentConnected }() err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{ WorkspaceName: "dev", - Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { - return resource, nil + Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) { + return agent, nil }, WarnInterval: 2 * time.Second, }) diff --git a/cmd/templater/main.go b/cmd/templater/main.go index 825ab6a3f0773..effd36fd49a1a 100644 --- a/cmd/templater/main.go +++ b/cmd/templater/main.go @@ -195,12 +195,11 @@ func parse(cmd *cobra.Command, parameters []codersdk.CreateParameterRequest) err return err } for _, resource := range resources { - if resource.Agent == nil { - continue - } - err = awaitAgent(cmd.Context(), client, resource) - if err != nil { - return err + for _, agent := range resource.Agents { + err = awaitAgent(cmd.Context(), client, agent) + if err != nil { + return err + } } } @@ -229,7 +228,7 @@ func parse(cmd *cobra.Command, parameters []codersdk.CreateParameterRequest) err return nil } -func awaitAgent(ctx context.Context, client *codersdk.Client, resource codersdk.WorkspaceResource) error { +func awaitAgent(ctx context.Context, client *codersdk.Client, agent codersdk.WorkspaceAgent) error { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { @@ -237,11 +236,11 @@ func awaitAgent(ctx context.Context, client *codersdk.Client, resource codersdk. case <-ctx.Done(): return ctx.Err() case <-ticker.C: - resource, err := client.WorkspaceResource(ctx, resource.ID) + agent, err := client.WorkspaceAgent(ctx, agent.ID) if err != nil { return err } - if resource.Agent.FirstConnectedAt == nil { + if agent.FirstConnectedAt == nil { continue } return nil diff --git a/coderd/coderd.go b/coderd/coderd.go index fc3ac5a9f33e2..3a226b30b87da 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -165,26 +165,31 @@ func New(options *Options) (http.Handler, func()) { }) }) }) - r.Route("/workspaceresources", func(r chi.Router) { - r.Route("/auth", func(r chi.Router) { - r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity) - r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity) - }) - r.Route("/agent", func(r chi.Router) { + r.Route("/workspaceagents", func(r chi.Router) { + r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity) + r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity) + r.Route("/me", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) r.Get("/", api.workspaceAgentListen) r.Get("/gitsshkey", api.agentGitSSHKey) }) - r.Route("/{workspaceresource}", func(r chi.Router) { + r.Route("/{workspaceagent}", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), - httpmw.ExtractWorkspaceResourceParam(options.Database), - httpmw.ExtractWorkspaceParam(options.Database), + httpmw.ExtractWorkspaceAgentParam(options.Database), ) - r.Get("/", api.workspaceResource) - r.Get("/dial", api.workspaceResourceDial) + r.Get("/", api.workspaceAgent) + r.Get("/dial", api.workspaceAgentDial) }) }) + r.Route("/workspaceresources/{workspaceresource}", func(r chi.Router) { + r.Use( + httpmw.ExtractAPIKey(options.Database, nil), + httpmw.ExtractWorkspaceResourceParam(options.Database), + httpmw.ExtractWorkspaceParam(options.Database), + ) + r.Get("/", api.workspaceResource) + }) r.Route("/workspaces/{workspace}", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d56e9002216c5..04667ef3f76c8 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -263,11 +263,10 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID resources, err = client.WorkspaceResourcesByBuild(context.Background(), build) require.NoError(t, err) for _, resource := range resources { - if resource.Agent == nil { - continue - } - if resource.Agent.FirstConnectedAt == nil { - return false + for _, agent := range resource.Agents { + if agent.FirstConnectedAt == nil { + return false + } } } return true diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 6ec73168912cd..45f652d038653 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -634,6 +634,20 @@ func (q *fakeQuerier) GetWorkspaceAgentByAuthToken(_ context.Context, authToken return database.WorkspaceAgent{}, sql.ErrNoRows } +func (q *fakeQuerier) GetWorkspaceAgentByID(_ context.Context, id uuid.UUID) (database.WorkspaceAgent, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + // The schema sorts this by created at, so we iterate the array backwards. + for i := len(q.provisionerJobAgent) - 1; i >= 0; i-- { + agent := q.provisionerJobAgent[i] + if agent.ID.String() == id.String() { + return agent, nil + } + } + return database.WorkspaceAgent{}, sql.ErrNoRows +} + func (q *fakeQuerier) GetWorkspaceAgentByInstanceID(_ context.Context, instanceID string) (database.WorkspaceAgent, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -648,16 +662,23 @@ func (q *fakeQuerier) GetWorkspaceAgentByInstanceID(_ context.Context, instanceI return database.WorkspaceAgent{}, sql.ErrNoRows } -func (q *fakeQuerier) GetWorkspaceAgentByResourceID(_ context.Context, resourceID uuid.UUID) (database.WorkspaceAgent, error) { +func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, resourceIDs []uuid.UUID) ([]database.WorkspaceAgent, error) { q.mutex.RLock() defer q.mutex.RUnlock() + workspaceAgents := make([]database.WorkspaceAgent, 0) for _, agent := range q.provisionerJobAgent { - if agent.ResourceID.String() == resourceID.String() { - return agent, nil + for _, resourceID := range resourceIDs { + if agent.ResourceID.String() != resourceID.String() { + continue + } + workspaceAgents = append(workspaceAgents, agent) } } - return database.WorkspaceAgent{}, sql.ErrNoRows + if len(workspaceAgents) == 0 { + return nil, sql.ErrNoRows + } + return workspaceAgents, nil } func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID) (database.ProvisionerDaemon, error) { @@ -982,6 +1003,9 @@ func (q *fakeQuerier) InsertWorkspaceAgent(_ context.Context, arg database.Inser AuthToken: arg.AuthToken, AuthInstanceID: arg.AuthInstanceID, EnvironmentVariables: arg.EnvironmentVariables, + Name: arg.Name, + Architecture: arg.Architecture, + OperatingSystem: arg.OperatingSystem, StartupScript: arg.StartupScript, InstanceMetadata: arg.InstanceMetadata, ResourceMetadata: arg.ResourceMetadata, @@ -1003,7 +1027,6 @@ func (q *fakeQuerier) InsertWorkspaceResource(_ context.Context, arg database.In Address: arg.Address, Type: arg.Type, Name: arg.Name, - AgentID: arg.AgentID, } q.provisionerJobResource = append(q.provisionerJobResource, resource) return resource, nil diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 1d6cab77e0073..fe6b026abf19a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -235,13 +235,16 @@ CREATE TABLE workspace_agents ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, + name character varying(64) NOT NULL, first_connected_at timestamp with time zone, last_connected_at timestamp with time zone, disconnected_at timestamp with time zone, resource_id uuid NOT NULL, auth_token uuid NOT NULL, auth_instance_id character varying(64), + architecture character varying(64) NOT NULL, environment_variables jsonb, + operating_system character varying(64) NOT NULL, startup_script character varying(65534), instance_metadata jsonb, resource_metadata jsonb @@ -269,8 +272,7 @@ CREATE TABLE workspace_resources ( transition workspace_transition NOT NULL, address character varying(256) NOT NULL, type character varying(192) NOT NULL, - name character varying(64) NOT NULL, - agent_id uuid + name character varying(64) NOT NULL ); CREATE TABLE workspaces ( diff --git a/coderd/database/migrations/000004_jobs.up.sql b/coderd/database/migrations/000004_jobs.up.sql index dbdd6ce1124c3..379857ed3abbd 100644 --- a/coderd/database/migrations/000004_jobs.up.sql +++ b/coderd/database/migrations/000004_jobs.up.sql @@ -69,7 +69,6 @@ CREATE TABLE workspace_resources ( address varchar(256) NOT NULL, type varchar(192) NOT NULL, name varchar(64) NOT NULL, - agent_id uuid, PRIMARY KEY (id) ); @@ -77,13 +76,16 @@ CREATE TABLE workspace_agents ( id uuid NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, + name varchar(64) NOT NULL, first_connected_at timestamptz, last_connected_at timestamptz, disconnected_at timestamptz, resource_id uuid NOT NULL REFERENCES workspace_resources (id) ON DELETE CASCADE, auth_token uuid NOT NULL UNIQUE, auth_instance_id varchar(64), + architecture varchar(64) NOT NULL, environment_variables jsonb, + operating_system varchar(64) NOT NULL, startup_script varchar(65534), instance_metadata jsonb, resource_metadata jsonb, diff --git a/coderd/database/models.go b/coderd/database/models.go index f8ab1959a046c..55dc014d82e66 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -403,13 +403,16 @@ type WorkspaceAgent struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` FirstConnectedAt sql.NullTime `db:"first_connected_at" json:"first_connected_at"` LastConnectedAt sql.NullTime `db:"last_connected_at" json:"last_connected_at"` DisconnectedAt sql.NullTime `db:"disconnected_at" json:"disconnected_at"` ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` AuthToken uuid.UUID `db:"auth_token" json:"auth_token"` AuthInstanceID sql.NullString `db:"auth_instance_id" json:"auth_instance_id"` + Architecture string `db:"architecture" json:"architecture"` EnvironmentVariables pqtype.NullRawMessage `db:"environment_variables" json:"environment_variables"` + OperatingSystem string `db:"operating_system" json:"operating_system"` StartupScript sql.NullString `db:"startup_script" json:"startup_script"` InstanceMetadata pqtype.NullRawMessage `db:"instance_metadata" json:"instance_metadata"` ResourceMetadata pqtype.NullRawMessage `db:"resource_metadata" json:"resource_metadata"` @@ -438,5 +441,4 @@ type WorkspaceResource struct { Address string `db:"address" json:"address"` Type string `db:"type" json:"type"` Name string `db:"name" json:"name"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9da31284a5c49..120da839a1a9e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -39,8 +39,9 @@ type querier interface { GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) 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) - GetWorkspaceAgentByResourceID(ctx context.Context, resourceID uuid.UUID) (WorkspaceAgent, error) + GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceBuild, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f0d620d2e64bf..b37c1e53c7ac8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1907,7 +1907,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one SELECT - id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata FROM workspace_agents WHERE @@ -1923,13 +1923,16 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken &i.ID, &i.CreatedAt, &i.UpdatedAt, + &i.Name, &i.FirstConnectedAt, &i.LastConnectedAt, &i.DisconnectedAt, &i.ResourceID, &i.AuthToken, &i.AuthInstanceID, + &i.Architecture, &i.EnvironmentVariables, + &i.OperatingSystem, &i.StartupScript, &i.InstanceMetadata, &i.ResourceMetadata, @@ -1937,31 +1940,32 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken return i, err } -const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one +const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one SELECT - id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata FROM workspace_agents WHERE - auth_instance_id = $1 :: TEXT -ORDER BY - created_at DESC + id = $1 ` -func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceAgentByInstanceID, authInstanceID) +func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceAgentByID, id) var i WorkspaceAgent err := row.Scan( &i.ID, &i.CreatedAt, &i.UpdatedAt, + &i.Name, &i.FirstConnectedAt, &i.LastConnectedAt, &i.DisconnectedAt, &i.ResourceID, &i.AuthToken, &i.AuthInstanceID, + &i.Architecture, &i.EnvironmentVariables, + &i.OperatingSystem, &i.StartupScript, &i.InstanceMetadata, &i.ResourceMetadata, @@ -1969,29 +1973,34 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst return i, err } -const getWorkspaceAgentByResourceID = `-- name: GetWorkspaceAgentByResourceID :one +const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one SELECT - id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata FROM workspace_agents WHERE - resource_id = $1 + auth_instance_id = $1 :: TEXT +ORDER BY + created_at DESC ` -func (q *sqlQuerier) GetWorkspaceAgentByResourceID(ctx context.Context, resourceID uuid.UUID) (WorkspaceAgent, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceAgentByResourceID, resourceID) +func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceAgentByInstanceID, authInstanceID) var i WorkspaceAgent err := row.Scan( &i.ID, &i.CreatedAt, &i.UpdatedAt, + &i.Name, &i.FirstConnectedAt, &i.LastConnectedAt, &i.DisconnectedAt, &i.ResourceID, &i.AuthToken, &i.AuthInstanceID, + &i.Architecture, &i.EnvironmentVariables, + &i.OperatingSystem, &i.StartupScript, &i.InstanceMetadata, &i.ResourceMetadata, @@ -1999,32 +2008,87 @@ func (q *sqlQuerier) GetWorkspaceAgentByResourceID(ctx context.Context, resource return i, err } +const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many +SELECT + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata +FROM + workspace_agents +WHERE + resource_id = ANY($1 :: uuid [ ]) +` + +func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentsByResourceIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgent + for rows.Next() { + var i WorkspaceAgent + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.FirstConnectedAt, + &i.LastConnectedAt, + &i.DisconnectedAt, + &i.ResourceID, + &i.AuthToken, + &i.AuthInstanceID, + &i.Architecture, + &i.EnvironmentVariables, + &i.OperatingSystem, + &i.StartupScript, + &i.InstanceMetadata, + &i.ResourceMetadata, + ); 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 insertWorkspaceAgent = `-- name: InsertWorkspaceAgent :one INSERT INTO workspace_agents ( id, created_at, updated_at, + name, resource_id, auth_token, auth_instance_id, + architecture, environment_variables, + operating_system, startup_script, instance_metadata, resource_metadata ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata ` type InsertWorkspaceAgentParams struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` AuthToken uuid.UUID `db:"auth_token" json:"auth_token"` AuthInstanceID sql.NullString `db:"auth_instance_id" json:"auth_instance_id"` + Architecture string `db:"architecture" json:"architecture"` EnvironmentVariables pqtype.NullRawMessage `db:"environment_variables" json:"environment_variables"` + OperatingSystem string `db:"operating_system" json:"operating_system"` StartupScript sql.NullString `db:"startup_script" json:"startup_script"` InstanceMetadata pqtype.NullRawMessage `db:"instance_metadata" json:"instance_metadata"` ResourceMetadata pqtype.NullRawMessage `db:"resource_metadata" json:"resource_metadata"` @@ -2035,10 +2099,13 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa arg.ID, arg.CreatedAt, arg.UpdatedAt, + arg.Name, arg.ResourceID, arg.AuthToken, arg.AuthInstanceID, + arg.Architecture, arg.EnvironmentVariables, + arg.OperatingSystem, arg.StartupScript, arg.InstanceMetadata, arg.ResourceMetadata, @@ -2048,13 +2115,16 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa &i.ID, &i.CreatedAt, &i.UpdatedAt, + &i.Name, &i.FirstConnectedAt, &i.LastConnectedAt, &i.DisconnectedAt, &i.ResourceID, &i.AuthToken, &i.AuthInstanceID, + &i.Architecture, &i.EnvironmentVariables, + &i.OperatingSystem, &i.StartupScript, &i.InstanceMetadata, &i.ResourceMetadata, @@ -2405,7 +2475,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor const getWorkspaceResourceByID = `-- name: GetWorkspaceResourceByID :one SELECT - id, created_at, job_id, transition, address, type, name, agent_id + id, created_at, job_id, transition, address, type, name FROM workspace_resources WHERE @@ -2423,14 +2493,13 @@ func (q *sqlQuerier) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) &i.Address, &i.Type, &i.Name, - &i.AgentID, ) return i, err } const getWorkspaceResourcesByJobID = `-- name: GetWorkspaceResourcesByJobID :many SELECT - id, created_at, job_id, transition, address, type, name, agent_id + id, created_at, job_id, transition, address, type, name FROM workspace_resources WHERE @@ -2454,7 +2523,6 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uui &i.Address, &i.Type, &i.Name, - &i.AgentID, ); err != nil { return nil, err } @@ -2478,11 +2546,10 @@ INSERT INTO transition, address, type, - name, - agent_id + name ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, job_id, transition, address, type, name, agent_id + ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, job_id, transition, address, type, name ` type InsertWorkspaceResourceParams struct { @@ -2493,7 +2560,6 @@ type InsertWorkspaceResourceParams struct { Address string `db:"address" json:"address"` Type string `db:"type" json:"type"` Name string `db:"name" json:"name"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` } func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) { @@ -2505,7 +2571,6 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork arg.Address, arg.Type, arg.Name, - arg.AgentID, ) var i WorkspaceResource err := row.Scan( @@ -2516,7 +2581,6 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork &i.Address, &i.Type, &i.Name, - &i.AgentID, ) return i, err } diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index a2a206bba07b0..7d9da4b64a067 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -8,6 +8,14 @@ WHERE ORDER BY created_at DESC; +-- name: GetWorkspaceAgentByID :one +SELECT + * +FROM + workspace_agents +WHERE + id = $1; + -- name: GetWorkspaceAgentByInstanceID :one SELECT * @@ -18,13 +26,13 @@ WHERE ORDER BY created_at DESC; --- name: GetWorkspaceAgentByResourceID :one +-- name: GetWorkspaceAgentsByResourceIDs :many SELECT * FROM workspace_agents WHERE - resource_id = $1; + resource_id = ANY(@ids :: uuid [ ]); -- name: InsertWorkspaceAgent :one INSERT INTO @@ -32,16 +40,19 @@ INSERT INTO id, created_at, updated_at, + name, resource_id, auth_token, auth_instance_id, + architecture, environment_variables, + operating_system, startup_script, instance_metadata, resource_metadata ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateWorkspaceAgentConnectionByID :exec UPDATE diff --git a/coderd/database/queries/workspaceresources.sql b/coderd/database/queries/workspaceresources.sql index 17a352a67f702..869f4a6311880 100644 --- a/coderd/database/queries/workspaceresources.sql +++ b/coderd/database/queries/workspaceresources.sql @@ -23,8 +23,7 @@ INSERT INTO transition, address, type, - name, - agent_id + name ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7) RETURNING *; diff --git a/coderd/gitsshkey_test.go b/coderd/gitsshkey_test.go index affb5d0184a0b..dc9ffe23ca5d4 100644 --- a/coderd/gitsshkey_test.go +++ b/coderd/gitsshkey_test.go @@ -79,51 +79,40 @@ func TestGitSSHKey(t *testing.T) { func TestAgentGitSSHKey(t *testing.T) { t.Parallel() - agentClient := func(algo gitsshkey.Algorithm) *codersdk.Client { - client := coderdtest.New(t, &coderdtest.Options{ - SSHKeygenAlgorithm: algo, - }) - user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionDryRun: echo.ProvisionComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agent: &proto.Agent{ - Id: uuid.NewString(), - Auth: &proto.Agent_Token{ - Token: authToken, - }, + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, }, }}, - }, + }}, }, - }}, - }) - project := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() - - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = authToken + }, + }}, + }) + project := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() - return agentClient - } + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken - t.Run("AgentKey", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - client := agentClient(gitsshkey.AlgorithmEd25519) - agentKey, err := client.AgentGitSSHKey(ctx) - require.NoError(t, err) - require.NotEmpty(t, agentKey.PrivateKey) - }) + agentKey, err := agentClient.AgentGitSSHKey(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, agentKey.PrivateKey) } diff --git a/coderd/httpmw/workspaceagentparam.go b/coderd/httpmw/workspaceagentparam.go new file mode 100644 index 0000000000000..de2b499e9b76d --- /dev/null +++ b/coderd/httpmw/workspaceagentparam.go @@ -0,0 +1,94 @@ +package httpmw + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" +) + +type workspaceAgentParamContextKey struct{} + +// WorkspaceAgentParam returns the workspace agent from the ExtractWorkspaceAgentParam handler. +func WorkspaceAgentParam(r *http.Request) database.WorkspaceAgent { + user, ok := r.Context().Value(workspaceAgentParamContextKey{}).(database.WorkspaceAgent) + if !ok { + panic("developer error: agent middleware not provided") + } + return user +} + +// ExtractWorkspaceAgentParam grabs a workspace agent from the "workspaceagent" URL parameter. +func ExtractWorkspaceAgentParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + agentUUID, parsed := parseUUID(rw, r, "workspaceagent") + if !parsed { + return + } + agent, err := db.GetWorkspaceAgentByID(r.Context(), agentUUID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: "agent doesn't exist with that id", + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get agent: %s", err), + }) + return + } + resource, err := db.GetWorkspaceResourceByID(r.Context(), agent.ResourceID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get resource: %s", err), + }) + return + } + + job, err := db.GetProvisionerJobByID(r.Context(), resource.JobID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get job: %s", err), + }) + return + } + if job.Type != database.ProvisionerJobTypeWorkspaceBuild { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "Workspace agents can only be fetched for builds.", + }) + return + } + build, err := db.GetWorkspaceBuildByJobID(r.Context(), job.ID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace build: %s", err), + }) + return + } + workspace, err := db.GetWorkspaceByID(r.Context(), build.WorkspaceID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace: %s", err), + }) + return + } + + apiKey := APIKey(r) + if apiKey.UserID != workspace.OwnerID { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "getting non-personal agents isn't supported", + }) + return + } + + ctx := context.WithValue(r.Context(), workspaceAgentParamContextKey{}, agent) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/workspaceagentparam_test.go b/coderd/httpmw/workspaceagentparam_test.go new file mode 100644 index 0000000000000..f014a8bd55b55 --- /dev/null +++ b/coderd/httpmw/workspaceagentparam_test.go @@ -0,0 +1,153 @@ +package httpmw_test + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/cryptorand" +) + +func TestWorkspaceAgentParam(t *testing.T) { + t.Parallel() + + setupAuthentication := func(db database.Store) (*http.Request, database.WorkspaceAgent) { + var ( + id, secret = randomAPIKeyParts() + hashed = sha256.Sum256([]byte(secret)) + ) + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{ + Name: httpmw.AuthCookie, + Value: fmt.Sprintf("%s-%s", id, secret), + }) + + userID := uuid.New() + username, err := cryptorand.String(8) + require.NoError(t, err) + user, err := db.InsertUser(r.Context(), database.InsertUserParams{ + ID: userID, + Email: "testaccount@coder.com", + Name: "example", + LoginType: database.LoginTypeBuiltIn, + HashedPassword: hashed[:], + Username: username, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + + _, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: id, + UserID: user.ID, + HashedSecret: hashed[:], + LastUsed: database.Now(), + ExpiresAt: database.Now().Add(time.Minute), + }) + require.NoError(t, err) + + workspace, err := db.InsertWorkspace(context.Background(), database.InsertWorkspaceParams{ + ID: uuid.New(), + TemplateID: uuid.New(), + OwnerID: user.ID, + Name: "potato", + }) + require.NoError(t, err) + + build, err := db.InsertWorkspaceBuild(context.Background(), database.InsertWorkspaceBuildParams{ + ID: uuid.New(), + WorkspaceID: workspace.ID, + JobID: uuid.New(), + }) + require.NoError(t, err) + + job, err := db.InsertProvisionerJob(context.Background(), database.InsertProvisionerJobParams{ + ID: build.JobID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + require.NoError(t, err) + + resource, err := db.InsertWorkspaceResource(context.Background(), database.InsertWorkspaceResourceParams{ + ID: uuid.New(), + JobID: job.ID, + }) + require.NoError(t, err) + + agent, err := db.InsertWorkspaceAgent(context.Background(), database.InsertWorkspaceAgentParams{ + ID: uuid.New(), + ResourceID: resource.ID, + }) + require.NoError(t, err) + + ctx := chi.NewRouteContext() + ctx.URLParams.Add("user", userID.String()) + ctx.URLParams.Add("workspaceagent", agent.ID.String()) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) + return r, agent + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractWorkspaceBuildParam(db)) + rtr.Get("/", nil) + r, _ := setupAuthentication(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractWorkspaceAgentParam(db)) + rtr.Get("/", nil) + + r, _ := setupAuthentication(db) + chi.RouteContext(r.Context()).URLParams.Add("workspaceagent", uuid.NewString()) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("WorkspaceAgent", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractWorkspaceAgentParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.WorkspaceAgentParam(r) + rw.WriteHeader(http.StatusOK) + }) + + r, _ := setupAuthentication(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) +} diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 1de20ce4c1604..4499bbbc3d155 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -587,25 +587,21 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. Address: address, Type: protoResource.Type, Name: protoResource.Name, - AgentID: uuid.NullUUID{ - UUID: uuid.New(), - Valid: protoResource.Agent != nil, - }, }) if err != nil { return xerrors.Errorf("insert provisioner job resource %q: %w", protoResource.Name, err) } - if resource.AgentID.Valid { + for _, agent := range protoResource.Agents { var instanceID sql.NullString - if protoResource.Agent.GetInstanceId() != "" { + if agent.GetInstanceId() != "" { instanceID = sql.NullString{ - String: protoResource.Agent.GetInstanceId(), + String: agent.GetInstanceId(), Valid: true, } } var env pqtype.NullRawMessage - if protoResource.Agent.Env != nil { - data, err := json.Marshal(protoResource.Agent.Env) + if agent.Env != nil { + data, err := json.Marshal(agent.Env) if err != nil { return xerrors.Errorf("marshal env: %w", err) } @@ -615,24 +611,27 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. } } authToken := uuid.New() - if protoResource.Agent.GetToken() != "" { - authToken, err = uuid.Parse(protoResource.Agent.GetToken()) + if agent.GetToken() != "" { + authToken, err = uuid.Parse(agent.GetToken()) if err != nil { return xerrors.Errorf("invalid auth token format; must be uuid: %w", err) } } _, err := db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{ - ID: resource.AgentID.UUID, + ID: uuid.New(), CreatedAt: database.Now(), UpdatedAt: database.Now(), ResourceID: resource.ID, + Name: agent.Name, AuthToken: authToken, AuthInstanceID: instanceID, + Architecture: agent.Architecture, EnvironmentVariables: env, + OperatingSystem: agent.OperatingSystem, StartupScript: sql.NullString{ - String: protoResource.Agent.StartupScript, - Valid: protoResource.Agent.StartupScript != "", + String: agent.StartupScript, + Valid: agent.StartupScript != "", }, }) if err != nil { diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 349805fafb086..71a8c0bdd2f3a 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -196,27 +196,38 @@ func (api *api) provisionerJobResources(rw http.ResponseWriter, r *http.Request, }) return } + resourceIDs := make([]uuid.UUID, 0) + for _, resource := range resources { + resourceIDs = append(resourceIDs, resource.ID) + } + resourceAgents, err := api.Database.GetWorkspaceAgentsByResourceIDs(r.Context(), resourceIDs) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace agents by resources: %s", err), + }) + return + } + apiResources := make([]codersdk.WorkspaceResource, 0) for _, resource := range resources { - if !resource.AgentID.Valid { - apiResources = append(apiResources, convertWorkspaceResource(resource, nil)) - continue - } - agent, err := api.Database.GetWorkspaceAgentByResourceID(r.Context(), resource.ID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get provisioner job agent: %s", err), - }) - return - } - apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("convert provisioner job agent: %s", err), - }) - return + agents := make([]codersdk.WorkspaceAgent, 0) + for _, agent := range resourceAgents { + if agent.ResourceID != resource.ID { + continue + } + apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert provisioner job agent: %s", err), + }) + return + } + agents = append(agents, apiAgent) } - apiResources = append(apiResources, convertWorkspaceResource(resource, &apiAgent)) + apiResources = append(apiResources, convertWorkspaceResource(resource, agents)) } render.Status(r, http.StatusOK) render.JSON(rw, r, apiResources) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index a0f41ccb21db1..d7e00cdd43115 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -210,10 +210,10 @@ func TestTemplateVersionResources(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{}, - }, + }}, }, { Name: "another", Type: "example", @@ -229,7 +229,7 @@ func TestTemplateVersionResources(t *testing.T) { require.Len(t, resources, 4) require.Equal(t, "some", resources[0].Name) require.Equal(t, "example", resources[0].Type) - require.NotNil(t, resources[0].Agent) + require.Len(t, resources[0].Agents, 1) }) } @@ -255,12 +255,12 @@ func TestTemplateVersionLogs(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{ Token: uuid.NewString(), }, - }, + }}, }, { Name: "another", Type: "example", diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go new file mode 100644 index 0000000000000..808ebfac0a924 --- /dev/null +++ b/coderd/workspaceagents.go @@ -0,0 +1,257 @@ +package coderd + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/go-chi/render" + "github.com/hashicorp/yamux" + "golang.org/x/xerrors" + "nhooyr.io/websocket" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/peerbroker" + "github.com/coder/coder/peerbroker/proto" + "github.com/coder/coder/provisionersdk" +) + +func (api *api) workspaceAgent(rw http.ResponseWriter, r *http.Request) { + agent := httpmw.WorkspaceAgentParam(r) + apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert workspace agent: %s", err), + }) + return + } + render.Status(r, http.StatusOK) + render.JSON(rw, r, apiAgent) +} + +func (api *api) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) { + api.websocketWaitMutex.Lock() + api.websocketWaitGroup.Add(1) + api.websocketWaitMutex.Unlock() + defer api.websocketWaitGroup.Done() + + agent := httpmw.WorkspaceAgentParam(r) + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "") + }() + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), config) + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + err = peerbroker.ProxyListen(r.Context(), session, peerbroker.ProxyOptions{ + ChannelID: agent.ID.String(), + Logger: api.Logger.Named("peerbroker-proxy-dial"), + Pubsub: api.Pubsub, + }) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("serve: %s", err)) + return + } +} + +func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { + api.websocketWaitMutex.Lock() + api.websocketWaitGroup.Add(1) + api.websocketWaitMutex.Unlock() + defer api.websocketWaitGroup.Done() + + agent := httpmw.WorkspaceAgent(r) + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + resource, err := api.Database.GetWorkspaceResourceByID(r.Context(), agent.ResourceID) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "") + }() + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), config) + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + closer, err := peerbroker.ProxyDial(proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(session)), peerbroker.ProxyOptions{ + ChannelID: agent.ID.String(), + Pubsub: api.Pubsub, + Logger: api.Logger.Named("peerbroker-proxy-listen"), + }) + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + defer closer.Close() + firstConnectedAt := agent.FirstConnectedAt + if !firstConnectedAt.Valid { + firstConnectedAt = sql.NullTime{ + Time: database.Now(), + Valid: true, + } + } + lastConnectedAt := sql.NullTime{ + Time: database.Now(), + Valid: true, + } + disconnectedAt := agent.DisconnectedAt + updateConnectionTimes := func() error { + err = api.Database.UpdateWorkspaceAgentConnectionByID(r.Context(), database.UpdateWorkspaceAgentConnectionByIDParams{ + ID: agent.ID, + FirstConnectedAt: firstConnectedAt, + LastConnectedAt: lastConnectedAt, + DisconnectedAt: disconnectedAt, + }) + if err != nil { + return err + } + return nil + } + build, err := api.Database.GetWorkspaceBuildByJobID(r.Context(), resource.JobID) + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + // Ensure the resource is still valid! + // We only accept agents for resources on the latest build. + ensureLatestBuild := func() error { + latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), build.WorkspaceID) + if err != nil { + return err + } + if build.ID.String() != latestBuild.ID.String() { + return xerrors.New("build is outdated") + } + return nil + } + + defer func() { + disconnectedAt = sql.NullTime{ + Time: database.Now(), + Valid: true, + } + _ = updateConnectionTimes() + }() + + err = updateConnectionTimes() + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + err = ensureLatestBuild() + if err != nil { + _ = conn.Close(websocket.StatusGoingAway, "") + return + } + + api.Logger.Info(r.Context(), "accepting agent", slog.F("resource", resource), slog.F("agent", agent)) + + ticker := time.NewTicker(api.AgentConnectionUpdateFrequency) + defer ticker.Stop() + for { + select { + case <-session.CloseChan(): + return + case <-ticker.C: + lastConnectedAt = sql.NullTime{ + Time: database.Now(), + Valid: true, + } + err = updateConnectionTimes() + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + err = ensureLatestBuild() + if err != nil { + // Disconnect agents that are no longer valid. + _ = conn.Close(websocket.StatusGoingAway, "") + return + } + } + } +} + +func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) { + var envs map[string]string + if dbAgent.EnvironmentVariables.Valid { + err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs) + if err != nil { + return codersdk.WorkspaceAgent{}, xerrors.Errorf("unmarshal: %w", err) + } + } + agent := codersdk.WorkspaceAgent{ + ID: dbAgent.ID, + CreatedAt: dbAgent.CreatedAt, + UpdatedAt: dbAgent.UpdatedAt, + ResourceID: dbAgent.ResourceID, + InstanceID: dbAgent.AuthInstanceID.String, + Name: dbAgent.Name, + Architecture: dbAgent.Architecture, + OperatingSystem: dbAgent.OperatingSystem, + StartupScript: dbAgent.StartupScript.String, + EnvironmentVariables: envs, + } + if dbAgent.FirstConnectedAt.Valid { + agent.FirstConnectedAt = &dbAgent.FirstConnectedAt.Time + } + if dbAgent.LastConnectedAt.Valid { + agent.LastConnectedAt = &dbAgent.LastConnectedAt.Time + } + if dbAgent.DisconnectedAt.Valid { + agent.DisconnectedAt = &dbAgent.DisconnectedAt.Time + } + switch { + case !dbAgent.FirstConnectedAt.Valid: + // If the agent never connected, it's waiting for the compute + // to start up. + agent.Status = codersdk.WorkspaceAgentWaiting + case dbAgent.DisconnectedAt.Time.After(dbAgent.LastConnectedAt.Time): + // If we've disconnected after our last connection, we know the + // agent is no longer connected. + agent.Status = codersdk.WorkspaceAgentDisconnected + case agentUpdateFrequency*2 >= database.Now().Sub(dbAgent.LastConnectedAt.Time): + // The connection updated it's timestamp within the update frequency. + // We multiply by two to allow for some lag. + agent.Status = codersdk.WorkspaceAgentConnected + case database.Now().Sub(dbAgent.LastConnectedAt.Time) > agentUpdateFrequency*2: + // The connection died without updating the last connected. + agent.Status = codersdk.WorkspaceAgentDisconnected + } + + return agent, nil +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go new file mode 100644 index 0000000000000..8905b7277e5bf --- /dev/null +++ b/coderd/workspaceagents_test.go @@ -0,0 +1,108 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/agent" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/peer" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" +) + +func TestWorkspaceAgent(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + 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, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + + resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID) + require.NoError(t, err) + _, err = client.WorkspaceAgent(context.Background(), resources[0].Agents[0].ID) + require.NoError(t, err) +} + +func TestWorkspaceAgentListen(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + 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, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + conn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = conn.Close() + }) + _, err = conn.Ping() + require.NoError(t, err) +} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 6ee8f2f44e66c..b0e21b5381f22 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -106,7 +106,7 @@ func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job codersdk. } } -func convertWorkspaceResource(resource database.WorkspaceResource, agent *codersdk.WorkspaceAgent) codersdk.WorkspaceResource { +func convertWorkspaceResource(resource database.WorkspaceResource, agents []codersdk.WorkspaceAgent) codersdk.WorkspaceResource { return codersdk.WorkspaceResource{ ID: resource.ID, CreatedAt: resource.CreatedAt, @@ -115,6 +115,6 @@ func convertWorkspaceResource(resource database.WorkspaceResource, agent *coders Address: resource.Address, Type: resource.Type, Name: resource.Name, - Agent: agent, + Agents: agents, } } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 8a576ff7075e8..2e3b2eb7e81e2 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -91,10 +91,10 @@ func TestWorkspaceBuildResources(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{}, - }, + }}, }, { Name: "another", Type: "example", @@ -113,7 +113,7 @@ func TestWorkspaceBuildResources(t *testing.T) { require.Len(t, resources, 2) require.Equal(t, "some", resources[0].Name) require.Equal(t, "example", resources[0].Type) - require.NotNil(t, resources[0].Agent) + require.Len(t, resources[0].Agents, 1) }) } @@ -138,10 +138,10 @@ func TestWorkspaceBuildLogs(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{}, - }, + }}, }, { Name: "another", Type: "example", diff --git a/coderd/workspaceresourceauth_test.go b/coderd/workspaceresourceauth_test.go index 9cfa8e77075c5..3bd9dbd4551ed 100644 --- a/coderd/workspaceresourceauth_test.go +++ b/coderd/workspaceresourceauth_test.go @@ -32,11 +32,11 @@ func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, @@ -98,11 +98,11 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, diff --git a/coderd/workspaceresources.go b/coderd/workspaceresources.go index 532fc64ea5c51..cd854c349f931 100644 --- a/coderd/workspaceresources.go +++ b/coderd/workspaceresources.go @@ -2,26 +2,16 @@ package coderd import ( "database/sql" - "encoding/json" + "errors" "fmt" - "io" "net/http" - "time" "github.com/go-chi/render" - "github.com/hashicorp/yamux" - "golang.org/x/xerrors" - "nhooyr.io/websocket" + "github.com/google/uuid" - "cdr.dev/slog" - - "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/codersdk" - "github.com/coder/coder/peerbroker" - "github.com/coder/coder/peerbroker/proto" - "github.com/coder/coder/provisionersdk" ) func (api *api) workspaceResource(rw http.ResponseWriter, r *http.Request) { @@ -40,255 +30,28 @@ func (api *api) workspaceResource(rw http.ResponseWriter, r *http.Request) { }) return } - var apiAgent *codersdk.WorkspaceAgent - if workspaceResource.AgentID.Valid { - agent, err := api.Database.GetWorkspaceAgentByResourceID(r.Context(), workspaceResource.ID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get provisioner job agent: %s", err), - }) - return - } - convertedAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("convert provisioner job agent: %s", err), - }) - return - } - apiAgent = &convertedAgent + agents, err := api.Database.GetWorkspaceAgentsByResourceIDs(r.Context(), []uuid.UUID{workspaceResource.ID}) + if errors.Is(err, sql.ErrNoRows) { + err = nil } - - render.Status(r, http.StatusOK) - render.JSON(rw, r, convertWorkspaceResource(workspaceResource, apiAgent)) -} - -func (api *api) workspaceResourceDial(rw http.ResponseWriter, r *http.Request) { - api.websocketWaitMutex.Lock() - api.websocketWaitGroup.Add(1) - api.websocketWaitMutex.Unlock() - defer api.websocketWaitGroup.Done() - - resource := httpmw.WorkspaceResourceParam(r) - if !resource.AgentID.Valid { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "resource doesn't have an agent", - }) - return - } - agent, err := api.Database.GetWorkspaceAgentByResourceID(r.Context(), resource.ID) if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("get provisioner job agent: %s", err), - }) - return - } - conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("accept websocket: %s", err), - }) - return - } - defer func() { - _ = conn.Close(websocket.StatusNormalClosure, "") - }() - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), config) - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - err = peerbroker.ProxyListen(r.Context(), session, peerbroker.ProxyOptions{ - ChannelID: agent.ID.String(), - Logger: api.Logger.Named("peerbroker-proxy-dial"), - Pubsub: api.Pubsub, - }) - if err != nil { - _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("serve: %s", err)) - return - } -} - -func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { - api.websocketWaitMutex.Lock() - api.websocketWaitGroup.Add(1) - api.websocketWaitMutex.Unlock() - defer api.websocketWaitGroup.Done() - - agent := httpmw.WorkspaceAgent(r) - conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("accept websocket: %s", err), - }) - return - } - resource, err := api.Database.GetWorkspaceResourceByID(r.Context(), agent.ResourceID) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("accept websocket: %s", err), - }) - return - } - - defer func() { - _ = conn.Close(websocket.StatusNormalClosure, "") - }() - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), config) - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - closer, err := peerbroker.ProxyDial(proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(session)), peerbroker.ProxyOptions{ - ChannelID: agent.ID.String(), - Pubsub: api.Pubsub, - Logger: api.Logger.Named("peerbroker-proxy-listen"), - }) - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - defer closer.Close() - firstConnectedAt := agent.FirstConnectedAt - if !firstConnectedAt.Valid { - firstConnectedAt = sql.NullTime{ - Time: database.Now(), - Valid: true, - } - } - lastConnectedAt := sql.NullTime{ - Time: database.Now(), - Valid: true, - } - disconnectedAt := agent.DisconnectedAt - updateConnectionTimes := func() error { - err = api.Database.UpdateWorkspaceAgentConnectionByID(r.Context(), database.UpdateWorkspaceAgentConnectionByIDParams{ - ID: agent.ID, - FirstConnectedAt: firstConnectedAt, - LastConnectedAt: lastConnectedAt, - DisconnectedAt: disconnectedAt, + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get provisioner job agents: %s", err), }) - if err != nil { - return err - } - return nil - } - build, err := api.Database.GetWorkspaceBuildByJobID(r.Context(), resource.JobID) - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) return } - // Ensure the resource is still valid! - // We only accept agents for resources on the latest build. - ensureLatestBuild := func() error { - latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), build.WorkspaceID) + apiAgents := make([]codersdk.WorkspaceAgent, 0) + for _, agent := range agents { + convertedAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) if err != nil { - return err - } - if build.ID.String() != latestBuild.ID.String() { - return xerrors.New("build is outdated") - } - return nil - } - - defer func() { - disconnectedAt = sql.NullTime{ - Time: database.Now(), - Valid: true, - } - _ = updateConnectionTimes() - }() - - err = updateConnectionTimes() - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - err = ensureLatestBuild() - if err != nil { - _ = conn.Close(websocket.StatusGoingAway, "") - return - } - - api.Logger.Info(r.Context(), "accepting agent", slog.F("resource", resource), slog.F("agent", agent)) - - ticker := time.NewTicker(api.AgentConnectionUpdateFrequency) - defer ticker.Stop() - for { - select { - case <-session.CloseChan(): + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert provisioner job agent: %s", err), + }) return - case <-ticker.C: - lastConnectedAt = sql.NullTime{ - Time: database.Now(), - Valid: true, - } - err = updateConnectionTimes() - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - err = ensureLatestBuild() - if err != nil { - // Disconnect agents that are no longer valid. - _ = conn.Close(websocket.StatusGoingAway, "") - return - } } + apiAgents = append(apiAgents, convertedAgent) } -} -func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) { - var envs map[string]string - if dbAgent.EnvironmentVariables.Valid { - err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs) - if err != nil { - return codersdk.WorkspaceAgent{}, xerrors.Errorf("unmarshal: %w", err) - } - } - agent := codersdk.WorkspaceAgent{ - ID: dbAgent.ID, - CreatedAt: dbAgent.CreatedAt, - UpdatedAt: dbAgent.UpdatedAt, - ResourceID: dbAgent.ResourceID, - InstanceID: dbAgent.AuthInstanceID.String, - StartupScript: dbAgent.StartupScript.String, - EnvironmentVariables: envs, - } - if dbAgent.FirstConnectedAt.Valid { - agent.FirstConnectedAt = &dbAgent.FirstConnectedAt.Time - } - if dbAgent.LastConnectedAt.Valid { - agent.LastConnectedAt = &dbAgent.LastConnectedAt.Time - } - if dbAgent.DisconnectedAt.Valid { - agent.DisconnectedAt = &dbAgent.DisconnectedAt.Time - } - switch { - case !dbAgent.FirstConnectedAt.Valid: - // If the agent never connected, it's waiting for the compute - // to start up. - agent.Status = codersdk.WorkspaceAgentWaiting - case dbAgent.DisconnectedAt.Time.After(dbAgent.LastConnectedAt.Time): - // If we've disconnected after our last connection, we know the - // agent is no longer connected. - agent.Status = codersdk.WorkspaceAgentDisconnected - case agentUpdateFrequency*2 >= database.Now().Sub(dbAgent.LastConnectedAt.Time): - // The connection updated it's timestamp within the update frequency. - // We multiply by two to allow for some lag. - agent.Status = codersdk.WorkspaceAgentConnected - case database.Now().Sub(dbAgent.LastConnectedAt.Time) > agentUpdateFrequency*2: - // The connection died without updating the last connected. - agent.Status = codersdk.WorkspaceAgentDisconnected - } - - return agent, nil + render.Status(r, http.StatusOK) + render.JSON(rw, r, convertWorkspaceResource(workspaceResource, apiAgents)) } diff --git a/coderd/workspaceresources_test.go b/coderd/workspaceresources_test.go index df4e5be31509d..e5673c191c301 100644 --- a/coderd/workspaceresources_test.go +++ b/coderd/workspaceresources_test.go @@ -4,16 +4,10 @@ import ( "context" "testing" - "github.com/google/uuid" "github.com/stretchr/testify/require" - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" - - "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" - "github.com/coder/coder/peer" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" ) @@ -33,10 +27,10 @@ func TestWorkspaceResource(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{}, - }, + }}, }}, }, }, @@ -52,55 +46,3 @@ func TestWorkspaceResource(t *testing.T) { require.NoError(t, err) }) } - -func TestWorkspaceAgentListen(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionDryRun: echo.ProvisionComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agent: &proto.Agent{ - 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, codersdk.Me, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() - - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil), - }) - t.Cleanup(func() { - _ = agentCloser.Close() - }) - resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) - conn, err := client.DialWorkspaceAgent(context.Background(), resources[0].ID, nil, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), - }) - require.NoError(t, err) - t.Cleanup(func() { - _ = conn.Close() - }) - _, err = conn.Ping() - require.NoError(t, err) -} diff --git a/codersdk/gitsshkey.go b/codersdk/gitsshkey.go index b7944ab53ee76..bae8a4c343ce6 100644 --- a/codersdk/gitsshkey.go +++ b/codersdk/gitsshkey.go @@ -56,7 +56,7 @@ func (c *Client) RegenerateGitSSHKey(ctx context.Context, userID uuid.UUID) (Git // AgentGitSSHKey will return the user's SSH key pair for the workspace. func (c *Client) AgentGitSSHKey(ctx context.Context) (AgentGitSSHKey, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceresources/agent/gitsshkey", nil) + res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil) if err != nil { return AgentGitSSHKey{}, xerrors.Errorf("execute request: %w", err) } diff --git a/codersdk/workspaceresourceauth.go b/codersdk/workspaceagents.go similarity index 51% rename from codersdk/workspaceresourceauth.go rename to codersdk/workspaceagents.go index 721a9ca487686..5a83d0b265556 100644 --- a/codersdk/workspaceresourceauth.go +++ b/codersdk/workspaceagents.go @@ -6,9 +6,21 @@ import ( "fmt" "io" "net/http" + "net/http/cookiejar" "cloud.google.com/go/compute/metadata" + "github.com/google/uuid" + "github.com/hashicorp/yamux" + "github.com/pion/webrtc/v3" "golang.org/x/xerrors" + "nhooyr.io/websocket" + + "github.com/coder/coder/agent" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/peer" + "github.com/coder/coder/peerbroker" + "github.com/coder/coder/peerbroker/proto" + "github.com/coder/coder/provisionersdk" ) type GoogleInstanceIdentityToken struct { @@ -43,7 +55,7 @@ func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, servic if err != nil { return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err) } - res, err := c.request(ctx, http.MethodPost, "/api/v2/workspaceresources/auth/google-instance-identity", GoogleInstanceIdentityToken{ + res, err := c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{ JSONWebToken: jwt, }) if err != nil { @@ -107,7 +119,7 @@ func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (Workspac return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err) } - res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceresources/auth/aws-instance-identity", AWSInstanceIdentityToken{ + res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{ Signature: string(signature), Document: string(document), }) @@ -121,3 +133,108 @@ func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (Workspac var resp WorkspaceAgentAuthenticateResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } + +// ListenWorkspaceAgent connects as a workspace agent. +// It obtains the agent ID based off the session token. +func (c *Client) ListenWorkspaceAgent(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) { + serverURL, err := c.URL.Parse("/api/v2/workspaceagents/me") + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: httpmw.AuthCookie, + Value: c.SessionToken, + }}) + httpClient := &http.Client{ + Jar: jar, + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + // Need to disable compression to avoid a data-race. + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if res == nil { + return nil, err + } + return nil, readBodyAsError(res) + } + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) + if err != nil { + return nil, xerrors.Errorf("multiplex client: %w", err) + } + return peerbroker.Listen(session, func(ctx context.Context) ([]webrtc.ICEServer, error) { + return []webrtc.ICEServer{{ + URLs: []string{"stun:stun.l.google.com:19302"}, + }}, nil + }, opts) +} + +// DialWorkspaceAgent creates a connection to the specified resource. +func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, iceServers []webrtc.ICEServer, opts *peer.ConnOptions) (*agent.Conn, error) { + serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/dial", agentID.String())) + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: httpmw.AuthCookie, + Value: c.SessionToken, + }}) + httpClient := &http.Client{ + Jar: jar, + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + // Need to disable compression to avoid a data-race. + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if res == nil { + return nil, err + } + return nil, readBodyAsError(res) + } + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) + if err != nil { + return nil, xerrors.Errorf("multiplex client: %w", err) + } + client := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(session)) + stream, err := client.NegotiateConnection(ctx) + if err != nil { + return nil, xerrors.Errorf("negotiate connection: %w", err) + } + peerConn, err := peerbroker.Dial(stream, iceServers, opts) + if err != nil { + return nil, xerrors.Errorf("dial peer: %w", err) + } + return &agent.Conn{ + Negotiator: client, + Conn: peerConn, + }, nil +} + +// 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) + if err != nil { + return WorkspaceAgent{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgent{}, readBodyAsError(res) + } + var workspaceAgent WorkspaceAgent + return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent) +} diff --git a/codersdk/workspaceresources.go b/codersdk/workspaceresources.go index 593026b7548cb..e5b818ccb4241 100644 --- a/codersdk/workspaceresources.go +++ b/codersdk/workspaceresources.go @@ -4,24 +4,12 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" - "net/http/cookiejar" "time" "github.com/google/uuid" - "github.com/hashicorp/yamux" - "github.com/pion/webrtc/v3" - "golang.org/x/xerrors" - "nhooyr.io/websocket" - "github.com/coder/coder/agent" "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/peer" - "github.com/coder/coder/peerbroker" - "github.com/coder/coder/peerbroker/proto" - "github.com/coder/coder/provisionersdk" ) type WorkspaceAgentStatus string @@ -40,7 +28,7 @@ type WorkspaceResource struct { Address string `json:"address"` Type string `json:"type"` Name string `json:"name"` - Agent *WorkspaceAgent `json:"agent,omitempty"` + Agents []WorkspaceAgent `json:"agents,omitempty"` } type WorkspaceAgent struct { @@ -51,9 +39,12 @@ type WorkspaceAgent struct { LastConnectedAt *time.Time `json:"last_connected_at,omitempty"` DisconnectedAt *time.Time `json:"disconnected_at,omitempty"` Status WorkspaceAgentStatus `json:"status"` + Name string `json:"name"` ResourceID uuid.UUID `json:"resource_id"` InstanceID string `json:"instance_id,omitempty"` + Architecture string `json:"architecture"` EnvironmentVariables map[string]string `json:"environment_variables"` + OperatingSystem string `json:"operating_system"` StartupScript string `json:"startup_script,omitempty"` } @@ -89,94 +80,3 @@ func (c *Client) WorkspaceResource(ctx context.Context, id uuid.UUID) (Workspace var resource WorkspaceResource return resource, json.NewDecoder(res.Body).Decode(&resource) } - -// DialWorkspaceAgent creates a connection to the specified resource. -func (c *Client) DialWorkspaceAgent(ctx context.Context, resource uuid.UUID, iceServers []webrtc.ICEServer, opts *peer.ConnOptions) (*agent.Conn, error) { - serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceresources/%s/dial", resource.String())) - if err != nil { - return nil, xerrors.Errorf("parse url: %w", err) - } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, - Value: c.SessionToken, - }}) - httpClient := &http.Client{ - Jar: jar, - } - conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ - HTTPClient: httpClient, - // Need to disable compression to avoid a data-race. - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - if res == nil { - return nil, err - } - return nil, readBodyAsError(res) - } - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) - if err != nil { - return nil, xerrors.Errorf("multiplex client: %w", err) - } - client := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(session)) - stream, err := client.NegotiateConnection(ctx) - if err != nil { - return nil, xerrors.Errorf("negotiate connection: %w", err) - } - peerConn, err := peerbroker.Dial(stream, iceServers, opts) - if err != nil { - return nil, xerrors.Errorf("dial peer: %w", err) - } - return &agent.Conn{ - Negotiator: client, - Conn: peerConn, - }, nil -} - -// ListenWorkspaceAgent connects as a workspace agent. -// It obtains the agent ID based off the session token. -func (c *Client) ListenWorkspaceAgent(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) { - serverURL, err := c.URL.Parse("/api/v2/workspaceresources/agent") - if err != nil { - return nil, xerrors.Errorf("parse url: %w", err) - } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, - Value: c.SessionToken, - }}) - httpClient := &http.Client{ - Jar: jar, - } - conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ - HTTPClient: httpClient, - // Need to disable compression to avoid a data-race. - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - if res == nil { - return nil, err - } - return nil, readBodyAsError(res) - } - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) - if err != nil { - return nil, xerrors.Errorf("multiplex client: %w", err) - } - return peerbroker.Listen(session, func(ctx context.Context) ([]webrtc.ICEServer, error) { - return []webrtc.ICEServer{{ - URLs: []string{"stun:stun.l.google.com:19302"}, - }}, nil - }, opts) -} diff --git a/examples/aws-linux/main.tf b/examples/aws-linux/main.tf index 6dfa861656ede..7a35095dbb35b 100644 --- a/examples/aws-linux/main.tf +++ b/examples/aws-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.2.1" + version = "~> 0.3.1" } } } @@ -55,12 +55,6 @@ provider "aws" { data "coder_workspace" "me" { } -data "coder_agent_script" "dev" { - arch = "amd64" - auth = "aws-instance-identity" - os = "linux" -} - data "aws_ami" "ubuntu" { most_recent = true filter { @@ -75,8 +69,9 @@ data "aws_ami" "ubuntu" { } resource "coder_agent" "dev" { - count = data.coder_workspace.me.transition == "start" ? 1 : 0 - instance_id = aws_instance.dev[0].id + arch = "amd64" + auth = "aws-instance-identity" + os = "linux" } locals { @@ -105,7 +100,7 @@ Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename="userdata.txt" #!/bin/bash -sudo -E -u ubuntu sh -c '${data.coder_agent_script.dev.value}' +sudo -E -u ubuntu sh -c '${coder_agent.dev.init_script}' --//-- EOT @@ -139,11 +134,9 @@ resource "aws_instance" "dev" { ami = data.aws_ami.ubuntu.id availability_zone = "${var.region}a" instance_type = "t3.micro" - count = 1 user_data = data.coder_workspace.me.transition == "start" ? local.user_data_start : local.user_data_end tags = { Name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}" } - } diff --git a/examples/aws-windows/main.tf b/examples/aws-windows/main.tf index a7104e5425e08..575ee75b8e720 100644 --- a/examples/aws-windows/main.tf +++ b/examples/aws-windows/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.2.1" + version = "~> 0.3.1" } } } @@ -42,12 +42,6 @@ provider "aws" { data "coder_workspace" "me" { } -data "coder_agent_script" "dev" { - arch = "amd64" - auth = "aws-instance-identity" - os = "windows" -} - data "aws_ami" "windows" { most_recent = true owners = ["amazon"] @@ -59,8 +53,9 @@ data "aws_ami" "windows" { } resource "coder_agent" "dev" { - count = data.coder_workspace.me.transition == "start" ? 1 : 0 - instance_id = aws_instance.dev[0].id + arch = "amd64" + auth = "aws-instance-identity" + os = "windows" } locals { @@ -71,7 +66,7 @@ locals { user_data_start = < [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -${data.coder_agent_script.dev.value} +${coder_agent.dev.init_script} true EOT diff --git a/examples/gcp-linux/main.tf b/examples/gcp-linux/main.tf index b3cf30a5cdaf2..ef069a380a03d 100644 --- a/examples/gcp-linux/main.tf +++ b/examples/gcp-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "~> 0.2" + version = "~> 0.3.1" } google = { source = "hashicorp/google" @@ -42,25 +42,14 @@ provider "google" { project = jsondecode(var.service_account).project_id } -data "coder_workspace" "me" { -} - -data "coder_agent_script" "dev" { - auth = "google-instance-identity" - arch = "amd64" - os = "linux" -} - data "google_compute_default_service_account" "default" { } -resource "random_string" "random" { - length = 8 - special = false +data "coder_workspace" "me" { } resource "google_compute_disk" "root" { - name = "coder-${lower(random_string.random.result)}" + name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-root" type = "pd-ssd" zone = var.zone image = "debian-cloud/debian-9" @@ -69,10 +58,16 @@ resource "google_compute_disk" "root" { } } +resource "coder_agent" "dev" { + auth = "google-instance-identity" + arch = "amd64" + os = "linux" +} + resource "google_compute_instance" "dev" { zone = var.zone - count = data.coder_workspace.me.transition == "start" ? 1 : 0 - name = "coder-${lower(random_string.random.result)}" + count = data.coder_workspace.me.start_count + name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}" machine_type = "e2-medium" network_interface { network = "default" @@ -88,10 +83,5 @@ resource "google_compute_instance" "dev" { email = data.google_compute_default_service_account.default.email scopes = ["cloud-platform"] } - metadata_startup_script = data.coder_agent_script.dev.value -} - -resource "coder_agent" "dev" { - count = length(google_compute_instance.dev) - instance_id = google_compute_instance.dev[0].instance_id + metadata_startup_script = coder_agent.dev.init_script } diff --git a/examples/gcp-windows/main.tf b/examples/gcp-windows/main.tf index fe466d7561d79..2202ba8889d69 100644 --- a/examples/gcp-windows/main.tf +++ b/examples/gcp-windows/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "~> 0.2" + version = "~> 0.3.1" } google = { source = "hashicorp/google" @@ -45,22 +45,11 @@ provider "google" { data "coder_workspace" "me" { } -data "coder_agent_script" "dev" { - auth = "google-instance-identity" - arch = "amd64" - os = "windows" -} - data "google_compute_default_service_account" "default" { } -resource "random_string" "random" { - length = 8 - special = false -} - resource "google_compute_disk" "root" { - name = "coder-${lower(random_string.random.result)}" + name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-root" type = "pd-ssd" zone = var.zone image = "projects/windows-cloud/global/images/windows-server-2022-dc-core-v20220215" @@ -69,9 +58,15 @@ resource "google_compute_disk" "root" { } } +resource "coder_agent" "dev" { + auth = "google-instance-identity" + arch = "amd64" + os = "windows" +} + resource "google_compute_instance" "dev" { zone = var.zone - count = data.coder_workspace.me.transition == "start" ? 1 : 0 + count = data.coder_workspace.me.start_count name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}" machine_type = "e2-medium" network_interface { @@ -89,12 +84,7 @@ resource "google_compute_instance" "dev" { scopes = ["cloud-platform"] } metadata = { - windows-startup-script-ps1 = data.coder_agent_script.dev.value + windows-startup-script-ps1 = coder_agent.dev.init_script serial-port-enable = "TRUE" } } - -resource "coder_agent" "dev" { - count = length(google_compute_instance.dev) - instance_id = google_compute_instance.dev[0].instance_id -} diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index 9f990336cce95..a6cd6a9b86bfb 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -286,12 +286,32 @@ func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfi continue } agent := &proto.Agent{ + Name: resource.Name, Auth: &proto.Agent_Token{}, } + if operatingSystemRaw, has := resource.Expressions["os"]; has { + operatingSystem, ok := operatingSystemRaw.ConstantValue.(string) + if ok { + agent.OperatingSystem = operatingSystem + } + } + if archRaw, has := resource.Expressions["arch"]; has { + arch, ok := archRaw.ConstantValue.(string) + if ok { + agent.Architecture = arch + } + } if envRaw, has := resource.Expressions["env"]; has { - env, ok := envRaw.ConstantValue.(map[string]string) + env, ok := envRaw.ConstantValue.(map[string]interface{}) if ok { - agent.Env = env + agent.Env = map[string]string{} + for key, valueRaw := range env { + value, valid := valueRaw.(string) + if !valid { + continue + } + agent.Env[key] = value + } } } if startupScriptRaw, has := resource.Expressions["startup_script"]; has { @@ -320,19 +340,20 @@ func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfi continue } // Associate resources that depend on an agent. - var agent *proto.Agent + resourceAgents := make([]*proto.Agent, 0) for _, dep := range resourceNode { var has bool - agent, has = agents[dep] - if has { - break + agent, has := agents[dep] + if !has { + continue } + resourceAgents = append(resourceAgents, agent) } resources = append(resources, &proto.Resource{ - Name: resource.Name, - Type: resource.Type, - Agent: agent, + Name: resource.Name, + Type: resource.Type, + Agents: resourceAgents, }) } @@ -365,11 +386,13 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state return nil, xerrors.Errorf("find dependencies: %w", err) } type agentAttributes struct { - ID string `mapstructure:"id"` - Token string `mapstructure:"token"` - InstanceID string `mapstructure:"instance_id"` - Env map[string]string `mapstructure:"env"` - StartupScript string `mapstructure:"startup_script"` + Auth string `mapstructure:"auth"` + OperatingSystem string `mapstructure:"os"` + Architecture string `mapstructure:"arch"` + ID string `mapstructure:"id"` + Token string `mapstructure:"token"` + Env map[string]string `mapstructure:"env"` + StartupScript string `mapstructure:"startup_script"` } agents := map[string]*proto.Agent{} @@ -384,24 +407,60 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state return nil, xerrors.Errorf("decode agent attributes: %w", err) } agent := &proto.Agent{ - Id: attrs.ID, - Env: attrs.Env, - StartupScript: attrs.StartupScript, - Auth: &proto.Agent_Token{ - Token: attrs.Token, - }, + Name: resource.Name, + Id: attrs.ID, + Env: attrs.Env, + StartupScript: attrs.StartupScript, + OperatingSystem: attrs.OperatingSystem, + Architecture: attrs.Architecture, } - if attrs.InstanceID != "" { - agent.Auth = &proto.Agent_InstanceId{ - InstanceId: attrs.InstanceID, + switch attrs.Auth { + case "token": + agent.Auth = &proto.Agent_Token{ + Token: attrs.Token, } + default: + agent.Auth = &proto.Agent_InstanceId{} } resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".") agents[resourceKey] = agent } + // Manually associate agents with instance IDs. for _, resource := range state.Values.RootModule.Resources { - if resource.Type == "coder_agent" { + if resource.Type != "coder_agent_instance" { + continue + } + agentIDRaw, valid := resource.AttributeValues["agent_id"] + if !valid { + continue + } + agentID, valid := agentIDRaw.(string) + if !valid { + continue + } + instanceIDRaw, valid := resource.AttributeValues["instance_id"] + if !valid { + continue + } + instanceID, valid := instanceIDRaw.(string) + if !valid { + continue + } + + for _, agent := range agents { + if agent.Id != agentID { + continue + } + agent.Auth = &proto.Agent_InstanceId{ + InstanceId: instanceID, + } + break + } + } + + for _, resource := range state.Values.RootModule.Resources { + if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" { continue } resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".") @@ -410,19 +469,46 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state continue } // Associate resources that depend on an agent. - var agent *proto.Agent + resourceAgents := make([]*proto.Agent, 0) for _, dep := range resourceNode { var has bool - agent, has = agents[dep] - if has { - break + agent, has := agents[dep] + if !has { + continue + } + resourceAgents = append(resourceAgents, agent) + + // Didn't use instance identity. + if agent.GetToken() != "" { + continue + } + + key, isValid := map[string]string{ + "google_compute_instance": "instance_id", + "aws_instance": "id", + }[resource.Type] + if !isValid { + // The resource type doesn't support + // automatically setting the instance ID. + continue + } + instanceIDRaw, valid := resource.AttributeValues[key] + if !valid { + continue + } + instanceID, valid := instanceIDRaw.(string) + if !valid { + continue + } + agent.Auth = &proto.Agent_InstanceId{ + InstanceId: instanceID, } } resources = append(resources, &proto.Resource{ - Name: resource.Name, - Type: resource.Type, - Agent: agent, + Name: resource.Name, + Type: resource.Type, + Agents: resourceAgents, }) } } @@ -467,9 +553,8 @@ func convertTerraformLogLevel(logLevel string) (proto.LogLevel, error) { } } -// findDirectDependencies maps Terraform resources to their parent and -// children nodes. This parses GraphViz output from Terraform which -// certainly is not ideal, but seems reliable. +// findDirectDependencies maps Terraform resources to their children nodes. +// This parses GraphViz output from Terraform which isn't ideal, but seems reliable. func findDirectDependencies(rawGraph string) (map[string][]string, error) { parsedGraph, err := gographviz.ParseString(rawGraph) if err != nil { @@ -488,22 +573,17 @@ func findDirectDependencies(rawGraph string) (map[string][]string, error) { label = strings.Trim(label, `"`) dependencies := make([]string, 0) - for _, edges := range []map[string][]*gographviz.Edge{ - graph.Edges.SrcToDsts[node.Name], - graph.Edges.DstToSrcs[node.Name], - } { - for destination := range edges { - dependencyNode, exists := graph.Nodes.Lookup[destination] - if !exists { - continue - } - label, exists := dependencyNode.Attrs["label"] - if !exists { - continue - } - label = strings.Trim(label, `"`) - dependencies = append(dependencies, label) + for destination := range graph.Edges.SrcToDsts[node.Name] { + dependencyNode, exists := graph.Nodes.Lookup[destination] + if !exists { + continue + } + label, exists := dependencyNode.Attrs["label"] + if !exists { + continue } + label = strings.Trim(label, `"`) + dependencies = append(dependencies, label) } direct[label] = dependencies } diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 3ecd1e72ac903..21c4175d292e1 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -27,7 +27,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.2.1" + version = "0.3" } } } @@ -156,7 +156,10 @@ provider "coder" { Name: "resource-associated-with-agent", Files: map[string]string{ "main.tf": provider + ` - resource "coder_agent" "A" {} + resource "coder_agent" "A" { + os = "windows" + arch = "arm64" + } resource "null_resource" "A" { depends_on = [ coder_agent.A @@ -176,45 +179,14 @@ provider "coder" { Resources: []*proto.Resource{{ Name: "A", Type: "null_resource", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ + Name: "A", + OperatingSystem: "windows", + Architecture: "arm64", Auth: &proto.Agent_Token{ Token: "", }, - }, - }}, - }, - }, - }, - }, { - Name: "agent-associated-with-resource", - Files: map[string]string{ - "main.tf": provider + ` - resource "coder_agent" "A" { - depends_on = [ - null_resource.A - ] - instance_id = "example" - } - resource "null_resource" "A" {}`, - }, - Request: &proto.Provision_Request{ - Type: &proto.Provision_Request_Start{ - Start: &proto.Provision_Start{ - Metadata: &proto.Provision_Metadata{}, - }, - }, - }, - Response: &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "A", - Type: "null_resource", - Agent: &proto.Agent{ - Auth: &proto.Agent_InstanceId{ - InstanceId: "example", - }, - }, + }}, }}, }, }, @@ -225,6 +197,12 @@ provider "coder" { "main.tf": provider + ` resource "coder_agent" "A" { count = 1 + os = "linux" + arch = "amd64" + env = { + test: "example" + } + startup_script = "code-server" } resource "null_resource" "A" { count = length(coder_agent.A) @@ -244,29 +222,42 @@ provider "coder" { Resources: []*proto.Resource{{ Name: "A", Type: "null_resource", - Agent: &proto.Agent{ - Auth: &proto.Agent_Token{}, - }, + Agents: []*proto.Agent{{ + Name: "A", + OperatingSystem: "linux", + Architecture: "amd64", + Auth: &proto.Agent_Token{}, + Env: map[string]string{ + "test": "example", + }, + StartupScript: "code-server", + }}, }}, }, }, }, }, { - Name: "dryrun-agent-associated-with-resource", + Name: "resource-manually-associated-with-agent", Files: map[string]string{ "main.tf": provider + ` resource "coder_agent" "A" { - count = length(null_resource.A) - instance_id = "an-instance" + os = "darwin" + arch = "amd64" } resource "null_resource" "A" { - count = 1 - }`, + depends_on = [ + coder_agent.A + ] + } + resource "coder_agent_instance" "A" { + agent_id = coder_agent.A.id + instance_id = "bananas" + } + `, }, Request: &proto.Provision_Request{ Type: &proto.Provision_Request_Start{ Start: &proto.Provision_Start{ - DryRun: true, Metadata: &proto.Provision_Metadata{}, }, }, @@ -277,31 +268,45 @@ provider "coder" { Resources: []*proto.Resource{{ Name: "A", Type: "null_resource", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ + Name: "A", + OperatingSystem: "darwin", + Architecture: "amd64", Auth: &proto.Agent_InstanceId{ - InstanceId: "", + InstanceId: "bananas", }, - }, + }}, }}, }, }, }, }, { - Name: "dryrun-agent-associated-with-resource-instance-id", + Name: "resource-manually-associated-with-multiple-agents", Files: map[string]string{ "main.tf": provider + ` resource "coder_agent" "A" { - count = length(null_resource.A) - instance_id = length(null_resource.A) + os = "darwin" + arch = "amd64" + } + resource "coder_agent" "B" { + os = "linux" + arch = "amd64" } resource "null_resource" "A" { - count = 1 - }`, + depends_on = [ + coder_agent.A, + coder_agent.B + ] + } + resource "coder_agent_instance" "A" { + agent_id = coder_agent.A.id + instance_id = "bananas" + } + `, }, Request: &proto.Provision_Request{ Type: &proto.Provision_Request_Start{ Start: &proto.Provision_Start{ - DryRun: true, Metadata: &proto.Provision_Metadata{}, }, }, @@ -312,11 +317,21 @@ provider "coder" { Resources: []*proto.Resource{{ Name: "A", Type: "null_resource", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ + Name: "A", + OperatingSystem: "darwin", + Architecture: "amd64", Auth: &proto.Agent_InstanceId{ - InstanceId: "", + InstanceId: "bananas", + }, + }, { + Name: "B", + OperatingSystem: "linux", + Architecture: "amd64", + Auth: &proto.Agent_Token{ + Token: "", }, - }, + }}, }}, }, }, @@ -375,15 +390,12 @@ provider "coder" { // Remove randomly generated data. for _, resource := range msg.GetComplete().Resources { - if resource.Agent == nil { - continue - } - resource.Agent.Id = "" - if resource.Agent.GetToken() == "" { - continue - } - resource.Agent.Auth = &proto.Agent_Token{ - Token: "", + for _, agent := range resource.Agents { + agent.Id = "" + if agent.GetToken() == "" { + continue + } + agent.Auth = &proto.Agent_Token{} } } diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index c5617f74cdfca..72d37a0083f94 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -651,7 +651,7 @@ func (x *Log) GetOutput() string { return "" } -type GoogleInstanceIdentityAuth struct { +type InstanceIdentityAuth struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields @@ -659,8 +659,8 @@ type GoogleInstanceIdentityAuth struct { InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` } -func (x *GoogleInstanceIdentityAuth) Reset() { - *x = GoogleInstanceIdentityAuth{} +func (x *InstanceIdentityAuth) Reset() { + *x = InstanceIdentityAuth{} if protoimpl.UnsafeEnabled { mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -668,13 +668,13 @@ func (x *GoogleInstanceIdentityAuth) Reset() { } } -func (x *GoogleInstanceIdentityAuth) String() string { +func (x *InstanceIdentityAuth) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GoogleInstanceIdentityAuth) ProtoMessage() {} +func (*InstanceIdentityAuth) ProtoMessage() {} -func (x *GoogleInstanceIdentityAuth) ProtoReflect() protoreflect.Message { +func (x *InstanceIdentityAuth) ProtoReflect() protoreflect.Message { mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -686,12 +686,12 @@ func (x *GoogleInstanceIdentityAuth) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GoogleInstanceIdentityAuth.ProtoReflect.Descriptor instead. -func (*GoogleInstanceIdentityAuth) Descriptor() ([]byte, []int) { +// Deprecated: Use InstanceIdentityAuth.ProtoReflect.Descriptor instead. +func (*InstanceIdentityAuth) Descriptor() ([]byte, []int) { return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{6} } -func (x *GoogleInstanceIdentityAuth) GetInstanceId() string { +func (x *InstanceIdentityAuth) GetInstanceId() string { if x != nil { return x.InstanceId } @@ -704,9 +704,12 @@ type Agent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Env map[string]string `protobuf:"bytes,2,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` - StartupScript string `protobuf:"bytes,3,opt,name=startup_script,json=startupScript,proto3" json:"startup_script,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + StartupScript string `protobuf:"bytes,4,opt,name=startup_script,json=startupScript,proto3" json:"startup_script,omitempty"` + OperatingSystem string `protobuf:"bytes,5,opt,name=operating_system,json=operatingSystem,proto3" json:"operating_system,omitempty"` + Architecture string `protobuf:"bytes,6,opt,name=architecture,proto3" json:"architecture,omitempty"` // Types that are assignable to Auth: // *Agent_Token // *Agent_InstanceId @@ -752,6 +755,13 @@ func (x *Agent) GetId() string { return "" } +func (x *Agent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + func (x *Agent) GetEnv() map[string]string { if x != nil { return x.Env @@ -766,6 +776,20 @@ func (x *Agent) GetStartupScript() string { return "" } +func (x *Agent) GetOperatingSystem() string { + if x != nil { + return x.OperatingSystem + } + return "" +} + +func (x *Agent) GetArchitecture() string { + if x != nil { + return x.Architecture + } + return "" +} + func (m *Agent) GetAuth() isAgent_Auth { if m != nil { return m.Auth @@ -792,11 +816,11 @@ type isAgent_Auth interface { } type Agent_Token struct { - Token string `protobuf:"bytes,4,opt,name=token,proto3,oneof"` + Token string `protobuf:"bytes,7,opt,name=token,proto3,oneof"` } type Agent_InstanceId struct { - InstanceId string `protobuf:"bytes,5,opt,name=instance_id,json=instanceId,proto3,oneof"` + InstanceId string `protobuf:"bytes,8,opt,name=instance_id,json=instanceId,proto3,oneof"` } func (*Agent_Token) isAgent_Auth() {} @@ -809,9 +833,9 @@ type Resource struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` - Agent *Agent `protobuf:"bytes,3,opt,name=agent,proto3" json:"agent,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Agents []*Agent `protobuf:"bytes,3,rep,name=agents,proto3" json:"agents,omitempty"` } func (x *Resource) Reset() { @@ -860,9 +884,9 @@ func (x *Resource) GetType() string { return "" } -func (x *Resource) GetAgent() *Agent { +func (x *Resource) GetAgents() []*Agent { if x != nil { - return x.Agent + return x.Agents } return nil } @@ -1609,119 +1633,125 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, - 0x70, 0x75, 0x74, 0x22, 0x3d, 0x0a, 0x1a, 0x47, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x49, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x41, 0x75, 0x74, - 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, - 0x49, 0x64, 0x22, 0xe8, 0x01, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2d, 0x0a, 0x03, - 0x65, 0x6e, 0x76, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12, 0x25, 0x0a, 0x0e, 0x73, - 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x53, 0x63, 0x72, 0x69, - 0x70, 0x74, 0x12, 0x16, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x48, 0x00, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0b, 0x69, 0x6e, - 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, - 0x00, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 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, 0x5c, 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, 0x28, 0x0a, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, - 0x67, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x22, 0xfc, 0x01, 0x0a, 0x05, - 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x70, 0x75, 0x74, 0x22, 0x37, 0x0a, 0x14, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x41, 0x75, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x69, + 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x22, 0xcb, 0x02, 0x0a, + 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2d, 0x0a, 0x03, 0x65, 0x6e, + 0x76, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, + 0x72, 0x74, 0x75, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x12, 0x29, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x79, + 0x73, 0x74, 0x65, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6e, 0x67, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x22, 0x0a, 0x0c, 0x61, + 0x72, 0x63, 0x68, 0x69, 0x74, 0x65, 0x63, 0x74, 0x75, 0x72, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0c, 0x61, 0x72, 0x63, 0x68, 0x69, 0x74, 0x65, 0x63, 0x74, 0x75, 0x72, 0x65, 0x12, + 0x16, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, + 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, + 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, + 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 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, 0x5e, 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, 0x22, 0xfc, 0x01, 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, 0x55, 0x0a, + 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 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, 0xa9, 0x06, 0x0a, 0x09, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xcc, 0x01, 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, 0x1a, 0xd9, 0x01, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, 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, 0x55, - 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 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, 0xa9, 0x06, 0x0a, 0x09, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xcc, 0x01, 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, 0x1a, 0xd9, 0x01, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, - 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, 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, 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, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, - 0x79, 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x72, 0x79, - 0x52, 0x75, 0x6e, 0x1a, 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0x80, 0x01, - 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, - 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 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, 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, 0x53, 0x74, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, - 0x37, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x02, 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, 0x6b, 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, 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, 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, + 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x79, + 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x72, 0x79, 0x52, + 0x75, 0x6e, 0x1a, 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0x80, 0x01, 0x0a, + 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x18, 0x01, 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, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x37, + 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x02, 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, + 0x6b, 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, 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, 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 ( @@ -1739,32 +1769,32 @@ func file_provisionersdk_proto_provisioner_proto_rawDescGZIP() []byte { var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 5) var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ - (LogLevel)(0), // 0: provisioner.LogLevel - (WorkspaceTransition)(0), // 1: provisioner.WorkspaceTransition - (ParameterSource_Scheme)(0), // 2: provisioner.ParameterSource.Scheme - (ParameterDestination_Scheme)(0), // 3: provisioner.ParameterDestination.Scheme - (ParameterSchema_TypeSystem)(0), // 4: provisioner.ParameterSchema.TypeSystem - (*Empty)(nil), // 5: provisioner.Empty - (*ParameterSource)(nil), // 6: provisioner.ParameterSource - (*ParameterDestination)(nil), // 7: provisioner.ParameterDestination - (*ParameterValue)(nil), // 8: provisioner.ParameterValue - (*ParameterSchema)(nil), // 9: provisioner.ParameterSchema - (*Log)(nil), // 10: provisioner.Log - (*GoogleInstanceIdentityAuth)(nil), // 11: provisioner.GoogleInstanceIdentityAuth - (*Agent)(nil), // 12: provisioner.Agent - (*Resource)(nil), // 13: provisioner.Resource - (*Parse)(nil), // 14: provisioner.Parse - (*Provision)(nil), // 15: provisioner.Provision - nil, // 16: provisioner.Agent.EnvEntry - (*Parse_Request)(nil), // 17: provisioner.Parse.Request - (*Parse_Complete)(nil), // 18: provisioner.Parse.Complete - (*Parse_Response)(nil), // 19: provisioner.Parse.Response - (*Provision_Metadata)(nil), // 20: provisioner.Provision.Metadata - (*Provision_Start)(nil), // 21: provisioner.Provision.Start - (*Provision_Cancel)(nil), // 22: provisioner.Provision.Cancel - (*Provision_Request)(nil), // 23: provisioner.Provision.Request - (*Provision_Complete)(nil), // 24: provisioner.Provision.Complete - (*Provision_Response)(nil), // 25: provisioner.Provision.Response + (LogLevel)(0), // 0: provisioner.LogLevel + (WorkspaceTransition)(0), // 1: provisioner.WorkspaceTransition + (ParameterSource_Scheme)(0), // 2: provisioner.ParameterSource.Scheme + (ParameterDestination_Scheme)(0), // 3: provisioner.ParameterDestination.Scheme + (ParameterSchema_TypeSystem)(0), // 4: provisioner.ParameterSchema.TypeSystem + (*Empty)(nil), // 5: provisioner.Empty + (*ParameterSource)(nil), // 6: provisioner.ParameterSource + (*ParameterDestination)(nil), // 7: provisioner.ParameterDestination + (*ParameterValue)(nil), // 8: provisioner.ParameterValue + (*ParameterSchema)(nil), // 9: provisioner.ParameterSchema + (*Log)(nil), // 10: provisioner.Log + (*InstanceIdentityAuth)(nil), // 11: provisioner.InstanceIdentityAuth + (*Agent)(nil), // 12: provisioner.Agent + (*Resource)(nil), // 13: provisioner.Resource + (*Parse)(nil), // 14: provisioner.Parse + (*Provision)(nil), // 15: provisioner.Provision + nil, // 16: provisioner.Agent.EnvEntry + (*Parse_Request)(nil), // 17: provisioner.Parse.Request + (*Parse_Complete)(nil), // 18: provisioner.Parse.Complete + (*Parse_Response)(nil), // 19: provisioner.Parse.Response + (*Provision_Metadata)(nil), // 20: provisioner.Provision.Metadata + (*Provision_Start)(nil), // 21: provisioner.Provision.Start + (*Provision_Cancel)(nil), // 22: provisioner.Provision.Cancel + (*Provision_Request)(nil), // 23: provisioner.Provision.Request + (*Provision_Complete)(nil), // 24: provisioner.Provision.Complete + (*Provision_Response)(nil), // 25: provisioner.Provision.Response } var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 2, // 0: provisioner.ParameterSource.scheme:type_name -> provisioner.ParameterSource.Scheme @@ -1775,7 +1805,7 @@ var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 4, // 5: provisioner.ParameterSchema.validation_type_system:type_name -> provisioner.ParameterSchema.TypeSystem 0, // 6: provisioner.Log.level:type_name -> provisioner.LogLevel 16, // 7: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry - 12, // 8: provisioner.Resource.agent:type_name -> provisioner.Agent + 12, // 8: provisioner.Resource.agents:type_name -> provisioner.Agent 9, // 9: provisioner.Parse.Complete.parameter_schemas:type_name -> provisioner.ParameterSchema 10, // 10: provisioner.Parse.Response.log:type_name -> provisioner.Log 18, // 11: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete @@ -1877,7 +1907,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GoogleInstanceIdentityAuth); i { + switch v := v.(*InstanceIdentityAuth); i { case 0: return &v.state case 1: diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 2f9e4b14589a0..84505a6e9e0e1 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -67,18 +67,21 @@ message Log { string output = 2; } -message GoogleInstanceIdentityAuth { +message InstanceIdentityAuth { string instance_id = 1; } // Agent represents a running agent on the workspace. message Agent { string id = 1; - map env = 2; - string startup_script = 3; + string name = 2; + map env = 3; + string startup_script = 4; + string operating_system = 5; + string architecture = 6; oneof auth { - string token = 4; - string instance_id = 5; + string token = 7; + string instance_id = 8; } } @@ -86,7 +89,7 @@ message Agent { message Resource { string name = 1; string type = 2; - Agent agent = 3; + repeated Agent agents = 3; } // Parse consumes source-code from a directory to produce inputs. From b0cb66d59e77360eccbecde95a33498bc5c730cd Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 9 Apr 2022 17:33:02 +0000 Subject: [PATCH 03/17] Add tree view --- cli/cliui/resources.go | 14 +++++++++++--- cmd/cliui/main.go | 5 +++++ go.mod | 2 ++ go.sum | 2 ++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index 3f73bb2bac077..cf7b8783d3acf 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -8,6 +8,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/spf13/cobra" + "github.com/xlab/treeprint" ) func WorkspaceResources(cmd *cobra.Command, resources []codersdk.WorkspaceResource) error { @@ -37,7 +38,7 @@ func WorkspaceResources(cmd *cobra.Command, resources []codersdk.WorkspaceResour } displayed[resource.Address] = struct{}{} - _, _ = fmt.Fprintf(writer, "%s\t%s\tMacOS\n", resource.Type, resource.Name) + tree := treeprint.NewWithRoot(resource.Type + "." + resource.Name) // _, _ = fmt.Fprintln(cmd.OutOrStdout(), resource.Type+"."+resource.Name) _, existsOnStop := addressOnStop[resource.Address] @@ -46,9 +47,16 @@ func WorkspaceResources(cmd *cobra.Command, resources []codersdk.WorkspaceResour } else { // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Keyword.Render("+ start")+Styles.Placeholder.Render(" (deletes on stop)")) } - if resource.Agent != nil { - // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Fuschia.Render("▲ allows ssh")) + + for _, agent := range resource.Agents { + tree.AddNode(agent.Name + " " + agent.OperatingSystem) } + + fmt.Fprintln(cmd.OutOrStdout(), tree.String()) + + // if resource.Agent != nil { + // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Fuschia.Render("▲ allows ssh")) + // } // _, _ = fmt.Fprintln(cmd.OutOrStdout()) } return writer.Flush() diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index b2ad6a9e866c4..df0825d7b1692 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -191,6 +191,11 @@ func main() { Transition: database.WorkspaceTransitionStart, Type: "google_compute_instance", Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + Name: "dev", + OperatingSystem: "linux", + Architecture: "amd64", + }}, }, { Address: "something", Transition: database.WorkspaceTransitionStart, diff --git a/go.mod b/go.mod index f78d2811529d1..33dd5a88477cd 100644 --- a/go.mod +++ b/go.mod @@ -96,6 +96,8 @@ require ( require github.com/go-chi/httprate v0.5.3 +require github.com/xlab/treeprint v1.1.0 + require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/BurntSushi/toml v1.0.0 // indirect diff --git a/go.sum b/go.sum index 714f071210ea8..2678a432b75c6 100644 --- a/go.sum +++ b/go.sum @@ -1717,6 +1717,8 @@ github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= +github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= From 6ed0bc0ed4e92aa6ebb5cb584e4ab5c2c7139c47 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 9 Apr 2022 18:41:41 +0000 Subject: [PATCH 04/17] Improve table UI --- cli/cliui/resources.go | 125 ++++++++++++++++++++++++++------- cli/cliui/resources_test.go | 85 ++++++++++++++++++++++ cli/ssh.go | 3 +- cli/ssh_test.go | 25 +++---- cli/templatecreate.go | 5 +- cli/templates.go | 49 ------------- cli/workspacecreate.go | 6 +- cli/workspaceshow.go | 22 +++++- cmd/cliui/main.go | 39 ++++++++-- coderd/workspaceagents.go | 2 +- codersdk/workspaceresources.go | 2 +- go.mod | 2 +- go.sum | 6 +- 13 files changed, 267 insertions(+), 104 deletions(-) create mode 100644 cli/cliui/resources_test.go diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index cf7b8783d3acf..36185352f0d91 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -2,20 +2,42 @@ package cliui import ( "fmt" + "io" "sort" - "text/tabwriter" + "strconv" + + "github.com/jedib0t/go-pretty/v6/table" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" - "github.com/spf13/cobra" - "github.com/xlab/treeprint" ) -func WorkspaceResources(cmd *cobra.Command, resources []codersdk.WorkspaceResource) error { +type WorkspaceResourcesOptions struct { + WorkspaceName string + HideAgentState bool + HideAccess bool +} + +// WorkspaceResources displays the connection status and tree-view of provided resources. +// ┌────────────────────────────────────────────────────────────────────────────┐ +// │ RESOURCE STATE ACCESS │ +// ├────────────────────────────────────────────────────────────────────────────┤ +// │ google_compute_disk.root persists on stop │ +// ├────────────────────────────────────────────────────────────────────────────┤ +// │ google_compute_instance.dev │ +// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh wow.dev │ +// ├────────────────────────────────────────────────────────────────────────────┤ +// │ kubernetes_pod.dev │ +// │ ├─ go (linux, amd64) ⦿ connected coder ssh wow.go │ +// │ └─ postgres (linux, amd64) ⦾ disconnected [4s] coder ssh wow.postgres │ +// └────────────────────────────────────────────────────────────────────────────┘ +func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource, options WorkspaceResourcesOptions) error { + // Sort resources by type for consistent output. sort.Slice(resources, func(i, j int) bool { - return fmt.Sprintf("%s.%s", resources[i].Type, resources[i].Name) < fmt.Sprintf("%s.%s", resources[j].Type, resources[j].Name) + return resources[i].Type < resources[j].Type }) + // Address on stop indexes whether a resource still exists when in the stopped transition. addressOnStop := map[string]codersdk.WorkspaceResource{} for _, resource := range resources { if resource.Transition != database.WorkspaceTransitionStop { @@ -23,41 +45,92 @@ func WorkspaceResources(cmd *cobra.Command, resources []codersdk.WorkspaceResour } addressOnStop[resource.Address] = resource } - - writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0) - _, _ = fmt.Fprintf(writer, "Type\tName\tGood\n") + // Displayed stores whether a resource has already been shown. + // Resources can be stored with numerous states, which we + // process prior to display. displayed := map[string]struct{}{} + + tableWriter := table.NewWriter() + tableWriter.SetStyle(table.StyleLight) + tableWriter.Style().Options.SeparateColumns = false + row := table.Row{"Resource", "State"} + if !options.HideAccess { + row = append(row, "Access") + } + tableWriter.AppendHeader(row) + + totalAgents := 0 + for _, resource := range resources { + totalAgents += len(resource.Agents) + } + for _, resource := range resources { if resource.Type == "random_string" { // Hide resources that aren't substantial to a user! + // This is an unfortunate case, and we should allow + // callers to hide resources eventually. continue } - _, alreadyShown := displayed[resource.Address] - if alreadyShown { + if _, shown := displayed[resource.Address]; shown { + // The same resource can have multiple transitions. continue } displayed[resource.Address] = struct{}{} - tree := treeprint.NewWithRoot(resource.Type + "." + resource.Name) - - // _, _ = fmt.Fprintln(cmd.OutOrStdout(), resource.Type+"."+resource.Name) + // Sort agents by name for consistent output. + sort.Slice(resource.Agents, func(i, j int) bool { + return resource.Agents[i].Name < resource.Agents[j].Name + }) _, existsOnStop := addressOnStop[resource.Address] + resourceState := "" if existsOnStop { - // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Warn.Render("~ persistent")) - } else { - // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Keyword.Render("+ start")+Styles.Placeholder.Render(" (deletes on stop)")) + resourceState = Styles.Placeholder.Render("persists on rebuild") } + // Display a line for the resource. + tableWriter.AppendRow(table.Row{ + Styles.Bold.Render(resource.Type + "." + resource.Name), + resourceState, + "", + }) + // Display all agents associated with the resource. + for index, agent := range resource.Agents { + sshCommand := "coder ssh " + options.WorkspaceName + if totalAgents > 1 { + sshCommand += "." + agent.Name + } + sshCommand = Styles.Code.Render(sshCommand) + var agentStatus string + if !options.HideAgentState { + switch agent.Status { + case codersdk.WorkspaceAgentConnecting: + since := database.Now().Sub(agent.CreatedAt) + agentStatus = Styles.Warn.Render("⦾ connecting") + " " + + Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") + case codersdk.WorkspaceAgentDisconnected: + since := database.Now().Sub(*agent.DisconnectedAt) + agentStatus = Styles.Error.Render("⦾ disconnected") + " " + + Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") + case codersdk.WorkspaceAgentConnected: + agentStatus = Styles.Keyword.Render("⦿ connected") + } + } - for _, agent := range resource.Agents { - tree.AddNode(agent.Name + " " + agent.OperatingSystem) + pipe := "├" + if index == len(resource.Agents)-1 { + pipe = "└" + } + row := table.Row{ + // These tree from a resource! + fmt.Sprintf("%s─ %s (%s, %s)", pipe, agent.Name, agent.OperatingSystem, agent.Architecture), + agentStatus, + } + if !options.HideAccess { + row = append(row, sshCommand) + } + tableWriter.AppendRow(row) } - - fmt.Fprintln(cmd.OutOrStdout(), tree.String()) - - // if resource.Agent != nil { - // _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Fuschia.Render("▲ allows ssh")) - // } - // _, _ = fmt.Fprintln(cmd.OutOrStdout()) + tableWriter.AppendSeparator() } - return writer.Flush() + _, err := fmt.Fprintln(writer, tableWriter.Render()) + return err } diff --git a/cli/cliui/resources_test.go b/cli/cliui/resources_test.go new file mode 100644 index 0000000000000..1215340bd018d --- /dev/null +++ b/cli/cliui/resources_test.go @@ -0,0 +1,85 @@ +package cliui_test + +import ( + "testing" + "time" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" +) + +func TestWorkspaceResources(t *testing.T) { + t.Parallel() + t.Run("SingleAgentSSH", func(t *testing.T) { + t.Parallel() + ptty := ptytest.New(t) + cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ + Type: "google_compute_instance", + Name: "dev", + Transition: database.WorkspaceTransitionStart, + Agents: []codersdk.WorkspaceAgent{{ + Name: "dev", + Status: codersdk.WorkspaceAgentConnected, + Architecture: "amd64", + OperatingSystem: "linux", + }}, + }}, cliui.WorkspaceResourcesOptions{ + WorkspaceName: "example", + }) + ptty.ExpectMatch("coder ssh example") + }) + + t.Run("MultipleStates", func(t *testing.T) { + t.Parallel() + ptty := ptytest.New(t) + disconnected := database.Now().Add(-4 * time.Second) + cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ + Address: "disk", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_disk", + Name: "root", + }, { + Address: "disk", + Transition: database.WorkspaceTransitionStop, + Type: "google_compute_disk", + Name: "root", + }, { + Address: "another", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_instance", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + CreatedAt: database.Now().Add(-10 * time.Second), + Status: codersdk.WorkspaceAgentConnecting, + Name: "dev", + OperatingSystem: "linux", + Architecture: "amd64", + }}, + }, { + Transition: database.WorkspaceTransitionStart, + Type: "kubernetes_pod", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + Status: codersdk.WorkspaceAgentConnected, + Name: "go", + Architecture: "amd64", + OperatingSystem: "linux", + }, { + DisconnectedAt: &disconnected, + Status: codersdk.WorkspaceAgentDisconnected, + Name: "postgres", + Architecture: "amd64", + OperatingSystem: "linux", + }}, + }}, cliui.WorkspaceResourcesOptions{ + WorkspaceName: "dev", + HideAgentState: false, + HideAccess: false, + }) + ptty.ExpectMatch("google_compute_disk.root") + ptty.ExpectMatch("google_compute_instance.dev") + ptty.ExpectMatch("coder ssh dev.postgres") + }) +} diff --git a/cli/ssh.go b/cli/ssh.go index 6cdda4423880e..ee503ed97df26 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -125,7 +125,8 @@ func ssh() *cobra.Command { return err } - if isatty.IsTerminal(os.Stdout.Fd()) { + stdoutFile, valid := cmd.OutOrStdout().(*os.File) + if valid && isatty.IsTerminal(stdoutFile.Fd()) { state, err := term.MakeRaw(int(os.Stdin.Fd())) if err != nil { return err diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 208d8379d0b24..d3bd0503607ec 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -54,31 +54,28 @@ func TestSSH(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) - go func() { - // Run this async so the SSH command has to wait for - // the build and agent to connect! - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = agentToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }) - t.Cleanup(func() { - _ = agentCloser.Close() - }) - }() - cmd, root := clitest.New(t, "ssh", workspace.Name) clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) cmd.SetIn(pty.Input()) + cmd.SetErr(pty.Output()) cmd.SetOut(pty.Output()) go func() { defer close(doneChan) err := cmd.Execute() require.NoError(t, err) }() + pty.ExpectMatch("Waiting") + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = agentToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) // Shells on Mac, Windows, and Linux all exit shells with the "exit" command. pty.WriteLine("exit") <-doneChan diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 5b35d12086224..93b8fc266a44a 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -196,5 +196,8 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org if err != nil { return nil, nil, err } - return &version, parameters, displayTemplateVersionInfo(cmd, resources) + return &version, parameters, cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ + HideAgentState: true, + HideAccess: true, + }) } diff --git a/cli/templates.go b/cli/templates.go index e0e47cbfe5f21..9ec6dbef10db0 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -1,15 +1,8 @@ package cli import ( - "fmt" - "sort" - "github.com/fatih/color" "github.com/spf13/cobra" - - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" ) func templates() *cobra.Command { @@ -41,45 +34,3 @@ func templates() *cobra.Command { return cmd } - -func displayTemplateVersionInfo(cmd *cobra.Command, resources []codersdk.WorkspaceResource) error { - fmt.Printf("Previewing template!\n") - - sort.Slice(resources, func(i, j int) bool { - return fmt.Sprintf("%s.%s", resources[i].Type, resources[i].Name) < fmt.Sprintf("%s.%s", resources[j].Type, resources[j].Name) - }) - - addressOnStop := map[string]codersdk.WorkspaceResource{} - for _, resource := range resources { - if resource.Transition != database.WorkspaceTransitionStop { - continue - } - addressOnStop[resource.Address] = resource - } - - displayed := map[string]struct{}{} - for _, resource := range resources { - if resource.Type == "random_string" { - // Hide resources that aren't substantial to a user! - continue - } - _, alreadyShown := displayed[resource.Address] - if alreadyShown { - continue - } - displayed[resource.Address] = struct{}{} - - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Bold.Render("resource."+resource.Type+"."+resource.Name)) - _, existsOnStop := addressOnStop[resource.Address] - if existsOnStop { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Warn.Render("~ persistent")) - } else { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Keyword.Render("+ start")+cliui.Styles.Placeholder.Render(" (deletes on stop)")) - } - if len(resource.Agents) > 0 { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Fuschia.Render("▲ allows ssh")) - } - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - } - return nil -} diff --git a/cli/workspacecreate.go b/cli/workspacecreate.go index 7dbfdf29d793d..d58615a3f9ed9 100644 --- a/cli/workspacecreate.go +++ b/cli/workspacecreate.go @@ -119,7 +119,11 @@ func workspaceCreate() *cobra.Command { if err != nil { return err } - err = displayTemplateVersionInfo(cmd, resources) + err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ + WorkspaceName: workspaceName, + // Since agent's haven't connected yet, hiding this makes more sense. + HideAgentState: true, + }) if err != nil { return err } diff --git a/cli/workspaceshow.go b/cli/workspaceshow.go index 36a7b5040432d..7bad343ce3a67 100644 --- a/cli/workspaceshow.go +++ b/cli/workspaceshow.go @@ -1,14 +1,32 @@ package cli import ( + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" "github.com/spf13/cobra" + "golang.org/x/xerrors" ) func workspaceShow() *cobra.Command { return &cobra.Command{ - Use: "show", + Use: "show", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return nil + client, err := createClient(cmd) + if err != nil { + return err + } + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return xerrors.Errorf("get workspace: %w", err) + } + resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) + if err != nil { + return xerrors.Errorf("get workspace resources: %w", err) + } + return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ + WorkspaceName: workspace.Name, + }) }, } } diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index df0825d7b1692..5e4d58284f8f8 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -186,22 +186,51 @@ func main() { root.AddCommand(&cobra.Command{ Use: "resources", RunE: func(cmd *cobra.Command, args []string) error { - return cliui.WorkspaceResources(cmd, []codersdk.WorkspaceResource{{ + disconnected := database.Now().Add(-4 * time.Second) + cliui.WorkspaceResources(cmd.OutOrStdout(), []codersdk.WorkspaceResource{{ + Address: "disk", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_disk", + Name: "root", + }, { + Address: "disk", + Transition: database.WorkspaceTransitionStop, + Type: "google_compute_disk", + Name: "root", + }, { Address: "another", Transition: database.WorkspaceTransitionStart, Type: "google_compute_instance", Name: "dev", Agents: []codersdk.WorkspaceAgent{{ + CreatedAt: database.Now().Add(-10 * time.Second), + Status: codersdk.WorkspaceAgentConnecting, Name: "dev", OperatingSystem: "linux", Architecture: "amd64", }}, }, { - Address: "something", Transition: database.WorkspaceTransitionStart, - Type: "google_compute_instance", - Name: "something", - }}) + Type: "kubernetes_pod", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + Status: codersdk.WorkspaceAgentConnected, + Name: "go", + Architecture: "amd64", + OperatingSystem: "linux", + }, { + DisconnectedAt: &disconnected, + Status: codersdk.WorkspaceAgentDisconnected, + Name: "postgres", + Architecture: "amd64", + OperatingSystem: "linux", + }}, + }}, cliui.WorkspaceResourcesOptions{ + WorkspaceName: "dev", + HideAgentState: false, + HideAccess: false, + }) + return nil }, }) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 808ebfac0a924..7868691bea85a 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -239,7 +239,7 @@ func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency case !dbAgent.FirstConnectedAt.Valid: // If the agent never connected, it's waiting for the compute // to start up. - agent.Status = codersdk.WorkspaceAgentWaiting + agent.Status = codersdk.WorkspaceAgentConnecting case dbAgent.DisconnectedAt.Time.After(dbAgent.LastConnectedAt.Time): // If we've disconnected after our last connection, we know the // agent is no longer connected. diff --git a/codersdk/workspaceresources.go b/codersdk/workspaceresources.go index e5b818ccb4241..9f9169f58480c 100644 --- a/codersdk/workspaceresources.go +++ b/codersdk/workspaceresources.go @@ -15,7 +15,7 @@ import ( type WorkspaceAgentStatus string const ( - WorkspaceAgentWaiting WorkspaceAgentStatus = "waiting" + WorkspaceAgentConnecting WorkspaceAgentStatus = "connecting" WorkspaceAgentConnected WorkspaceAgentStatus = "connected" WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected" ) diff --git a/go.mod b/go.mod index 33dd5a88477cd..c84bb78b8051b 100644 --- a/go.mod +++ b/go.mod @@ -96,7 +96,7 @@ require ( require github.com/go-chi/httprate v0.5.3 -require github.com/xlab/treeprint v1.1.0 +require github.com/jedib0t/go-pretty/v6 v6.3.0 require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect diff --git a/go.sum b/go.sum index 2678a432b75c6..5c9e2cbda4132 100644 --- a/go.sum +++ b/go.sum @@ -1034,6 +1034,8 @@ github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv github.com/jackc/puddle v1.2.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jedib0t/go-pretty/v6 v6.3.0 h1:QQ5yZPDUMEjbZRXDJtZlvwfDQqCYFaxV3yEzTkogUgk= +github.com/jedib0t/go-pretty/v6 v6.3.0/go.mod h1:FMkOpgGD3EZ91cW8g/96RfxoV7bdeJyzXPYgz1L1ln0= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= @@ -1445,6 +1447,7 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= +github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -1717,8 +1720,6 @@ github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= -github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1990,6 +1991,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cO golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= From 3ad0766bd64376422e544303a5635522dabecf34 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 8 Apr 2022 19:34:40 +0000 Subject: [PATCH 05/17] feat: Allow workspace resources to attach multiple agents This enables a "kubernetes_pod" to attach multiple agents that could be for multiple services. Each agent is required to have a unique name, so SSH syntax is: `coder ssh .` A resource can have zero agents too, they aren't required. --- .vscode/settings.json | 2 +- Makefile | 5 +- cli/cliui/agent.go | 16 +- cli/cliui/agent_test.go | 12 +- cli/configssh.go | 34 +- cli/gitssh_test.go | 6 +- cli/ssh.go | 50 ++- cli/ssh_test.go | 8 +- cli/templates.go | 2 +- cli/workspaceagent_test.go | 12 +- cli/workspaceshow.go | 23 -- cmd/cliui/main.go | 14 +- cmd/templater/main.go | 17 +- coderd/coderd.go | 27 +- coderd/coderdtest/coderdtest.go | 9 +- coderd/database/databasefake/databasefake.go | 33 +- coderd/database/dump.sql | 6 +- coderd/database/migrations/000004_jobs.up.sql | 4 +- coderd/database/models.go | 4 +- coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 112 ++++-- coderd/database/queries/workspaceagents.sql | 17 +- .../database/queries/workspaceresources.sql | 5 +- coderd/gitsshkey_test.go | 73 ++-- coderd/httpmw/workspaceagentparam.go | 94 +++++ coderd/httpmw/workspaceagentparam_test.go | 153 ++++++++ coderd/provisionerdaemons.go | 27 +- coderd/provisionerjobs.go | 47 ++- coderd/templateversions_test.go | 10 +- coderd/workspaceagents.go | 257 +++++++++++++ coderd/workspaceagents_test.go | 108 ++++++ coderd/workspacebuilds.go | 4 +- coderd/workspacebuilds_test.go | 10 +- coderd/workspaceresourceauth_test.go | 8 +- coderd/workspaceresources.go | 269 +------------- coderd/workspaceresources_test.go | 62 +--- codersdk/gitsshkey.go | 2 +- ...paceresourceauth.go => workspaceagents.go} | 121 +++++- codersdk/workspaceresources.go | 108 +----- examples/aws-linux/main.tf | 17 +- examples/aws-windows/main.tf | 15 +- examples/gcp-linux/main.tf | 34 +- examples/gcp-windows/main.tf | 30 +- provisioner/terraform/provision.go | 178 ++++++--- provisioner/terraform/provision_test.go | 153 ++++---- provisionersdk/proto/provisioner.pb.go | 346 ++++++++++-------- provisionersdk/proto/provisioner.proto | 15 +- 47 files changed, 1536 insertions(+), 1026 deletions(-) create mode 100644 coderd/httpmw/workspaceagentparam.go create mode 100644 coderd/httpmw/workspaceagentparam_test.go create mode 100644 coderd/workspaceagents.go create mode 100644 coderd/workspaceagents_test.go rename codersdk/{workspaceresourceauth.go => workspaceagents.go} (51%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 38a091b323f59..2988daa9f0d75 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -62,7 +62,7 @@ "emeraldwalk.runonsave": { "commands": [ { - "match": "database/query.sql", + "match": "database/queries/*.sql", "cmd": "make gen" } ] diff --git a/Makefile b/Makefile index 91baebe6cc1ee..681d565a22c25 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,10 @@ fmt/sql: $(wildcard coderd/database/queries/*.sql) sed -i 's/@ /@/g' ./coderd/database/queries/*.sql -fmt: fmt/prettier fmt/sql +fmt/terraform: $(wildcard *.tf) + terraform fmt -recursive + +fmt: fmt/prettier fmt/sql fmt/terraform .PHONY: fmt gen: coderd/database/generate peerbroker/proto provisionersdk/proto provisionerd/proto diff --git a/cli/cliui/agent.go b/cli/cliui/agent.go index ca32fd3270106..3770d07d5aecf 100644 --- a/cli/cliui/agent.go +++ b/cli/cliui/agent.go @@ -15,7 +15,7 @@ import ( type AgentOptions struct { WorkspaceName string - Fetch func(context.Context) (codersdk.WorkspaceResource, error) + Fetch func(context.Context) (codersdk.WorkspaceAgent, error) FetchInterval time.Duration WarnInterval time.Duration } @@ -29,20 +29,20 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error { opts.WarnInterval = 30 * time.Second } var resourceMutex sync.Mutex - resource, err := opts.Fetch(ctx) + agent, err := opts.Fetch(ctx) if err != nil { return xerrors.Errorf("fetch: %w", err) } - if resource.Agent.Status == codersdk.WorkspaceAgentConnected { + if agent.Status == codersdk.WorkspaceAgentConnected { return nil } - if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { + if agent.Status == codersdk.WorkspaceAgentDisconnected { opts.WarnInterval = 0 } spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen")) spin.Writer = writer spin.ForceOutput = true - spin.Suffix = " Waiting for connection from " + Styles.Field.Render(resource.Type+"."+resource.Name) + "..." + spin.Suffix = " Waiting for connection from " + Styles.Field.Render(agent.Name) + "..." spin.Start() defer spin.Stop() @@ -59,7 +59,7 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error { resourceMutex.Lock() defer resourceMutex.Unlock() message := "Don't panic, your workspace is booting up!" - if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { + if agent.Status == codersdk.WorkspaceAgentDisconnected { message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder workspaces rebuild "+opts.WorkspaceName) } // This saves the cursor position, then defers clearing from the cursor @@ -74,11 +74,11 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error { case <-ticker.C: } resourceMutex.Lock() - resource, err = opts.Fetch(ctx) + agent, err = opts.Fetch(ctx) if err != nil { return xerrors.Errorf("fetch: %w", err) } - if resource.Agent.Status != codersdk.WorkspaceAgentConnected { + if agent.Status != codersdk.WorkspaceAgentConnected { resourceMutex.Unlock() continue } diff --git a/cli/cliui/agent_test.go b/cli/cliui/agent_test.go index 87323c17a6ded..6f717541edd46 100644 --- a/cli/cliui/agent_test.go +++ b/cli/cliui/agent_test.go @@ -22,16 +22,14 @@ func TestAgent(t *testing.T) { RunE: func(cmd *cobra.Command, args []string) error { err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{ WorkspaceName: "example", - Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { - resource := codersdk.WorkspaceResource{ - Agent: &codersdk.WorkspaceAgent{ - Status: codersdk.WorkspaceAgentDisconnected, - }, + Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) { + agent := codersdk.WorkspaceAgent{ + Status: codersdk.WorkspaceAgentDisconnected, } if disconnected.Load() { - resource.Agent.Status = codersdk.WorkspaceAgentConnected + agent.Status = codersdk.WorkspaceAgentConnected } - return resource, nil + return agent, nil }, FetchInterval: time.Millisecond, WarnInterval: 10 * time.Millisecond, diff --git a/cli/configssh.go b/cli/configssh.go index d5c2dca40ff75..6bcd117372f09 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" ) @@ -70,29 +71,30 @@ func configSSH() *cobra.Command { for _, workspace := range workspaces { workspace := workspace errGroup.Go(func() error { - resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) + resources, err := client.TemplateVersionResources(cmd.Context(), workspace.LatestBuild.TemplateVersionID) if err != nil { return err } - resourcesWithAgents := make([]codersdk.WorkspaceResource, 0) for _, resource := range resources { - if resource.Agent == nil { + if resource.Transition != database.WorkspaceTransitionStart { continue } - resourcesWithAgents = append(resourcesWithAgents, resource) - } - sshConfigContentMutex.Lock() - defer sshConfigContentMutex.Unlock() - if len(resourcesWithAgents) == 1 { - sshConfigContent += strings.Join([]string{ - "Host coder." + workspace.Name, - "\tHostName coder." + workspace.Name, - fmt.Sprintf("\tProxyCommand %q ssh --stdio %s", binPath, workspace.Name), - "\tConnectTimeout=0", - "\tStrictHostKeyChecking=no", - }, "\n") + "\n" + for _, agent := range resource.Agents { + sshConfigContentMutex.Lock() + hostname := workspace.Name + if len(resource.Agents) > 1 { + hostname += "." + agent.Name + } + sshConfigContent += strings.Join([]string{ + "Host coder." + hostname, + "\tHostName coder." + hostname, + fmt.Sprintf("\tProxyCommand %q ssh --stdio %s", binPath, hostname), + "\tConnectTimeout=0", + "\tStrictHostKeyChecking=no", + }, "\n") + "\n" + sshConfigContentMutex.Unlock() + } } - return nil }) } diff --git a/cli/gitssh_test.go b/cli/gitssh_test.go index d9b36d103f20d..4ad6f6daea62b 100644 --- a/cli/gitssh_test.go +++ b/cli/gitssh_test.go @@ -49,11 +49,11 @@ func TestGitSSH(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, @@ -81,7 +81,7 @@ func TestGitSSH(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) - dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil, nil) require.NoError(t, err) defer dialer.Close() _, err = dialer.Ping() diff --git a/cli/ssh.go b/cli/ssh.go index e8310c53a2302..6cdda4423880e 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -7,6 +7,7 @@ import ( "os" "time" + "github.com/google/uuid" "github.com/mattn/go-isatty" "github.com/pion/webrtc/v3" "github.com/spf13/cobra" @@ -25,7 +26,7 @@ func ssh() *cobra.Command { stdio bool ) cmd := &cobra.Command{ - Use: "ssh [resource]", + Use: "ssh [agent]", RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { @@ -57,50 +58,45 @@ func ssh() *cobra.Command { return err } - resourceByAddress := make(map[string]codersdk.WorkspaceResource) + agents := make([]codersdk.WorkspaceAgent, 0) for _, resource := range resources { - if resource.Agent == nil { - continue - } - resourceByAddress[resource.Address] = resource + agents = append(agents, resource.Agents...) } - - var resourceAddress string + if len(agents) == 0 { + return xerrors.New("workspace has no agents") + } + var agent codersdk.WorkspaceAgent if len(args) >= 2 { - resourceAddress = args[1] - } else { - // No resource name was provided! - if len(resourceByAddress) > 1 { - // List available resources to connect into? - return xerrors.Errorf("multiple agents") - } - for _, resource := range resourceByAddress { - resourceAddress = resource.Address + for _, otherAgent := range agents { + if otherAgent.Name != args[1] { + continue + } + agent = otherAgent break } + if agent.ID == uuid.Nil { + return xerrors.Errorf("agent not found by name %q", args[1]) + } } - - resource, exists := resourceByAddress[resourceAddress] - if !exists { - resourceKeys := make([]string, 0) - for resourceKey := range resourceByAddress { - resourceKeys = append(resourceKeys, resourceKey) + if agent.ID == uuid.Nil { + if len(agents) > 1 { + return xerrors.New("you must specify the name of an agent") } - return xerrors.Errorf("no sshable agent with address %q: %+v", resourceAddress, resourceKeys) + agent = agents[0] } // OpenSSH passes stderr directly to the calling TTY. // This is required in "stdio" mode so a connecting indicator can be displayed. err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{ WorkspaceName: workspace.Name, - Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { - return client.WorkspaceResource(ctx, resource.ID) + Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) { + return client.WorkspaceAgent(ctx, agent.ID) }, }) if err != nil { return xerrors.Errorf("await agent: %w", err) } - conn, err := client.DialWorkspaceAgent(cmd.Context(), resource.ID, []webrtc.ICEServer{{ + conn, err := client.DialWorkspaceAgent(cmd.Context(), agent.ID, []webrtc.ICEServer{{ URLs: []string{"stun:stun.l.google.com:19302"}, }}, nil) if err != nil { diff --git a/cli/ssh_test.go b/cli/ssh_test.go index cc192150fd028..208d8379d0b24 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -40,12 +40,12 @@ func TestSSH(t *testing.T) { Resources: []*proto.Resource{{ Name: "dev", Type: "google_compute_instance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: uuid.NewString(), Auth: &proto.Agent_Token{ Token: agentToken, }, - }, + }}, }}, }, }, @@ -98,12 +98,12 @@ func TestSSH(t *testing.T) { Resources: []*proto.Resource{{ Name: "dev", Type: "google_compute_instance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: uuid.NewString(), Auth: &proto.Agent_Token{ Token: agentToken, }, - }, + }}, }}, }, }, diff --git a/cli/templates.go b/cli/templates.go index 340d000d03e68..e1f65ca4b1adc 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -74,7 +74,7 @@ func displayTemplateVersionInfo(cmd *cobra.Command, resources []codersdk.Workspa } else { _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Keyword.Render("+ start")+cliui.Styles.Placeholder.Render(" (deletes on stop)")) } - if resource.Agent != nil { + if len(resource.Agents) > 0 { _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Fuschia.Render("▲ allows ssh")) } _, _ = fmt.Fprintln(cmd.OutOrStdout()) diff --git a/cli/workspaceagent_test.go b/cli/workspaceagent_test.go index abad44d13a735..c672c7bb7946c 100644 --- a/cli/workspaceagent_test.go +++ b/cli/workspaceagent_test.go @@ -32,11 +32,11 @@ func TestWorkspaceAgent(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, @@ -61,7 +61,7 @@ func TestWorkspaceAgent(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) - dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil, nil) require.NoError(t, err) defer dialer.Close() _, err = dialer.Ping() @@ -86,11 +86,11 @@ func TestWorkspaceAgent(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, @@ -115,7 +115,7 @@ func TestWorkspaceAgent(t *testing.T) { coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) - dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil, nil) require.NoError(t, err) defer dialer.Close() _, err = dialer.Ping() diff --git a/cli/workspaceshow.go b/cli/workspaceshow.go index 80a2fcc3ca052..36a7b5040432d 100644 --- a/cli/workspaceshow.go +++ b/cli/workspaceshow.go @@ -1,36 +1,13 @@ package cli import ( - "fmt" - "github.com/spf13/cobra" - - "github.com/coder/coder/codersdk" ) func workspaceShow() *cobra.Command { return &cobra.Command{ Use: "show", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) - if err != nil { - return err - } - workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) - if err != nil { - return err - } - resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } - for _, resource := range resources { - if resource.Agent == nil { - continue - } - - _, _ = fmt.Printf("Agent: %+v\n", resource.Agent) - } return nil }, } diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index c633a6b6d2e0f..391e76a35c002 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -161,21 +161,17 @@ func main() { root.AddCommand(&cobra.Command{ Use: "agent", RunE: func(cmd *cobra.Command, args []string) error { - resource := codersdk.WorkspaceResource{ - Type: "google_compute_instance", - Name: "dev", - Agent: &codersdk.WorkspaceAgent{ - Status: codersdk.WorkspaceAgentDisconnected, - }, + agent := codersdk.WorkspaceAgent{ + Status: codersdk.WorkspaceAgentDisconnected, } go func() { time.Sleep(3 * time.Second) - resource.Agent.Status = codersdk.WorkspaceAgentConnected + agent.Status = codersdk.WorkspaceAgentConnected }() err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{ WorkspaceName: "dev", - Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { - return resource, nil + Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) { + return agent, nil }, WarnInterval: 2 * time.Second, }) diff --git a/cmd/templater/main.go b/cmd/templater/main.go index 825ab6a3f0773..effd36fd49a1a 100644 --- a/cmd/templater/main.go +++ b/cmd/templater/main.go @@ -195,12 +195,11 @@ func parse(cmd *cobra.Command, parameters []codersdk.CreateParameterRequest) err return err } for _, resource := range resources { - if resource.Agent == nil { - continue - } - err = awaitAgent(cmd.Context(), client, resource) - if err != nil { - return err + for _, agent := range resource.Agents { + err = awaitAgent(cmd.Context(), client, agent) + if err != nil { + return err + } } } @@ -229,7 +228,7 @@ func parse(cmd *cobra.Command, parameters []codersdk.CreateParameterRequest) err return nil } -func awaitAgent(ctx context.Context, client *codersdk.Client, resource codersdk.WorkspaceResource) error { +func awaitAgent(ctx context.Context, client *codersdk.Client, agent codersdk.WorkspaceAgent) error { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { @@ -237,11 +236,11 @@ func awaitAgent(ctx context.Context, client *codersdk.Client, resource codersdk. case <-ctx.Done(): return ctx.Err() case <-ticker.C: - resource, err := client.WorkspaceResource(ctx, resource.ID) + agent, err := client.WorkspaceAgent(ctx, agent.ID) if err != nil { return err } - if resource.Agent.FirstConnectedAt == nil { + if agent.FirstConnectedAt == nil { continue } return nil diff --git a/coderd/coderd.go b/coderd/coderd.go index fc3ac5a9f33e2..3a226b30b87da 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -165,26 +165,31 @@ func New(options *Options) (http.Handler, func()) { }) }) }) - r.Route("/workspaceresources", func(r chi.Router) { - r.Route("/auth", func(r chi.Router) { - r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity) - r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity) - }) - r.Route("/agent", func(r chi.Router) { + r.Route("/workspaceagents", func(r chi.Router) { + r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity) + r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity) + r.Route("/me", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) r.Get("/", api.workspaceAgentListen) r.Get("/gitsshkey", api.agentGitSSHKey) }) - r.Route("/{workspaceresource}", func(r chi.Router) { + r.Route("/{workspaceagent}", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), - httpmw.ExtractWorkspaceResourceParam(options.Database), - httpmw.ExtractWorkspaceParam(options.Database), + httpmw.ExtractWorkspaceAgentParam(options.Database), ) - r.Get("/", api.workspaceResource) - r.Get("/dial", api.workspaceResourceDial) + r.Get("/", api.workspaceAgent) + r.Get("/dial", api.workspaceAgentDial) }) }) + r.Route("/workspaceresources/{workspaceresource}", func(r chi.Router) { + r.Use( + httpmw.ExtractAPIKey(options.Database, nil), + httpmw.ExtractWorkspaceResourceParam(options.Database), + httpmw.ExtractWorkspaceParam(options.Database), + ) + r.Get("/", api.workspaceResource) + }) r.Route("/workspaces/{workspace}", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d56e9002216c5..04667ef3f76c8 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -263,11 +263,10 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID resources, err = client.WorkspaceResourcesByBuild(context.Background(), build) require.NoError(t, err) for _, resource := range resources { - if resource.Agent == nil { - continue - } - if resource.Agent.FirstConnectedAt == nil { - return false + for _, agent := range resource.Agents { + if agent.FirstConnectedAt == nil { + return false + } } } return true diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 6ec73168912cd..45f652d038653 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -634,6 +634,20 @@ func (q *fakeQuerier) GetWorkspaceAgentByAuthToken(_ context.Context, authToken return database.WorkspaceAgent{}, sql.ErrNoRows } +func (q *fakeQuerier) GetWorkspaceAgentByID(_ context.Context, id uuid.UUID) (database.WorkspaceAgent, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + // The schema sorts this by created at, so we iterate the array backwards. + for i := len(q.provisionerJobAgent) - 1; i >= 0; i-- { + agent := q.provisionerJobAgent[i] + if agent.ID.String() == id.String() { + return agent, nil + } + } + return database.WorkspaceAgent{}, sql.ErrNoRows +} + func (q *fakeQuerier) GetWorkspaceAgentByInstanceID(_ context.Context, instanceID string) (database.WorkspaceAgent, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -648,16 +662,23 @@ func (q *fakeQuerier) GetWorkspaceAgentByInstanceID(_ context.Context, instanceI return database.WorkspaceAgent{}, sql.ErrNoRows } -func (q *fakeQuerier) GetWorkspaceAgentByResourceID(_ context.Context, resourceID uuid.UUID) (database.WorkspaceAgent, error) { +func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, resourceIDs []uuid.UUID) ([]database.WorkspaceAgent, error) { q.mutex.RLock() defer q.mutex.RUnlock() + workspaceAgents := make([]database.WorkspaceAgent, 0) for _, agent := range q.provisionerJobAgent { - if agent.ResourceID.String() == resourceID.String() { - return agent, nil + for _, resourceID := range resourceIDs { + if agent.ResourceID.String() != resourceID.String() { + continue + } + workspaceAgents = append(workspaceAgents, agent) } } - return database.WorkspaceAgent{}, sql.ErrNoRows + if len(workspaceAgents) == 0 { + return nil, sql.ErrNoRows + } + return workspaceAgents, nil } func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID) (database.ProvisionerDaemon, error) { @@ -982,6 +1003,9 @@ func (q *fakeQuerier) InsertWorkspaceAgent(_ context.Context, arg database.Inser AuthToken: arg.AuthToken, AuthInstanceID: arg.AuthInstanceID, EnvironmentVariables: arg.EnvironmentVariables, + Name: arg.Name, + Architecture: arg.Architecture, + OperatingSystem: arg.OperatingSystem, StartupScript: arg.StartupScript, InstanceMetadata: arg.InstanceMetadata, ResourceMetadata: arg.ResourceMetadata, @@ -1003,7 +1027,6 @@ func (q *fakeQuerier) InsertWorkspaceResource(_ context.Context, arg database.In Address: arg.Address, Type: arg.Type, Name: arg.Name, - AgentID: arg.AgentID, } q.provisionerJobResource = append(q.provisionerJobResource, resource) return resource, nil diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 1d6cab77e0073..fe6b026abf19a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -235,13 +235,16 @@ CREATE TABLE workspace_agents ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, + name character varying(64) NOT NULL, first_connected_at timestamp with time zone, last_connected_at timestamp with time zone, disconnected_at timestamp with time zone, resource_id uuid NOT NULL, auth_token uuid NOT NULL, auth_instance_id character varying(64), + architecture character varying(64) NOT NULL, environment_variables jsonb, + operating_system character varying(64) NOT NULL, startup_script character varying(65534), instance_metadata jsonb, resource_metadata jsonb @@ -269,8 +272,7 @@ CREATE TABLE workspace_resources ( transition workspace_transition NOT NULL, address character varying(256) NOT NULL, type character varying(192) NOT NULL, - name character varying(64) NOT NULL, - agent_id uuid + name character varying(64) NOT NULL ); CREATE TABLE workspaces ( diff --git a/coderd/database/migrations/000004_jobs.up.sql b/coderd/database/migrations/000004_jobs.up.sql index dbdd6ce1124c3..379857ed3abbd 100644 --- a/coderd/database/migrations/000004_jobs.up.sql +++ b/coderd/database/migrations/000004_jobs.up.sql @@ -69,7 +69,6 @@ CREATE TABLE workspace_resources ( address varchar(256) NOT NULL, type varchar(192) NOT NULL, name varchar(64) NOT NULL, - agent_id uuid, PRIMARY KEY (id) ); @@ -77,13 +76,16 @@ CREATE TABLE workspace_agents ( id uuid NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, + name varchar(64) NOT NULL, first_connected_at timestamptz, last_connected_at timestamptz, disconnected_at timestamptz, resource_id uuid NOT NULL REFERENCES workspace_resources (id) ON DELETE CASCADE, auth_token uuid NOT NULL UNIQUE, auth_instance_id varchar(64), + architecture varchar(64) NOT NULL, environment_variables jsonb, + operating_system varchar(64) NOT NULL, startup_script varchar(65534), instance_metadata jsonb, resource_metadata jsonb, diff --git a/coderd/database/models.go b/coderd/database/models.go index f8ab1959a046c..55dc014d82e66 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -403,13 +403,16 @@ type WorkspaceAgent struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` FirstConnectedAt sql.NullTime `db:"first_connected_at" json:"first_connected_at"` LastConnectedAt sql.NullTime `db:"last_connected_at" json:"last_connected_at"` DisconnectedAt sql.NullTime `db:"disconnected_at" json:"disconnected_at"` ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` AuthToken uuid.UUID `db:"auth_token" json:"auth_token"` AuthInstanceID sql.NullString `db:"auth_instance_id" json:"auth_instance_id"` + Architecture string `db:"architecture" json:"architecture"` EnvironmentVariables pqtype.NullRawMessage `db:"environment_variables" json:"environment_variables"` + OperatingSystem string `db:"operating_system" json:"operating_system"` StartupScript sql.NullString `db:"startup_script" json:"startup_script"` InstanceMetadata pqtype.NullRawMessage `db:"instance_metadata" json:"instance_metadata"` ResourceMetadata pqtype.NullRawMessage `db:"resource_metadata" json:"resource_metadata"` @@ -438,5 +441,4 @@ type WorkspaceResource struct { Address string `db:"address" json:"address"` Type string `db:"type" json:"type"` Name string `db:"name" json:"name"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9da31284a5c49..120da839a1a9e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -39,8 +39,9 @@ type querier interface { GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) 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) - GetWorkspaceAgentByResourceID(ctx context.Context, resourceID uuid.UUID) (WorkspaceAgent, error) + GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceBuild, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f0d620d2e64bf..b37c1e53c7ac8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1907,7 +1907,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one SELECT - id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata FROM workspace_agents WHERE @@ -1923,13 +1923,16 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken &i.ID, &i.CreatedAt, &i.UpdatedAt, + &i.Name, &i.FirstConnectedAt, &i.LastConnectedAt, &i.DisconnectedAt, &i.ResourceID, &i.AuthToken, &i.AuthInstanceID, + &i.Architecture, &i.EnvironmentVariables, + &i.OperatingSystem, &i.StartupScript, &i.InstanceMetadata, &i.ResourceMetadata, @@ -1937,31 +1940,32 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken return i, err } -const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one +const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one SELECT - id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata FROM workspace_agents WHERE - auth_instance_id = $1 :: TEXT -ORDER BY - created_at DESC + id = $1 ` -func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceAgentByInstanceID, authInstanceID) +func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceAgentByID, id) var i WorkspaceAgent err := row.Scan( &i.ID, &i.CreatedAt, &i.UpdatedAt, + &i.Name, &i.FirstConnectedAt, &i.LastConnectedAt, &i.DisconnectedAt, &i.ResourceID, &i.AuthToken, &i.AuthInstanceID, + &i.Architecture, &i.EnvironmentVariables, + &i.OperatingSystem, &i.StartupScript, &i.InstanceMetadata, &i.ResourceMetadata, @@ -1969,29 +1973,34 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst return i, err } -const getWorkspaceAgentByResourceID = `-- name: GetWorkspaceAgentByResourceID :one +const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one SELECT - id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata FROM workspace_agents WHERE - resource_id = $1 + auth_instance_id = $1 :: TEXT +ORDER BY + created_at DESC ` -func (q *sqlQuerier) GetWorkspaceAgentByResourceID(ctx context.Context, resourceID uuid.UUID) (WorkspaceAgent, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceAgentByResourceID, resourceID) +func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceAgentByInstanceID, authInstanceID) var i WorkspaceAgent err := row.Scan( &i.ID, &i.CreatedAt, &i.UpdatedAt, + &i.Name, &i.FirstConnectedAt, &i.LastConnectedAt, &i.DisconnectedAt, &i.ResourceID, &i.AuthToken, &i.AuthInstanceID, + &i.Architecture, &i.EnvironmentVariables, + &i.OperatingSystem, &i.StartupScript, &i.InstanceMetadata, &i.ResourceMetadata, @@ -1999,32 +2008,87 @@ func (q *sqlQuerier) GetWorkspaceAgentByResourceID(ctx context.Context, resource return i, err } +const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many +SELECT + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata +FROM + workspace_agents +WHERE + resource_id = ANY($1 :: uuid [ ]) +` + +func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentsByResourceIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgent + for rows.Next() { + var i WorkspaceAgent + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.FirstConnectedAt, + &i.LastConnectedAt, + &i.DisconnectedAt, + &i.ResourceID, + &i.AuthToken, + &i.AuthInstanceID, + &i.Architecture, + &i.EnvironmentVariables, + &i.OperatingSystem, + &i.StartupScript, + &i.InstanceMetadata, + &i.ResourceMetadata, + ); 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 insertWorkspaceAgent = `-- name: InsertWorkspaceAgent :one INSERT INTO workspace_agents ( id, created_at, updated_at, + name, resource_id, auth_token, auth_instance_id, + architecture, environment_variables, + operating_system, startup_script, instance_metadata, resource_metadata ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata ` type InsertWorkspaceAgentParams struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` AuthToken uuid.UUID `db:"auth_token" json:"auth_token"` AuthInstanceID sql.NullString `db:"auth_instance_id" json:"auth_instance_id"` + Architecture string `db:"architecture" json:"architecture"` EnvironmentVariables pqtype.NullRawMessage `db:"environment_variables" json:"environment_variables"` + OperatingSystem string `db:"operating_system" json:"operating_system"` StartupScript sql.NullString `db:"startup_script" json:"startup_script"` InstanceMetadata pqtype.NullRawMessage `db:"instance_metadata" json:"instance_metadata"` ResourceMetadata pqtype.NullRawMessage `db:"resource_metadata" json:"resource_metadata"` @@ -2035,10 +2099,13 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa arg.ID, arg.CreatedAt, arg.UpdatedAt, + arg.Name, arg.ResourceID, arg.AuthToken, arg.AuthInstanceID, + arg.Architecture, arg.EnvironmentVariables, + arg.OperatingSystem, arg.StartupScript, arg.InstanceMetadata, arg.ResourceMetadata, @@ -2048,13 +2115,16 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa &i.ID, &i.CreatedAt, &i.UpdatedAt, + &i.Name, &i.FirstConnectedAt, &i.LastConnectedAt, &i.DisconnectedAt, &i.ResourceID, &i.AuthToken, &i.AuthInstanceID, + &i.Architecture, &i.EnvironmentVariables, + &i.OperatingSystem, &i.StartupScript, &i.InstanceMetadata, &i.ResourceMetadata, @@ -2405,7 +2475,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor const getWorkspaceResourceByID = `-- name: GetWorkspaceResourceByID :one SELECT - id, created_at, job_id, transition, address, type, name, agent_id + id, created_at, job_id, transition, address, type, name FROM workspace_resources WHERE @@ -2423,14 +2493,13 @@ func (q *sqlQuerier) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) &i.Address, &i.Type, &i.Name, - &i.AgentID, ) return i, err } const getWorkspaceResourcesByJobID = `-- name: GetWorkspaceResourcesByJobID :many SELECT - id, created_at, job_id, transition, address, type, name, agent_id + id, created_at, job_id, transition, address, type, name FROM workspace_resources WHERE @@ -2454,7 +2523,6 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uui &i.Address, &i.Type, &i.Name, - &i.AgentID, ); err != nil { return nil, err } @@ -2478,11 +2546,10 @@ INSERT INTO transition, address, type, - name, - agent_id + name ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, job_id, transition, address, type, name, agent_id + ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, job_id, transition, address, type, name ` type InsertWorkspaceResourceParams struct { @@ -2493,7 +2560,6 @@ type InsertWorkspaceResourceParams struct { Address string `db:"address" json:"address"` Type string `db:"type" json:"type"` Name string `db:"name" json:"name"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` } func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) { @@ -2505,7 +2571,6 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork arg.Address, arg.Type, arg.Name, - arg.AgentID, ) var i WorkspaceResource err := row.Scan( @@ -2516,7 +2581,6 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork &i.Address, &i.Type, &i.Name, - &i.AgentID, ) return i, err } diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index a2a206bba07b0..7d9da4b64a067 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -8,6 +8,14 @@ WHERE ORDER BY created_at DESC; +-- name: GetWorkspaceAgentByID :one +SELECT + * +FROM + workspace_agents +WHERE + id = $1; + -- name: GetWorkspaceAgentByInstanceID :one SELECT * @@ -18,13 +26,13 @@ WHERE ORDER BY created_at DESC; --- name: GetWorkspaceAgentByResourceID :one +-- name: GetWorkspaceAgentsByResourceIDs :many SELECT * FROM workspace_agents WHERE - resource_id = $1; + resource_id = ANY(@ids :: uuid [ ]); -- name: InsertWorkspaceAgent :one INSERT INTO @@ -32,16 +40,19 @@ INSERT INTO id, created_at, updated_at, + name, resource_id, auth_token, auth_instance_id, + architecture, environment_variables, + operating_system, startup_script, instance_metadata, resource_metadata ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateWorkspaceAgentConnectionByID :exec UPDATE diff --git a/coderd/database/queries/workspaceresources.sql b/coderd/database/queries/workspaceresources.sql index 17a352a67f702..869f4a6311880 100644 --- a/coderd/database/queries/workspaceresources.sql +++ b/coderd/database/queries/workspaceresources.sql @@ -23,8 +23,7 @@ INSERT INTO transition, address, type, - name, - agent_id + name ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7) RETURNING *; diff --git a/coderd/gitsshkey_test.go b/coderd/gitsshkey_test.go index affb5d0184a0b..dc9ffe23ca5d4 100644 --- a/coderd/gitsshkey_test.go +++ b/coderd/gitsshkey_test.go @@ -79,51 +79,40 @@ func TestGitSSHKey(t *testing.T) { func TestAgentGitSSHKey(t *testing.T) { t.Parallel() - agentClient := func(algo gitsshkey.Algorithm) *codersdk.Client { - client := coderdtest.New(t, &coderdtest.Options{ - SSHKeygenAlgorithm: algo, - }) - user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionDryRun: echo.ProvisionComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agent: &proto.Agent{ - Id: uuid.NewString(), - Auth: &proto.Agent_Token{ - Token: authToken, - }, + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, }, }}, - }, + }}, }, - }}, - }) - project := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() - - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = authToken + }, + }}, + }) + project := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() - return agentClient - } + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken - t.Run("AgentKey", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - client := agentClient(gitsshkey.AlgorithmEd25519) - agentKey, err := client.AgentGitSSHKey(ctx) - require.NoError(t, err) - require.NotEmpty(t, agentKey.PrivateKey) - }) + agentKey, err := agentClient.AgentGitSSHKey(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, agentKey.PrivateKey) } diff --git a/coderd/httpmw/workspaceagentparam.go b/coderd/httpmw/workspaceagentparam.go new file mode 100644 index 0000000000000..de2b499e9b76d --- /dev/null +++ b/coderd/httpmw/workspaceagentparam.go @@ -0,0 +1,94 @@ +package httpmw + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" +) + +type workspaceAgentParamContextKey struct{} + +// WorkspaceAgentParam returns the workspace agent from the ExtractWorkspaceAgentParam handler. +func WorkspaceAgentParam(r *http.Request) database.WorkspaceAgent { + user, ok := r.Context().Value(workspaceAgentParamContextKey{}).(database.WorkspaceAgent) + if !ok { + panic("developer error: agent middleware not provided") + } + return user +} + +// ExtractWorkspaceAgentParam grabs a workspace agent from the "workspaceagent" URL parameter. +func ExtractWorkspaceAgentParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + agentUUID, parsed := parseUUID(rw, r, "workspaceagent") + if !parsed { + return + } + agent, err := db.GetWorkspaceAgentByID(r.Context(), agentUUID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: "agent doesn't exist with that id", + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get agent: %s", err), + }) + return + } + resource, err := db.GetWorkspaceResourceByID(r.Context(), agent.ResourceID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get resource: %s", err), + }) + return + } + + job, err := db.GetProvisionerJobByID(r.Context(), resource.JobID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get job: %s", err), + }) + return + } + if job.Type != database.ProvisionerJobTypeWorkspaceBuild { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "Workspace agents can only be fetched for builds.", + }) + return + } + build, err := db.GetWorkspaceBuildByJobID(r.Context(), job.ID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace build: %s", err), + }) + return + } + workspace, err := db.GetWorkspaceByID(r.Context(), build.WorkspaceID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace: %s", err), + }) + return + } + + apiKey := APIKey(r) + if apiKey.UserID != workspace.OwnerID { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "getting non-personal agents isn't supported", + }) + return + } + + ctx := context.WithValue(r.Context(), workspaceAgentParamContextKey{}, agent) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/workspaceagentparam_test.go b/coderd/httpmw/workspaceagentparam_test.go new file mode 100644 index 0000000000000..f014a8bd55b55 --- /dev/null +++ b/coderd/httpmw/workspaceagentparam_test.go @@ -0,0 +1,153 @@ +package httpmw_test + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/cryptorand" +) + +func TestWorkspaceAgentParam(t *testing.T) { + t.Parallel() + + setupAuthentication := func(db database.Store) (*http.Request, database.WorkspaceAgent) { + var ( + id, secret = randomAPIKeyParts() + hashed = sha256.Sum256([]byte(secret)) + ) + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{ + Name: httpmw.AuthCookie, + Value: fmt.Sprintf("%s-%s", id, secret), + }) + + userID := uuid.New() + username, err := cryptorand.String(8) + require.NoError(t, err) + user, err := db.InsertUser(r.Context(), database.InsertUserParams{ + ID: userID, + Email: "testaccount@coder.com", + Name: "example", + LoginType: database.LoginTypeBuiltIn, + HashedPassword: hashed[:], + Username: username, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + + _, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: id, + UserID: user.ID, + HashedSecret: hashed[:], + LastUsed: database.Now(), + ExpiresAt: database.Now().Add(time.Minute), + }) + require.NoError(t, err) + + workspace, err := db.InsertWorkspace(context.Background(), database.InsertWorkspaceParams{ + ID: uuid.New(), + TemplateID: uuid.New(), + OwnerID: user.ID, + Name: "potato", + }) + require.NoError(t, err) + + build, err := db.InsertWorkspaceBuild(context.Background(), database.InsertWorkspaceBuildParams{ + ID: uuid.New(), + WorkspaceID: workspace.ID, + JobID: uuid.New(), + }) + require.NoError(t, err) + + job, err := db.InsertProvisionerJob(context.Background(), database.InsertProvisionerJobParams{ + ID: build.JobID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + require.NoError(t, err) + + resource, err := db.InsertWorkspaceResource(context.Background(), database.InsertWorkspaceResourceParams{ + ID: uuid.New(), + JobID: job.ID, + }) + require.NoError(t, err) + + agent, err := db.InsertWorkspaceAgent(context.Background(), database.InsertWorkspaceAgentParams{ + ID: uuid.New(), + ResourceID: resource.ID, + }) + require.NoError(t, err) + + ctx := chi.NewRouteContext() + ctx.URLParams.Add("user", userID.String()) + ctx.URLParams.Add("workspaceagent", agent.ID.String()) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) + return r, agent + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractWorkspaceBuildParam(db)) + rtr.Get("/", nil) + r, _ := setupAuthentication(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractWorkspaceAgentParam(db)) + rtr.Get("/", nil) + + r, _ := setupAuthentication(db) + chi.RouteContext(r.Context()).URLParams.Add("workspaceagent", uuid.NewString()) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("WorkspaceAgent", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractWorkspaceAgentParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.WorkspaceAgentParam(r) + rw.WriteHeader(http.StatusOK) + }) + + r, _ := setupAuthentication(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) +} diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 1de20ce4c1604..4499bbbc3d155 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -587,25 +587,21 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. Address: address, Type: protoResource.Type, Name: protoResource.Name, - AgentID: uuid.NullUUID{ - UUID: uuid.New(), - Valid: protoResource.Agent != nil, - }, }) if err != nil { return xerrors.Errorf("insert provisioner job resource %q: %w", protoResource.Name, err) } - if resource.AgentID.Valid { + for _, agent := range protoResource.Agents { var instanceID sql.NullString - if protoResource.Agent.GetInstanceId() != "" { + if agent.GetInstanceId() != "" { instanceID = sql.NullString{ - String: protoResource.Agent.GetInstanceId(), + String: agent.GetInstanceId(), Valid: true, } } var env pqtype.NullRawMessage - if protoResource.Agent.Env != nil { - data, err := json.Marshal(protoResource.Agent.Env) + if agent.Env != nil { + data, err := json.Marshal(agent.Env) if err != nil { return xerrors.Errorf("marshal env: %w", err) } @@ -615,24 +611,27 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. } } authToken := uuid.New() - if protoResource.Agent.GetToken() != "" { - authToken, err = uuid.Parse(protoResource.Agent.GetToken()) + if agent.GetToken() != "" { + authToken, err = uuid.Parse(agent.GetToken()) if err != nil { return xerrors.Errorf("invalid auth token format; must be uuid: %w", err) } } _, err := db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{ - ID: resource.AgentID.UUID, + ID: uuid.New(), CreatedAt: database.Now(), UpdatedAt: database.Now(), ResourceID: resource.ID, + Name: agent.Name, AuthToken: authToken, AuthInstanceID: instanceID, + Architecture: agent.Architecture, EnvironmentVariables: env, + OperatingSystem: agent.OperatingSystem, StartupScript: sql.NullString{ - String: protoResource.Agent.StartupScript, - Valid: protoResource.Agent.StartupScript != "", + String: agent.StartupScript, + Valid: agent.StartupScript != "", }, }) if err != nil { diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 349805fafb086..71a8c0bdd2f3a 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -196,27 +196,38 @@ func (api *api) provisionerJobResources(rw http.ResponseWriter, r *http.Request, }) return } + resourceIDs := make([]uuid.UUID, 0) + for _, resource := range resources { + resourceIDs = append(resourceIDs, resource.ID) + } + resourceAgents, err := api.Database.GetWorkspaceAgentsByResourceIDs(r.Context(), resourceIDs) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace agents by resources: %s", err), + }) + return + } + apiResources := make([]codersdk.WorkspaceResource, 0) for _, resource := range resources { - if !resource.AgentID.Valid { - apiResources = append(apiResources, convertWorkspaceResource(resource, nil)) - continue - } - agent, err := api.Database.GetWorkspaceAgentByResourceID(r.Context(), resource.ID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get provisioner job agent: %s", err), - }) - return - } - apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("convert provisioner job agent: %s", err), - }) - return + agents := make([]codersdk.WorkspaceAgent, 0) + for _, agent := range resourceAgents { + if agent.ResourceID != resource.ID { + continue + } + apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert provisioner job agent: %s", err), + }) + return + } + agents = append(agents, apiAgent) } - apiResources = append(apiResources, convertWorkspaceResource(resource, &apiAgent)) + apiResources = append(apiResources, convertWorkspaceResource(resource, agents)) } render.Status(r, http.StatusOK) render.JSON(rw, r, apiResources) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index a0f41ccb21db1..d7e00cdd43115 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -210,10 +210,10 @@ func TestTemplateVersionResources(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{}, - }, + }}, }, { Name: "another", Type: "example", @@ -229,7 +229,7 @@ func TestTemplateVersionResources(t *testing.T) { require.Len(t, resources, 4) require.Equal(t, "some", resources[0].Name) require.Equal(t, "example", resources[0].Type) - require.NotNil(t, resources[0].Agent) + require.Len(t, resources[0].Agents, 1) }) } @@ -255,12 +255,12 @@ func TestTemplateVersionLogs(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{ Token: uuid.NewString(), }, - }, + }}, }, { Name: "another", Type: "example", diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go new file mode 100644 index 0000000000000..808ebfac0a924 --- /dev/null +++ b/coderd/workspaceagents.go @@ -0,0 +1,257 @@ +package coderd + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/go-chi/render" + "github.com/hashicorp/yamux" + "golang.org/x/xerrors" + "nhooyr.io/websocket" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/peerbroker" + "github.com/coder/coder/peerbroker/proto" + "github.com/coder/coder/provisionersdk" +) + +func (api *api) workspaceAgent(rw http.ResponseWriter, r *http.Request) { + agent := httpmw.WorkspaceAgentParam(r) + apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert workspace agent: %s", err), + }) + return + } + render.Status(r, http.StatusOK) + render.JSON(rw, r, apiAgent) +} + +func (api *api) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) { + api.websocketWaitMutex.Lock() + api.websocketWaitGroup.Add(1) + api.websocketWaitMutex.Unlock() + defer api.websocketWaitGroup.Done() + + agent := httpmw.WorkspaceAgentParam(r) + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "") + }() + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), config) + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + err = peerbroker.ProxyListen(r.Context(), session, peerbroker.ProxyOptions{ + ChannelID: agent.ID.String(), + Logger: api.Logger.Named("peerbroker-proxy-dial"), + Pubsub: api.Pubsub, + }) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("serve: %s", err)) + return + } +} + +func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { + api.websocketWaitMutex.Lock() + api.websocketWaitGroup.Add(1) + api.websocketWaitMutex.Unlock() + defer api.websocketWaitGroup.Done() + + agent := httpmw.WorkspaceAgent(r) + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + resource, err := api.Database.GetWorkspaceResourceByID(r.Context(), agent.ResourceID) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "") + }() + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), config) + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + closer, err := peerbroker.ProxyDial(proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(session)), peerbroker.ProxyOptions{ + ChannelID: agent.ID.String(), + Pubsub: api.Pubsub, + Logger: api.Logger.Named("peerbroker-proxy-listen"), + }) + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + defer closer.Close() + firstConnectedAt := agent.FirstConnectedAt + if !firstConnectedAt.Valid { + firstConnectedAt = sql.NullTime{ + Time: database.Now(), + Valid: true, + } + } + lastConnectedAt := sql.NullTime{ + Time: database.Now(), + Valid: true, + } + disconnectedAt := agent.DisconnectedAt + updateConnectionTimes := func() error { + err = api.Database.UpdateWorkspaceAgentConnectionByID(r.Context(), database.UpdateWorkspaceAgentConnectionByIDParams{ + ID: agent.ID, + FirstConnectedAt: firstConnectedAt, + LastConnectedAt: lastConnectedAt, + DisconnectedAt: disconnectedAt, + }) + if err != nil { + return err + } + return nil + } + build, err := api.Database.GetWorkspaceBuildByJobID(r.Context(), resource.JobID) + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + // Ensure the resource is still valid! + // We only accept agents for resources on the latest build. + ensureLatestBuild := func() error { + latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), build.WorkspaceID) + if err != nil { + return err + } + if build.ID.String() != latestBuild.ID.String() { + return xerrors.New("build is outdated") + } + return nil + } + + defer func() { + disconnectedAt = sql.NullTime{ + Time: database.Now(), + Valid: true, + } + _ = updateConnectionTimes() + }() + + err = updateConnectionTimes() + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + err = ensureLatestBuild() + if err != nil { + _ = conn.Close(websocket.StatusGoingAway, "") + return + } + + api.Logger.Info(r.Context(), "accepting agent", slog.F("resource", resource), slog.F("agent", agent)) + + ticker := time.NewTicker(api.AgentConnectionUpdateFrequency) + defer ticker.Stop() + for { + select { + case <-session.CloseChan(): + return + case <-ticker.C: + lastConnectedAt = sql.NullTime{ + Time: database.Now(), + Valid: true, + } + err = updateConnectionTimes() + if err != nil { + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + return + } + err = ensureLatestBuild() + if err != nil { + // Disconnect agents that are no longer valid. + _ = conn.Close(websocket.StatusGoingAway, "") + return + } + } + } +} + +func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) { + var envs map[string]string + if dbAgent.EnvironmentVariables.Valid { + err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs) + if err != nil { + return codersdk.WorkspaceAgent{}, xerrors.Errorf("unmarshal: %w", err) + } + } + agent := codersdk.WorkspaceAgent{ + ID: dbAgent.ID, + CreatedAt: dbAgent.CreatedAt, + UpdatedAt: dbAgent.UpdatedAt, + ResourceID: dbAgent.ResourceID, + InstanceID: dbAgent.AuthInstanceID.String, + Name: dbAgent.Name, + Architecture: dbAgent.Architecture, + OperatingSystem: dbAgent.OperatingSystem, + StartupScript: dbAgent.StartupScript.String, + EnvironmentVariables: envs, + } + if dbAgent.FirstConnectedAt.Valid { + agent.FirstConnectedAt = &dbAgent.FirstConnectedAt.Time + } + if dbAgent.LastConnectedAt.Valid { + agent.LastConnectedAt = &dbAgent.LastConnectedAt.Time + } + if dbAgent.DisconnectedAt.Valid { + agent.DisconnectedAt = &dbAgent.DisconnectedAt.Time + } + switch { + case !dbAgent.FirstConnectedAt.Valid: + // If the agent never connected, it's waiting for the compute + // to start up. + agent.Status = codersdk.WorkspaceAgentWaiting + case dbAgent.DisconnectedAt.Time.After(dbAgent.LastConnectedAt.Time): + // If we've disconnected after our last connection, we know the + // agent is no longer connected. + agent.Status = codersdk.WorkspaceAgentDisconnected + case agentUpdateFrequency*2 >= database.Now().Sub(dbAgent.LastConnectedAt.Time): + // The connection updated it's timestamp within the update frequency. + // We multiply by two to allow for some lag. + agent.Status = codersdk.WorkspaceAgentConnected + case database.Now().Sub(dbAgent.LastConnectedAt.Time) > agentUpdateFrequency*2: + // The connection died without updating the last connected. + agent.Status = codersdk.WorkspaceAgentDisconnected + } + + return agent, nil +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go new file mode 100644 index 0000000000000..8905b7277e5bf --- /dev/null +++ b/coderd/workspaceagents_test.go @@ -0,0 +1,108 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/agent" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/peer" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" +) + +func TestWorkspaceAgent(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + 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, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + + resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID) + require.NoError(t, err) + _, err = client.WorkspaceAgent(context.Background(), resources[0].Agents[0].ID) + require.NoError(t, err) +} + +func TestWorkspaceAgentListen(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + 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, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + conn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = conn.Close() + }) + _, err = conn.Ping() + require.NoError(t, err) +} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 6ee8f2f44e66c..b0e21b5381f22 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -106,7 +106,7 @@ func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job codersdk. } } -func convertWorkspaceResource(resource database.WorkspaceResource, agent *codersdk.WorkspaceAgent) codersdk.WorkspaceResource { +func convertWorkspaceResource(resource database.WorkspaceResource, agents []codersdk.WorkspaceAgent) codersdk.WorkspaceResource { return codersdk.WorkspaceResource{ ID: resource.ID, CreatedAt: resource.CreatedAt, @@ -115,6 +115,6 @@ func convertWorkspaceResource(resource database.WorkspaceResource, agent *coders Address: resource.Address, Type: resource.Type, Name: resource.Name, - Agent: agent, + Agents: agents, } } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 8a576ff7075e8..2e3b2eb7e81e2 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -91,10 +91,10 @@ func TestWorkspaceBuildResources(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{}, - }, + }}, }, { Name: "another", Type: "example", @@ -113,7 +113,7 @@ func TestWorkspaceBuildResources(t *testing.T) { require.Len(t, resources, 2) require.Equal(t, "some", resources[0].Name) require.Equal(t, "example", resources[0].Type) - require.NotNil(t, resources[0].Agent) + require.Len(t, resources[0].Agents, 1) }) } @@ -138,10 +138,10 @@ func TestWorkspaceBuildLogs(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{}, - }, + }}, }, { Name: "another", Type: "example", diff --git a/coderd/workspaceresourceauth_test.go b/coderd/workspaceresourceauth_test.go index 9cfa8e77075c5..3bd9dbd4551ed 100644 --- a/coderd/workspaceresourceauth_test.go +++ b/coderd/workspaceresourceauth_test.go @@ -32,11 +32,11 @@ func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, @@ -98,11 +98,11 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Auth: &proto.Agent_InstanceId{ InstanceId: instanceID, }, - }, + }}, }}, }, }, diff --git a/coderd/workspaceresources.go b/coderd/workspaceresources.go index 532fc64ea5c51..cd854c349f931 100644 --- a/coderd/workspaceresources.go +++ b/coderd/workspaceresources.go @@ -2,26 +2,16 @@ package coderd import ( "database/sql" - "encoding/json" + "errors" "fmt" - "io" "net/http" - "time" "github.com/go-chi/render" - "github.com/hashicorp/yamux" - "golang.org/x/xerrors" - "nhooyr.io/websocket" + "github.com/google/uuid" - "cdr.dev/slog" - - "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/codersdk" - "github.com/coder/coder/peerbroker" - "github.com/coder/coder/peerbroker/proto" - "github.com/coder/coder/provisionersdk" ) func (api *api) workspaceResource(rw http.ResponseWriter, r *http.Request) { @@ -40,255 +30,28 @@ func (api *api) workspaceResource(rw http.ResponseWriter, r *http.Request) { }) return } - var apiAgent *codersdk.WorkspaceAgent - if workspaceResource.AgentID.Valid { - agent, err := api.Database.GetWorkspaceAgentByResourceID(r.Context(), workspaceResource.ID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get provisioner job agent: %s", err), - }) - return - } - convertedAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("convert provisioner job agent: %s", err), - }) - return - } - apiAgent = &convertedAgent + agents, err := api.Database.GetWorkspaceAgentsByResourceIDs(r.Context(), []uuid.UUID{workspaceResource.ID}) + if errors.Is(err, sql.ErrNoRows) { + err = nil } - - render.Status(r, http.StatusOK) - render.JSON(rw, r, convertWorkspaceResource(workspaceResource, apiAgent)) -} - -func (api *api) workspaceResourceDial(rw http.ResponseWriter, r *http.Request) { - api.websocketWaitMutex.Lock() - api.websocketWaitGroup.Add(1) - api.websocketWaitMutex.Unlock() - defer api.websocketWaitGroup.Done() - - resource := httpmw.WorkspaceResourceParam(r) - if !resource.AgentID.Valid { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "resource doesn't have an agent", - }) - return - } - agent, err := api.Database.GetWorkspaceAgentByResourceID(r.Context(), resource.ID) if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("get provisioner job agent: %s", err), - }) - return - } - conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("accept websocket: %s", err), - }) - return - } - defer func() { - _ = conn.Close(websocket.StatusNormalClosure, "") - }() - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), config) - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - err = peerbroker.ProxyListen(r.Context(), session, peerbroker.ProxyOptions{ - ChannelID: agent.ID.String(), - Logger: api.Logger.Named("peerbroker-proxy-dial"), - Pubsub: api.Pubsub, - }) - if err != nil { - _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("serve: %s", err)) - return - } -} - -func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { - api.websocketWaitMutex.Lock() - api.websocketWaitGroup.Add(1) - api.websocketWaitMutex.Unlock() - defer api.websocketWaitGroup.Done() - - agent := httpmw.WorkspaceAgent(r) - conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("accept websocket: %s", err), - }) - return - } - resource, err := api.Database.GetWorkspaceResourceByID(r.Context(), agent.ResourceID) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("accept websocket: %s", err), - }) - return - } - - defer func() { - _ = conn.Close(websocket.StatusNormalClosure, "") - }() - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), config) - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - closer, err := peerbroker.ProxyDial(proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(session)), peerbroker.ProxyOptions{ - ChannelID: agent.ID.String(), - Pubsub: api.Pubsub, - Logger: api.Logger.Named("peerbroker-proxy-listen"), - }) - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - defer closer.Close() - firstConnectedAt := agent.FirstConnectedAt - if !firstConnectedAt.Valid { - firstConnectedAt = sql.NullTime{ - Time: database.Now(), - Valid: true, - } - } - lastConnectedAt := sql.NullTime{ - Time: database.Now(), - Valid: true, - } - disconnectedAt := agent.DisconnectedAt - updateConnectionTimes := func() error { - err = api.Database.UpdateWorkspaceAgentConnectionByID(r.Context(), database.UpdateWorkspaceAgentConnectionByIDParams{ - ID: agent.ID, - FirstConnectedAt: firstConnectedAt, - LastConnectedAt: lastConnectedAt, - DisconnectedAt: disconnectedAt, + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get provisioner job agents: %s", err), }) - if err != nil { - return err - } - return nil - } - build, err := api.Database.GetWorkspaceBuildByJobID(r.Context(), resource.JobID) - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) return } - // Ensure the resource is still valid! - // We only accept agents for resources on the latest build. - ensureLatestBuild := func() error { - latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), build.WorkspaceID) + apiAgents := make([]codersdk.WorkspaceAgent, 0) + for _, agent := range agents { + convertedAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) if err != nil { - return err - } - if build.ID.String() != latestBuild.ID.String() { - return xerrors.New("build is outdated") - } - return nil - } - - defer func() { - disconnectedAt = sql.NullTime{ - Time: database.Now(), - Valid: true, - } - _ = updateConnectionTimes() - }() - - err = updateConnectionTimes() - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - err = ensureLatestBuild() - if err != nil { - _ = conn.Close(websocket.StatusGoingAway, "") - return - } - - api.Logger.Info(r.Context(), "accepting agent", slog.F("resource", resource), slog.F("agent", agent)) - - ticker := time.NewTicker(api.AgentConnectionUpdateFrequency) - defer ticker.Stop() - for { - select { - case <-session.CloseChan(): + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert provisioner job agent: %s", err), + }) return - case <-ticker.C: - lastConnectedAt = sql.NullTime{ - Time: database.Now(), - Valid: true, - } - err = updateConnectionTimes() - if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) - return - } - err = ensureLatestBuild() - if err != nil { - // Disconnect agents that are no longer valid. - _ = conn.Close(websocket.StatusGoingAway, "") - return - } } + apiAgents = append(apiAgents, convertedAgent) } -} -func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) { - var envs map[string]string - if dbAgent.EnvironmentVariables.Valid { - err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs) - if err != nil { - return codersdk.WorkspaceAgent{}, xerrors.Errorf("unmarshal: %w", err) - } - } - agent := codersdk.WorkspaceAgent{ - ID: dbAgent.ID, - CreatedAt: dbAgent.CreatedAt, - UpdatedAt: dbAgent.UpdatedAt, - ResourceID: dbAgent.ResourceID, - InstanceID: dbAgent.AuthInstanceID.String, - StartupScript: dbAgent.StartupScript.String, - EnvironmentVariables: envs, - } - if dbAgent.FirstConnectedAt.Valid { - agent.FirstConnectedAt = &dbAgent.FirstConnectedAt.Time - } - if dbAgent.LastConnectedAt.Valid { - agent.LastConnectedAt = &dbAgent.LastConnectedAt.Time - } - if dbAgent.DisconnectedAt.Valid { - agent.DisconnectedAt = &dbAgent.DisconnectedAt.Time - } - switch { - case !dbAgent.FirstConnectedAt.Valid: - // If the agent never connected, it's waiting for the compute - // to start up. - agent.Status = codersdk.WorkspaceAgentWaiting - case dbAgent.DisconnectedAt.Time.After(dbAgent.LastConnectedAt.Time): - // If we've disconnected after our last connection, we know the - // agent is no longer connected. - agent.Status = codersdk.WorkspaceAgentDisconnected - case agentUpdateFrequency*2 >= database.Now().Sub(dbAgent.LastConnectedAt.Time): - // The connection updated it's timestamp within the update frequency. - // We multiply by two to allow for some lag. - agent.Status = codersdk.WorkspaceAgentConnected - case database.Now().Sub(dbAgent.LastConnectedAt.Time) > agentUpdateFrequency*2: - // The connection died without updating the last connected. - agent.Status = codersdk.WorkspaceAgentDisconnected - } - - return agent, nil + render.Status(r, http.StatusOK) + render.JSON(rw, r, convertWorkspaceResource(workspaceResource, apiAgents)) } diff --git a/coderd/workspaceresources_test.go b/coderd/workspaceresources_test.go index df4e5be31509d..e5673c191c301 100644 --- a/coderd/workspaceresources_test.go +++ b/coderd/workspaceresources_test.go @@ -4,16 +4,10 @@ import ( "context" "testing" - "github.com/google/uuid" "github.com/stretchr/testify/require" - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" - - "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" - "github.com/coder/coder/peer" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" ) @@ -33,10 +27,10 @@ func TestWorkspaceResource(t *testing.T) { Resources: []*proto.Resource{{ Name: "some", Type: "example", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ Id: "something", Auth: &proto.Agent_Token{}, - }, + }}, }}, }, }, @@ -52,55 +46,3 @@ func TestWorkspaceResource(t *testing.T) { require.NoError(t, err) }) } - -func TestWorkspaceAgentListen(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionDryRun: echo.ProvisionComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agent: &proto.Agent{ - 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, codersdk.Me, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() - - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil), - }) - t.Cleanup(func() { - _ = agentCloser.Close() - }) - resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) - conn, err := client.DialWorkspaceAgent(context.Background(), resources[0].ID, nil, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), - }) - require.NoError(t, err) - t.Cleanup(func() { - _ = conn.Close() - }) - _, err = conn.Ping() - require.NoError(t, err) -} diff --git a/codersdk/gitsshkey.go b/codersdk/gitsshkey.go index b7944ab53ee76..bae8a4c343ce6 100644 --- a/codersdk/gitsshkey.go +++ b/codersdk/gitsshkey.go @@ -56,7 +56,7 @@ func (c *Client) RegenerateGitSSHKey(ctx context.Context, userID uuid.UUID) (Git // AgentGitSSHKey will return the user's SSH key pair for the workspace. func (c *Client) AgentGitSSHKey(ctx context.Context) (AgentGitSSHKey, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceresources/agent/gitsshkey", nil) + res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/gitsshkey", nil) if err != nil { return AgentGitSSHKey{}, xerrors.Errorf("execute request: %w", err) } diff --git a/codersdk/workspaceresourceauth.go b/codersdk/workspaceagents.go similarity index 51% rename from codersdk/workspaceresourceauth.go rename to codersdk/workspaceagents.go index 721a9ca487686..5a83d0b265556 100644 --- a/codersdk/workspaceresourceauth.go +++ b/codersdk/workspaceagents.go @@ -6,9 +6,21 @@ import ( "fmt" "io" "net/http" + "net/http/cookiejar" "cloud.google.com/go/compute/metadata" + "github.com/google/uuid" + "github.com/hashicorp/yamux" + "github.com/pion/webrtc/v3" "golang.org/x/xerrors" + "nhooyr.io/websocket" + + "github.com/coder/coder/agent" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/peer" + "github.com/coder/coder/peerbroker" + "github.com/coder/coder/peerbroker/proto" + "github.com/coder/coder/provisionersdk" ) type GoogleInstanceIdentityToken struct { @@ -43,7 +55,7 @@ func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, servic if err != nil { return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err) } - res, err := c.request(ctx, http.MethodPost, "/api/v2/workspaceresources/auth/google-instance-identity", GoogleInstanceIdentityToken{ + res, err := c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/google-instance-identity", GoogleInstanceIdentityToken{ JSONWebToken: jwt, }) if err != nil { @@ -107,7 +119,7 @@ func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (Workspac return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err) } - res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceresources/auth/aws-instance-identity", AWSInstanceIdentityToken{ + res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/aws-instance-identity", AWSInstanceIdentityToken{ Signature: string(signature), Document: string(document), }) @@ -121,3 +133,108 @@ func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (Workspac var resp WorkspaceAgentAuthenticateResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } + +// ListenWorkspaceAgent connects as a workspace agent. +// It obtains the agent ID based off the session token. +func (c *Client) ListenWorkspaceAgent(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) { + serverURL, err := c.URL.Parse("/api/v2/workspaceagents/me") + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: httpmw.AuthCookie, + Value: c.SessionToken, + }}) + httpClient := &http.Client{ + Jar: jar, + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + // Need to disable compression to avoid a data-race. + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if res == nil { + return nil, err + } + return nil, readBodyAsError(res) + } + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) + if err != nil { + return nil, xerrors.Errorf("multiplex client: %w", err) + } + return peerbroker.Listen(session, func(ctx context.Context) ([]webrtc.ICEServer, error) { + return []webrtc.ICEServer{{ + URLs: []string{"stun:stun.l.google.com:19302"}, + }}, nil + }, opts) +} + +// DialWorkspaceAgent creates a connection to the specified resource. +func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, iceServers []webrtc.ICEServer, opts *peer.ConnOptions) (*agent.Conn, error) { + serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/dial", agentID.String())) + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: httpmw.AuthCookie, + Value: c.SessionToken, + }}) + httpClient := &http.Client{ + Jar: jar, + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + // Need to disable compression to avoid a data-race. + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if res == nil { + return nil, err + } + return nil, readBodyAsError(res) + } + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) + if err != nil { + return nil, xerrors.Errorf("multiplex client: %w", err) + } + client := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(session)) + stream, err := client.NegotiateConnection(ctx) + if err != nil { + return nil, xerrors.Errorf("negotiate connection: %w", err) + } + peerConn, err := peerbroker.Dial(stream, iceServers, opts) + if err != nil { + return nil, xerrors.Errorf("dial peer: %w", err) + } + return &agent.Conn{ + Negotiator: client, + Conn: peerConn, + }, nil +} + +// 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) + if err != nil { + return WorkspaceAgent{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgent{}, readBodyAsError(res) + } + var workspaceAgent WorkspaceAgent + return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent) +} diff --git a/codersdk/workspaceresources.go b/codersdk/workspaceresources.go index 593026b7548cb..e5b818ccb4241 100644 --- a/codersdk/workspaceresources.go +++ b/codersdk/workspaceresources.go @@ -4,24 +4,12 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" - "net/http/cookiejar" "time" "github.com/google/uuid" - "github.com/hashicorp/yamux" - "github.com/pion/webrtc/v3" - "golang.org/x/xerrors" - "nhooyr.io/websocket" - "github.com/coder/coder/agent" "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/peer" - "github.com/coder/coder/peerbroker" - "github.com/coder/coder/peerbroker/proto" - "github.com/coder/coder/provisionersdk" ) type WorkspaceAgentStatus string @@ -40,7 +28,7 @@ type WorkspaceResource struct { Address string `json:"address"` Type string `json:"type"` Name string `json:"name"` - Agent *WorkspaceAgent `json:"agent,omitempty"` + Agents []WorkspaceAgent `json:"agents,omitempty"` } type WorkspaceAgent struct { @@ -51,9 +39,12 @@ type WorkspaceAgent struct { LastConnectedAt *time.Time `json:"last_connected_at,omitempty"` DisconnectedAt *time.Time `json:"disconnected_at,omitempty"` Status WorkspaceAgentStatus `json:"status"` + Name string `json:"name"` ResourceID uuid.UUID `json:"resource_id"` InstanceID string `json:"instance_id,omitempty"` + Architecture string `json:"architecture"` EnvironmentVariables map[string]string `json:"environment_variables"` + OperatingSystem string `json:"operating_system"` StartupScript string `json:"startup_script,omitempty"` } @@ -89,94 +80,3 @@ func (c *Client) WorkspaceResource(ctx context.Context, id uuid.UUID) (Workspace var resource WorkspaceResource return resource, json.NewDecoder(res.Body).Decode(&resource) } - -// DialWorkspaceAgent creates a connection to the specified resource. -func (c *Client) DialWorkspaceAgent(ctx context.Context, resource uuid.UUID, iceServers []webrtc.ICEServer, opts *peer.ConnOptions) (*agent.Conn, error) { - serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceresources/%s/dial", resource.String())) - if err != nil { - return nil, xerrors.Errorf("parse url: %w", err) - } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, - Value: c.SessionToken, - }}) - httpClient := &http.Client{ - Jar: jar, - } - conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ - HTTPClient: httpClient, - // Need to disable compression to avoid a data-race. - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - if res == nil { - return nil, err - } - return nil, readBodyAsError(res) - } - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) - if err != nil { - return nil, xerrors.Errorf("multiplex client: %w", err) - } - client := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(session)) - stream, err := client.NegotiateConnection(ctx) - if err != nil { - return nil, xerrors.Errorf("negotiate connection: %w", err) - } - peerConn, err := peerbroker.Dial(stream, iceServers, opts) - if err != nil { - return nil, xerrors.Errorf("dial peer: %w", err) - } - return &agent.Conn{ - Negotiator: client, - Conn: peerConn, - }, nil -} - -// ListenWorkspaceAgent connects as a workspace agent. -// It obtains the agent ID based off the session token. -func (c *Client) ListenWorkspaceAgent(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) { - serverURL, err := c.URL.Parse("/api/v2/workspaceresources/agent") - if err != nil { - return nil, xerrors.Errorf("parse url: %w", err) - } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, - Value: c.SessionToken, - }}) - httpClient := &http.Client{ - Jar: jar, - } - conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ - HTTPClient: httpClient, - // Need to disable compression to avoid a data-race. - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - if res == nil { - return nil, err - } - return nil, readBodyAsError(res) - } - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) - if err != nil { - return nil, xerrors.Errorf("multiplex client: %w", err) - } - return peerbroker.Listen(session, func(ctx context.Context) ([]webrtc.ICEServer, error) { - return []webrtc.ICEServer{{ - URLs: []string{"stun:stun.l.google.com:19302"}, - }}, nil - }, opts) -} diff --git a/examples/aws-linux/main.tf b/examples/aws-linux/main.tf index 6dfa861656ede..7a35095dbb35b 100644 --- a/examples/aws-linux/main.tf +++ b/examples/aws-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.2.1" + version = "~> 0.3.1" } } } @@ -55,12 +55,6 @@ provider "aws" { data "coder_workspace" "me" { } -data "coder_agent_script" "dev" { - arch = "amd64" - auth = "aws-instance-identity" - os = "linux" -} - data "aws_ami" "ubuntu" { most_recent = true filter { @@ -75,8 +69,9 @@ data "aws_ami" "ubuntu" { } resource "coder_agent" "dev" { - count = data.coder_workspace.me.transition == "start" ? 1 : 0 - instance_id = aws_instance.dev[0].id + arch = "amd64" + auth = "aws-instance-identity" + os = "linux" } locals { @@ -105,7 +100,7 @@ Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename="userdata.txt" #!/bin/bash -sudo -E -u ubuntu sh -c '${data.coder_agent_script.dev.value}' +sudo -E -u ubuntu sh -c '${coder_agent.dev.init_script}' --//-- EOT @@ -139,11 +134,9 @@ resource "aws_instance" "dev" { ami = data.aws_ami.ubuntu.id availability_zone = "${var.region}a" instance_type = "t3.micro" - count = 1 user_data = data.coder_workspace.me.transition == "start" ? local.user_data_start : local.user_data_end tags = { Name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}" } - } diff --git a/examples/aws-windows/main.tf b/examples/aws-windows/main.tf index a7104e5425e08..575ee75b8e720 100644 --- a/examples/aws-windows/main.tf +++ b/examples/aws-windows/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.2.1" + version = "~> 0.3.1" } } } @@ -42,12 +42,6 @@ provider "aws" { data "coder_workspace" "me" { } -data "coder_agent_script" "dev" { - arch = "amd64" - auth = "aws-instance-identity" - os = "windows" -} - data "aws_ami" "windows" { most_recent = true owners = ["amazon"] @@ -59,8 +53,9 @@ data "aws_ami" "windows" { } resource "coder_agent" "dev" { - count = data.coder_workspace.me.transition == "start" ? 1 : 0 - instance_id = aws_instance.dev[0].id + arch = "amd64" + auth = "aws-instance-identity" + os = "windows" } locals { @@ -71,7 +66,7 @@ locals { user_data_start = < [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -${data.coder_agent_script.dev.value} +${coder_agent.dev.init_script} true EOT diff --git a/examples/gcp-linux/main.tf b/examples/gcp-linux/main.tf index b3cf30a5cdaf2..ef069a380a03d 100644 --- a/examples/gcp-linux/main.tf +++ b/examples/gcp-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "~> 0.2" + version = "~> 0.3.1" } google = { source = "hashicorp/google" @@ -42,25 +42,14 @@ provider "google" { project = jsondecode(var.service_account).project_id } -data "coder_workspace" "me" { -} - -data "coder_agent_script" "dev" { - auth = "google-instance-identity" - arch = "amd64" - os = "linux" -} - data "google_compute_default_service_account" "default" { } -resource "random_string" "random" { - length = 8 - special = false +data "coder_workspace" "me" { } resource "google_compute_disk" "root" { - name = "coder-${lower(random_string.random.result)}" + name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-root" type = "pd-ssd" zone = var.zone image = "debian-cloud/debian-9" @@ -69,10 +58,16 @@ resource "google_compute_disk" "root" { } } +resource "coder_agent" "dev" { + auth = "google-instance-identity" + arch = "amd64" + os = "linux" +} + resource "google_compute_instance" "dev" { zone = var.zone - count = data.coder_workspace.me.transition == "start" ? 1 : 0 - name = "coder-${lower(random_string.random.result)}" + count = data.coder_workspace.me.start_count + name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}" machine_type = "e2-medium" network_interface { network = "default" @@ -88,10 +83,5 @@ resource "google_compute_instance" "dev" { email = data.google_compute_default_service_account.default.email scopes = ["cloud-platform"] } - metadata_startup_script = data.coder_agent_script.dev.value -} - -resource "coder_agent" "dev" { - count = length(google_compute_instance.dev) - instance_id = google_compute_instance.dev[0].instance_id + metadata_startup_script = coder_agent.dev.init_script } diff --git a/examples/gcp-windows/main.tf b/examples/gcp-windows/main.tf index fe466d7561d79..2202ba8889d69 100644 --- a/examples/gcp-windows/main.tf +++ b/examples/gcp-windows/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "~> 0.2" + version = "~> 0.3.1" } google = { source = "hashicorp/google" @@ -45,22 +45,11 @@ provider "google" { data "coder_workspace" "me" { } -data "coder_agent_script" "dev" { - auth = "google-instance-identity" - arch = "amd64" - os = "windows" -} - data "google_compute_default_service_account" "default" { } -resource "random_string" "random" { - length = 8 - special = false -} - resource "google_compute_disk" "root" { - name = "coder-${lower(random_string.random.result)}" + name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-root" type = "pd-ssd" zone = var.zone image = "projects/windows-cloud/global/images/windows-server-2022-dc-core-v20220215" @@ -69,9 +58,15 @@ resource "google_compute_disk" "root" { } } +resource "coder_agent" "dev" { + auth = "google-instance-identity" + arch = "amd64" + os = "windows" +} + resource "google_compute_instance" "dev" { zone = var.zone - count = data.coder_workspace.me.transition == "start" ? 1 : 0 + count = data.coder_workspace.me.start_count name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}" machine_type = "e2-medium" network_interface { @@ -89,12 +84,7 @@ resource "google_compute_instance" "dev" { scopes = ["cloud-platform"] } metadata = { - windows-startup-script-ps1 = data.coder_agent_script.dev.value + windows-startup-script-ps1 = coder_agent.dev.init_script serial-port-enable = "TRUE" } } - -resource "coder_agent" "dev" { - count = length(google_compute_instance.dev) - instance_id = google_compute_instance.dev[0].instance_id -} diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index 9f990336cce95..a6cd6a9b86bfb 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -286,12 +286,32 @@ func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfi continue } agent := &proto.Agent{ + Name: resource.Name, Auth: &proto.Agent_Token{}, } + if operatingSystemRaw, has := resource.Expressions["os"]; has { + operatingSystem, ok := operatingSystemRaw.ConstantValue.(string) + if ok { + agent.OperatingSystem = operatingSystem + } + } + if archRaw, has := resource.Expressions["arch"]; has { + arch, ok := archRaw.ConstantValue.(string) + if ok { + agent.Architecture = arch + } + } if envRaw, has := resource.Expressions["env"]; has { - env, ok := envRaw.ConstantValue.(map[string]string) + env, ok := envRaw.ConstantValue.(map[string]interface{}) if ok { - agent.Env = env + agent.Env = map[string]string{} + for key, valueRaw := range env { + value, valid := valueRaw.(string) + if !valid { + continue + } + agent.Env[key] = value + } } } if startupScriptRaw, has := resource.Expressions["startup_script"]; has { @@ -320,19 +340,20 @@ func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfi continue } // Associate resources that depend on an agent. - var agent *proto.Agent + resourceAgents := make([]*proto.Agent, 0) for _, dep := range resourceNode { var has bool - agent, has = agents[dep] - if has { - break + agent, has := agents[dep] + if !has { + continue } + resourceAgents = append(resourceAgents, agent) } resources = append(resources, &proto.Resource{ - Name: resource.Name, - Type: resource.Type, - Agent: agent, + Name: resource.Name, + Type: resource.Type, + Agents: resourceAgents, }) } @@ -365,11 +386,13 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state return nil, xerrors.Errorf("find dependencies: %w", err) } type agentAttributes struct { - ID string `mapstructure:"id"` - Token string `mapstructure:"token"` - InstanceID string `mapstructure:"instance_id"` - Env map[string]string `mapstructure:"env"` - StartupScript string `mapstructure:"startup_script"` + Auth string `mapstructure:"auth"` + OperatingSystem string `mapstructure:"os"` + Architecture string `mapstructure:"arch"` + ID string `mapstructure:"id"` + Token string `mapstructure:"token"` + Env map[string]string `mapstructure:"env"` + StartupScript string `mapstructure:"startup_script"` } agents := map[string]*proto.Agent{} @@ -384,24 +407,60 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state return nil, xerrors.Errorf("decode agent attributes: %w", err) } agent := &proto.Agent{ - Id: attrs.ID, - Env: attrs.Env, - StartupScript: attrs.StartupScript, - Auth: &proto.Agent_Token{ - Token: attrs.Token, - }, + Name: resource.Name, + Id: attrs.ID, + Env: attrs.Env, + StartupScript: attrs.StartupScript, + OperatingSystem: attrs.OperatingSystem, + Architecture: attrs.Architecture, } - if attrs.InstanceID != "" { - agent.Auth = &proto.Agent_InstanceId{ - InstanceId: attrs.InstanceID, + switch attrs.Auth { + case "token": + agent.Auth = &proto.Agent_Token{ + Token: attrs.Token, } + default: + agent.Auth = &proto.Agent_InstanceId{} } resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".") agents[resourceKey] = agent } + // Manually associate agents with instance IDs. for _, resource := range state.Values.RootModule.Resources { - if resource.Type == "coder_agent" { + if resource.Type != "coder_agent_instance" { + continue + } + agentIDRaw, valid := resource.AttributeValues["agent_id"] + if !valid { + continue + } + agentID, valid := agentIDRaw.(string) + if !valid { + continue + } + instanceIDRaw, valid := resource.AttributeValues["instance_id"] + if !valid { + continue + } + instanceID, valid := instanceIDRaw.(string) + if !valid { + continue + } + + for _, agent := range agents { + if agent.Id != agentID { + continue + } + agent.Auth = &proto.Agent_InstanceId{ + InstanceId: instanceID, + } + break + } + } + + for _, resource := range state.Values.RootModule.Resources { + if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" { continue } resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".") @@ -410,19 +469,46 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state continue } // Associate resources that depend on an agent. - var agent *proto.Agent + resourceAgents := make([]*proto.Agent, 0) for _, dep := range resourceNode { var has bool - agent, has = agents[dep] - if has { - break + agent, has := agents[dep] + if !has { + continue + } + resourceAgents = append(resourceAgents, agent) + + // Didn't use instance identity. + if agent.GetToken() != "" { + continue + } + + key, isValid := map[string]string{ + "google_compute_instance": "instance_id", + "aws_instance": "id", + }[resource.Type] + if !isValid { + // The resource type doesn't support + // automatically setting the instance ID. + continue + } + instanceIDRaw, valid := resource.AttributeValues[key] + if !valid { + continue + } + instanceID, valid := instanceIDRaw.(string) + if !valid { + continue + } + agent.Auth = &proto.Agent_InstanceId{ + InstanceId: instanceID, } } resources = append(resources, &proto.Resource{ - Name: resource.Name, - Type: resource.Type, - Agent: agent, + Name: resource.Name, + Type: resource.Type, + Agents: resourceAgents, }) } } @@ -467,9 +553,8 @@ func convertTerraformLogLevel(logLevel string) (proto.LogLevel, error) { } } -// findDirectDependencies maps Terraform resources to their parent and -// children nodes. This parses GraphViz output from Terraform which -// certainly is not ideal, but seems reliable. +// findDirectDependencies maps Terraform resources to their children nodes. +// This parses GraphViz output from Terraform which isn't ideal, but seems reliable. func findDirectDependencies(rawGraph string) (map[string][]string, error) { parsedGraph, err := gographviz.ParseString(rawGraph) if err != nil { @@ -488,22 +573,17 @@ func findDirectDependencies(rawGraph string) (map[string][]string, error) { label = strings.Trim(label, `"`) dependencies := make([]string, 0) - for _, edges := range []map[string][]*gographviz.Edge{ - graph.Edges.SrcToDsts[node.Name], - graph.Edges.DstToSrcs[node.Name], - } { - for destination := range edges { - dependencyNode, exists := graph.Nodes.Lookup[destination] - if !exists { - continue - } - label, exists := dependencyNode.Attrs["label"] - if !exists { - continue - } - label = strings.Trim(label, `"`) - dependencies = append(dependencies, label) + for destination := range graph.Edges.SrcToDsts[node.Name] { + dependencyNode, exists := graph.Nodes.Lookup[destination] + if !exists { + continue + } + label, exists := dependencyNode.Attrs["label"] + if !exists { + continue } + label = strings.Trim(label, `"`) + dependencies = append(dependencies, label) } direct[label] = dependencies } diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 3ecd1e72ac903..645c87ba030f2 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "sort" "testing" "github.com/stretchr/testify/require" @@ -27,7 +28,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.2.1" + version = "0.3.1" } } } @@ -156,7 +157,10 @@ provider "coder" { Name: "resource-associated-with-agent", Files: map[string]string{ "main.tf": provider + ` - resource "coder_agent" "A" {} + resource "coder_agent" "A" { + os = "windows" + arch = "arm64" + } resource "null_resource" "A" { depends_on = [ coder_agent.A @@ -176,45 +180,14 @@ provider "coder" { Resources: []*proto.Resource{{ Name: "A", Type: "null_resource", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ + Name: "A", + OperatingSystem: "windows", + Architecture: "arm64", Auth: &proto.Agent_Token{ Token: "", }, - }, - }}, - }, - }, - }, - }, { - Name: "agent-associated-with-resource", - Files: map[string]string{ - "main.tf": provider + ` - resource "coder_agent" "A" { - depends_on = [ - null_resource.A - ] - instance_id = "example" - } - resource "null_resource" "A" {}`, - }, - Request: &proto.Provision_Request{ - Type: &proto.Provision_Request_Start{ - Start: &proto.Provision_Start{ - Metadata: &proto.Provision_Metadata{}, - }, - }, - }, - Response: &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "A", - Type: "null_resource", - Agent: &proto.Agent{ - Auth: &proto.Agent_InstanceId{ - InstanceId: "example", - }, - }, + }}, }}, }, }, @@ -225,6 +198,12 @@ provider "coder" { "main.tf": provider + ` resource "coder_agent" "A" { count = 1 + os = "linux" + arch = "amd64" + env = { + test: "example" + } + startup_script = "code-server" } resource "null_resource" "A" { count = length(coder_agent.A) @@ -244,29 +223,42 @@ provider "coder" { Resources: []*proto.Resource{{ Name: "A", Type: "null_resource", - Agent: &proto.Agent{ - Auth: &proto.Agent_Token{}, - }, + Agents: []*proto.Agent{{ + Name: "A", + OperatingSystem: "linux", + Architecture: "amd64", + Auth: &proto.Agent_Token{}, + Env: map[string]string{ + "test": "example", + }, + StartupScript: "code-server", + }}, }}, }, }, }, }, { - Name: "dryrun-agent-associated-with-resource", + Name: "resource-manually-associated-with-agent", Files: map[string]string{ "main.tf": provider + ` resource "coder_agent" "A" { - count = length(null_resource.A) - instance_id = "an-instance" + os = "darwin" + arch = "amd64" } resource "null_resource" "A" { - count = 1 - }`, + depends_on = [ + coder_agent.A + ] + } + resource "coder_agent_instance" "A" { + agent_id = coder_agent.A.id + instance_id = "bananas" + } + `, }, Request: &proto.Provision_Request{ Type: &proto.Provision_Request_Start{ Start: &proto.Provision_Start{ - DryRun: true, Metadata: &proto.Provision_Metadata{}, }, }, @@ -277,31 +269,45 @@ provider "coder" { Resources: []*proto.Resource{{ Name: "A", Type: "null_resource", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ + Name: "A", + OperatingSystem: "darwin", + Architecture: "amd64", Auth: &proto.Agent_InstanceId{ - InstanceId: "", + InstanceId: "bananas", }, - }, + }}, }}, }, }, }, }, { - Name: "dryrun-agent-associated-with-resource-instance-id", + Name: "resource-manually-associated-with-multiple-agents", Files: map[string]string{ "main.tf": provider + ` resource "coder_agent" "A" { - count = length(null_resource.A) - instance_id = length(null_resource.A) + os = "darwin" + arch = "amd64" + } + resource "coder_agent" "B" { + os = "linux" + arch = "amd64" } resource "null_resource" "A" { - count = 1 - }`, + depends_on = [ + coder_agent.A, + coder_agent.B + ] + } + resource "coder_agent_instance" "A" { + agent_id = coder_agent.A.id + instance_id = "bananas" + } + `, }, Request: &proto.Provision_Request{ Type: &proto.Provision_Request_Start{ Start: &proto.Provision_Start{ - DryRun: true, Metadata: &proto.Provision_Metadata{}, }, }, @@ -312,11 +318,21 @@ provider "coder" { Resources: []*proto.Resource{{ Name: "A", Type: "null_resource", - Agent: &proto.Agent{ + Agents: []*proto.Agent{{ + Name: "A", + OperatingSystem: "darwin", + Architecture: "amd64", Auth: &proto.Agent_InstanceId{ - InstanceId: "", + InstanceId: "bananas", }, - }, + }, { + Name: "B", + OperatingSystem: "linux", + Architecture: "amd64", + Auth: &proto.Agent_Token{ + Token: "", + }, + }}, }}, }, }, @@ -375,15 +391,16 @@ provider "coder" { // Remove randomly generated data. for _, resource := range msg.GetComplete().Resources { - if resource.Agent == nil { - continue - } - resource.Agent.Id = "" - if resource.Agent.GetToken() == "" { - continue - } - resource.Agent.Auth = &proto.Agent_Token{ - Token: "", + sort.Slice(resource.Agents, func(i, j int) bool { + return resource.Agents[i].Name < resource.Agents[j].Name + }) + + for _, agent := range resource.Agents { + agent.Id = "" + if agent.GetToken() == "" { + continue + } + agent.Auth = &proto.Agent_Token{} } } diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index c5617f74cdfca..72d37a0083f94 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -651,7 +651,7 @@ func (x *Log) GetOutput() string { return "" } -type GoogleInstanceIdentityAuth struct { +type InstanceIdentityAuth struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields @@ -659,8 +659,8 @@ type GoogleInstanceIdentityAuth struct { InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` } -func (x *GoogleInstanceIdentityAuth) Reset() { - *x = GoogleInstanceIdentityAuth{} +func (x *InstanceIdentityAuth) Reset() { + *x = InstanceIdentityAuth{} if protoimpl.UnsafeEnabled { mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -668,13 +668,13 @@ func (x *GoogleInstanceIdentityAuth) Reset() { } } -func (x *GoogleInstanceIdentityAuth) String() string { +func (x *InstanceIdentityAuth) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GoogleInstanceIdentityAuth) ProtoMessage() {} +func (*InstanceIdentityAuth) ProtoMessage() {} -func (x *GoogleInstanceIdentityAuth) ProtoReflect() protoreflect.Message { +func (x *InstanceIdentityAuth) ProtoReflect() protoreflect.Message { mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -686,12 +686,12 @@ func (x *GoogleInstanceIdentityAuth) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GoogleInstanceIdentityAuth.ProtoReflect.Descriptor instead. -func (*GoogleInstanceIdentityAuth) Descriptor() ([]byte, []int) { +// Deprecated: Use InstanceIdentityAuth.ProtoReflect.Descriptor instead. +func (*InstanceIdentityAuth) Descriptor() ([]byte, []int) { return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{6} } -func (x *GoogleInstanceIdentityAuth) GetInstanceId() string { +func (x *InstanceIdentityAuth) GetInstanceId() string { if x != nil { return x.InstanceId } @@ -704,9 +704,12 @@ type Agent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Env map[string]string `protobuf:"bytes,2,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` - StartupScript string `protobuf:"bytes,3,opt,name=startup_script,json=startupScript,proto3" json:"startup_script,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + StartupScript string `protobuf:"bytes,4,opt,name=startup_script,json=startupScript,proto3" json:"startup_script,omitempty"` + OperatingSystem string `protobuf:"bytes,5,opt,name=operating_system,json=operatingSystem,proto3" json:"operating_system,omitempty"` + Architecture string `protobuf:"bytes,6,opt,name=architecture,proto3" json:"architecture,omitempty"` // Types that are assignable to Auth: // *Agent_Token // *Agent_InstanceId @@ -752,6 +755,13 @@ func (x *Agent) GetId() string { return "" } +func (x *Agent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + func (x *Agent) GetEnv() map[string]string { if x != nil { return x.Env @@ -766,6 +776,20 @@ func (x *Agent) GetStartupScript() string { return "" } +func (x *Agent) GetOperatingSystem() string { + if x != nil { + return x.OperatingSystem + } + return "" +} + +func (x *Agent) GetArchitecture() string { + if x != nil { + return x.Architecture + } + return "" +} + func (m *Agent) GetAuth() isAgent_Auth { if m != nil { return m.Auth @@ -792,11 +816,11 @@ type isAgent_Auth interface { } type Agent_Token struct { - Token string `protobuf:"bytes,4,opt,name=token,proto3,oneof"` + Token string `protobuf:"bytes,7,opt,name=token,proto3,oneof"` } type Agent_InstanceId struct { - InstanceId string `protobuf:"bytes,5,opt,name=instance_id,json=instanceId,proto3,oneof"` + InstanceId string `protobuf:"bytes,8,opt,name=instance_id,json=instanceId,proto3,oneof"` } func (*Agent_Token) isAgent_Auth() {} @@ -809,9 +833,9 @@ type Resource struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` - Agent *Agent `protobuf:"bytes,3,opt,name=agent,proto3" json:"agent,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Agents []*Agent `protobuf:"bytes,3,rep,name=agents,proto3" json:"agents,omitempty"` } func (x *Resource) Reset() { @@ -860,9 +884,9 @@ func (x *Resource) GetType() string { return "" } -func (x *Resource) GetAgent() *Agent { +func (x *Resource) GetAgents() []*Agent { if x != nil { - return x.Agent + return x.Agents } return nil } @@ -1609,119 +1633,125 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, - 0x70, 0x75, 0x74, 0x22, 0x3d, 0x0a, 0x1a, 0x47, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x49, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x41, 0x75, 0x74, - 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, - 0x49, 0x64, 0x22, 0xe8, 0x01, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2d, 0x0a, 0x03, - 0x65, 0x6e, 0x76, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12, 0x25, 0x0a, 0x0e, 0x73, - 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x53, 0x63, 0x72, 0x69, - 0x70, 0x74, 0x12, 0x16, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x48, 0x00, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0b, 0x69, 0x6e, - 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, - 0x00, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 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, 0x5c, 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, 0x28, 0x0a, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, - 0x67, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x22, 0xfc, 0x01, 0x0a, 0x05, - 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x70, 0x75, 0x74, 0x22, 0x37, 0x0a, 0x14, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x41, 0x75, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x69, + 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x22, 0xcb, 0x02, 0x0a, + 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2d, 0x0a, 0x03, 0x65, 0x6e, + 0x76, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, + 0x72, 0x74, 0x75, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x12, 0x29, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x79, + 0x73, 0x74, 0x65, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6e, 0x67, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x22, 0x0a, 0x0c, 0x61, + 0x72, 0x63, 0x68, 0x69, 0x74, 0x65, 0x63, 0x74, 0x75, 0x72, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0c, 0x61, 0x72, 0x63, 0x68, 0x69, 0x74, 0x65, 0x63, 0x74, 0x75, 0x72, 0x65, 0x12, + 0x16, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, + 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, + 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, + 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 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, 0x5e, 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, 0x22, 0xfc, 0x01, 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, 0x55, 0x0a, + 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 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, 0xa9, 0x06, 0x0a, 0x09, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xcc, 0x01, 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, 0x1a, 0xd9, 0x01, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, 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, 0x55, - 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 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, 0xa9, 0x06, 0x0a, 0x09, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xcc, 0x01, 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, 0x1a, 0xd9, 0x01, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, - 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, 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, 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, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, - 0x79, 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x72, 0x79, - 0x52, 0x75, 0x6e, 0x1a, 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0x80, 0x01, - 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, - 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 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, 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, 0x53, 0x74, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, - 0x37, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x02, 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, 0x6b, 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, 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, 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, + 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x79, + 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x72, 0x79, 0x52, + 0x75, 0x6e, 0x1a, 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0x80, 0x01, 0x0a, + 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x18, 0x01, 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, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x37, + 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x02, 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, + 0x6b, 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, 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, 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 ( @@ -1739,32 +1769,32 @@ func file_provisionersdk_proto_provisioner_proto_rawDescGZIP() []byte { var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 5) var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ - (LogLevel)(0), // 0: provisioner.LogLevel - (WorkspaceTransition)(0), // 1: provisioner.WorkspaceTransition - (ParameterSource_Scheme)(0), // 2: provisioner.ParameterSource.Scheme - (ParameterDestination_Scheme)(0), // 3: provisioner.ParameterDestination.Scheme - (ParameterSchema_TypeSystem)(0), // 4: provisioner.ParameterSchema.TypeSystem - (*Empty)(nil), // 5: provisioner.Empty - (*ParameterSource)(nil), // 6: provisioner.ParameterSource - (*ParameterDestination)(nil), // 7: provisioner.ParameterDestination - (*ParameterValue)(nil), // 8: provisioner.ParameterValue - (*ParameterSchema)(nil), // 9: provisioner.ParameterSchema - (*Log)(nil), // 10: provisioner.Log - (*GoogleInstanceIdentityAuth)(nil), // 11: provisioner.GoogleInstanceIdentityAuth - (*Agent)(nil), // 12: provisioner.Agent - (*Resource)(nil), // 13: provisioner.Resource - (*Parse)(nil), // 14: provisioner.Parse - (*Provision)(nil), // 15: provisioner.Provision - nil, // 16: provisioner.Agent.EnvEntry - (*Parse_Request)(nil), // 17: provisioner.Parse.Request - (*Parse_Complete)(nil), // 18: provisioner.Parse.Complete - (*Parse_Response)(nil), // 19: provisioner.Parse.Response - (*Provision_Metadata)(nil), // 20: provisioner.Provision.Metadata - (*Provision_Start)(nil), // 21: provisioner.Provision.Start - (*Provision_Cancel)(nil), // 22: provisioner.Provision.Cancel - (*Provision_Request)(nil), // 23: provisioner.Provision.Request - (*Provision_Complete)(nil), // 24: provisioner.Provision.Complete - (*Provision_Response)(nil), // 25: provisioner.Provision.Response + (LogLevel)(0), // 0: provisioner.LogLevel + (WorkspaceTransition)(0), // 1: provisioner.WorkspaceTransition + (ParameterSource_Scheme)(0), // 2: provisioner.ParameterSource.Scheme + (ParameterDestination_Scheme)(0), // 3: provisioner.ParameterDestination.Scheme + (ParameterSchema_TypeSystem)(0), // 4: provisioner.ParameterSchema.TypeSystem + (*Empty)(nil), // 5: provisioner.Empty + (*ParameterSource)(nil), // 6: provisioner.ParameterSource + (*ParameterDestination)(nil), // 7: provisioner.ParameterDestination + (*ParameterValue)(nil), // 8: provisioner.ParameterValue + (*ParameterSchema)(nil), // 9: provisioner.ParameterSchema + (*Log)(nil), // 10: provisioner.Log + (*InstanceIdentityAuth)(nil), // 11: provisioner.InstanceIdentityAuth + (*Agent)(nil), // 12: provisioner.Agent + (*Resource)(nil), // 13: provisioner.Resource + (*Parse)(nil), // 14: provisioner.Parse + (*Provision)(nil), // 15: provisioner.Provision + nil, // 16: provisioner.Agent.EnvEntry + (*Parse_Request)(nil), // 17: provisioner.Parse.Request + (*Parse_Complete)(nil), // 18: provisioner.Parse.Complete + (*Parse_Response)(nil), // 19: provisioner.Parse.Response + (*Provision_Metadata)(nil), // 20: provisioner.Provision.Metadata + (*Provision_Start)(nil), // 21: provisioner.Provision.Start + (*Provision_Cancel)(nil), // 22: provisioner.Provision.Cancel + (*Provision_Request)(nil), // 23: provisioner.Provision.Request + (*Provision_Complete)(nil), // 24: provisioner.Provision.Complete + (*Provision_Response)(nil), // 25: provisioner.Provision.Response } var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 2, // 0: provisioner.ParameterSource.scheme:type_name -> provisioner.ParameterSource.Scheme @@ -1775,7 +1805,7 @@ var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 4, // 5: provisioner.ParameterSchema.validation_type_system:type_name -> provisioner.ParameterSchema.TypeSystem 0, // 6: provisioner.Log.level:type_name -> provisioner.LogLevel 16, // 7: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry - 12, // 8: provisioner.Resource.agent:type_name -> provisioner.Agent + 12, // 8: provisioner.Resource.agents:type_name -> provisioner.Agent 9, // 9: provisioner.Parse.Complete.parameter_schemas:type_name -> provisioner.ParameterSchema 10, // 10: provisioner.Parse.Response.log:type_name -> provisioner.Log 18, // 11: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete @@ -1877,7 +1907,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GoogleInstanceIdentityAuth); i { + switch v := v.(*InstanceIdentityAuth); i { case 0: return &v.state case 1: diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 2f9e4b14589a0..84505a6e9e0e1 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -67,18 +67,21 @@ message Log { string output = 2; } -message GoogleInstanceIdentityAuth { +message InstanceIdentityAuth { string instance_id = 1; } // Agent represents a running agent on the workspace. message Agent { string id = 1; - map env = 2; - string startup_script = 3; + string name = 2; + map env = 3; + string startup_script = 4; + string operating_system = 5; + string architecture = 6; oneof auth { - string token = 4; - string instance_id = 5; + string token = 7; + string instance_id = 8; } } @@ -86,7 +89,7 @@ message Agent { message Resource { string name = 1; string type = 2; - Agent agent = 3; + repeated Agent agents = 3; } // Parse consumes source-code from a directory to produce inputs. From 4496f3618a40522d62f02c003f14e61aaf1fecd3 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 10 Apr 2022 17:23:28 +0000 Subject: [PATCH 06/17] Rename `tunnel` to `skip-tunnel` This command was `true` by default, which causes a confusing user experience. --- cli/cliui/resources.go | 22 +++++++++--------- cli/start.go | 43 +++++++++++++++++++++++------------ cli/start_test.go | 14 ++++++------ cli/workspaceshow.go | 5 ++-- cmd/cliui/main.go | 3 +-- cmd/coder/main.go | 2 +- develop.sh | 2 +- site/e2e/playwright.config.ts | 2 +- 8 files changed, 54 insertions(+), 39 deletions(-) diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index 36185352f0d91..c109693240f96 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -20,16 +20,16 @@ type WorkspaceResourcesOptions struct { // WorkspaceResources displays the connection status and tree-view of provided resources. // ┌────────────────────────────────────────────────────────────────────────────┐ -// │ RESOURCE STATE ACCESS │ +// │ RESOURCE ACCESS │ // ├────────────────────────────────────────────────────────────────────────────┤ -// │ google_compute_disk.root persists on stop │ +// │ google_compute_disk.root persistent │ // ├────────────────────────────────────────────────────────────────────────────┤ -// │ google_compute_instance.dev │ -// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh wow.dev │ +// │ google_compute_instance.dev ephemeral │ +// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh dev.dev │ // ├────────────────────────────────────────────────────────────────────────────┤ -// │ kubernetes_pod.dev │ -// │ ├─ go (linux, amd64) ⦿ connected coder ssh wow.go │ -// │ └─ postgres (linux, amd64) ⦾ disconnected [4s] coder ssh wow.postgres │ +// │ kubernetes_pod.dev ephemeral │ +// │ ├─ go (linux, amd64) ⦿ connected coder ssh dev.go │ +// │ └─ postgres (linux, amd64) ⦾ disconnected [4s] coder ssh dev.postgres │ // └────────────────────────────────────────────────────────────────────────────┘ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource, options WorkspaceResourcesOptions) error { // Sort resources by type for consistent output. @@ -53,7 +53,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource tableWriter := table.NewWriter() tableWriter.SetStyle(table.StyleLight) tableWriter.Style().Options.SeparateColumns = false - row := table.Row{"Resource", "State"} + row := table.Row{"Resource", ""} if !options.HideAccess { row = append(row, "Access") } @@ -82,14 +82,14 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource return resource.Agents[i].Name < resource.Agents[j].Name }) _, existsOnStop := addressOnStop[resource.Address] - resourceState := "" + resourceState := "ephemeral" if existsOnStop { - resourceState = Styles.Placeholder.Render("persists on rebuild") + resourceState = "persistent" } // Display a line for the resource. tableWriter.AppendRow(table.Row{ Styles.Bold.Render(resource.Type + "." + resource.Name), - resourceState, + Styles.Placeholder.Render(resourceState), "", }) // Display all agents associated with the resource. diff --git a/cli/start.go b/cli/start.go index 9726c9a1a8d1e..73cf8c28b123e 100644 --- a/cli/start.go +++ b/cli/start.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "database/sql" "encoding/pem" + "errors" "fmt" "net" "net/http" @@ -55,7 +56,7 @@ func start() *cobra.Command { tlsEnable bool tlsKeyFile string tlsMinVersion string - useTunnel bool + skipTunnel bool traceDatadog bool secureAuthCookie bool sshKeygenAlgorithmRaw string @@ -100,24 +101,35 @@ func start() *cobra.Command { } if accessURL == "" { accessURL = localURL.String() + } else { + // If an access URL is specified, always skip tunneling. + skipTunnel = true } var tunnelErr <-chan error // If we're attempting to tunnel in dev-mode, the access URL // needs to be changed to use the tunnel. - if dev && useTunnel { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Coder requires a network endpoint that can be accessed by provisioned workspaces. In dev mode, a free tunnel can be created for you. This will expose your Coder deployment to the internet.")+"\n") + if dev && !skipTunnel { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render( + "Coder requires a URL accessible by workspaces you provision. "+ + "A free tunnel can be created for simple setup. This will "+ + "expose your Coder deployment to a publicly accessible URL. "+ + cliui.Styles.Field.Render("--access-url")+" can be specified instead.\n", + )) _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: "Would you like Coder to start a tunnel for simple setup?", + Text: "Would you like to start a tunnel for simple setup?", IsConfirm: true, }) + if errors.Is(err, cliui.Canceled) { + return err + } if err == nil { accessURL, tunnelErr, err = tunnel.New(cmd.Context(), localURL.String()) if err != nil { return xerrors.Errorf("create tunnel: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL)+"\n") } + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) } validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication()) if err != nil { @@ -145,6 +157,10 @@ func start() *cobra.Command { SSHKeygenAlgorithm: sshKeygenAlgorithm, } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "access-url: %s\n", accessURL) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "provisioner-daemons: %d\n", provisionerDaemonCount) + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) + if !dev { sqlDB, err := sql.Open("postgres", postgresURL) if err != nil { @@ -213,25 +229,24 @@ func start() *cobra.Command { return xerrors.Errorf("create first user: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Wrap.Copy().Margin(0, 0, 0, 2).Render(cliui.Styles.Wrap.Render(`Started in dev mode. All data is in-memory! `+cliui.Styles.Bold.Render("Do not use in production")+`. Press `+cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`))+ - ` -`+ - cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(`Run `+cliui.Styles.Code.Render("coder templates init")+" in a new terminal to start creating development workspaces.\n"))+` -`) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render(`Started in dev mode. All data is in-memory! `+cliui.Styles.Bold.Render("Do not use in production")+`. Press `+ + cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`)+"\n\n") + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render(`Run `+cliui.Styles.Code.Render("coder templates init")+ + " in a new terminal to start creating workspaces.")+"\n") } else { // This is helpful for tests, but can be silently ignored. // Coder may be ran as users that don't have permission to write in the homedir, // such as via the systemd service. _ = config.URL().Write(client.URL.String()) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+ + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+ cliui.Styles.Field.Render("production")+` mode. All data is stored in the PostgreSQL provided! Press `+cliui.Styles.Field.Render("ctrl+c")+` to gracefully shutdown.`))+"\n") hasFirstUser, err := client.HasFirstUser(cmd.Context()) if !hasFirstUser && err == nil { // This could fail for a variety of TLS-related reasons. // This is a helpful starter message, and not critical for user interaction. - _, _ = fmt.Fprint(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder login "+client.URL.String())+" in a new terminal to get started.\n"))) + _, _ = fmt.Fprint(cmd.ErrOrStderr(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder login "+client.URL.String())+" in a new terminal to get started.\n"))) } } @@ -341,8 +356,8 @@ func start() *cobra.Command { "Specifies the path to the private key for the certificate. It requires a PEM-encoded file") cliflag.StringVarP(root.Flags(), &tlsMinVersion, "tls-min-version", "", "CODER_TLS_MIN_VERSION", "tls12", `Specifies the minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`) - cliflag.BoolVarP(root.Flags(), &useTunnel, "tunnel", "", "CODER_DEV_TUNNEL", true, "Serve dev mode through a Cloudflare Tunnel for easy setup") - _ = root.Flags().MarkHidden("tunnel") + cliflag.BoolVarP(root.Flags(), &skipTunnel, "skip-tunnel", "", "CODER_DEV_SKIP_TUNNEL", false, "Skip serving dev mode through an exposed tunnel for simple setup.") + _ = root.Flags().MarkHidden("skip-tunnel") cliflag.BoolVarP(root.Flags(), &traceDatadog, "trace-datadog", "", "CODER_TRACE_DATADOG", false, "Send tracing data to a datadog agent") cliflag.BoolVarP(root.Flags(), &secureAuthCookie, "secure-auth-cookie", "", "CODER_SECURE_AUTH_COOKIE", false, "Specifies if the 'Secure' property is set on browser session cookies") cliflag.StringVarP(root.Flags(), &sshKeygenAlgorithmRaw, "ssh-keygen-algorithm", "", "CODER_SSH_KEYGEN_ALGORITHM", "ed25519", "Specifies the algorithm to use for generating ssh keys. "+ diff --git a/cli/start_test.go b/cli/start_test.go index 78ac0c86b5315..bbe49ad463e1a 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -72,7 +72,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0") + root, cfg := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0") go func() { err := root.ExecuteContext(ctx) require.ErrorIs(t, err, context.Canceled) @@ -97,7 +97,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", + root, _ := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--tls-enable", "--tls-min-version", "tls9") err := root.ExecuteContext(ctx) require.Error(t, err) @@ -106,7 +106,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", + root, _ := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--tls-enable", "--tls-client-auth", "something") err := root.ExecuteContext(ctx) require.Error(t, err) @@ -115,7 +115,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", + root, _ := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--tls-enable") err := root.ExecuteContext(ctx) require.Error(t, err) @@ -126,7 +126,7 @@ func TestStart(t *testing.T) { defer cancelFunc() certPath, keyPath := generateTLSCertificate(t) - root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", + root, cfg := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--tls-enable", "--tls-cert-file", certPath, "--tls-key-file", keyPath) go func() { err := root.ExecuteContext(ctx) @@ -162,7 +162,7 @@ func TestStart(t *testing.T) { } ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", "--provisioner-daemons", "0") + root, cfg := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--provisioner-daemons", "0") done := make(chan struct{}) go func() { defer close(done) @@ -204,7 +204,7 @@ func TestStart(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", "--trace-datadog=true") + root, _ := clitest.New(t, "start", "--dev", "--skip-tunnel", "--address", ":0", "--trace-datadog=true") done := make(chan struct{}) go func() { defer close(done) diff --git a/cli/workspaceshow.go b/cli/workspaceshow.go index 7bad343ce3a67..a968916a3efe6 100644 --- a/cli/workspaceshow.go +++ b/cli/workspaceshow.go @@ -1,10 +1,11 @@ package cli import ( - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" "github.com/spf13/cobra" "golang.org/x/xerrors" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" ) func workspaceShow() *cobra.Command { diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 5e4d58284f8f8..18612b63ba11a 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -187,7 +187,7 @@ func main() { Use: "resources", RunE: func(cmd *cobra.Command, args []string) error { disconnected := database.Now().Add(-4 * time.Second) - cliui.WorkspaceResources(cmd.OutOrStdout(), []codersdk.WorkspaceResource{{ + return cliui.WorkspaceResources(cmd.OutOrStdout(), []codersdk.WorkspaceResource{{ Address: "disk", Transition: database.WorkspaceTransitionStart, Type: "google_compute_disk", @@ -230,7 +230,6 @@ func main() { HideAgentState: false, HideAccess: false, }) - return nil }, }) diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 55cc8ca794e57..df91af57afcc3 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -15,7 +15,7 @@ func main() { if errors.Is(err, cliui.Canceled) { os.Exit(1) } - fmt.Fprintln(os.Stderr, cliui.Styles.Error.Render(err.Error())) + _, _ = fmt.Fprintln(os.Stderr, cliui.Styles.Error.Render(err.Error())) os.Exit(1) } } diff --git a/develop.sh b/develop.sh index 5b6e587f1b84f..20c3ad08a2e62 100755 --- a/develop.sh +++ b/develop.sh @@ -14,5 +14,5 @@ cd "${PROJECT_ROOT}" ( trap 'kill 0' SIGINT CODERV2_HOST=http://127.0.0.1:3000 INSPECT_XSTATE=true yarn --cwd=./site dev & - go run cmd/coder/main.go start --dev --tunnel=false + go run cmd/coder/main.go start --dev --skip-tunnel ) diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 5b719475a8cd3..dd5af571d5e9d 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -17,7 +17,7 @@ const config: PlaywrightTestConfig = { // https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests webServer: { // Run the coder daemon directly. - command: `go run -tags embed ${path.join(__dirname, "../../cmd/coder/main.go")} start --dev --tunnel=false`, + command: `go run -tags embed ${path.join(__dirname, "../../cmd/coder/main.go")} start --dev --skip-tunnel`, port: 3000, timeout: 120 * 10000, reuseExistingServer: false, From 65c19bdcf1bcf6187a4a2c226174697839d9c9f0 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 10 Apr 2022 18:14:05 +0000 Subject: [PATCH 07/17] Add disclaimer about editing templates --- cli/cliui/resources.go | 4 ++-- cli/templateinit.go | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index c109693240f96..316e5b83a27b6 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -20,7 +20,7 @@ type WorkspaceResourcesOptions struct { // WorkspaceResources displays the connection status and tree-view of provided resources. // ┌────────────────────────────────────────────────────────────────────────────┐ -// │ RESOURCE ACCESS │ +// │ RESOURCE STATUS ACCESS │ // ├────────────────────────────────────────────────────────────────────────────┤ // │ google_compute_disk.root persistent │ // ├────────────────────────────────────────────────────────────────────────────┤ @@ -53,7 +53,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource tableWriter := table.NewWriter() tableWriter.SetStyle(table.StyleLight) tableWriter.Style().Options.SeparateColumns = false - row := table.Row{"Resource", ""} + row := table.Row{"Resource", "Status"} if !options.HideAccess { row = append(row, "Access") } diff --git a/cli/templateinit.go b/cli/templateinit.go index 166e761e4140e..16ef5f028926c 100644 --- a/cli/templateinit.go +++ b/cli/templateinit.go @@ -66,7 +66,8 @@ func templateInit() *cobra.Command { return err } _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Create your template by running:") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("cd "+relPath+"\ncoder templates create"))+"\n") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("cd "+relPath+" && coder templates create"))+"\n") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Examples provide a starting point, are expected to be edited! 🎨")) return nil }, } From c50d170d87408c887421463b03494fc260b6d0fe Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 10 Apr 2022 20:08:08 +0000 Subject: [PATCH 08/17] Add help to template create --- cli/cliui/prompt.go | 38 +++++++++++++++++--------------- cli/cliui/resources.go | 4 ++++ cli/templatecreate.go | 40 +++++++++++++++++++++++++++------- cli/templateupdate.go | 2 +- cmd/templater/main.go | 2 +- codersdk/provisionerdaemons.go | 3 +++ provisionerd/provisionerd.go | 5 ++--- provisionersdk/archive.go | 16 ++++++++++++-- provisionersdk/archive_test.go | 4 ++-- 9 files changed, 79 insertions(+), 35 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 82aa2a07cbffe..98580fcfb3903 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -5,7 +5,6 @@ import ( "bytes" "encoding/json" "fmt" - "io" "os" "os/signal" "runtime" @@ -44,11 +43,11 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { var line string var err error - inFile, valid := cmd.InOrStdin().(*os.File) - if opts.Secret && valid && isatty.IsTerminal(inFile.Fd()) { + inFile, isInputFile := cmd.InOrStdin().(*os.File) + if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { line, err = speakeasy.Ask("") } else { - if runtime.GOOS == "darwin" && valid { + if runtime.GOOS == "darwin" && isInputFile { var restore func() restore, err = removeLineLengthLimit(int(inFile.Fd())) if err != nil { @@ -65,21 +64,24 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { // This enables multiline JSON to be pasted into an input, and have // it parse properly. if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) { - pipeReader, pipeWriter := io.Pipe() - defer pipeWriter.Close() - defer pipeReader.Close() - go func() { - _, _ = pipeWriter.Write([]byte(line)) - _, _ = reader.WriteTo(pipeWriter) - }() - var rawMessage json.RawMessage - err := json.NewDecoder(pipeReader).Decode(&rawMessage) - if err == nil { - var buf bytes.Buffer - err = json.Compact(&buf, rawMessage) - if err == nil { - line = buf.String() + var data bytes.Buffer + _, _ = data.WriteString(line) + for { + // Read line-by-line. We can't use a JSON decoder + // here because it doesn't work by newline, so + // reads will block. + line, err = reader.ReadString('\n') + if err != nil { + break } + _, _ = data.WriteString(line) + var rawMessage json.RawMessage + err = json.Unmarshal(data.Bytes(), &rawMessage) + if err != nil { + continue + } + line = data.String() + break } } } diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index 316e5b83a27b6..324d0f452ac67 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -16,6 +16,7 @@ type WorkspaceResourcesOptions struct { WorkspaceName string HideAgentState bool HideAccess bool + Title string } // WorkspaceResources displays the connection status and tree-view of provided resources. @@ -51,6 +52,9 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource displayed := map[string]struct{}{} tableWriter := table.NewWriter() + if options.Title != "" { + tableWriter.SetTitle(options.Title) + } tableWriter.SetStyle(table.StyleLight) tableWriter.Style().Options.SeparateColumns = false row := table.Row{"Resource", "Status"} diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 93b8fc266a44a..244af0cb04142 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strings" "time" "github.com/briandowns/spinner" @@ -27,7 +28,7 @@ func templateCreate() *cobra.Command { provisioner string ) cmd := &cobra.Command{ - Use: "create [name]", + Use: "create [name]", Short: "Create a template from the current directory", RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) @@ -50,12 +51,32 @@ func templateCreate() *cobra.Command { return xerrors.Errorf("A template already exists named %q!", templateName) } + // Confirm upload of the users current directory. + // Truncate if in the home directory, because a shorter path looks nicer. + displayDirectory := directory + userHomeDir, err := os.UserHomeDir() + if err != nil { + return xerrors.Errorf("get home dir: %w", err) + } + if strings.HasPrefix(displayDirectory, userHomeDir) { + displayDirectory = strings.TrimPrefix(displayDirectory, userHomeDir) + displayDirectory = "~" + displayDirectory + } + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: fmt.Sprintf("Create and upload %q?", displayDirectory), + IsConfirm: true, + Default: "yes", + }) + if err != nil { + return err + } + spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond) spin.Writer = cmd.OutOrStdout() spin.Suffix = cliui.Styles.Keyword.Render(" Uploading current directory...") spin.Start() defer spin.Stop() - archive, err := provisionersdk.Tar(directory) + archive, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit) if err != nil { return err } @@ -66,9 +87,6 @@ func templateCreate() *cobra.Command { } spin.Stop() - spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond) - spin.Writer = cmd.OutOrStdout() - spin.Suffix = cliui.Styles.Keyword.Render("Something") job, parameters, err := createValidTemplateVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash) if err != nil { return err @@ -76,9 +94,8 @@ func templateCreate() *cobra.Command { if !yes { _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: "Create template?", + Text: "Confirm create?", IsConfirm: true, - Default: "yes", }) if err != nil { if errors.Is(err, promptui.ErrAbort) { @@ -97,7 +114,13 @@ func templateCreate() *cobra.Command { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s template has been created!\n", templateName) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n"+cliui.Styles.Wrap.Render( + "The "+cliui.Styles.Keyword.Render(templateName)+" template has been created! "+ + "Developers can provision a workspace with this template using:")+"\n") + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder workspace create "+templateName)) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + return nil }, } @@ -199,5 +222,6 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org return &version, parameters, cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ HideAgentState: true, HideAccess: true, + Title: "Template Preview", }) } diff --git a/cli/templateupdate.go b/cli/templateupdate.go index cd88561bbd5bb..333003ecf3ed7 100644 --- a/cli/templateupdate.go +++ b/cli/templateupdate.go @@ -43,7 +43,7 @@ func templateUpdate() *cobra.Command { return err } } - content, err := provisionersdk.Tar(directory) + content, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit) if err != nil { return err } diff --git a/cmd/templater/main.go b/cmd/templater/main.go index effd36fd49a1a..4600aa420ff5a 100644 --- a/cmd/templater/main.go +++ b/cmd/templater/main.go @@ -120,7 +120,7 @@ func parse(cmd *cobra.Command, parameters []codersdk.CreateParameterRequest) err if err != nil { return err } - content, err := provisionersdk.Tar(dir) + content, err := provisionersdk.Tar(dir, provisionersdk.TemplateArchiveLimit) if err != nil { return err } diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index a66f8d3ff2ac2..de59ee9e99b16 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -70,6 +70,9 @@ func (c *Client) ListenProvisionerDaemon(ctx context.Context) (proto.DRPCProvisi } return nil, readBodyAsError(res) } + // Allow _somewhat_ large payloads. + conn.SetReadLimit((1 << 20) * 2) + config := yamux.DefaultConfig() config.LogOutput = io.Discard session, err := yamux.Client(websocket.NetConn(ctx, conn, websocket.MessageBinary), config) diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index e27024da2a399..f36893122457d 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -207,7 +207,6 @@ func (p *Server) acquireJob(ctx context.Context) { return } if job.JobId == "" { - // p.opts.Logger.Debug(context.Background(), "no jobs available") return } ctx, p.jobCancel = context.WithCancel(ctx) @@ -456,7 +455,7 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, Level: sdkproto.LogLevel_INFO, - Stage: "Detecting resources when started", + Stage: "Detecting persistent resources", CreatedAt: time.Now().UTC().UnixMilli(), }}, }) @@ -477,7 +476,7 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, Level: sdkproto.LogLevel_INFO, - Stage: "Detecting resources when stopped", + Stage: "Detecting ephemeral resources", CreatedAt: time.Now().UTC().UnixMilli(), }}, }) diff --git a/provisionersdk/archive.go b/provisionersdk/archive.go index 614a9666878b6..ff43880cec396 100644 --- a/provisionersdk/archive.go +++ b/provisionersdk/archive.go @@ -11,10 +11,16 @@ import ( "golang.org/x/xerrors" ) +const ( + // TemplateArchiveLimit represents the maximum size of a template in bytes. + TemplateArchiveLimit = 1 << 20 +) + // Tar archives a directory. -func Tar(directory string) ([]byte, error) { +func Tar(directory string, limit int64) ([]byte, error) { var buffer bytes.Buffer tarWriter := tar.NewWriter(&buffer) + totalSize := int64(0) err := filepath.Walk(directory, func(file string, fileInfo os.FileInfo, err error) error { if err != nil { return err @@ -46,9 +52,15 @@ func Tar(directory string) ([]byte, error) { if err != nil { return err } - if _, err := io.Copy(tarWriter, data); err != nil { + defer data.Close() + wrote, err := io.Copy(tarWriter, data) + if err != nil { return err } + totalSize += wrote + if limit != 0 && totalSize >= limit { + return xerrors.Errorf("Archive too big. Must be <= %d bytes", limit) + } return data.Close() }) if err != nil { diff --git a/provisionersdk/archive_test.go b/provisionersdk/archive_test.go index 102b2513e57f4..7abe9ce47bda8 100644 --- a/provisionersdk/archive_test.go +++ b/provisionersdk/archive_test.go @@ -16,7 +16,7 @@ func TestTar(t *testing.T) { file, err := os.CreateTemp(dir, "") require.NoError(t, err) _ = file.Close() - _, err = provisionersdk.Tar(dir) + _, err = provisionersdk.Tar(dir, 1024) require.NoError(t, err) } @@ -26,7 +26,7 @@ func TestUntar(t *testing.T) { file, err := os.CreateTemp(dir, "") require.NoError(t, err) _ = file.Close() - archive, err := provisionersdk.Tar(dir) + archive, err := provisionersdk.Tar(dir, 1024) require.NoError(t, err) dir = t.TempDir() err = provisionersdk.Untar(dir, archive) From 326f2d57e20bbf3ceb8a55accd41d9eccf65aa40 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 10 Apr 2022 21:00:28 +0000 Subject: [PATCH 09/17] Improve workspace create flow --- cli/cliui/prompt.go | 28 ++++++--- cli/cliui/select.go | 1 - cli/start.go | 4 +- cli/templatecreate.go | 5 -- cli/templatecreate_test.go | 3 +- cli/templateinit.go | 6 +- cli/workspacecreate.go | 94 ++++++++++++++++------------ cli/workspacecreate_test.go | 120 +++++++++++++++++++++++++++++++++++- go.mod | 4 -- go.sum | 12 ---- 10 files changed, 199 insertions(+), 78 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 98580fcfb3903..f40de6e8734d2 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -65,21 +65,33 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { // it parse properly. if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) { var data bytes.Buffer - _, _ = data.WriteString(line) for { - // Read line-by-line. We can't use a JSON decoder - // here because it doesn't work by newline, so - // reads will block. - line, err = reader.ReadString('\n') - if err != nil { - break - } _, _ = data.WriteString(line) var rawMessage json.RawMessage err = json.Unmarshal(data.Bytes(), &rawMessage) if err != nil { + if err.Error() != "unexpected end of JSON input" { + err = nil + line = data.String() + break + } + + // Read line-by-line. We can't use a JSON decoder + // here because it doesn't work by newline, so + // reads will block. + line, err = reader.ReadString('\n') + if err != nil { + break + } continue } + // Compacting the JSON makes it easier for parsing and testing. + bytes := data.Bytes() + data.Reset() + err = json.Compact(&data, bytes) + if err != nil { + break + } line = data.String() break } diff --git a/cli/cliui/select.go b/cli/cliui/select.go index ed7c3b87b275c..5b9f535049aa2 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -50,7 +50,6 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) { if flag.Lookup("test.v") != nil { return opts.Options[0], nil } - opts.HideSearch = false var value string err := survey.AskOne(&survey.Select{ Options: opts.Options, diff --git a/cli/start.go b/cli/start.go index 73cf8c28b123e..c7b98db850929 100644 --- a/cli/start.go +++ b/cli/start.go @@ -423,8 +423,8 @@ func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger s return provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{ Logger: logger, - PollInterval: 50 * time.Millisecond, - UpdateInterval: 50 * time.Millisecond, + PollInterval: 500 * time.Millisecond, + UpdateInterval: 500 * time.Millisecond, Provisioners: provisionerd.Provisioners{ string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)), }, diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 244af0cb04142..d2f1219094438 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -1,7 +1,6 @@ package cli import ( - "errors" "fmt" "os" "path/filepath" @@ -10,7 +9,6 @@ import ( "time" "github.com/briandowns/spinner" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -98,9 +96,6 @@ func templateCreate() *cobra.Command { IsConfirm: true, }) if err != nil { - if errors.Is(err, promptui.ErrAbort) { - return nil - } return err } } diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index 7fce2d80d5c01..ce4ce48624eca 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -35,7 +35,8 @@ func TestTemplateCreate(t *testing.T) { require.NoError(t, err) }() matches := []string{ - "Create template?", "yes", + "Create and upload", "yes", + "Confirm create?", "yes", } for i := 0; i < len(matches); i += 2 { match := matches[i] diff --git a/cli/templateinit.go b/cli/templateinit.go index 16ef5f028926c..84afa5874fc46 100644 --- a/cli/templateinit.go +++ b/cli/templateinit.go @@ -28,7 +28,9 @@ func templateInit() *cobra.Command { exampleByName[example.Name] = example } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("A template defines infrastructure as code to be provisioned for individual developer workspaces. Select an example to get started:\n")) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render( + "A template defines infrastructure as code to be provisioned "+ + "for individual developer workspaces. Select an example to get started:\n")) option, err := cliui.Select(cmd, cliui.SelectOptions{ Options: exampleNames, }) @@ -67,7 +69,7 @@ func templateInit() *cobra.Command { } _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Create your template by running:") _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("cd "+relPath+" && coder templates create"))+"\n") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Examples provide a starting point, are expected to be edited! 🎨")) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Examples provide a starting point and are expected to be edited! 🎨")) return nil }, } diff --git a/cli/workspacecreate.go b/cli/workspacecreate.go index d58615a3f9ed9..f7225da59d7b8 100644 --- a/cli/workspacecreate.go +++ b/cli/workspacecreate.go @@ -1,16 +1,14 @@ package cli import ( - "errors" "fmt" "sort" "time" - "github.com/fatih/color" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/xerrors" + "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" @@ -18,11 +16,10 @@ import ( func workspaceCreate() *cobra.Command { var ( - templateName string + workspaceName string ) cmd := &cobra.Command{ - Use: "create ", - Args: cobra.ExactArgs(1), + Use: "create [template]", Short: "Create a workspace from a template", RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) @@ -34,9 +31,14 @@ func workspaceCreate() *cobra.Command { return err } + templateName := "" + if len(args) >= 1 { + templateName = args[0] + } + var template codersdk.Template if templateName == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a template:")) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a template below to preview the provisioned infrastructure:")) templateNames := []string{} templateByName := map[string]codersdk.Template{} @@ -45,8 +47,16 @@ func workspaceCreate() *cobra.Command { return err } for _, template := range templates { - templateNames = append(templateNames, template.Name) - templateByName[template.Name] = template + templateName := template.Name + if template.WorkspaceOwnerCount > 0 { + developerText := "developer" + if template.WorkspaceOwnerCount != 1 { + developerText = "developers" + } + templateName += cliui.Styles.Placeholder.Render(fmt.Sprintf(" (used by %d %s)", template.WorkspaceOwnerCount, developerText)) + } + templateNames = append(templateNames, templateName) + templateByName[templateName] = template } sort.Slice(templateNames, func(i, j int) bool { return templateByName[templateNames[i]].WorkspaceOwnerCount > templateByName[templateNames[j]].WorkspaceOwnerCount @@ -70,10 +80,22 @@ func workspaceCreate() *cobra.Command { } } - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Creating with the "+cliui.Styles.Field.Render(template.Name)+" template...") + if workspaceName == "" { + workspaceName, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Specify a name for your workspace:", + Validate: func(workspaceName string) error { + _, err = client.WorkspaceByName(cmd.Context(), codersdk.Me, workspaceName) + if err == nil { + return xerrors.Errorf("A workspace already exists named %q!", workspaceName) + } + return nil + }, + }) + if err != nil { + return err + } + } - workspaceName := args[0] _, err = client.WorkspaceByName(cmd.Context(), codersdk.Me, workspaceName) if err == nil { return xerrors.Errorf("A workspace already exists named %q!", workspaceName) @@ -95,10 +117,9 @@ func workspaceCreate() *cobra.Command { continue } if !printed { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters! These can be changed after create, but may have unintended side effects (like data loss).")+"\r\n") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n") printed = true } - value, err := cliui.ParameterSchema(cmd, parameterSchema) if err != nil { return err @@ -110,11 +131,8 @@ func workspaceCreate() *cobra.Command { DestinationScheme: parameterSchema.DefaultDestinationScheme, }) } - if printed { - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.FocusedPrompt.String()+"Previewing resources...") - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - } + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + resources, err := client.TemplateVersionResources(cmd.Context(), templateVersion.ID) if err != nil { return err @@ -123,20 +141,17 @@ func workspaceCreate() *cobra.Command { WorkspaceName: workspaceName, // Since agent's haven't connected yet, hiding this makes more sense. HideAgentState: true, + Title: "Workspace Preview", }) if err != nil { return err } _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: fmt.Sprintf("Create workspace %s?", color.HiCyanString(workspaceName)), - Default: "yes", + Text: "Confirm create?", IsConfirm: true, }) if err != nil { - if errors.Is(err, promptui.ErrAbort) { - return nil - } return err } @@ -149,29 +164,26 @@ func workspaceCreate() *cobra.Command { if err != nil { return err } - err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{ - Fetch: func() (codersdk.ProvisionerJob, error) { - build, err := client.WorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID) - return build.Job, err - }, - Cancel: func() error { - return client.CancelWorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID) - }, - Logs: func() (<-chan codersdk.ProvisionerJobLog, error) { - return client.WorkspaceBuildLogsAfter(cmd.Context(), workspace.LatestBuild.ID, before) - }, - }) + err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID, before) + if err != nil { + return err + } + resources, err = client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) if err != nil { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created!\n\n", cliui.Styles.Keyword.Render(workspace.Name)) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder ssh "+workspace.Name)) - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - return err + err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{ + WorkspaceName: workspaceName, + }) + if err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s workspace has been created!\n", cliui.Styles.Keyword.Render(workspace.Name)) + return nil }, } - cmd.Flags().StringVarP(&templateName, "template", "p", "", "Specify a template name.") + cliflag.StringVarP(cmd.Flags(), &workspaceName, "name", "n", "CODER_WORKSPACE_NAME", "", "Specify a workspace name.") return cmd } diff --git a/cli/workspacecreate_test.go b/cli/workspacecreate_test.go index 575263d0533b1..26b751328dc34 100644 --- a/cli/workspacecreate_test.go +++ b/cli/workspacecreate_test.go @@ -7,6 +7,8 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/pty/ptytest" ) @@ -20,7 +22,7 @@ func TestWorkspaceCreate(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - cmd, root := clitest.New(t, "workspaces", "create", "my-workspace", "--template", template.Name) + cmd, root := clitest.New(t, "workspaces", "create", template.Name, "--name", "my-workspace") clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) @@ -32,7 +34,121 @@ func TestWorkspaceCreate(t *testing.T) { require.NoError(t, err) }() matches := []string{ - "Create workspace", "yes", + "Confirm create", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) + t.Run("CreateFromList", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + cmd, root := clitest.New(t, "workspaces", "create", "--name", "my-workspace") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + matches := []string{ + "Confirm create", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) + t.Run("FromNothing", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + cmd, root := clitest.New(t, "workspaces", "create", "--name", "") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + matches := []string{ + "Specify a name", "my-workspace", + "Confirm create?", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) + t.Run("WithParameter", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: []*proto.Parse_Response{{ + Type: &proto.Parse_Response_Complete{ + Complete: &proto.Parse_Complete{ + ParameterSchemas: []*proto.ParameterSchema{{ + AllowOverrideSource: true, + Name: "region", + Description: "description", + DefaultSource: &proto.ParameterSource{ + Scheme: proto.ParameterSource_DATA, + Value: "something", + }, + DefaultDestination: &proto.ParameterDestination{ + Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE, + }, + }}, + }, + }, + }}, + Provision: echo.ProvisionComplete, + ProvisionDryRun: echo.ProvisionComplete, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + cmd, root := clitest.New(t, "workspaces", "create", "--name", "") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + matches := []string{ + "Specify a name", "my-workspace", + "Enter a value", "bananas", + "Confirm create?", "yes", } for i := 0; i < len(matches); i += 2 { match := matches[i] diff --git a/go.mod b/go.mod index c84bb78b8051b..0b2a537d54212 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,6 @@ require ( github.com/justinas/nosurf v1.1.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/lib/pq v1.10.4 - github.com/manifoldco/promptui v0.9.0 github.com/mattn/go-isatty v0.0.14 github.com/mitchellh/mapstructure v1.4.3 github.com/moby/moby v20.10.14+incompatible @@ -119,7 +118,6 @@ require ( github.com/charmbracelet/bubbles v0.10.3 // indirect github.com/charmbracelet/bubbletea v0.20.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect - github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/clbanning/mxj/v2 v2.5.5 // indirect github.com/cloudflare/brotli-go v0.0.0-20191101163834-d34379f7ff93 // indirect github.com/cloudflare/golibs v0.0.0-20210909181612-21743d7dd02a // indirect @@ -171,13 +169,11 @@ require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.15.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/lucas-clemente/quic-go v0.25.1-0.20220307142123-ad1cb27c1b64 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/marten-seemann/qtls-go1-16 v0.1.4 // indirect github.com/marten-seemann/qtls-go1-17 v0.1.0 // indirect diff --git a/go.sum b/go.sum index 5c9e2cbda4132..c863a644cf68a 100644 --- a/go.sum +++ b/go.sum @@ -288,9 +288,7 @@ github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d8 github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg= github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc= @@ -1068,9 +1066,6 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= -github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= -github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= github.com/julienschmidt/httprouter v1.1.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= @@ -1127,9 +1122,6 @@ github.com/kylecarbs/cloudflared v0.0.0-20220323202451-083379ce31c3 h1:JopBWZaVm github.com/kylecarbs/cloudflared v0.0.0-20220323202451-083379ce31c3/go.mod h1:4chGYq3uDzeHSpht2LFNZc/8ulHhMW9MvHPvzT5aZx8= github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b h1:1Y1X6aR78kMEQE1iCjQodB3lA7VO4jB88Wf8ZrzXSsA= github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -github.com/kylecarbs/promptui v0.8.1-0.20201231190244-d8f2159af2b2 h1:MUREBTh4kybLY1KyuBfSx+QPfTB8XiUHs6ZxUhOPTnU= -github.com/kylecarbs/promptui v0.8.1-0.20201231190244-d8f2159af2b2/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= -github.com/kylecarbs/readline v0.0.0-20220211054233-0d62993714c8 h1:Y7O3Z3YeNRtw14QrtHpevU4dSjCkov0J40MtQ7Nc0n8= github.com/kylecarbs/readline v0.0.0-20220211054233-0d62993714c8/go.mod h1:n/KX1BZoN1m9EwoXkn/xAV4fd3k8c++gGBsgLONaPOY= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e h1:OP0ZMFeZkUnOzTFRfpuK3m7Kp4fNvC6qN+exwj7aI4M= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= @@ -1165,8 +1157,6 @@ github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1 github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= -github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -1198,7 +1188,6 @@ github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -2000,7 +1989,6 @@ golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= From 008ba69a64d04234a845e2067f4832a12992b436 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 11 Apr 2022 00:09:56 +0000 Subject: [PATCH 10/17] Add end-to-end test for config-ssh --- agent/agent.go | 4 +++ cli/configssh.go | 15 +++++++--- cli/configssh_test.go | 68 +++++++++++++++++++++++++++++++++++++++++-- cli/templatelist.go | 34 +++++++++------------- 4 files changed, 94 insertions(+), 27 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index cc4131297c390..25e828e5a5d1b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -100,6 +100,10 @@ func (a *agent) run(ctx context.Context) { } func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { + go func() { + <-a.closed + _ = conn.Close() + }() go func() { <-conn.Closed() a.connCloseWait.Done() diff --git a/cli/configssh.go b/cli/configssh.go index 6bcd117372f09..82c02446b98c3 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -31,6 +31,7 @@ const sshEndToken = "# ------------END-CODER------------" func configSSH() *cobra.Command { var ( + binaryFile string sshConfigFile string ) cmd := &cobra.Command{ @@ -60,11 +61,15 @@ func configSSH() *cobra.Command { if len(workspaces) == 0 { return xerrors.New("You don't have any workspaces!") } - binPath, err := currentBinPath(cmd) - if err != nil { - return err + + if binaryFile == "" { + binaryFile, err = currentBinPath(cmd) + if err != nil { + return err + } } + root := createConfig(cmd) sshConfigContent += "\n" + sshStartToken + "\n" + sshStartMessage + "\n\n" sshConfigContentMutex := sync.Mutex{} var errGroup errgroup.Group @@ -88,7 +93,7 @@ func configSSH() *cobra.Command { sshConfigContent += strings.Join([]string{ "Host coder." + hostname, "\tHostName coder." + hostname, - fmt.Sprintf("\tProxyCommand %q ssh --stdio %s", binPath, hostname), + fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname), "\tConnectTimeout=0", "\tStrictHostKeyChecking=no", }, "\n") + "\n" @@ -117,6 +122,8 @@ func configSSH() *cobra.Command { return nil }, } + cliflag.StringVarP(cmd.Flags(), &binaryFile, "binary-file", "", "CODER_BINARY_PATH", "", "Specifies the path to a Coder binary.") + _ = cmd.Flags().MarkHidden("binary-file") cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", "~/.ssh/config", "Specifies the path to an SSH config.") return cmd diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 662275f2979da..cdf417a7643b1 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -2,32 +2,88 @@ package cli_test import ( "os" + "os/exec" + "path/filepath" + "strings" "testing" + "time" + "cdr.dev/slog/sloggers/slogtest" + "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/coder/coder/agent" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" + "github.com/coder/coder/peer" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/pty/ptytest" ) func TestConfigSSH(t *testing.T) { t.Parallel() - t.Run("Create", func(t *testing.T) { + binPath := filepath.Join(t.TempDir(), "coder") + _, err := exec.Command("go", "build", "-o", binPath, "github.com/coder/coder/cmd/coder").CombinedOutput() + require.NoError(t, err) + + t.Run("Dial", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) coderdtest.NewProvisionerDaemon(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + }}, + }}, + }, + }, + }}, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) tempFile, err := os.CreateTemp(t.TempDir(), "") require.NoError(t, err) _ = tempFile.Close() - cmd, root := clitest.New(t, "config-ssh", "--ssh-config-file", tempFile.Name()) + cmd, root := clitest.New(t, "config-ssh", "--binary-file", binPath, "--ssh-config-file", tempFile.Name()) clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) @@ -39,5 +95,11 @@ func TestConfigSSH(t *testing.T) { require.NoError(t, err) }() <-doneChan + t.Log(tempFile.Name()) + time.Sleep(time.Hour) + output, err := exec.Command("ssh", "-F", tempFile.Name(), "coder."+workspace.Name, "echo", "test").Output() + t.Log(string(output)) + require.NoError(t, err) + require.Equal(t, "test", strings.TrimSpace(string(output))) }) } diff --git a/cli/templatelist.go b/cli/templatelist.go index 410e489f15c3d..1d613c02a68ef 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -2,10 +2,10 @@ package cli import ( "fmt" - "text/tabwriter" - "time" + "github.com/coder/coder/cli/cliui" "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" ) @@ -18,7 +18,6 @@ func templateList() *cobra.Command { if err != nil { return err } - start := time.Now() organization, err := currentOrganization(cmd, client) if err != nil { return err @@ -34,30 +33,25 @@ func templateList() *cobra.Command { return nil } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Templates found in %s %s\n\n", - caret, - color.HiWhiteString(organization.Name), - color.HiBlackString("[%dms]", - time.Since(start).Milliseconds())) + tableWriter := table.NewWriter() + tableWriter.SetStyle(table.StyleLight) + tableWriter.Style().Options.SeparateColumns = false + tableWriter.AppendHeader(table.Row{"Name", "Source", "Last Updated", "Used By"}) - writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0) - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", - color.HiBlackString("Template"), - color.HiBlackString("Source"), - color.HiBlackString("Last Updated"), - color.HiBlackString("Used By")) for _, template := range templates { suffix := "" if template.WorkspaceOwnerCount != 1 { suffix = "s" } - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", - color.New(color.FgHiCyan).Sprint(template.Name), - color.WhiteString("Archive"), - color.WhiteString(template.UpdatedAt.Format("January 2, 2006")), - color.New(color.FgHiWhite).Sprintf("%d developer%s", template.WorkspaceOwnerCount, suffix)) + tableWriter.AppendRow(table.Row{ + cliui.Styles.Bold.Render(template.Name), + "Archive", + template.UpdatedAt.Format("January 2, 2006"), + cliui.Styles.Fuschia.Render(fmt.Sprintf("%d developer%s", template.WorkspaceOwnerCount, suffix)), + }) } - return writer.Flush() + _, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render()) + return err }, } } From 71e45440d56534f8069eea0c535512fc12a0ce08 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 11 Apr 2022 01:22:56 +0000 Subject: [PATCH 11/17] Improve testing of config-ssh --- agent/agent_test.go | 88 +++++++++++++++++--- cli/configssh.go | 23 +++--- cli/configssh_test.go | 168 ++++++++++++++++++++++---------------- cli/templatelist.go | 3 +- coderd/workspaceagents.go | 14 ++++ 5 files changed, 202 insertions(+), 94 deletions(-) diff --git a/agent/agent_test.go b/agent/agent_test.go index 9461da962041a..720bec27f4715 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2,7 +2,13 @@ package agent_test import ( "context" + "fmt" + "io" + "net" + "os/exec" + "path/filepath" "runtime" + "strconv" "strings" "testing" @@ -29,7 +35,8 @@ func TestAgent(t *testing.T) { t.Parallel() t.Run("SessionExec", func(t *testing.T) { t.Parallel() - session := setupSSH(t) + session := setupSSHSession(t) + command := "echo test" if runtime.GOOS == "windows" { command = "cmd.exe /c echo test" @@ -41,7 +48,7 @@ func TestAgent(t *testing.T) { t.Run("GitSSH", func(t *testing.T) { t.Parallel() - session := setupSSH(t) + session := setupSSHSession(t) command := "sh -c 'echo $GIT_SSH_COMMAND'" if runtime.GOOS == "windows" { command = "cmd.exe /c echo %GIT_SSH_COMMAND%" @@ -53,7 +60,7 @@ func TestAgent(t *testing.T) { t.Run("SessionTTY", func(t *testing.T) { t.Parallel() - session := setupSSH(t) + session := setupSSHSession(t) prompt := "$" command := "bash" if runtime.GOOS == "windows" { @@ -76,9 +83,73 @@ func TestAgent(t *testing.T) { err = session.Wait() require.NoError(t, err) }) + + t.Run("LocalForwarding", func(t *testing.T) { + t.Parallel() + random, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + _ = random.Close() + tcpAddr, valid := random.Addr().(*net.TCPAddr) + require.True(t, valid) + randomPort := tcpAddr.Port + + local, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + tcpAddr, valid = local.Addr().(*net.TCPAddr) + require.True(t, valid) + localPort := tcpAddr.Port + done := make(chan struct{}) + go func() { + conn, err := local.Accept() + require.NoError(t, err) + _ = conn.Close() + close(done) + }() + + err = setupSSHCommand(t, []string{"-L", fmt.Sprintf("%d:127.0.0.1:%d", randomPort, localPort)}, []string{"echo", "test"}).Start() + require.NoError(t, err) + + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(localPort)) + require.NoError(t, err) + conn.Close() + <-done + }) } -func setupSSH(t *testing.T) *ssh.Session { +func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { + agentConn := setupAgent(t) + socket := filepath.Join(t.TempDir(), "ssh") + listener, err := net.Listen("unix", socket) + require.NoError(t, err) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + ssh, err := agentConn.SSH() + require.NoError(t, err) + go io.Copy(conn, ssh) + go io.Copy(ssh, conn) + } + }() + t.Cleanup(func() { + _ = listener.Close() + }) + args := append(beforeArgs, "-o", "ProxyCommand socat - UNIX-CLIENT:"+socket, "-o", "StrictHostKeyChecking=no", "host") + args = append(args, afterArgs...) + return exec.Command("ssh", args...) +} + +func setupSSHSession(t *testing.T) *ssh.Session { + sshClient, err := setupAgent(t).SSHClient() + require.NoError(t, err) + session, err := sshClient.NewSession() + require.NoError(t, err) + return session +} + +func setupAgent(t *testing.T) *agent.Conn { client, server := provisionersdk.TransportPipe() closer := agent.New(func(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) { return peerbroker.Listen(server, nil, opts) @@ -100,14 +171,9 @@ func setupSSH(t *testing.T) *ssh.Session { t.Cleanup(func() { _ = conn.Close() }) - agentClient := &agent.Conn{ + + return &agent.Conn{ Negotiator: api, Conn: conn, } - sshClient, err := agentClient.SSHClient() - require.NoError(t, err) - session, err := sshClient.NewSession() - require.NoError(t, err) - - return session } diff --git a/cli/configssh.go b/cli/configssh.go index 82c02446b98c3..1408570444034 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -31,8 +31,8 @@ const sshEndToken = "# ------------END-CODER------------" func configSSH() *cobra.Command { var ( - binaryFile string sshConfigFile string + sshOptions []string ) cmd := &cobra.Command{ Use: "config-ssh", @@ -62,11 +62,9 @@ func configSSH() *cobra.Command { return xerrors.New("You don't have any workspaces!") } - if binaryFile == "" { - binaryFile, err = currentBinPath(cmd) - if err != nil { - return err - } + binaryFile, err := currentBinPath(cmd) + if err != nil { + return err } root := createConfig(cmd) @@ -90,13 +88,19 @@ func configSSH() *cobra.Command { if len(resource.Agents) > 1 { hostname += "." + agent.Name } - sshConfigContent += strings.Join([]string{ + configOptions := []string{ "Host coder." + hostname, "\tHostName coder." + hostname, + } + for _, option := range sshOptions { + configOptions = append(configOptions, "\t"+option) + } + configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname), "\tConnectTimeout=0", "\tStrictHostKeyChecking=no", - }, "\n") + "\n" + ) + sshConfigContent += strings.Join(configOptions, "\n") + "\n" sshConfigContentMutex.Unlock() } } @@ -122,9 +126,8 @@ func configSSH() *cobra.Command { return nil }, } - cliflag.StringVarP(cmd.Flags(), &binaryFile, "binary-file", "", "CODER_BINARY_PATH", "", "Specifies the path to a Coder binary.") - _ = cmd.Flags().MarkHidden("binary-file") cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", "~/.ssh/config", "Specifies the path to an SSH config.") + cmd.Flags().StringArrayVarP(&sshOptions, "ssh-option", "o", []string{}, "Specifies additional options embedded in each host.") return cmd } diff --git a/cli/configssh_test.go b/cli/configssh_test.go index cdf417a7643b1..2b648e6977a2f 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -1,17 +1,20 @@ package cli_test import ( + "context" + "io" + "net" "os" "os/exec" "path/filepath" "strings" "testing" - "time" - "cdr.dev/slog/sloggers/slogtest" "github.com/google/uuid" "github.com/stretchr/testify/require" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/agent" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" @@ -24,82 +27,103 @@ import ( func TestConfigSSH(t *testing.T) { t.Parallel() - binPath := filepath.Join(t.TempDir(), "coder") - _, err := exec.Command("go", "build", "-o", binPath, "github.com/coder/coder/cmd/coder").CombinedOutput() - require.NoError(t, err) - - t.Run("Dial", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - coderdtest.NewProvisionerDaemon(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionDryRun: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Name: "example", - }}, }}, - }, + }}, }, - }}, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ + }, + }}, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Name: "example", - Auth: &proto.Agent_Token{ - Token: authToken, - }, - }}, + Auth: &proto.Agent_Token{ + Token: authToken, + }, }}, - }, + }}, }, - }}, - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil), - }) - t.Cleanup(func() { - _ = agentCloser.Close() - }) - tempFile, err := os.CreateTemp(t.TempDir(), "") - require.NoError(t, err) - _ = tempFile.Close() - cmd, root := clitest.New(t, "config-ssh", "--binary-file", binPath, "--ssh-config-file", tempFile.Name()) - clitest.SetupConfig(t, client, root) - doneChan := make(chan struct{}) - pty := ptytest.New(t) - cmd.SetIn(pty.Input()) - cmd.SetOut(pty.Output()) - go func() { - defer close(doneChan) - err := cmd.Execute() + }, + }}, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + tempFile, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + _ = tempFile.Close() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil, nil) + require.NoError(t, err) + defer agentConn.Close() + + // Using socat we can force SSH to use a UNIX socket + // created in this test. That way we still validate + // our configuration, but use the native SSH command + // line to interface. + socket := filepath.Join(t.TempDir(), "ssh") + listener, err := net.Listen("unix", socket) + require.NoError(t, err) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + ssh, err := agentConn.SSH() require.NoError(t, err) - }() - <-doneChan - t.Log(tempFile.Name()) - time.Sleep(time.Hour) - output, err := exec.Command("ssh", "-F", tempFile.Name(), "coder."+workspace.Name, "echo", "test").Output() - t.Log(string(output)) - require.NoError(t, err) - require.Equal(t, "test", strings.TrimSpace(string(output))) + go io.Copy(conn, ssh) + go io.Copy(ssh, conn) + } + }() + t.Cleanup(func() { + _ = listener.Close() }) + + cmd, root := clitest.New(t, "config-ssh", "--ssh-option", "ProxyCommand socat - UNIX-CLIENT:"+socket, "--ssh-config-file", tempFile.Name()) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + <-doneChan + + t.Log(tempFile.Name()) + // #nosec + data, err := exec.Command("ssh", "-F", tempFile.Name(), "coder."+workspace.Name, "echo", "test").Output() + require.NoError(t, err) + require.Equal(t, "test", strings.TrimSpace(string(data))) } diff --git a/cli/templatelist.go b/cli/templatelist.go index 1d613c02a68ef..980215bcb4a5d 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -3,10 +3,11 @@ package cli import ( "fmt" - "github.com/coder/coder/cli/cliui" "github.com/fatih/color" "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" + + "github.com/coder/coder/cli/cliui" ) func templateList() *cobra.Command { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 7868691bea85a..c235be0cf620c 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -43,6 +43,20 @@ func (api *api) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) { defer api.websocketWaitGroup.Done() agent := httpmw.WorkspaceAgentParam(r) + apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert workspace agent: %s", err), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{ + Message: "Agent isn't connected!", + }) + return + } + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ CompressionMode: websocket.CompressionDisabled, }) From 8fecb67dccd403d1f84afabafc6259d99cfd6727 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 11 Apr 2022 01:38:59 +0000 Subject: [PATCH 12/17] Fix workspace list --- cli/configssh_test.go | 4 ++- cli/workspacedelete.go | 14 +-------- cli/workspacelist.go | 64 +++++++++++++++++++++++++++--------------- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 2b648e6977a2f..e68972d6afcf0 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -123,7 +123,9 @@ func TestConfigSSH(t *testing.T) { t.Log(tempFile.Name()) // #nosec - data, err := exec.Command("ssh", "-F", tempFile.Name(), "coder."+workspace.Name, "echo", "test").Output() + sshCmd := exec.Command("ssh", "-F", tempFile.Name(), "coder."+workspace.Name, "echo", "test") + sshCmd.Stderr = os.Stderr + data, err := sshCmd.Output() require.NoError(t, err) require.Equal(t, "test", strings.TrimSpace(string(data))) } diff --git a/cli/workspacedelete.go b/cli/workspacedelete.go index e77e66bd9dea7..17edffa9b7239 100644 --- a/cli/workspacedelete.go +++ b/cli/workspacedelete.go @@ -32,19 +32,7 @@ func workspaceDelete() *cobra.Command { if err != nil { return err } - err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{ - Fetch: func() (codersdk.ProvisionerJob, error) { - build, err := client.WorkspaceBuild(cmd.Context(), build.ID) - return build.Job, err - }, - Cancel: func() error { - return client.CancelWorkspaceBuild(cmd.Context(), build.ID) - }, - Logs: func() (<-chan codersdk.ProvisionerJobLog, error) { - return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before) - }, - }) - return err + return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before) }, } } diff --git a/cli/workspacelist.go b/cli/workspacelist.go index 9b3e97b26f3de..91416c502b0e7 100644 --- a/cli/workspacelist.go +++ b/cli/workspacelist.go @@ -2,13 +2,12 @@ package cli import ( "fmt" - "text/tabwriter" - "time" - "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" ) @@ -21,7 +20,6 @@ func workspaceList() *cobra.Command { if err != nil { return err } - start := time.Now() workspaces, err := client.WorkspacesByUser(cmd.Context(), codersdk.Me) if err != nil { return err @@ -34,27 +32,47 @@ func workspaceList() *cobra.Command { return nil } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Workspaces found %s\n\n", - caret, - color.HiBlackString("[%dms]", - time.Since(start).Milliseconds())) - - writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0) - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", - color.HiBlackString("Workspace"), - color.HiBlackString("Template"), - color.HiBlackString("Status"), - color.HiBlackString("Last Built"), - color.HiBlackString("Outdated")) + tableWriter := table.NewWriter() + tableWriter.SetStyle(table.StyleLight) + tableWriter.Style().Options.SeparateColumns = false + tableWriter.AppendHeader(table.Row{"Workspace", "Template", "Status", "Last Built", "Outdated"}) + for _, workspace := range workspaces { - _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%+v\n", - color.New(color.FgHiCyan).Sprint(workspace.Name), - color.WhiteString(workspace.TemplateName), - color.WhiteString(string(workspace.LatestBuild.Transition)), - color.WhiteString(workspace.LatestBuild.Job.CompletedAt.Format("January 2, 2006")), - workspace.Outdated) + status := "" + inProgress := false + if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobRunning || + workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobCanceling { + inProgress = true + } + + switch workspace.LatestBuild.Transition { + case database.WorkspaceTransitionStart: + status = "start" + if inProgress { + status = "starting" + } + case database.WorkspaceTransitionStop: + status = "stop" + if inProgress { + status = "stopping" + } + case database.WorkspaceTransitionDelete: + status = "delete" + if inProgress { + status = "deleting" + } + } + + tableWriter.AppendRow(table.Row{ + cliui.Styles.Bold.Render(workspace.Name), + workspace.TemplateName, + status, + workspace.LatestBuild.Job.CreatedAt.Format("January 2, 2006"), + workspace.Outdated, + }) } - return writer.Flush() + _, err = fmt.Fprintf(cmd.OutOrStdout(), tableWriter.Render()) + return err }, } } From 677686bdc365cc11f40e9ab02b8fee300d6aafa2 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 11 Apr 2022 21:10:54 +0000 Subject: [PATCH 13/17] Fix config ssh tests --- .github/workflows/coder.yaml | 4 ++++ cli/configssh_test.go | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index dbaea72aa1f43..1ec2e82430f22 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -158,6 +158,10 @@ jobs: terraform_version: 1.1.2 terraform_wrapper: false + - name: Install socat + if: runner.os == 'Linux' + run: sudo apt-get install -y socat + - name: Test with Mock Database shell: bash env: diff --git a/cli/configssh_test.go b/cli/configssh_test.go index e68972d6afcf0..3cbdd9441b4ab 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -6,7 +6,6 @@ import ( "net" "os" "os/exec" - "path/filepath" "strings" "testing" @@ -27,6 +26,11 @@ import ( func TestConfigSSH(t *testing.T) { t.Parallel() + _, err := exec.LookPath("socat") + if err != nil { + t.Skip("You must have socat installed to run this test!") + } + client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) coderdtest.NewProvisionerDaemon(t, client) @@ -85,13 +89,15 @@ func TestConfigSSH(t *testing.T) { require.NoError(t, err) defer agentConn.Close() - // Using socat we can force SSH to use a UNIX socket + // Using socat we can force SSH to use a TCP port // created in this test. That way we still validate // our configuration, but use the native SSH command // line to interface. - socket := filepath.Join(t.TempDir(), "ssh") - listener, err := net.Listen("unix", socket) + listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) + t.Cleanup(func() { + _ = listener.Close() + }) go func() { for { conn, err := listener.Accept() @@ -108,7 +114,7 @@ func TestConfigSSH(t *testing.T) { _ = listener.Close() }) - cmd, root := clitest.New(t, "config-ssh", "--ssh-option", "ProxyCommand socat - UNIX-CLIENT:"+socket, "--ssh-config-file", tempFile.Name()) + cmd, root := clitest.New(t, "config-ssh", "--ssh-option", "ProxyCommand socat - TCP4:"+listener.Addr().String(), "--ssh-config-file", tempFile.Name()) clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) From 603bd90c393336feb9247746adf969ca919aaff1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 11 Apr 2022 16:39:55 -0500 Subject: [PATCH 14/17] Update cli/configssh.go Co-authored-by: Cian Johnston --- cli/configssh.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/configssh.go b/cli/configssh.go index 1408570444034..1f706c27e1d25 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -127,7 +127,7 @@ func configSSH() *cobra.Command { }, } cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", "~/.ssh/config", "Specifies the path to an SSH config.") - cmd.Flags().StringArrayVarP(&sshOptions, "ssh-option", "o", []string{}, "Specifies additional options embedded in each host.") + cmd.Flags().StringArrayVarP(&sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.") return cmd } From 2a6c607c9ad4eea5c53b60ee504886f0c0efd469 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 11 Apr 2022 22:02:27 +0000 Subject: [PATCH 15/17] Fix requested changes --- .github/workflows/coder.yaml | 4 +++ agent/agent.go | 9 ++--- cli/cliui/prompt.go | 68 ++++++++++++++++++++---------------- 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index 1ec2e82430f22..b18b4924d78d3 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -162,6 +162,10 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get install -y socat + - name: Install socat + if: runner.os == 'macOS' + run: brew install socat + - name: Test with Mock Database shell: bash env: diff --git a/agent/agent.go b/agent/agent.go index 25e828e5a5d1b..141075c8ce5ea 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -101,10 +101,11 @@ func (a *agent) run(ctx context.Context) { func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { go func() { - <-a.closed - _ = conn.Close() - }() - go func() { + select { + case <-a.closed: + _ = conn.Close() + case <-conn.Closed(): + } <-conn.Closed() a.connCloseWait.Done() }() diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 41279ff498ef6..3a75b3f8930ba 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -65,37 +65,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { // This enables multiline JSON to be pasted into an input, and have // it parse properly. if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) { - var data bytes.Buffer - for { - _, _ = data.WriteString(line) - var rawMessage json.RawMessage - err = json.Unmarshal(data.Bytes(), &rawMessage) - if err != nil { - if err.Error() != "unexpected end of JSON input" { - err = nil - line = data.String() - break - } - - // Read line-by-line. We can't use a JSON decoder - // here because it doesn't work by newline, so - // reads will block. - line, err = reader.ReadString('\n') - if err != nil { - break - } - continue - } - // Compacting the JSON makes it easier for parsing and testing. - bytes := data.Bytes() - data.Reset() - err = json.Compact(&data, bytes) - if err != nil { - break - } - line = data.String() - break - } + line, err = promptJSON(reader, line) } } if err != nil { @@ -132,3 +102,39 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { return "", Canceled } } + +func promptJSON(reader *bufio.Reader, line string) (string, error) { + var data bytes.Buffer + for { + _, _ = data.WriteString(line) + var rawMessage json.RawMessage + err := json.Unmarshal(data.Bytes(), &rawMessage) + if err != nil { + if err.Error() != "unexpected end of JSON input" { + // If a real syntax error occurs in JSON, + // we want to return that partial line to the user. + err = nil + line = data.String() + break + } + + // Read line-by-line. We can't use a JSON decoder + // here because it doesn't work by newline, so + // reads will block. + line, err = reader.ReadString('\n') + if err != nil { + break + } + continue + } + // Compacting the JSON makes it easier for parsing and testing. + bytes := data.Bytes() + data.Reset() + err = json.Compact(&data, bytes) + if err != nil { + return line, xerrors.Errorf("compact json: %w", err) + } + return data.String(), nil + } + return line, nil +} From f397afcfbaea207ba58152fa706a9d7e994cbff8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 11 Apr 2022 22:21:01 +0000 Subject: [PATCH 16/17] Remove socat requirement --- .github/workflows/coder.yaml | 8 -------- agent/agent_test.go | 11 +++++++---- cli/cliui/prompt.go | 4 ++-- cli/cliui/resources_test.go | 7 +++++-- cli/configssh.go | 13 +++++++++---- cli/configssh_test.go | 17 ++++++++--------- cli/workspaceautostart_test.go | 3 ++- cli/workspaceautostop_test.go | 3 ++- coderd/coderdtest/coderdtest.go | 2 +- 9 files changed, 36 insertions(+), 32 deletions(-) diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index b18b4924d78d3..dbaea72aa1f43 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -158,14 +158,6 @@ jobs: terraform_version: 1.1.2 terraform_wrapper: false - - name: Install socat - if: runner.os == 'Linux' - run: sudo apt-get install -y socat - - - name: Install socat - if: runner.os == 'macOS' - run: brew install socat - - name: Test with Mock Database shell: bash env: diff --git a/agent/agent_test.go b/agent/agent_test.go index 720bec27f4715..0ceea3830c0fc 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -6,7 +6,6 @@ import ( "io" "net" "os/exec" - "path/filepath" "runtime" "strconv" "strings" @@ -118,8 +117,7 @@ func TestAgent(t *testing.T) { func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { agentConn := setupAgent(t) - socket := filepath.Join(t.TempDir(), "ssh") - listener, err := net.Listen("unix", socket) + listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) go func() { for { @@ -136,7 +134,12 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe t.Cleanup(func() { _ = listener.Close() }) - args := append(beforeArgs, "-o", "ProxyCommand socat - UNIX-CLIENT:"+socket, "-o", "StrictHostKeyChecking=no", "host") + tcpAddr, valid := listener.Addr().(*net.TCPAddr) + require.True(t, valid) + args := append(beforeArgs, + "-o", "HostName "+tcpAddr.IP.String(), + "-o", "Port "+strconv.Itoa(tcpAddr.Port), + "-o", "StrictHostKeyChecking=no", "host") args = append(args, afterArgs...) return exec.Command("ssh", args...) } diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 3a75b3f8930ba..6a741e526def3 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -128,9 +128,9 @@ func promptJSON(reader *bufio.Reader, line string) (string, error) { continue } // Compacting the JSON makes it easier for parsing and testing. - bytes := data.Bytes() + rawJSON := data.Bytes() data.Reset() - err = json.Compact(&data, bytes) + err = json.Compact(&data, rawJSON) if err != nil { return line, xerrors.Errorf("compact json: %w", err) } diff --git a/cli/cliui/resources_test.go b/cli/cliui/resources_test.go index 1215340bd018d..6414c34be9d4b 100644 --- a/cli/cliui/resources_test.go +++ b/cli/cliui/resources_test.go @@ -8,6 +8,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" "github.com/coder/coder/pty/ptytest" + "github.com/stretchr/testify/require" ) func TestWorkspaceResources(t *testing.T) { @@ -15,7 +16,7 @@ func TestWorkspaceResources(t *testing.T) { t.Run("SingleAgentSSH", func(t *testing.T) { t.Parallel() ptty := ptytest.New(t) - cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ + err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ Type: "google_compute_instance", Name: "dev", Transition: database.WorkspaceTransitionStart, @@ -28,6 +29,7 @@ func TestWorkspaceResources(t *testing.T) { }}, cliui.WorkspaceResourcesOptions{ WorkspaceName: "example", }) + require.NoError(t, err) ptty.ExpectMatch("coder ssh example") }) @@ -35,7 +37,7 @@ func TestWorkspaceResources(t *testing.T) { t.Parallel() ptty := ptytest.New(t) disconnected := database.Now().Add(-4 * time.Second) - cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ + err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ Address: "disk", Transition: database.WorkspaceTransitionStart, Type: "google_compute_disk", @@ -78,6 +80,7 @@ func TestWorkspaceResources(t *testing.T) { HideAgentState: false, HideAccess: false, }) + require.NoError(t, err) ptty.ExpectMatch("google_compute_disk.root") ptty.ExpectMatch("google_compute_instance.dev") ptty.ExpectMatch("coder ssh dev.postgres") diff --git a/cli/configssh.go b/cli/configssh.go index 1f706c27e1d25..46d50283eb83f 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -31,8 +31,9 @@ const sshEndToken = "# ------------END-CODER------------" func configSSH() *cobra.Command { var ( - sshConfigFile string - sshOptions []string + sshConfigFile string + sshOptions []string + skipProxyCommand bool ) cmd := &cobra.Command{ Use: "config-ssh", @@ -90,16 +91,18 @@ func configSSH() *cobra.Command { } configOptions := []string{ "Host coder." + hostname, - "\tHostName coder." + hostname, } for _, option := range sshOptions { configOptions = append(configOptions, "\t"+option) } configOptions = append(configOptions, - fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname), + "\tHostName coder."+hostname, "\tConnectTimeout=0", "\tStrictHostKeyChecking=no", ) + if !skipProxyCommand { + configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname)) + } sshConfigContent += strings.Join(configOptions, "\n") + "\n" sshConfigContentMutex.Unlock() } @@ -128,6 +131,8 @@ func configSSH() *cobra.Command { } cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", "~/.ssh/config", "Specifies the path to an SSH config.") cmd.Flags().StringArrayVarP(&sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.") + cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.") + _ = cmd.Flags().MarkHidden("skip-proxy-command") return cmd } diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 3cbdd9441b4ab..56f8b4f83ec9a 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -6,6 +6,7 @@ import ( "net" "os" "os/exec" + "strconv" "strings" "testing" @@ -26,10 +27,6 @@ import ( func TestConfigSSH(t *testing.T) { t.Parallel() - _, err := exec.LookPath("socat") - if err != nil { - t.Skip("You must have socat installed to run this test!") - } client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -89,10 +86,6 @@ func TestConfigSSH(t *testing.T) { require.NoError(t, err) defer agentConn.Close() - // Using socat we can force SSH to use a TCP port - // created in this test. That way we still validate - // our configuration, but use the native SSH command - // line to interface. listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) t.Cleanup(func() { @@ -114,7 +107,13 @@ func TestConfigSSH(t *testing.T) { _ = listener.Close() }) - cmd, root := clitest.New(t, "config-ssh", "--ssh-option", "ProxyCommand socat - TCP4:"+listener.Addr().String(), "--ssh-config-file", tempFile.Name()) + tcpAddr, valid := listener.Addr().(*net.TCPAddr) + require.True(t, valid) + cmd, root := clitest.New(t, "config-ssh", + "--ssh-option", "HostName "+tcpAddr.IP.String(), + "--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port), + "--ssh-config-file", tempFile.Name(), + "--skip-proxy-command") clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) diff --git a/cli/workspaceautostart_test.go b/cli/workspaceautostart_test.go index c7de625f91068..760def044d166 100644 --- a/cli/workspaceautostart_test.go +++ b/cli/workspaceautostart_test.go @@ -5,10 +5,11 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" - "github.com/stretchr/testify/require" ) func TestWorkspaceAutostart(t *testing.T) { diff --git a/cli/workspaceautostop_test.go b/cli/workspaceautostop_test.go index 780ea40cf37d4..eb8fa583d2d61 100644 --- a/cli/workspaceautostop_test.go +++ b/cli/workspaceautostop_test.go @@ -5,10 +5,11 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/codersdk" - "github.com/stretchr/testify/require" ) func TestWorkspaceAutostop(t *testing.T) { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 04667ef3f76c8..02cf05a9f509f 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -264,7 +264,7 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID require.NoError(t, err) for _, resource := range resources { for _, agent := range resource.Agents { - if agent.FirstConnectedAt == nil { + if agent.Status != codersdk.WorkspaceAgentConnected { return false } } From 683f87e9c02e25248cd71d205d3ceb48cec3b8fa Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 11 Apr 2022 18:07:17 -0500 Subject: [PATCH 17/17] Fix resources not reading in TTY --- cli/cliui/resources_test.go | 118 +++++++++++++++++--------------- coderd/coderdtest/coderdtest.go | 2 +- coderd/workspaceagents.go | 10 +-- 3 files changed, 67 insertions(+), 63 deletions(-) diff --git a/cli/cliui/resources_test.go b/cli/cliui/resources_test.go index 6414c34be9d4b..2711a695c8934 100644 --- a/cli/cliui/resources_test.go +++ b/cli/cliui/resources_test.go @@ -16,20 +16,22 @@ func TestWorkspaceResources(t *testing.T) { t.Run("SingleAgentSSH", func(t *testing.T) { t.Parallel() ptty := ptytest.New(t) - err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ - Type: "google_compute_instance", - Name: "dev", - Transition: database.WorkspaceTransitionStart, - Agents: []codersdk.WorkspaceAgent{{ - Name: "dev", - Status: codersdk.WorkspaceAgentConnected, - Architecture: "amd64", - OperatingSystem: "linux", - }}, - }}, cliui.WorkspaceResourcesOptions{ - WorkspaceName: "example", - }) - require.NoError(t, err) + go func() { + err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ + Type: "google_compute_instance", + Name: "dev", + Transition: database.WorkspaceTransitionStart, + Agents: []codersdk.WorkspaceAgent{{ + Name: "dev", + Status: codersdk.WorkspaceAgentConnected, + Architecture: "amd64", + OperatingSystem: "linux", + }}, + }}, cliui.WorkspaceResourcesOptions{ + WorkspaceName: "example", + }) + require.NoError(t, err) + }() ptty.ExpectMatch("coder ssh example") }) @@ -37,50 +39,52 @@ func TestWorkspaceResources(t *testing.T) { t.Parallel() ptty := ptytest.New(t) disconnected := database.Now().Add(-4 * time.Second) - err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ - Address: "disk", - Transition: database.WorkspaceTransitionStart, - Type: "google_compute_disk", - Name: "root", - }, { - Address: "disk", - Transition: database.WorkspaceTransitionStop, - Type: "google_compute_disk", - Name: "root", - }, { - Address: "another", - Transition: database.WorkspaceTransitionStart, - Type: "google_compute_instance", - Name: "dev", - Agents: []codersdk.WorkspaceAgent{{ - CreatedAt: database.Now().Add(-10 * time.Second), - Status: codersdk.WorkspaceAgentConnecting, - Name: "dev", - OperatingSystem: "linux", - Architecture: "amd64", - }}, - }, { - Transition: database.WorkspaceTransitionStart, - Type: "kubernetes_pod", - Name: "dev", - Agents: []codersdk.WorkspaceAgent{{ - Status: codersdk.WorkspaceAgentConnected, - Name: "go", - Architecture: "amd64", - OperatingSystem: "linux", + go func() { + err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ + Address: "disk", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_disk", + Name: "root", }, { - DisconnectedAt: &disconnected, - Status: codersdk.WorkspaceAgentDisconnected, - Name: "postgres", - Architecture: "amd64", - OperatingSystem: "linux", - }}, - }}, cliui.WorkspaceResourcesOptions{ - WorkspaceName: "dev", - HideAgentState: false, - HideAccess: false, - }) - require.NoError(t, err) + Address: "disk", + Transition: database.WorkspaceTransitionStop, + Type: "google_compute_disk", + Name: "root", + }, { + Address: "another", + Transition: database.WorkspaceTransitionStart, + Type: "google_compute_instance", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + CreatedAt: database.Now().Add(-10 * time.Second), + Status: codersdk.WorkspaceAgentConnecting, + Name: "dev", + OperatingSystem: "linux", + Architecture: "amd64", + }}, + }, { + Transition: database.WorkspaceTransitionStart, + Type: "kubernetes_pod", + Name: "dev", + Agents: []codersdk.WorkspaceAgent{{ + Status: codersdk.WorkspaceAgentConnected, + Name: "go", + Architecture: "amd64", + OperatingSystem: "linux", + }, { + DisconnectedAt: &disconnected, + Status: codersdk.WorkspaceAgentDisconnected, + Name: "postgres", + Architecture: "amd64", + OperatingSystem: "linux", + }}, + }}, cliui.WorkspaceResourcesOptions{ + WorkspaceName: "dev", + HideAgentState: false, + HideAccess: false, + }) + require.NoError(t, err) + }() ptty.ExpectMatch("google_compute_disk.root") ptty.ExpectMatch("google_compute_instance.dev") ptty.ExpectMatch("coder ssh dev.postgres") diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 02cf05a9f509f..7f30e992d23d6 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -108,7 +108,7 @@ func New(t *testing.T, options *Options) *codersdk.Client { // We set the handler after server creation for the access URL. srv.Config.Handler, closeWait = coderd.New(&coderd.Options{ - AgentConnectionUpdateFrequency: 25 * time.Millisecond, + AgentConnectionUpdateFrequency: 150 * time.Millisecond, AccessURL: serverURL, Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), Database: db, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index c235be0cf620c..ab47000cc912d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -52,7 +52,7 @@ func (api *api) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) { } if apiAgent.Status != codersdk.WorkspaceAgentConnected { httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{ - Message: "Agent isn't connected!", + Message: fmt.Sprintf("Agent isn't connected! Status: %s", apiAgent.Status), }) return } @@ -181,14 +181,14 @@ func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) { _ = updateConnectionTimes() }() - err = updateConnectionTimes() + err = ensureLatestBuild() if err != nil { - _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) + _ = conn.Close(websocket.StatusGoingAway, "") return } - err = ensureLatestBuild() + err = updateConnectionTimes() if err != nil { - _ = conn.Close(websocket.StatusGoingAway, "") + _ = conn.Close(websocket.StatusAbnormalClosure, err.Error()) return }