diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index 87fd447e1d..d8329e6384 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -94,7 +94,7 @@ jobs: - name: Install Protoc uses: arduino/setup-protoc@v1 with: - version: "3.19.4" + version: "3.20.0" - uses: actions/setup-go@v3 with: go-version: "~1.18" @@ -172,7 +172,7 @@ jobs: key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - name: Install goreleaser - uses: jaxxstorm/action-install-gh-release@v1.4.0 + uses: jaxxstorm/action-install-gh-release@v1.5.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -241,7 +241,7 @@ jobs: key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - name: Install goreleaser - uses: jaxxstorm/action-install-gh-release@v1.4.0 + uses: jaxxstorm/action-install-gh-release@v1.5.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.gitignore b/.gitignore index 940c2eef38..6380b3699a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ site/out/ *.tfplan *.lock.hcl .terraform/ + +.vscode/*.log diff --git a/.vscode/settings.json b/.vscode/settings.json index 160a8cd98a..771981cf7a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,6 +35,7 @@ "nolint", "nosec", "ntqry", + "OIDC", "oneof", "parameterscopeid", "pqtype", @@ -46,6 +47,7 @@ "ptytest", "retrier", "sdkproto", + "Signup", "stretchr", "TCGETS", "tcpip", diff --git a/Makefile b/Makefile index 67f502583f..a2bfa0d985 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ coderd/database/dump.sql: $(wildcard coderd/database/migrations/*.sql) .PHONY: coderd/database/dump.sql # Generates Go code for querying the database. -coderd/database/generate: fmt/sql coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql) +coderd/database/generate: coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql) coderd/database/generate.sh .PHONY: coderd/database/generate @@ -34,22 +34,10 @@ else endif .PHONY: fmt/prettier -fmt/sql: $(wildcard coderd/database/queries/*.sql) - for fi in coderd/database/queries/*.sql; do \ - npx sql-formatter \ - --language postgresql \ - --lines-between-queries 2 \ - --tab-indent \ - $$fi \ - --output $$fi; \ - done - - sed -i 's/@ /@/g' ./coderd/database/queries/*.sql - fmt/terraform: $(wildcard *.tf) terraform fmt -recursive -fmt: fmt/prettier fmt/sql fmt/terraform +fmt: fmt/prettier fmt/terraform .PHONY: fmt gen: coderd/database/generate peerbroker/proto provisionersdk/proto provisionerd/proto apitypings/generate @@ -57,7 +45,7 @@ gen: coderd/database/generate peerbroker/proto provisionersdk/proto provisionerd install: bin @echo "--- Copying from bin to $(INSTALL_DIR)" - cp -r ./dist/coder_$(GOOS)_$(GOARCH)/* $(INSTALL_DIR) + cp -r ./dist/coder-$(GOOS)_$(GOOS)_$(GOARCH)*/* $(INSTALL_DIR) @echo "-- CLI available at $(shell ls $(INSTALL_DIR)/coder*)" .PHONY: install diff --git a/cli/agent.go b/cli/agent.go index d17aa60e82..cb1a456105 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -120,6 +120,10 @@ func workspaceAgent() *cobra.Command { if err != nil { return xerrors.Errorf("writing agent session token to config: %w", err) } + err = cfg.URL().Write(client.URL.String()) + if err != nil { + return xerrors.Errorf("writing agent url to config: %w", err) + } closer := agent.New(client.ListenWorkspaceAgent, logger) <-cmd.Context().Done() diff --git a/cli/cliflag/cliflag.go b/cli/cliflag/cliflag.go index e846d5fc39..be2117b4d4 100644 --- a/cli/cliflag/cliflag.go +++ b/cli/cliflag/cliflag.go @@ -14,6 +14,7 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/spf13/pflag" ) @@ -27,6 +28,14 @@ func StringVarP(flagset *pflag.FlagSet, p *string, name string, shorthand string flagset.StringVarP(p, name, shorthand, v, fmtUsage(usage, env)) } +func StringArrayVarP(flagset *pflag.FlagSet, ptr *[]string, name string, shorthand string, env string, def []string, usage string) { + val, ok := os.LookupEnv(env) + if ok { + def = strings.Split(val, ",") + } + flagset.StringArrayVarP(ptr, name, shorthand, def, usage) +} + // Uint8VarP sets a uint8 flag on the given flag set. func Uint8VarP(flagset *pflag.FlagSet, ptr *uint8, name string, shorthand string, env string, def uint8, usage string) { val, ok := os.LookupEnv(env) diff --git a/cli/cliflag/cliflag_test.go b/cli/cliflag/cliflag_test.go index 2228b7e10b..b0684fedb1 100644 --- a/cli/cliflag/cliflag_test.go +++ b/cli/cliflag/cliflag_test.go @@ -54,6 +54,26 @@ func TestCliflag(t *testing.T) { require.NotContains(t, flagset.FlagUsages(), " - consumes") }) + t.Run("StringArrayDefault", func(t *testing.T) { + var ptr []string + flagset, name, shorthand, env, usage := randomFlag() + def := []string{"hello"} + cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, def, usage) + got, err := flagset.GetStringArray(name) + require.NoError(t, err) + require.Equal(t, def, got) + }) + + t.Run("StringArrayEnvVar", func(t *testing.T) { + var ptr []string + flagset, name, shorthand, env, usage := randomFlag() + t.Setenv(env, "wow,test") + cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, nil, usage) + got, err := flagset.GetStringArray(name) + require.NoError(t, err) + require.Equal(t, []string{"wow", "test"}, got) + }) + t.Run("IntDefault", func(t *testing.T) { var ptr uint8 flagset, name, shorthand, env, usage := randomFlag() diff --git a/cli/gitssh.go b/cli/gitssh.go index 6945c652dd..e88c24747c 100644 --- a/cli/gitssh.go +++ b/cli/gitssh.go @@ -1,9 +1,14 @@ package cli import ( + "fmt" + "net/url" "os" "os/exec" + "strings" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" "github.com/spf13/cobra" "golang.org/x/xerrors" ) @@ -14,15 +19,20 @@ func gitssh() *cobra.Command { Hidden: true, Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`, RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) - if err != nil { - return xerrors.Errorf("create codersdk client: %w", err) - } cfg := createConfig(cmd) + rawURL, err := cfg.URL().Read() + if err != nil { + return xerrors.Errorf("read agent url from config: %w", err) + } + parsedURL, err := url.Parse(rawURL) + if err != nil { + return xerrors.Errorf("parse agent url from config: %w", err) + } session, err := cfg.AgentSession().Read() if err != nil { return xerrors.Errorf("read agent session from config: %w", err) } + client := codersdk.New(parsedURL) client.SessionToken = session key, err := client.AgentGitSSHKey(cmd.Context()) @@ -47,12 +57,25 @@ func gitssh() *cobra.Command { return xerrors.Errorf("close temp gitsshkey file: %w", err) } - a := append([]string{"-i", privateKeyFile.Name()}, args...) - c := exec.CommandContext(cmd.Context(), "ssh", a...) + args = append([]string{"-i", privateKeyFile.Name()}, args...) + c := exec.CommandContext(cmd.Context(), "ssh", args...) + c.Stderr = cmd.ErrOrStderr() c.Stdout = cmd.OutOrStdout() c.Stdin = cmd.InOrStdin() err = c.Run() if err != nil { + exitErr := &exec.ExitError{} + if xerrors.As(err, &exitErr) && exitErr.ExitCode() == 255 { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), + "\n"+cliui.Styles.Wrap.Render("Coder authenticates with "+cliui.Styles.Field.Render("git")+ + " using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n") + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n") + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Add to GitHub and GitLab:") + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new") + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys") + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) + return err + } return xerrors.Errorf("run ssh command: %w", err) } diff --git a/cli/publickey.go b/cli/publickey.go index d61542aee2..d8d59a292c 100644 --- a/cli/publickey.go +++ b/cli/publickey.go @@ -1,15 +1,19 @@ package cli import ( + "strings" + "github.com/spf13/cobra" "golang.org/x/xerrors" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" ) func publickey() *cobra.Command { return &cobra.Command{ - Use: "publickey", + Use: "publickey", + Aliases: []string{"pubkey"}, RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { @@ -21,7 +25,16 @@ func publickey() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - cmd.Println(key.PublicKey) + cmd.Println(cliui.Styles.Wrap.Render( + "This is your public key for using " + cliui.Styles.Field.Render("git") + " in " + + "Coder. All clones with SSH will be authenticated automatically 🪄.", + )) + cmd.Println() + cmd.Println(cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))) + cmd.Println() + cmd.Println("Add to GitHub and GitLab:") + cmd.Println(cliui.Styles.Prompt.String() + "https://github.com/settings/ssh/new") + cmd.Println(cliui.Styles.Prompt.String() + "https://gitlab.com/-/profile/keys") return nil }, diff --git a/cli/root.go b/cli/root.go index 29083d6d03..0c30724db7 100644 --- a/cli/root.go +++ b/cli/root.go @@ -43,7 +43,7 @@ func Root() *cobra.Command { `, Example: cliui.Styles.Paragraph.Render(`Start Coder in "dev" mode. This dev-mode requires no further setup, and your local `+cliui.Styles.Code.Render("coder")+` CLI will be authenticated to talk to it. This makes it easy to experiment with Coder.`) + ` - ` + cliui.Styles.Code.Render("$ coder start --dev") + ` + ` + cliui.Styles.Code.Render("$ coder server --dev") + ` ` + cliui.Styles.Paragraph.Render("Get started by creating a template from an example.") + ` ` + cliui.Styles.Code.Render("$ coder templates init"), @@ -63,7 +63,7 @@ func Root() *cobra.Command { cmd.AddCommand( configSSH(), - start(), + server(), login(), parameters(), templates(), diff --git a/cli/start.go b/cli/server.go similarity index 84% rename from cli/start.go rename to cli/server.go index 6a1776cb20..4fa1339179 100644 --- a/cli/start.go +++ b/cli/server.go @@ -18,8 +18,12 @@ import ( "github.com/briandowns/spinner" "github.com/coreos/go-systemd/daemon" + "github.com/google/go-github/v43/github" "github.com/pion/turn/v2" + "github.com/pion/webrtc/v3" "github.com/spf13/cobra" + "golang.org/x/oauth2" + xgithub "golang.org/x/oauth2/github" "golang.org/x/xerrors" "google.golang.org/api/idtoken" "google.golang.org/api/option" @@ -43,7 +47,8 @@ import ( "github.com/coder/coder/provisionersdk/proto" ) -func start() *cobra.Command { +// nolint:gocyclo +func server() *cobra.Command { var ( accessURL string address string @@ -51,23 +56,28 @@ func start() *cobra.Command { dev bool postgresURL string // provisionerDaemonCount is a uint8 to ensure a number > 0. - provisionerDaemonCount uint8 - tlsCertFile string - tlsClientCAFile string - tlsClientAuth string - tlsEnable bool - tlsKeyFile string - tlsMinVersion string - turnRelayAddress string - skipTunnel bool - traceDatadog bool - secureAuthCookie bool - sshKeygenAlgorithmRaw string - spooky bool + provisionerDaemonCount uint8 + oauth2GithubClientID string + oauth2GithubClientSecret string + oauth2GithubAllowedOrganizations []string + oauth2GithubAllowSignups bool + tlsCertFile string + tlsClientCAFile string + tlsClientAuth string + tlsEnable bool + tlsKeyFile string + tlsMinVersion string + turnRelayAddress string + skipTunnel bool + stunServers []string + traceDatadog bool + secureAuthCookie bool + sshKeygenAlgorithmRaw string + spooky bool ) root := &cobra.Command{ - Use: "start", + Use: "server", RunE: func(cmd *cobra.Command, args []string) error { logger := slog.Make(sloghuman.Sink(os.Stderr)) if traceDatadog { @@ -169,8 +179,15 @@ func start() *cobra.Command { return xerrors.Errorf("create turn server: %w", err) } + iceServers := make([]webrtc.ICEServer, 0) + for _, stunServer := range stunServers { + iceServers = append(iceServers, webrtc.ICEServer{ + URLs: []string{stunServer}, + }) + } options := &coderd.Options{ AccessURL: accessURLParsed, + ICEServers: iceServers, Logger: logger.Named("coderd"), Database: databasefake.New(), Pubsub: database.NewPubsubInMemory(), @@ -180,6 +197,13 @@ func start() *cobra.Command { TURNServer: turnServer, } + if oauth2GithubClientSecret != "" { + options.GithubOAuth2Config, err = configureGithubOAuth2(accessURLParsed, oauth2GithubClientID, oauth2GithubClientSecret, oauth2GithubAllowSignups, oauth2GithubAllowedOrganizations) + if err != nil { + return xerrors.Errorf("configure github oauth2: %w", err) + } + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "access-url: %s\n", accessURL) _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "provisioner-daemons: %d\n", provisionerDaemonCount) _, _ = fmt.Fprintln(cmd.ErrOrStderr()) @@ -373,6 +397,14 @@ func start() *cobra.Command { cliflag.BoolVarP(root.Flags(), &dev, "dev", "", "CODER_DEV_MODE", false, "Serve Coder in dev mode for tinkering") cliflag.StringVarP(root.Flags(), &postgresURL, "postgres-url", "", "CODER_PG_CONNECTION_URL", "", "URL of a PostgreSQL database to connect to") cliflag.Uint8VarP(root.Flags(), &provisionerDaemonCount, "provisioner-daemons", "", "CODER_PROVISIONER_DAEMONS", 1, "The amount of provisioner daemons to create on start.") + cliflag.StringVarP(root.Flags(), &oauth2GithubClientID, "oauth2-github-client-id", "", "CODER_OAUTH2_GITHUB_CLIENT_ID", "", + "Specifies a client ID to use for oauth2 with GitHub.") + cliflag.StringVarP(root.Flags(), &oauth2GithubClientSecret, "oauth2-github-client-secret", "", "CODER_OAUTH2_GITHUB_CLIENT_SECRET", "", + "Specifies a client secret to use for oauth2 with GitHub.") + cliflag.StringArrayVarP(root.Flags(), &oauth2GithubAllowedOrganizations, "oauth2-github-allowed-orgs", "", "CODER_OAUTH2_GITHUB_ALLOWED_ORGS", nil, + "Specifies organizations the user must be a member of to authenticate with GitHub.") + cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false, + "Specifies whether new users can sign up with GitHub.") cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false, "Specifies if TLS will be enabled") cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "", "Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+ @@ -389,6 +421,9 @@ func start() *cobra.Command { `Specifies the minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`) 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.StringArrayVarP(root.Flags(), &stunServers, "stun-server", "", "CODER_STUN_SERVERS", []string{ + "stun:stun.l.google.com:19302", + }, "Specify URLs for STUN servers to enable P2P connections.") cliflag.BoolVarP(root.Flags(), &traceDatadog, "trace-datadog", "", "CODER_TRACE_DATADOG", false, "Send tracing data to a datadog agent") cliflag.StringVarP(root.Flags(), &turnRelayAddress, "turn-relay-address", "", "CODER_TURN_RELAY_ADDRESS", "127.0.0.1", "Specifies the address to bind TURN connections.") @@ -572,6 +607,42 @@ func configureTLS(listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFi return tls.NewListener(listener, tlsConfig), nil } +func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, allowSignups bool, allowOrgs []string) (*coderd.GithubOAuth2Config, error) { + redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback") + if err != nil { + return nil, xerrors.Errorf("parse github oauth callback url: %w", err) + } + return &coderd.GithubOAuth2Config{ + OAuth2Config: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: xgithub.Endpoint, + RedirectURL: redirectURL.String(), + Scopes: []string{ + "read:user", + "read:org", + "user:email", + }, + }, + AllowSignups: allowSignups, + AllowOrganizations: allowOrgs, + AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { + user, _, err := github.NewClient(client).Users.Get(ctx, "") + return user, err + }, + ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { + emails, _, err := github.NewClient(client).Users.ListEmails(ctx, &github.ListOptions{}) + return emails, err + }, + ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + memberships, _, err := github.NewClient(client).Organizations.ListOrgMemberships(ctx, &github.ListOrgMembershipsOptions{ + State: "active", + }) + return memberships, err + }, + }, nil +} + type datadogLogger struct { logger slog.Logger } diff --git a/cli/start_test.go b/cli/server_test.go similarity index 90% rename from cli/start_test.go rename to cli/server_test.go index bbe49ad463..6d848f92cb 100644 --- a/cli/start_test.go +++ b/cli/server_test.go @@ -29,7 +29,7 @@ import ( // This cannot be ran in parallel because it uses a signal. // nolint:tparallel -func TestStart(t *testing.T) { +func TestServer(t *testing.T) { t.Run("Production", func(t *testing.T) { t.Parallel() if runtime.GOOS != "linux" || testing.Short() { @@ -41,7 +41,7 @@ func TestStart(t *testing.T) { defer closeFunc() ctx, cancelFunc := context.WithCancel(context.Background()) done := make(chan struct{}) - root, cfg := clitest.New(t, "start", "--address", ":0", "--postgres-url", connectionURL) + root, cfg := clitest.New(t, "server", "--address", ":0", "--postgres-url", connectionURL) go func() { defer close(done) err = root.ExecuteContext(ctx) @@ -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", "--skip-tunnel", "--address", ":0") + root, cfg := clitest.New(t, "server", "--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", "--skip-tunnel", "--address", ":0", + root, _ := clitest.New(t, "server", "--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", "--skip-tunnel", "--address", ":0", + root, _ := clitest.New(t, "server", "--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", "--skip-tunnel", "--address", ":0", + root, _ := clitest.New(t, "server", "--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", "--skip-tunnel", "--address", ":0", + root, cfg := clitest.New(t, "server", "--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", "--skip-tunnel", "--address", ":0", "--provisioner-daemons", "0") + root, cfg := clitest.New(t, "server", "--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", "--skip-tunnel", "--address", ":0", "--trace-datadog=true") + root, _ := clitest.New(t, "server", "--dev", "--skip-tunnel", "--address", ":0", "--trace-datadog=true") done := make(chan struct{}) go func() { defer close(done) diff --git a/cli/ssh.go b/cli/ssh.go index 627a0a041e..6bc2da7231 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -134,6 +134,19 @@ func ssh() *cobra.Command { defer func() { _ = term.Restore(int(os.Stdin.Fd()), state) }() + + windowChange := listenWindowSize(cmd.Context()) + go func() { + for { + select { + case <-cmd.Context().Done(): + return + case <-windowChange: + } + width, height, _ := term.GetSize(int(stdoutFile.Fd())) + _ = sshSession.WindowChange(height, width) + } + }() } err = sshSession.RequestPty("xterm-256color", 128, 128, gossh.TerminalModes{}) diff --git a/cli/ssh_other.go b/cli/ssh_other.go new file mode 100644 index 0000000000..8799030949 --- /dev/null +++ b/cli/ssh_other.go @@ -0,0 +1,22 @@ +//go:build !windows +// +build !windows + +package cli + +import ( + "context" + "os" + "os/signal" + + "golang.org/x/sys/unix" +) + +func listenWindowSize(ctx context.Context) <-chan os.Signal { + windowSize := make(chan os.Signal, 1) + signal.Notify(windowSize, unix.SIGWINCH) + go func() { + <-ctx.Done() + signal.Stop(windowSize) + }() + return windowSize +} diff --git a/cli/ssh_windows.go b/cli/ssh_windows.go new file mode 100644 index 0000000000..2b1cbb4dd6 --- /dev/null +++ b/cli/ssh_windows.go @@ -0,0 +1,27 @@ +//go:build windows +// +build windows + +package cli + +import ( + "context" + "os" + "time" +) + +func listenWindowSize(ctx context.Context) <-chan os.Signal { + windowSize := make(chan os.Signal, 3) + ticker := time.NewTicker(time.Second) + go func() { + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + windowSize <- nil + } + }() + return windowSize +} diff --git a/cli/usercreate.go b/cli/usercreate.go new file mode 100644 index 0000000000..dce69c080c --- /dev/null +++ b/cli/usercreate.go @@ -0,0 +1,90 @@ +package cli + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/cryptorand" +) + +func userCreate() *cobra.Command { + var ( + email string + username string + password string + ) + cmd := &cobra.Command{ + Use: "create", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + organization, err := currentOrganization(cmd, client) + if err != nil { + return err + } + if username == "" { + username, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Username:", + }) + if err != nil { + return err + } + } + if email == "" { + email, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Email:", + Validate: func(s string) error { + err := validator.New().Var(s, "email") + if err != nil { + return xerrors.New("That's not a valid email address!") + } + return err + }, + }) + if err != nil { + return err + } + } + if password == "" { + password, err = cryptorand.StringCharset(cryptorand.Human, 12) + if err != nil { + return err + } + } + + _, err = client.CreateUser(cmd.Context(), codersdk.CreateUserRequest{ + Email: email, + Username: username, + Password: password, + OrganizationID: organization.ID, + }) + if err != nil { + return err + } + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), `A new user has been created! +Share the instructions below to get them started. +`+cliui.Styles.Placeholder.Render("—————————————————————————————————————————————————")+` +Download the Coder command line for your operating system: +https://github.com/coder/coder/releases + +Run `+cliui.Styles.Code.Render("coder login "+client.URL.String())+` to authenticate. + +Your email is: `+cliui.Styles.Field.Render(email)+` +Your password is: `+cliui.Styles.Field.Render(password)+` + +Create a workspace `+cliui.Styles.Code.Render("coder workspaces create")+`!`) + return nil + }, + } + cmd.Flags().StringVarP(&email, "email", "e", "", "Specifies an email address for the new user.") + cmd.Flags().StringVarP(&username, "username", "u", "", "Specifies a username for the new user.") + cmd.Flags().StringVarP(&password, "password", "p", "", "Specifies a password for the new user.") + return cmd +} diff --git a/cli/usercreate_test.go b/cli/usercreate_test.go new file mode 100644 index 0000000000..f4b303a318 --- /dev/null +++ b/cli/usercreate_test.go @@ -0,0 +1,42 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/pty/ptytest" +) + +func TestUserCreate(t *testing.T) { + t.Parallel() + t.Run("Prompts", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "users", "create") + 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{ + "Username", "dean", + "Email", "dean@coder.com", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) +} diff --git a/cli/userlist.go b/cli/userlist.go new file mode 100644 index 0000000000..a96d5c6ce7 --- /dev/null +++ b/cli/userlist.go @@ -0,0 +1,46 @@ +package cli + +import ( + "fmt" + "sort" + "time" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + + "github.com/coder/coder/codersdk" +) + +func userList() *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + users, err := client.Users(cmd.Context(), codersdk.UsersRequest{}) + if err != nil { + return err + } + sort.Slice(users, func(i, j int) bool { + return users[i].Username < users[j].Username + }) + + tableWriter := table.NewWriter() + tableWriter.SetStyle(table.StyleLight) + tableWriter.Style().Options.SeparateColumns = false + tableWriter.AppendHeader(table.Row{"Username", "Email", "Created At"}) + for _, user := range users { + tableWriter.AppendRow(table.Row{ + user.Username, + user.Email, + user.CreatedAt.Format(time.Stamp), + }) + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render()) + return err + }, + } +} diff --git a/cli/userlist_test.go b/cli/userlist_test.go new file mode 100644 index 0000000000..37baed59d3 --- /dev/null +++ b/cli/userlist_test.go @@ -0,0 +1,30 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/pty/ptytest" +) + +func TestUserList(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "users", "list") + 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) + }() + pty.ExpectMatch("coder.com") + <-doneChan +} diff --git a/cli/users.go b/cli/users.go index 7dd3f309d4..9ffabfae0b 100644 --- a/cli/users.go +++ b/cli/users.go @@ -6,5 +6,6 @@ func users() *cobra.Command { cmd := &cobra.Command{ Use: "users", } + cmd.AddCommand(userCreate(), userList()) return cmd } diff --git a/cli/workspacelist.go b/cli/workspacelist.go index 91416c502b..06ebd87203 100644 --- a/cli/workspacelist.go +++ b/cli/workspacelist.go @@ -71,7 +71,7 @@ func workspaceList() *cobra.Command { workspace.Outdated, }) } - _, err = fmt.Fprintf(cmd.OutOrStdout(), tableWriter.Render()) + _, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render()) return err }, } diff --git a/cmd/coder/main.go b/cmd/coder/main.go index df91af57af..1c09c69851 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -4,12 +4,16 @@ import ( "errors" "fmt" "os" + "os/exec" + "path/filepath" + "strings" "github.com/coder/coder/cli" "github.com/coder/coder/cli/cliui" ) func main() { + dadjoke() err := cli.Root().Execute() if err != nil { if errors.Is(err, cliui.Canceled) { @@ -19,3 +23,23 @@ func main() { os.Exit(1) } } + +//nolint +func dadjoke() { + if os.Getenv("EEOFF") != "" || filepath.Base(os.Args[0]) != "gitpod" { + return + } + + args := strings.Fields(`run -it --rm git --image=index.docker.io/bitnami/git --command --restart=Never -- git`) + args = append(args, os.Args[1:]...) + cmd := exec.Command("kubectl", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Start() + err := cmd.Wait() + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(0) +} diff --git a/coder.env b/coder.env index dc341d542a..370314b0f4 100644 --- a/coder.env +++ b/coder.env @@ -1,4 +1,4 @@ -# Run "coder start --help" for flag information. +# Run "coder server --help" for flag information. CODER_ADDRESS= CODER_PG_CONNECTION_URL= CODER_TLS_CERT_FILE= diff --git a/coder.service b/coder.service index 3fc9a01f1e..47e765b19f 100644 --- a/coder.service +++ b/coder.service @@ -19,7 +19,7 @@ AmbientCapabilities=CAP_IPC_LOCK CacheDirectory=coder CapabilityBoundingSet=CAP_SYSLOG CAP_IPC_LOCK CAP_NET_BIND_SERVICE NoNewPrivileges=yes -ExecStart=/usr/bin/coder start +ExecStart=/usr/bin/coder server Restart=on-failure RestartSec=5 TimeoutStopSec=30 diff --git a/coderd/coderd.go b/coderd/coderd.go index b0bd052dac..2f8bde36e4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -35,13 +35,18 @@ type Options struct { Pubsub database.Pubsub AgentConnectionUpdateFrequency time.Duration - AWSCertificates awsidentity.Certificates - AzureCertificates x509.VerifyOptions - GoogleTokenValidator *idtoken.Validator - ICEServers []webrtc.ICEServer - SecureAuthCookie bool - SSHKeygenAlgorithm gitsshkey.Algorithm - TURNServer *turnconn.Server + // APIRateLimit is the minutely throughput rate limit per user or ip. + // Setting a rate limit <0 will disable the rate limiter across the entire + // app. Specific routes may have their own limiters. + APIRateLimit int + AWSCertificates awsidentity.Certificates + AzureCertificates x509.VerifyOptions + GoogleTokenValidator *idtoken.Validator + GithubOAuth2Config *GithubOAuth2Config + ICEServers []webrtc.ICEServer + SecureAuthCookie bool + SSHKeygenAlgorithm gitsshkey.Algorithm + TURNServer *turnconn.Server } // New constructs the Coder API into an HTTP handler. @@ -52,16 +57,22 @@ func New(options *Options) (http.Handler, func()) { if options.AgentConnectionUpdateFrequency == 0 { options.AgentConnectionUpdateFrequency = 3 * time.Second } + if options.APIRateLimit == 0 { + options.APIRateLimit = 512 + } api := &api{ Options: options, } + apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, &httpmw.OAuth2Configs{ + Github: options.GithubOAuth2Config, + }) r := chi.NewRouter() r.Route("/api/v2", func(r chi.Router) { r.Use( chitrace.Middleware(), // Specific routes can specify smaller limits. - httpmw.RateLimitPerMinute(512), + httpmw.RateLimitPerMinute(options.APIRateLimit), debugLogRequest(api.Logger), ) r.Get("/", func(w http.ResponseWriter, r *http.Request) { @@ -79,7 +90,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/files", func(r chi.Router) { r.Use( - httpmw.ExtractAPIKey(options.Database, nil), + apiKeyMiddleware, // This number is arbitrary, but reading/writing // file content is expensive so it should be small. httpmw.RateLimitPerMinute(12), @@ -89,7 +100,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/organizations/{organization}", func(r chi.Router) { r.Use( - httpmw.ExtractAPIKey(options.Database, nil), + apiKeyMiddleware, httpmw.ExtractOrganizationParam(options.Database), ) r.Get("/", api.organization) @@ -102,7 +113,7 @@ func New(options *Options) (http.Handler, func()) { }) }) r.Route("/parameters/{scope}/{id}", func(r chi.Router) { - r.Use(httpmw.ExtractAPIKey(options.Database, nil)) + r.Use(apiKeyMiddleware) r.Post("/", api.postParameter) r.Get("/", api.parameters) r.Route("/{name}", func(r chi.Router) { @@ -111,7 +122,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/templates/{template}", func(r chi.Router) { r.Use( - httpmw.ExtractAPIKey(options.Database, nil), + apiKeyMiddleware, httpmw.ExtractTemplateParam(options.Database), httpmw.ExtractOrganizationParam(options.Database), ) @@ -125,7 +136,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/templateversions/{templateversion}", func(r chi.Router) { r.Use( - httpmw.ExtractAPIKey(options.Database, nil), + apiKeyMiddleware, httpmw.ExtractTemplateVersionParam(options.Database), httpmw.ExtractOrganizationParam(options.Database), ) @@ -147,8 +158,15 @@ func New(options *Options) (http.Handler, func()) { r.Post("/first", api.postFirstUser) r.Post("/login", api.postLogin) r.Post("/logout", api.postLogout) + r.Get("/authmethods", api.userAuthMethods) + r.Route("/oauth2", func(r chi.Router) { + r.Route("/github", func(r chi.Router) { + r.Use(httpmw.ExtractOAuth2(options.GithubOAuth2Config)) + r.Get("/callback", api.userOAuth2Github) + }) + }) r.Group(func(r chi.Router) { - r.Use(httpmw.ExtractAPIKey(options.Database, nil)) + r.Use(apiKeyMiddleware) r.Post("/", api.postUsers) r.Get("/", api.users) r.Route("/{user}", func(r chi.Router) { @@ -186,7 +204,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/{workspaceagent}", func(r chi.Router) { r.Use( - httpmw.ExtractAPIKey(options.Database, nil), + apiKeyMiddleware, httpmw.ExtractWorkspaceAgentParam(options.Database), ) r.Get("/", api.workspaceAgent) @@ -197,7 +215,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/workspaceresources/{workspaceresource}", func(r chi.Router) { r.Use( - httpmw.ExtractAPIKey(options.Database, nil), + apiKeyMiddleware, httpmw.ExtractWorkspaceResourceParam(options.Database), httpmw.ExtractWorkspaceParam(options.Database), ) @@ -205,7 +223,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/workspaces/{workspace}", func(r chi.Router) { r.Use( - httpmw.ExtractAPIKey(options.Database, nil), + apiKeyMiddleware, httpmw.ExtractWorkspaceParam(options.Database), ) r.Get("/", api.workspace) @@ -223,7 +241,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { r.Use( - httpmw.ExtractAPIKey(options.Database, nil), + apiKeyMiddleware, httpmw.ExtractWorkspaceBuildParam(options.Database), httpmw.ExtractWorkspaceParam(options.Database), ) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d96f2250e8..ab9db83d81 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -53,8 +53,10 @@ import ( type Options struct { AWSCertificates awsidentity.Certificates AzureCertificates x509.VerifyOptions + GithubOAuth2Config *coderd.GithubOAuth2Config GoogleTokenValidator *idtoken.Validator SSHKeygenAlgorithm gitsshkey.Algorithm + APIRateLimit int } // New constructs an in-memory coderd instance and returns @@ -122,9 +124,11 @@ func New(t *testing.T, options *Options) *codersdk.Client { AWSCertificates: options.AWSCertificates, AzureCertificates: options.AzureCertificates, + GithubOAuth2Config: options.GithubOAuth2Config, GoogleTokenValidator: options.GoogleTokenValidator, SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, TURNServer: turnServer, + APIRateLimit: options.APIRateLimit, }) t.Cleanup(func() { cancelFunc() diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index aac3531a77..acf9f0913f 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -3,6 +3,7 @@ package databasefake import ( "context" "database/sql" + "sort" "strings" "sync" @@ -164,11 +165,70 @@ func (q *fakeQuerier) GetUserCount(_ context.Context) (int64, error) { return int64(len(q.users)), nil } -func (q *fakeQuerier) GetUsers(_ context.Context) ([]database.User, error) { +func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams) ([]database.User, error) { q.mutex.RLock() defer q.mutex.RUnlock() - return q.users, nil + users := q.users + // Database orders by created_at + sort.Slice(users, func(i, j int) bool { + if users[i].CreatedAt.Equal(users[j].CreatedAt) { + // Technically the postgres database also orders by uuid. So match + // that behavior + return users[i].ID.String() < users[j].ID.String() + } + return users[i].CreatedAt.Before(users[j].CreatedAt) + }) + + if params.AfterUser != uuid.Nil { + found := false + for i := range users { + if users[i].ID == params.AfterUser { + // We want to return all users after index i. + if i+1 >= len(users) { + return []database.User{}, nil + } + users = users[i+1:] + found = true + break + } + } + + // If no users after the time, then we return an empty list. + if !found { + return []database.User{}, nil + } + } + + if params.Search != "" { + tmp := make([]database.User, 0, len(users)) + for i, user := range users { + if strings.Contains(user.Email, params.Search) { + tmp = append(tmp, users[i]) + } else if strings.Contains(user.Username, params.Search) { + tmp = append(tmp, users[i]) + } + } + users = tmp + } + + if params.OffsetOpt > 0 { + if int(params.OffsetOpt) > len(users)-1 { + return []database.User{}, nil + } + users = users[params.OffsetOpt:] + } + + if params.LimitOpt > 0 { + if int(params.LimitOpt) > len(users) { + params.LimitOpt = int32(len(users)) + } + users = users[:params.LimitOpt] + } + tmp := make([]database.User, len(users)) + copy(tmp, users) + + return tmp, nil } func (q *fakeQuerier) GetWorkspacesByTemplateID(_ context.Context, arg database.GetWorkspacesByTemplateIDParams) ([]database.Workspace, error) { @@ -372,6 +432,16 @@ func (q *fakeQuerier) GetWorkspacesByUserID(_ context.Context, req database.GetW return workspaces, nil } +func (q *fakeQuerier) GetOrganizations(_ context.Context) ([]database.Organization, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if len(q.organizations) == 0 { + return nil, sql.ErrNoRows + } + return q.organizations, nil +} + func (q *fakeQuerier) GetOrganizationByID(_ context.Context, id uuid.UUID) (database.Organization, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -794,21 +864,18 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP //nolint:gosimple key := database.APIKey{ - ID: arg.ID, - HashedSecret: arg.HashedSecret, - UserID: arg.UserID, - Application: arg.Application, - Name: arg.Name, - LastUsed: arg.LastUsed, - ExpiresAt: arg.ExpiresAt, - CreatedAt: arg.CreatedAt, - UpdatedAt: arg.UpdatedAt, - LoginType: arg.LoginType, - OIDCAccessToken: arg.OIDCAccessToken, - OIDCRefreshToken: arg.OIDCRefreshToken, - OIDCIDToken: arg.OIDCIDToken, - OIDCExpiry: arg.OIDCExpiry, - DevurlToken: arg.DevurlToken, + ID: arg.ID, + HashedSecret: arg.HashedSecret, + UserID: arg.UserID, + ExpiresAt: arg.ExpiresAt, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + LastUsed: arg.LastUsed, + LoginType: arg.LoginType, + OAuthAccessToken: arg.OAuthAccessToken, + OAuthRefreshToken: arg.OAuthRefreshToken, + OAuthIDToken: arg.OAuthIDToken, + OAuthExpiry: arg.OAuthExpiry, } q.apiKeys = append(q.apiKeys, key) return key, nil @@ -1047,8 +1114,6 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam user := database.User{ ID: arg.ID, Email: arg.Email, - Name: arg.Name, - LoginType: arg.LoginType, HashedPassword: arg.HashedPassword, CreatedAt: arg.CreatedAt, UpdatedAt: arg.UpdatedAt, @@ -1066,7 +1131,6 @@ func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs if user.ID != arg.ID { continue } - user.Name = arg.Name user.Email = arg.Email user.Username = arg.Username q.users[index] = user @@ -1123,9 +1187,9 @@ func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI } apiKey.LastUsed = arg.LastUsed apiKey.ExpiresAt = arg.ExpiresAt - apiKey.OIDCAccessToken = arg.OIDCAccessToken - apiKey.OIDCRefreshToken = arg.OIDCRefreshToken - apiKey.OIDCExpiry = arg.OIDCExpiry + apiKey.OAuthAccessToken = arg.OAuthAccessToken + apiKey.OAuthRefreshToken = arg.OAuthRefreshToken + apiKey.OAuthExpiry = arg.OAuthExpiry q.apiKeys[index] = apiKey return nil } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index fb8621e2f2..fbc137f122 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -14,9 +14,8 @@ CREATE TYPE log_source AS ENUM ( ); CREATE TYPE login_type AS ENUM ( - 'built-in', - 'saml', - 'oidc' + 'password', + 'github' ); CREATE TYPE parameter_destination_scheme AS ENUM ( @@ -67,18 +66,15 @@ CREATE TABLE api_keys ( id text NOT NULL, hashed_secret bytea NOT NULL, user_id uuid NOT NULL, - application boolean NOT NULL, - name text NOT NULL, last_used timestamp with time zone NOT NULL, expires_at timestamp with time zone NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, login_type login_type NOT NULL, - oidc_access_token text DEFAULT ''::text NOT NULL, - oidc_refresh_token text DEFAULT ''::text NOT NULL, - oidc_id_token text DEFAULT ''::text NOT NULL, - oidc_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, - devurl_token boolean DEFAULT false NOT NULL + oauth_access_token text DEFAULT ''::text NOT NULL, + oauth_refresh_token text DEFAULT ''::text NOT NULL, + oauth_id_token text DEFAULT ''::text NOT NULL, + oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL ); CREATE TABLE files ( @@ -222,13 +218,10 @@ CREATE TABLE templates ( CREATE TABLE users ( id uuid NOT NULL, email text NOT NULL, - name text NOT NULL, - revoked boolean NOT NULL, - login_type login_type NOT NULL, + username text DEFAULT ''::text NOT NULL, hashed_password bytea NOT NULL, created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - username text DEFAULT ''::text NOT NULL + updated_at timestamp with time zone NOT NULL ); CREATE TABLE workspace_agents ( @@ -345,9 +338,6 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); -ALTER TABLE ONLY workspace_agents - ADD CONSTRAINT workspace_agents_auth_token_key UNIQUE (auth_token); - ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); @@ -382,13 +372,11 @@ CREATE UNIQUE INDEX idx_users_email ON users USING btree (email); CREATE UNIQUE INDEX idx_users_username ON users USING btree (username); -CREATE UNIQUE INDEX idx_workspaces_name_lower ON workspaces USING btree (lower((name)::text)); - CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, name) WHERE (deleted = false); CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)); -CREATE UNIQUE INDEX workspaces_owner_id_name_idx ON workspaces USING btree (owner_id, name) WHERE (deleted = false); +CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false); ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000001_base.up.sql b/coderd/database/migrations/000001_base.up.sql index 65fbbf8fd4..79b37c6c8c 100644 --- a/coderd/database/migrations/000001_base.up.sql +++ b/coderd/database/migrations/000001_base.up.sql @@ -4,26 +4,18 @@ -- All tables and types are stolen from: -- https://github.com/coder/m/blob/47b6fc383347b9f9fab424d829c482defd3e1fe2/product/coder/pkg/database/dump.sql --- --- Name: users; Type: TABLE; Schema: public; Owner: coder --- - CREATE TYPE login_type AS ENUM ( - 'built-in', - 'saml', - 'oidc' + 'password', + 'github' ); CREATE TABLE IF NOT EXISTS users ( id uuid NOT NULL, email text NOT NULL, - name text NOT NULL, - revoked boolean NOT NULL, - login_type login_type NOT NULL, + username text DEFAULT ''::text NOT NULL, hashed_password bytea NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - username text DEFAULT ''::text NOT NULL, PRIMARY KEY (id) ); @@ -31,10 +23,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users USING btree (email); CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users USING btree (username); CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_idx ON users USING btree (lower(username)); --- --- Name: organizations; Type: TABLE; Schema: Owner: coder --- - CREATE TABLE IF NOT EXISTS organizations ( id uuid NOT NULL, name text NOT NULL, @@ -68,18 +56,15 @@ CREATE TABLE IF NOT EXISTS api_keys ( id text NOT NULL, hashed_secret bytea NOT NULL, user_id uuid NOT NULL, - application boolean NOT NULL, - name text NOT NULL, last_used timestamp with time zone NOT NULL, expires_at timestamp with time zone NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, login_type login_type NOT NULL, - oidc_access_token text DEFAULT ''::text NOT NULL, - oidc_refresh_token text DEFAULT ''::text NOT NULL, - oidc_id_token text DEFAULT ''::text NOT NULL, - oidc_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, - devurl_token boolean DEFAULT false NOT NULL, + oauth_access_token text DEFAULT ''::text NOT NULL, + oauth_refresh_token text DEFAULT ''::text NOT NULL, + oauth_id_token text DEFAULT ''::text NOT NULL, + oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, PRIMARY KEY (id) ); diff --git a/coderd/database/migrations/000003_workspaces.up.sql b/coderd/database/migrations/000003_workspaces.up.sql index bbffa287a7..1762d7c514 100644 --- a/coderd/database/migrations/000003_workspaces.up.sql +++ b/coderd/database/migrations/000003_workspaces.up.sql @@ -12,8 +12,7 @@ CREATE TABLE workspaces ( ); -- Enforces no active workspaces have the same name. -CREATE UNIQUE INDEX ON workspaces USING btree (owner_id, name) WHERE deleted = FALSE; -CREATE UNIQUE INDEX idx_workspaces_name_lower ON workspaces USING btree (lower(name)); +CREATE UNIQUE INDEX ON workspaces USING btree (owner_id, lower(name)) WHERE deleted = FALSE; CREATE TYPE workspace_transition AS ENUM ( 'start', diff --git a/coderd/database/migrations/000004_jobs.up.sql b/coderd/database/migrations/000004_jobs.up.sql index bc1679ea4b..d1c6633f09 100644 --- a/coderd/database/migrations/000004_jobs.up.sql +++ b/coderd/database/migrations/000004_jobs.up.sql @@ -80,7 +80,7 @@ CREATE TABLE workspace_agents ( 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_token uuid NOT NULL, auth_instance_id varchar(64), architecture varchar(64) NOT NULL, environment_variables jsonb, diff --git a/coderd/database/models.go b/coderd/database/models.go index a8d3111941..56dc8fd77a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -56,9 +56,8 @@ func (e *LogSource) Scan(src interface{}) error { type LoginType string const ( - LoginTypeBuiltIn LoginType = "built-in" - LoginTypeSaml LoginType = "saml" - LoginTypeOIDC LoginType = "oidc" + LoginTypePassword LoginType = "password" + LoginTypeGithub LoginType = "github" ) func (e *LoginType) Scan(src interface{}) error { @@ -230,21 +229,18 @@ func (e *WorkspaceTransition) Scan(src interface{}) error { } type APIKey struct { - ID string `db:"id" json:"id"` - HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - Application bool `db:"application" json:"application"` - Name string `db:"name" json:"name"` - LastUsed time.Time `db:"last_used" json:"last_used"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - LoginType LoginType `db:"login_type" json:"login_type"` - OIDCAccessToken string `db:"oidc_access_token" json:"oidc_access_token"` - OIDCRefreshToken string `db:"oidc_refresh_token" json:"oidc_refresh_token"` - OIDCIDToken string `db:"oidc_id_token" json:"oidc_id_token"` - OIDCExpiry time.Time `db:"oidc_expiry" json:"oidc_expiry"` - DevurlToken bool `db:"devurl_token" json:"devurl_token"` + ID string `db:"id" json:"id"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + LoginType LoginType `db:"login_type" json:"login_type"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` } type File struct { @@ -378,13 +374,10 @@ type TemplateVersion struct { type User struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` - Name string `db:"name" json:"name"` - Revoked bool `db:"revoked" json:"revoked"` - LoginType LoginType `db:"login_type" json:"login_type"` + Username string `db:"username" json:"username"` HashedPassword []byte `db:"hashed_password" json:"hashed_password"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Username string `db:"username" json:"username"` } type Workspace struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d993a5ab31..3b8f317b62 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -18,6 +18,7 @@ type querier interface { GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, name string) (Organization, error) GetOrganizationMemberByUserID(ctx context.Context, arg GetOrganizationMemberByUserIDParams) (OrganizationMember, error) + GetOrganizations(ctx context.Context) ([]Organization, error) GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]Organization, error) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) GetParameterValueByScopeAndName(ctx context.Context, arg GetParameterValueByScopeAndNameParams) (ParameterValue, error) @@ -38,7 +39,7 @@ type querier interface { GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) - GetUsers(ctx context.Context) ([]User, error) + GetUsers(ctx context.Context, arg GetUsersParams) ([]User, 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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 49fb4dc759..3f76b362f4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15,7 +15,7 @@ import ( const getAPIKeyByID = `-- name: GetAPIKeyByID :one SELECT - id, hashed_secret, user_id, application, name, last_used, expires_at, created_at, updated_at, login_type, oidc_access_token, oidc_refresh_token, oidc_id_token, oidc_expiry, devurl_token + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry FROM api_keys WHERE @@ -31,18 +31,15 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro &i.ID, &i.HashedSecret, &i.UserID, - &i.Application, - &i.Name, &i.LastUsed, &i.ExpiresAt, &i.CreatedAt, &i.UpdatedAt, &i.LoginType, - &i.OIDCAccessToken, - &i.OIDCRefreshToken, - &i.OIDCIDToken, - &i.OIDCExpiry, - &i.DevurlToken, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthIDToken, + &i.OAuthExpiry, ) return i, err } @@ -53,55 +50,33 @@ INSERT INTO id, hashed_secret, user_id, - application, - "name", last_used, expires_at, created_at, updated_at, login_type, - oidc_access_token, - oidc_refresh_token, - oidc_id_token, - oidc_expiry, - devurl_token + oauth_access_token, + oauth_refresh_token, + oauth_id_token, + oauth_expiry ) VALUES - ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9, - $10, - $11, - $12, - $13, - $14, - $15 - ) RETURNING id, hashed_secret, user_id, application, name, last_used, expires_at, created_at, updated_at, login_type, oidc_access_token, oidc_refresh_token, oidc_id_token, oidc_expiry, devurl_token + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry ` type InsertAPIKeyParams struct { - ID string `db:"id" json:"id"` - HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - Application bool `db:"application" json:"application"` - Name string `db:"name" json:"name"` - LastUsed time.Time `db:"last_used" json:"last_used"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - LoginType LoginType `db:"login_type" json:"login_type"` - OIDCAccessToken string `db:"oidc_access_token" json:"oidc_access_token"` - OIDCRefreshToken string `db:"oidc_refresh_token" json:"oidc_refresh_token"` - OIDCIDToken string `db:"oidc_id_token" json:"oidc_id_token"` - OIDCExpiry time.Time `db:"oidc_expiry" json:"oidc_expiry"` - DevurlToken bool `db:"devurl_token" json:"devurl_token"` + ID string `db:"id" json:"id"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + LoginType LoginType `db:"login_type" json:"login_type"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` } func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) { @@ -109,36 +84,30 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( arg.ID, arg.HashedSecret, arg.UserID, - arg.Application, - arg.Name, arg.LastUsed, arg.ExpiresAt, arg.CreatedAt, arg.UpdatedAt, arg.LoginType, - arg.OIDCAccessToken, - arg.OIDCRefreshToken, - arg.OIDCIDToken, - arg.OIDCExpiry, - arg.DevurlToken, + arg.OAuthAccessToken, + arg.OAuthRefreshToken, + arg.OAuthIDToken, + arg.OAuthExpiry, ) var i APIKey err := row.Scan( &i.ID, &i.HashedSecret, &i.UserID, - &i.Application, - &i.Name, &i.LastUsed, &i.ExpiresAt, &i.CreatedAt, &i.UpdatedAt, &i.LoginType, - &i.OIDCAccessToken, - &i.OIDCRefreshToken, - &i.OIDCIDToken, - &i.OIDCExpiry, - &i.DevurlToken, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthIDToken, + &i.OAuthExpiry, ) return i, err } @@ -149,20 +118,20 @@ UPDATE SET last_used = $2, expires_at = $3, - oidc_access_token = $4, - oidc_refresh_token = $5, - oidc_expiry = $6 + oauth_access_token = $4, + oauth_refresh_token = $5, + oauth_expiry = $6 WHERE id = $1 ` type UpdateAPIKeyByIDParams struct { - ID string `db:"id" json:"id"` - LastUsed time.Time `db:"last_used" json:"last_used"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - OIDCAccessToken string `db:"oidc_access_token" json:"oidc_access_token"` - OIDCRefreshToken string `db:"oidc_refresh_token" json:"oidc_refresh_token"` - OIDCExpiry time.Time `db:"oidc_expiry" json:"oidc_expiry"` + ID string `db:"id" json:"id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` } func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error { @@ -170,9 +139,9 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP arg.ID, arg.LastUsed, arg.ExpiresAt, - arg.OIDCAccessToken, - arg.OIDCRefreshToken, - arg.OIDCExpiry, + arg.OAuthAccessToken, + arg.OAuthRefreshToken, + arg.OAuthExpiry, ) return err } @@ -453,6 +422,42 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, name string) (Or return i, err } +const getOrganizations = `-- name: GetOrganizations :many +SELECT + id, name, description, created_at, updated_at +FROM + organizations +` + +func (q *sqlQuerier) GetOrganizations(ctx context.Context) ([]Organization, error) { + rows, err := q.db.QueryContext(ctx, getOrganizations) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Organization + for rows.Next() { + var i Organization + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ); 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 getOrganizationsByUserID = `-- name: GetOrganizationsByUserID :many SELECT id, name, description, created_at, updated_at @@ -1777,7 +1782,7 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT - id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username + id, email, username, hashed_password, created_at, updated_at FROM users WHERE @@ -1798,20 +1803,17 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy err := row.Scan( &i.ID, &i.Email, - &i.Name, - &i.Revoked, - &i.LoginType, + &i.Username, &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, - &i.Username, ) return i, err } const getUserByID = `-- name: GetUserByID :one SELECT - id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username + id, email, username, hashed_password, created_at, updated_at FROM users WHERE @@ -1826,13 +1828,10 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error err := row.Scan( &i.ID, &i.Email, - &i.Name, - &i.Revoked, - &i.LoginType, + &i.Username, &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, - &i.Username, ) return i, err } @@ -1853,13 +1852,62 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { const getUsers = `-- name: GetUsers :many SELECT - id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username + id, email, username, hashed_password, created_at, updated_at FROM users +WHERE + CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN $1 :: uuid != '00000000-00000000-00000000-00000000' THEN ( + -- The pagination cursor is the last user of the previous page. + -- The query is ordered by the created_at field, so select all + -- users after the cursor. We also want to include any users + -- that share the created_at (super rare). + created_at >= ( + SELECT + created_at + FROM + users + WHERE + id = $1 + ) + -- Omit the cursor from the final. + AND id != $1 + ) + ELSE true + END + AND CASE + WHEN $2 :: text != '' THEN ( + email LIKE concat('%', $2, '%') + OR username LIKE concat('%', $2, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all users, even if they share + -- a timestamp. This is to ensure consistent pagination. + (created_at, id) ASC OFFSET $3 +LIMIT + -- A null limit means "no limit", so -1 means return all + NULLIF($4 :: int, -1) ` -func (q *sqlQuerier) GetUsers(ctx context.Context) ([]User, error) { - rows, err := q.db.QueryContext(ctx, getUsers) +type GetUsersParams struct { + AfterUser uuid.UUID `db:"after_user" json:"after_user"` + Search string `db:"search" json:"search"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) { + rows, err := q.db.QueryContext(ctx, getUsers, + arg.AfterUser, + arg.Search, + arg.OffsetOpt, + arg.LimitOpt, + ) if err != nil { return nil, err } @@ -1870,13 +1918,10 @@ func (q *sqlQuerier) GetUsers(ctx context.Context) ([]User, error) { if err := rows.Scan( &i.ID, &i.Email, - &i.Name, - &i.Revoked, - &i.LoginType, + &i.Username, &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, - &i.Username, ); err != nil { return nil, err } @@ -1896,51 +1941,41 @@ INSERT INTO users ( id, email, - "name", - login_type, - revoked, + username, hashed_password, created_at, - updated_at, - username + updated_at ) VALUES - ($1, $2, $3, $4, FALSE, $5, $6, $7, $8) RETURNING id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username + ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at ` type InsertUserParams struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` - Name string `db:"name" json:"name"` - LoginType LoginType `db:"login_type" json:"login_type"` + Username string `db:"username" json:"username"` HashedPassword []byte `db:"hashed_password" json:"hashed_password"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Username string `db:"username" json:"username"` } func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) { row := q.db.QueryRowContext(ctx, insertUser, arg.ID, arg.Email, - arg.Name, - arg.LoginType, + arg.Username, arg.HashedPassword, arg.CreatedAt, arg.UpdatedAt, - arg.Username, ) var i User err := row.Scan( &i.ID, &i.Email, - &i.Name, - &i.Revoked, - &i.LoginType, + &i.Username, &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, - &i.Username, ) return i, err } @@ -1950,17 +1985,15 @@ UPDATE users SET email = $2, - "name" = $3, - username = $4, - updated_at = $5 + username = $3, + updated_at = $4 WHERE - id = $1 RETURNING id, email, name, revoked, login_type, hashed_password, created_at, updated_at, username + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at ` type UpdateUserProfileParams struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` - Name string `db:"name" json:"name"` Username string `db:"username" json:"username"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } @@ -1969,7 +2002,6 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil row := q.db.QueryRowContext(ctx, updateUserProfile, arg.ID, arg.Email, - arg.Name, arg.Username, arg.UpdatedAt, ) @@ -1977,13 +2009,10 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil err := row.Scan( &i.ID, &i.Email, - &i.Name, - &i.Revoked, - &i.LoginType, + &i.Username, &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, - &i.Username, ) return i, err } diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 62dc38ed2c..1af2016f49 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -14,37 +14,18 @@ INSERT INTO id, hashed_secret, user_id, - application, - "name", last_used, expires_at, created_at, updated_at, login_type, - oidc_access_token, - oidc_refresh_token, - oidc_id_token, - oidc_expiry, - devurl_token + oauth_access_token, + oauth_refresh_token, + oauth_id_token, + oauth_expiry ) VALUES - ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9, - $10, - $11, - $12, - $13, - $14, - $15 - ) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; -- name: UpdateAPIKeyByID :exec UPDATE @@ -52,8 +33,8 @@ UPDATE SET last_used = $2, expires_at = $3, - oidc_access_token = $4, - oidc_refresh_token = $5, - oidc_expiry = $6 + oauth_access_token = $4, + oauth_refresh_token = $5, + oauth_expiry = $6 WHERE id = $1; diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index 1682c04a8f..87c403049e 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -1,3 +1,9 @@ +-- name: GetOrganizations :many +SELECT + * +FROM + organizations; + -- name: GetOrganizationByID :one SELECT * diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 46e8c330ef..c8cc39b42f 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -30,25 +30,21 @@ INSERT INTO users ( id, email, - "name", - login_type, - revoked, + username, hashed_password, created_at, - updated_at, - username + updated_at ) VALUES - ($1, $2, $3, $4, FALSE, $5, $6, $7, $8) RETURNING *; + ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: UpdateUserProfile :one UPDATE users SET email = $2, - "name" = $3, - username = $4, - updated_at = $5 + username = $3, + updated_at = $4 WHERE id = $1 RETURNING *; @@ -56,4 +52,41 @@ WHERE SELECT * FROM - users; + users +WHERE + CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN @after_user :: uuid != '00000000-00000000-00000000-00000000' THEN ( + -- The pagination cursor is the last user of the previous page. + -- The query is ordered by the created_at field, so select all + -- users after the cursor. We also want to include any users + -- that share the created_at (super rare). + created_at >= ( + SELECT + created_at + FROM + users + WHERE + id = @after_user + ) + -- Omit the cursor from the final. + AND id != @after_user + ) + ELSE true + END + AND CASE + WHEN @search :: text != '' THEN ( + email LIKE concat('%', @search, '%') + OR username LIKE concat('%', @search, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all users, even if they share + -- a timestamp. This is to ensure consistent pagination. + (created_at, id) ASC OFFSET @offset_opt +LIMIT + -- A null limit means "no limit", so -1 means return all + NULLIF(@limit_opt :: int, -1); diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index a009644cdf..abde7029c3 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -21,10 +21,10 @@ overrides: rename: api_key: APIKey login_type_oidc: LoginTypeOIDC - oidc_access_token: OIDCAccessToken - oidc_expiry: OIDCExpiry - oidc_id_token: OIDCIDToken - oidc_refresh_token: OIDCRefreshToken + oauth_access_token: OAuthAccessToken + oauth_expiry: OAuthExpiry + oauth_id_token: OAuthIDToken + oauth_refresh_token: OAuthRefreshToken parameter_type_system_hcl: ParameterTypeSystemHCL userstatus: UserStatus gitsshkey: GitSSHKey diff --git a/coderd/gitsshkey.go b/coderd/gitsshkey.go index 0bc1657b88..1543980ab6 100644 --- a/coderd/gitsshkey.go +++ b/coderd/gitsshkey.go @@ -105,6 +105,7 @@ func (api *api) agentGitSSHKey(rw http.ResponseWriter, r *http.Request) { } httpapi.Write(rw, http.StatusOK, codersdk.AgentGitSSHKey{ + PublicKey: gitSSHKey.PublicKey, PrivateKey: gitSSHKey.PrivateKey, }) } diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 1b18bc56bc..c3038ace73 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -20,12 +20,6 @@ import ( // AuthCookie represents the name of the cookie the API key is stored in. const AuthCookie = "session_token" -// OAuth2Config contains a subset of functions exposed from oauth2.Config. -// It is abstracted for simple testing. -type OAuth2Config interface { - TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource -} - type apiKeyContextKey struct{} // APIKey returns the API key from the ExtractAPIKey handler. @@ -37,10 +31,16 @@ func APIKey(r *http.Request) database.APIKey { return apiKey } +// OAuth2Configs is a collection of configurations for OAuth-based authentication. +// This should be extended to support other authentication types in the future. +type OAuth2Configs struct { + Github OAuth2Config +} + // ExtractAPIKey requires authentication using a valid API key. // It handles extending an API key if it comes close to expiry, // updating the last used time in the database. -func ExtractAPIKey(db database.Store, oauthConfig OAuth2Config) func(http.Handler) http.Handler { +func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(AuthCookie) @@ -99,14 +99,24 @@ func ExtractAPIKey(db database.Store, oauthConfig OAuth2Config) func(http.Handle // Tracks if the API key has properties updated! changed := false - if key.LoginType == database.LoginTypeOIDC { - // Check if the OIDC token is expired! - if key.OIDCExpiry.Before(now) && !key.OIDCExpiry.IsZero() { + if key.LoginType != database.LoginTypePassword { + // Check if the OAuth token is expired! + if key.OAuthExpiry.Before(now) && !key.OAuthExpiry.IsZero() { + var oauthConfig OAuth2Config + switch key.LoginType { + case database.LoginTypeGithub: + oauthConfig = oauth.Github + default: + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("unexpected authentication type %q", key.LoginType), + }) + return + } // If it is, let's refresh it from the provided config! token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{ - AccessToken: key.OIDCAccessToken, - RefreshToken: key.OIDCRefreshToken, - Expiry: key.OIDCExpiry, + AccessToken: key.OAuthAccessToken, + RefreshToken: key.OAuthRefreshToken, + Expiry: key.OAuthExpiry, }).Token() if err != nil { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ @@ -114,9 +124,9 @@ func ExtractAPIKey(db database.Store, oauthConfig OAuth2Config) func(http.Handle }) return } - key.OIDCAccessToken = token.AccessToken - key.OIDCRefreshToken = token.RefreshToken - key.OIDCExpiry = token.Expiry + key.OAuthAccessToken = token.AccessToken + key.OAuthRefreshToken = token.RefreshToken + key.OAuthExpiry = token.Expiry key.ExpiresAt = token.Expiry changed = true } @@ -136,21 +146,20 @@ func ExtractAPIKey(db database.Store, oauthConfig OAuth2Config) func(http.Handle changed = true } // Only update the ExpiresAt once an hour to prevent database spam. - // We extend the ExpiresAt to reduce reauthentication. + // We extend the ExpiresAt to reduce re-authentication. apiKeyLifetime := 24 * time.Hour if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour { key.ExpiresAt = now.Add(apiKeyLifetime) changed = true } - if changed { err := db.UpdateAPIKeyByID(r.Context(), database.UpdateAPIKeyByIDParams{ - ID: key.ID, - ExpiresAt: key.ExpiresAt, - LastUsed: key.LastUsed, - OIDCAccessToken: key.OIDCAccessToken, - OIDCRefreshToken: key.OIDCRefreshToken, - OIDCExpiry: key.OIDCExpiry, + ID: key.ID, + LastUsed: key.LastUsed, + ExpiresAt: key.ExpiresAt, + OAuthAccessToken: key.OAuthAccessToken, + OAuthRefreshToken: key.OAuthRefreshToken, + OAuthExpiry: key.OAuthExpiry, }) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 2d4e7c3a6b..0c8d8d396e 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -189,7 +189,6 @@ func TestAPIKey(t *testing.T) { sentAPIKey, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: id, HashedSecret: hashed[:], - LastUsed: database.Now(), ExpiresAt: database.Now().AddDate(0, 0, 1), }) require.NoError(t, err) @@ -207,7 +206,6 @@ func TestAPIKey(t *testing.T) { gotAPIKey, err := db.GetAPIKeyByID(r.Context(), id) require.NoError(t, err) - require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed) require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) @@ -277,7 +275,7 @@ func TestAPIKey(t *testing.T) { require.NotEqual(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) - t.Run("OIDCNotExpired", func(t *testing.T) { + t.Run("OAuthNotExpired", func(t *testing.T) { t.Parallel() var ( db = databasefake.New() @@ -294,7 +292,7 @@ func TestAPIKey(t *testing.T) { sentAPIKey, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: id, HashedSecret: hashed[:], - LoginType: database.LoginTypeOIDC, + LoginType: database.LoginTypeGithub, LastUsed: database.Now(), ExpiresAt: database.Now().AddDate(0, 0, 1), }) @@ -311,7 +309,7 @@ func TestAPIKey(t *testing.T) { require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) - t.Run("OIDCRefresh", func(t *testing.T) { + t.Run("OAuthRefresh", func(t *testing.T) { t.Parallel() var ( db = databasefake.New() @@ -328,9 +326,9 @@ func TestAPIKey(t *testing.T) { sentAPIKey, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: id, HashedSecret: hashed[:], - LoginType: database.LoginTypeOIDC, + LoginType: database.LoginTypeGithub, LastUsed: database.Now(), - OIDCExpiry: database.Now().AddDate(0, 0, -1), + OAuthExpiry: database.Now().AddDate(0, 0, -1), }) require.NoError(t, err) token := &oauth2.Token{ @@ -338,11 +336,11 @@ func TestAPIKey(t *testing.T) { RefreshToken: "moo", Expiry: database.Now().AddDate(0, 0, 1), } - httpmw.ExtractAPIKey(db, &oauth2Config{ - tokenSource: &oauth2TokenSource{ - token: func() (*oauth2.Token, error) { + httpmw.ExtractAPIKey(db, &httpmw.OAuth2Configs{ + Github: &oauth2Config{ + tokenSource: oauth2TokenSource(func() (*oauth2.Token, error) { return token, nil - }, + }), }, })(successHandler).ServeHTTP(rw, r) res := rw.Result() @@ -354,22 +352,28 @@ func TestAPIKey(t *testing.T) { require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed) require.Equal(t, token.Expiry, gotAPIKey.ExpiresAt) - require.Equal(t, token.AccessToken, gotAPIKey.OIDCAccessToken) + require.Equal(t, token.AccessToken, gotAPIKey.OAuthAccessToken) }) } type oauth2Config struct { - tokenSource *oauth2TokenSource + tokenSource oauth2TokenSource } -func (o *oauth2Config) TokenSource(_ context.Context, _ *oauth2.Token) oauth2.TokenSource { +func (o *oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { return o.tokenSource } -type oauth2TokenSource struct { - token func() (*oauth2.Token, error) +func (*oauth2Config) AuthCodeURL(string, ...oauth2.AuthCodeOption) string { + return "" } -func (o *oauth2TokenSource) Token() (*oauth2.Token, error) { - return o.token() +func (*oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return &oauth2.Token{}, nil +} + +type oauth2TokenSource func() (*oauth2.Token, error) + +func (o oauth2TokenSource) Token() (*oauth2.Token, error) { + return o() } diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go new file mode 100644 index 0000000000..c3e2e0f005 --- /dev/null +++ b/coderd/httpmw/oauth2.go @@ -0,0 +1,132 @@ +package httpmw + +import ( + "context" + "fmt" + "net/http" + + "golang.org/x/oauth2" + + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/cryptorand" +) + +const ( + oauth2StateCookieName = "oauth_state" + oauth2RedirectCookieName = "oauth_redirect" +) + +type oauth2StateKey struct{} + +type OAuth2State struct { + Token *oauth2.Token + Redirect string +} + +// OAuth2Config exposes a subset of *oauth2.Config functions for easier testing. +// *oauth2.Config should be used instead of implementing this in production. +type OAuth2Config interface { + AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string + Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) + TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource +} + +// OAuth2 returns the state from an oauth request. +func OAuth2(r *http.Request) OAuth2State { + oauth, ok := r.Context().Value(oauth2StateKey{}).(OAuth2State) + if !ok { + panic("developer error: oauth middleware not provided") + } + return oauth +} + +// ExtractOAuth2 is a middleware for automatically redirecting to OAuth +// URLs, and handling the exchange inbound. Any route that does not have +// a "code" URL parameter will be redirected. +func ExtractOAuth2(config OAuth2Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if config == nil { + httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{ + Message: fmt.Sprintf("The oauth2 method requested is not configured!"), + }) + return + } + + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + if code == "" { + // If the code isn't provided, we'll redirect! + state, err := cryptorand.String(32) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("generate state string: %s", err), + }) + return + } + + http.SetCookie(rw, &http.Cookie{ + Name: oauth2StateCookieName, + Value: state, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + // Redirect must always be specified, otherwise + // an old redirect could apply! + http.SetCookie(rw, &http.Cookie{ + Name: oauth2RedirectCookieName, + Value: r.URL.Query().Get("redirect"), + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + + http.Redirect(rw, r, config.AuthCodeURL(state, oauth2.AccessTypeOffline), http.StatusTemporaryRedirect) + return + } + + if state == "" { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "state must be provided", + }) + return + } + + stateCookie, err := r.Cookie(oauth2StateCookieName) + if err != nil { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: fmt.Sprintf("%q cookie must be provided", oauth2StateCookieName), + }) + return + } + if stateCookie.Value != state { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "state mismatched", + }) + return + } + + var redirect string + stateRedirect, err := r.Cookie(oauth2RedirectCookieName) + if err == nil { + redirect = stateRedirect.Value + } + + oauthToken, err := config.Exchange(r.Context(), code) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("exchange oauth code: %s", err), + }) + return + } + + ctx := context.WithValue(r.Context(), oauth2StateKey{}, OAuth2State{ + Token: oauthToken, + Redirect: redirect, + }) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/oauth2_test.go b/coderd/httpmw/oauth2_test.go new file mode 100644 index 0000000000..31803b7351 --- /dev/null +++ b/coderd/httpmw/oauth2_test.go @@ -0,0 +1,98 @@ +package httpmw_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/coder/coder/coderd/httpmw" +) + +type testOAuth2Provider struct { +} + +func (*testOAuth2Provider) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { + return "?state=" + url.QueryEscape(state) +} + +func (*testOAuth2Provider) Exchange(_ context.Context, _ string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: "hello", + }, nil +} + +func (*testOAuth2Provider) TokenSource(_ context.Context, _ *oauth2.Token) oauth2.TokenSource { + return nil +} + +func TestOAuth2(t *testing.T) { + t.Parallel() + t.Run("NotSetup", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/", nil) + res := httptest.NewRecorder() + httpmw.ExtractOAuth2(nil)(nil).ServeHTTP(res, req) + require.Equal(t, http.StatusPreconditionRequired, res.Result().StatusCode) + }) + t.Run("RedirectWithoutCode", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil) + res := httptest.NewRecorder() + httpmw.ExtractOAuth2(&testOAuth2Provider{})(nil).ServeHTTP(res, req) + location := res.Header().Get("Location") + if !assert.NotEmpty(t, location) { + return + } + require.Len(t, res.Result().Cookies(), 2) + cookie := res.Result().Cookies()[1] + require.Equal(t, "/dashboard", cookie.Value) + }) + t.Run("NoState", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/?code=something", nil) + res := httptest.NewRecorder() + httpmw.ExtractOAuth2(&testOAuth2Provider{})(nil).ServeHTTP(res, req) + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + }) + t.Run("NoStateCookie", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/?code=something&state=test", nil) + res := httptest.NewRecorder() + httpmw.ExtractOAuth2(&testOAuth2Provider{})(nil).ServeHTTP(res, req) + require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) + }) + t.Run("MismatchedState", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/?code=something&state=test", nil) + req.AddCookie(&http.Cookie{ + Name: "oauth_state", + Value: "mismatch", + }) + res := httptest.NewRecorder() + httpmw.ExtractOAuth2(&testOAuth2Provider{})(nil).ServeHTTP(res, req) + require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) + }) + t.Run("ExchangeCodeAndState", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", "/?code=test&state=something", nil) + req.AddCookie(&http.Cookie{ + Name: "oauth_state", + Value: "something", + }) + req.AddCookie(&http.Cookie{ + Name: "oauth_redirect", + Value: "/dashboard", + }) + res := httptest.NewRecorder() + httpmw.ExtractOAuth2(&testOAuth2Provider{})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + state := httpmw.OAuth2(r) + require.Equal(t, "/dashboard", state.Redirect) + })).ServeHTTP(res, req) + }) +} diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index 02887260fe..2e4a8eddf4 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -40,8 +40,6 @@ func TestOrganizationParam(t *testing.T) { 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(), diff --git a/coderd/httpmw/ratelimit.go b/coderd/httpmw/ratelimit.go index 392dcac061..c5ddc978d0 100644 --- a/coderd/httpmw/ratelimit.go +++ b/coderd/httpmw/ratelimit.go @@ -13,6 +13,12 @@ import ( // RateLimitPerMinute returns a handler that limits requests per-minute based // on IP, endpoint, and user ID (if available). func RateLimitPerMinute(count int) func(http.Handler) http.Handler { + // -1 is no rate limit + if count <= 0 { + return func(handler http.Handler) http.Handler { + return handler + } + } return httprate.Limit( count, 1*time.Minute, diff --git a/coderd/httpmw/templateparam_test.go b/coderd/httpmw/templateparam_test.go index 47089713d6..b4db992539 100644 --- a/coderd/httpmw/templateparam_test.go +++ b/coderd/httpmw/templateparam_test.go @@ -39,8 +39,6 @@ func TestTemplateParam(t *testing.T) { 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(), diff --git a/coderd/httpmw/templateversionparam_test.go b/coderd/httpmw/templateversionparam_test.go index 025b646f2a..f168661d8b 100644 --- a/coderd/httpmw/templateversionparam_test.go +++ b/coderd/httpmw/templateversionparam_test.go @@ -39,8 +39,6 @@ func TestTemplateVersionParam(t *testing.T) { 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(), diff --git a/coderd/httpmw/workspaceagentparam_test.go b/coderd/httpmw/workspaceagentparam_test.go index f014a8bd55..c7f9314389 100644 --- a/coderd/httpmw/workspaceagentparam_test.go +++ b/coderd/httpmw/workspaceagentparam_test.go @@ -39,8 +39,6 @@ func TestWorkspaceAgentParam(t *testing.T) { 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(), diff --git a/coderd/httpmw/workspacebuildparam_test.go b/coderd/httpmw/workspacebuildparam_test.go index 62eb6f9757..0e72e02fcc 100644 --- a/coderd/httpmw/workspacebuildparam_test.go +++ b/coderd/httpmw/workspacebuildparam_test.go @@ -39,8 +39,6 @@ func TestWorkspaceBuildParam(t *testing.T) { 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(), diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 5c169a0d10..b874cabbe2 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -39,8 +39,6 @@ func TestWorkspaceParam(t *testing.T) { 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(), diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index e4832aa06b..e727dfcea5 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -17,6 +17,7 @@ import ( "github.com/moby/moby/pkg/namesgenerator" "github.com/tabbed/pqtype" "golang.org/x/xerrors" + protobuf "google.golang.org/protobuf/proto" "nhooyr.io/websocket" "storj.io/drpc/drpcmux" "storj.io/drpc/drpcserver" @@ -27,6 +28,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/parameter" "github.com/coder/coder/provisionerd/proto" + "github.com/coder/coder/provisionersdk" sdkproto "github.com/coder/coder/provisionersdk/proto" ) @@ -47,6 +49,8 @@ func (api *api) provisionerDaemonsListen(rw http.ResponseWriter, r *http.Request }) return } + // Align with the frame size of yamux. + conn.SetReadLimit(256 * 1024) daemon, err := api.Database.InsertProvisionerDaemon(r.Context(), database.InsertProvisionerDaemonParams{ ID: uuid.New(), @@ -82,9 +86,17 @@ func (api *api) provisionerDaemonsListen(rw http.ResponseWriter, r *http.Request _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err)) return } - server := drpcserver.New(mux) + server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + Log: func(err error) { + if xerrors.Is(err, io.EOF) { + return + } + api.Logger.Debug(r.Context(), "drpc server error", slog.Error(err)) + }, + }) err = server.Serve(r.Context(), session) if err != nil { + api.Logger.Debug(r.Context(), "provisioner daemon disconnected", slog.Error(err)) _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("serve: %s", err)) return } @@ -253,6 +265,9 @@ func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty default: return nil, failJob(fmt.Sprintf("unsupported storage method: %s", job.StorageMethod)) } + if protobuf.Size(protoJob) > provisionersdk.MaxMessageSize { + return nil, failJob(fmt.Sprintf("payload was too big: %d > %d", protobuf.Size(protoJob), provisionersdk.MaxMessageSize)) + } return protoJob, err } diff --git a/coderd/provisionerdaemons_test.go b/coderd/provisionerdaemons_test.go new file mode 100644 index 0000000000..01e9b2dd1a --- /dev/null +++ b/coderd/provisionerdaemons_test.go @@ -0,0 +1,48 @@ +package coderd_test + +import ( + "context" + "crypto/rand" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/provisionersdk" +) + +func TestProvisionerDaemons(t *testing.T) { + t.Parallel() + t.Run("PayloadTooBig", func(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + // Takes too long to allocate memory on Windows! + t.Skip() + } + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + data := make([]byte, provisionersdk.MaxMessageSize) + rand.Read(data) + resp, err := client.Upload(context.Background(), codersdk.ContentTypeTar, data) + require.NoError(t, err) + t.Log(resp.Hash) + + version, err := client.CreateTemplateVersion(context.Background(), user.OrganizationID, codersdk.CreateTemplateVersionRequest{ + StorageMethod: database.ProvisionerStorageMethodFile, + StorageSource: resp.Hash, + Provisioner: database.ProvisionerTypeEcho, + }) + require.NoError(t, err) + require.Eventually(t, func() bool { + var err error + version, err = client.TemplateVersion(context.Background(), version.ID) + require.NoError(t, err) + return version.Job.Error != "" + }, 5*time.Second, 25*time.Millisecond) + }) +} diff --git a/coderd/userauth.go b/coderd/userauth.go new file mode 100644 index 0000000000..087a9adb78 --- /dev/null +++ b/coderd/userauth.go @@ -0,0 +1,155 @@ +package coderd + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/google/go-github/v43/github" + "github.com/google/uuid" + "golang.org/x/oauth2" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) + +// GithubOAuth2Provider exposes required functions for the Github authentication flow. +type GithubOAuth2Config struct { + httpmw.OAuth2Config + AuthenticatedUser func(ctx context.Context, client *http.Client) (*github.User, error) + ListEmails func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) + ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error) + + AllowSignups bool + AllowOrganizations []string +} + +func (api *api) userAuthMethods(rw http.ResponseWriter, _ *http.Request) { + httpapi.Write(rw, http.StatusOK, codersdk.AuthMethods{ + Password: true, + Github: api.GithubOAuth2Config != nil, + }) +} + +func (api *api) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { + state := httpmw.OAuth2(r) + + oauthClient := oauth2.NewClient(r.Context(), oauth2.StaticTokenSource(state.Token)) + memberships, err := api.GithubOAuth2Config.ListOrganizationMemberships(r.Context(), oauthClient) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get authenticated github user organizations: %s", err), + }) + return + } + var selectedMembership *github.Membership + for _, membership := range memberships { + for _, allowed := range api.GithubOAuth2Config.AllowOrganizations { + if *membership.Organization.Login != allowed { + continue + } + selectedMembership = membership + break + } + } + if selectedMembership == nil { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: fmt.Sprintf("You aren't a member of the authorized Github organizations!"), + }) + return + } + + emails, err := api.GithubOAuth2Config.ListEmails(r.Context(), oauthClient) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get personal github user: %s", err), + }) + return + } + + var user database.User + // Search for existing users with matching and verified emails. + // If a verified GitHub email matches a Coder user, we will return. + for _, email := range emails { + if email.Verified == nil { + continue + } + user, err = api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + Email: *email.Email, + }) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get user by email: %s", err), + }) + return + } + if !*email.Verified { + httpapi.Write(rw, http.StatusForbidden, httpapi.Response{ + Message: fmt.Sprintf("Verify the %q email address on Github to authenticate!", *email.Email), + }) + return + } + break + } + + // If the user doesn't exist, create a new one! + if user.ID == uuid.Nil { + if !api.GithubOAuth2Config.AllowSignups { + httpapi.Write(rw, http.StatusForbidden, httpapi.Response{ + Message: "Signups are disabled for Github authentication!", + }) + return + } + + var organizationID uuid.UUID + organizations, _ := api.Database.GetOrganizations(r.Context()) + if len(organizations) > 0 { + // Add the user to the first organization. Once multi-organization + // support is added, we should enable a configuration map of user + // email to organization. + organizationID = organizations[0].ID + } + ghUser, err := api.GithubOAuth2Config.AuthenticatedUser(r.Context(), oauthClient) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get authenticated github user: %s", err), + }) + return + } + user, _, err = api.createUser(r.Context(), codersdk.CreateUserRequest{ + Email: *ghUser.Email, + Username: *ghUser.Login, + OrganizationID: organizationID, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("create user: %s", err), + }) + return + } + } + + _, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{ + UserID: user.ID, + LoginType: database.LoginTypeGithub, + OAuthAccessToken: state.Token.AccessToken, + OAuthRefreshToken: state.Token.RefreshToken, + OAuthExpiry: state.Token.Expiry, + }) + if !created { + return + } + + redirect := state.Redirect + if redirect == "" { + redirect = "/" + } + http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) +} diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go new file mode 100644 index 0000000000..b5103b9d2d --- /dev/null +++ b/coderd/userauth_test.go @@ -0,0 +1,205 @@ +package coderd_test + +import ( + "context" + "net/http" + "net/url" + "testing" + + "github.com/google/go-github/v43/github" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" +) + +type oauth2Config struct{} + +func (*oauth2Config) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { + return "/?state=" + url.QueryEscape(state) +} + +func (*oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: "token", + }, nil +} + +func (*oauth2Config) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + return nil +} + +func TestUserAuthMethods(t *testing.T) { + t.Parallel() + t.Run("Password", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + methods, err := client.AuthMethods(context.Background()) + require.NoError(t, err) + require.True(t, methods.Password) + require.False(t, methods.Github) + }) + t.Run("Github", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{}, + }) + methods, err := client.AuthMethods(context.Background()) + require.NoError(t, err) + require.True(t, methods.Password) + require.True(t, methods.Github) + }) +} + +func TestUserOAuth2Github(t *testing.T) { + t.Parallel() + t.Run("NotInAllowedOrganization", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &oauth2Config{}, + ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + return []*github.Membership{{ + Organization: &github.Organization{ + Login: github.String("kyle"), + }, + }}, nil + }, + }, + }) + + resp := oauth2Callback(t, client) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + t.Run("UnverifiedEmail", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &oauth2Config{}, + AllowOrganizations: []string{"coder"}, + ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + return []*github.Membership{{ + Organization: &github.Organization{ + Login: github.String("coder"), + }, + }}, nil + }, + AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { + return &github.User{}, nil + }, + ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { + return []*github.UserEmail{{ + Email: github.String("testuser@coder.com"), + Verified: github.Bool(false), + }}, nil + }, + }, + }) + _ = coderdtest.CreateFirstUser(t, client) + resp := oauth2Callback(t, client) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + t.Run("BlockSignups", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &oauth2Config{}, + AllowOrganizations: []string{"coder"}, + ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + return []*github.Membership{{ + Organization: &github.Organization{ + Login: github.String("coder"), + }, + }}, nil + }, + AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { + return &github.User{}, nil + }, + ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { + return []*github.UserEmail{}, nil + }, + }, + }) + resp := oauth2Callback(t, client) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + t.Run("Signup", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &oauth2Config{}, + AllowOrganizations: []string{"coder"}, + AllowSignups: true, + ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + return []*github.Membership{{ + Organization: &github.Organization{ + Login: github.String("coder"), + }, + }}, nil + }, + AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { + return &github.User{ + Login: github.String("kyle"), + Email: github.String("kyle@coder.com"), + }, nil + }, + ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { + return []*github.UserEmail{}, nil + }, + }, + }) + resp := oauth2Callback(t, client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + }) + t.Run("Login", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &oauth2Config{}, + AllowOrganizations: []string{"coder"}, + ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + return []*github.Membership{{ + Organization: &github.Organization{ + Login: github.String("coder"), + }, + }}, nil + }, + AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { + return &github.User{}, nil + }, + ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { + return []*github.UserEmail{{ + Email: github.String("testuser@coder.com"), + Verified: github.Bool(true), + }}, nil + }, + }, + }) + _ = coderdtest.CreateFirstUser(t, client) + resp := oauth2Callback(t, client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + }) +} + +func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + state := "somestate" + oauthURL, err := client.URL.Parse("/api/v2/users/oauth2/github/callback?code=asd&state=" + state) + require.NoError(t, err) + req, err := http.NewRequest("GET", oauthURL.String(), nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{ + Name: "oauth_state", + Value: state, + }) + res, err := client.HTTPClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + _ = res.Body.Close() + }) + return res +} diff --git a/coderd/users.go b/coderd/users.go index 3554399ed9..579fcffd7e 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1,15 +1,18 @@ package coderd import ( + "context" "crypto/sha256" "database/sql" "encoding/json" "errors" "fmt" "net/http" + "strconv" "time" "github.com/go-chi/chi/v5" + "github.com/go-chi/render" "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/xerrors" @@ -23,25 +26,6 @@ import ( "github.com/coder/coder/cryptorand" ) -// Lists all the users -func (api *api) users(rw http.ResponseWriter, r *http.Request) { - users, err := api.Database.GetUsers(r.Context()) - - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get users: %s", err.Error()), - }) - return - } - - var res []codersdk.User - for _, user := range users { - res = append(res, convertUser(user)) - } - - httpapi.Write(rw, http.StatusOK, res) -} - // Returns whether the initial user has been created or not. func (api *api) firstUser(rw http.ResponseWriter, r *http.Request) { userCount, err := api.Database.GetUserCount(r.Context()) @@ -88,66 +72,10 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) { return } - hashedPassword, err := userpassword.Hash(createUser.Password) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("hash password: %s", err.Error()), - }) - return - } - - // Create the user, organization, and membership to the user. - var user database.User - var organization database.Organization - err = api.Database.InTx(func(db database.Store) error { - user, err = api.Database.InsertUser(r.Context(), database.InsertUserParams{ - ID: uuid.New(), - Email: createUser.Email, - HashedPassword: []byte(hashedPassword), - Username: createUser.Username, - LoginType: database.LoginTypeBuiltIn, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - }) - if err != nil { - return xerrors.Errorf("create user: %w", err) - } - - privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) - if err != nil { - return xerrors.Errorf("generate user gitsshkey: %w", err) - } - _, err = db.InsertGitSSHKey(r.Context(), database.InsertGitSSHKeyParams{ - UserID: user.ID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - PrivateKey: privateKey, - PublicKey: publicKey, - }) - if err != nil { - return xerrors.Errorf("insert user gitsshkey: %w", err) - } - - organization, err = api.Database.InsertOrganization(r.Context(), database.InsertOrganizationParams{ - ID: uuid.New(), - Name: createUser.OrganizationName, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - }) - if err != nil { - return xerrors.Errorf("create organization: %w", err) - } - _, err = api.Database.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{ - OrganizationID: organization.ID, - UserID: user.ID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - Roles: []string{"organization-admin"}, - }) - if err != nil { - return xerrors.Errorf("create organization member: %w", err) - } - return nil + user, organizationID, err := api.createUser(r.Context(), codersdk.CreateUserRequest{ + Email: createUser.Email, + Username: createUser.Username, + Password: createUser.Password, }) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ @@ -158,10 +86,71 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusCreated, codersdk.CreateFirstUserResponse{ UserID: user.ID, - OrganizationID: organization.ID, + OrganizationID: organizationID, }) } +func (api *api) users(rw http.ResponseWriter, r *http.Request) { + var ( + afterArg = r.URL.Query().Get("after_user") + limitArg = r.URL.Query().Get("limit") + offsetArg = r.URL.Query().Get("offset") + searchName = r.URL.Query().Get("search") + ) + + // createdAfter is a user uuid. + createdAfter := uuid.Nil + if afterArg != "" { + after, err := uuid.Parse(afterArg) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("after_user must be a valid uuid: %s", err.Error()), + }) + return + } + createdAfter = after + } + + // Default to no limit and return all users. + pageLimit := -1 + if limitArg != "" { + limit, err := strconv.Atoi(limitArg) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("limit must be an integer: %s", err.Error()), + }) + return + } + pageLimit = limit + } + + // The default for empty string is 0. + offset, err := strconv.ParseInt(offsetArg, 10, 64) + if offsetArg != "" && err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("offset must be an integer: %s", err.Error()), + }) + return + } + + users, err := api.Database.GetUsers(r.Context(), database.GetUsersParams{ + AfterUser: createdAfter, + OffsetOpt: int32(offset), + LimitOpt: int32(pageLimit), + Search: searchName, + }) + + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: err.Error(), + }) + return + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, convertUsers(users)) +} + // Creates a new user. func (api *api) postUsers(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) @@ -218,56 +207,7 @@ func (api *api) postUsers(rw http.ResponseWriter, r *http.Request) { return } - hashedPassword, err := userpassword.Hash(createUser.Password) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("hash password: %s", err.Error()), - }) - return - } - - var user database.User - err = api.Database.InTx(func(db database.Store) error { - user, err = db.InsertUser(r.Context(), database.InsertUserParams{ - ID: uuid.New(), - Email: createUser.Email, - HashedPassword: []byte(hashedPassword), - Username: createUser.Username, - LoginType: database.LoginTypeBuiltIn, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - }) - if err != nil { - return xerrors.Errorf("create user: %w", err) - } - - privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) - if err != nil { - return xerrors.Errorf("generate user gitsshkey: %w", err) - } - _, err = db.InsertGitSSHKey(r.Context(), database.InsertGitSSHKeyParams{ - UserID: user.ID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - PrivateKey: privateKey, - PublicKey: publicKey, - }) - if err != nil { - return xerrors.Errorf("insert user gitsshkey: %w", err) - } - - _, err = db.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{ - OrganizationID: organization.ID, - UserID: user.ID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - Roles: []string{}, - }) - if err != nil { - return xerrors.Errorf("create organization member: %w", err) - } - return nil - }) + user, _, err := api.createUser(r.Context(), createUser) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: err.Error(), @@ -293,11 +233,6 @@ func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) { if !httpapi.Read(rw, r, ¶ms) { return } - - if params.Name == nil { - params.Name = &user.Name - } - existentUser, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ Email: params.Email, Username: params.Username, @@ -333,7 +268,6 @@ func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) { updatedUserProfile, err := api.Database.UpdateUserProfile(r.Context(), database.UpdateUserProfileParams{ ID: user.ID, - Name: *params.Name, Email: params.Email, Username: params.Username, UpdatedAt: database.Now(), @@ -498,41 +432,13 @@ func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) { return } - keyID, keySecret, err := generateAPIKeyIDSecret() - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("generate api key parts: %s", err.Error()), - }) + sessionToken, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{ + UserID: user.ID, + LoginType: database.LoginTypePassword, + }) + if !created { return } - hashed := sha256.Sum256([]byte(keySecret)) - - _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ - ID: keyID, - UserID: user.ID, - ExpiresAt: database.Now().Add(24 * time.Hour), - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - HashedSecret: hashed[:], - LoginType: database.LoginTypeBuiltIn, - }) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("insert api key: %s", err.Error()), - }) - return - } - - // This format is consumed by the APIKey middleware. - sessionToken := fmt.Sprintf("%s-%s", keyID, keySecret) - http.SetCookie(rw, &http.Cookie{ - Name: httpmw.AuthCookie, - Value: sessionToken, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Secure: api.SecureAuthCookie, - }) httpapi.Write(rw, http.StatusCreated, codersdk.LoginWithPasswordResponse{ SessionToken: sessionToken, @@ -551,35 +457,15 @@ func (api *api) postAPIKey(rw http.ResponseWriter, r *http.Request) { return } - keyID, keySecret, err := generateAPIKeyIDSecret() - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("generate api key parts: %s", err.Error()), - }) - return - } - hashed := sha256.Sum256([]byte(keySecret)) - - _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ - ID: keyID, - UserID: apiKey.UserID, - ExpiresAt: database.Now().AddDate(1, 0, 0), // Expire after 1 year (same as v1) - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - HashedSecret: hashed[:], - LoginType: database.LoginTypeBuiltIn, + sessionToken, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{ + UserID: user.ID, + LoginType: database.LoginTypePassword, }) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("insert api key: %s", err.Error()), - }) + if !created { return } - // This format is consumed by the APIKey middleware. - generatedAPIKey := fmt.Sprintf("%s-%s", keyID, keySecret) - - httpapi.Write(rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: generatedAPIKey}) + httpapi.Write(rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: sessionToken}) } // Clear the user's session cookie @@ -940,12 +826,129 @@ func generateAPIKeyIDSecret() (id string, secret string, err error) { return id, secret, nil } +func (api *api) createAPIKey(rw http.ResponseWriter, r *http.Request, params database.InsertAPIKeyParams) (string, bool) { + keyID, keySecret, err := generateAPIKeyIDSecret() + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("generate api key parts: %s", err.Error()), + }) + return "", false + } + hashed := sha256.Sum256([]byte(keySecret)) + + _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: keyID, + UserID: params.UserID, + ExpiresAt: database.Now().Add(24 * time.Hour), + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + HashedSecret: hashed[:], + LoginType: params.LoginType, + OAuthAccessToken: params.OAuthAccessToken, + OAuthRefreshToken: params.OAuthRefreshToken, + OAuthIDToken: params.OAuthIDToken, + OAuthExpiry: params.OAuthExpiry, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("insert api key: %s", err.Error()), + }) + return "", false + } + + // This format is consumed by the APIKey middleware. + sessionToken := fmt.Sprintf("%s-%s", keyID, keySecret) + http.SetCookie(rw, &http.Cookie{ + Name: httpmw.AuthCookie, + Value: sessionToken, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: api.SecureAuthCookie, + }) + return sessionToken, true +} + +func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest) (database.User, uuid.UUID, error) { + var user database.User + return user, req.OrganizationID, api.Database.InTx(func(db database.Store) error { + // If no organization is provided, create a new one for the user. + if req.OrganizationID == uuid.Nil { + organization, err := db.InsertOrganization(ctx, database.InsertOrganizationParams{ + ID: uuid.New(), + Name: req.Username, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + if err != nil { + return xerrors.Errorf("create organization: %w", err) + } + req.OrganizationID = organization.ID + } + + params := database.InsertUserParams{ + ID: uuid.New(), + Email: req.Email, + Username: req.Username, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + } + // If a user signs up with OAuth, they can have no password! + if req.Password != "" { + hashedPassword, err := userpassword.Hash(req.Password) + if err != nil { + return xerrors.Errorf("hash password: %w", err) + } + params.HashedPassword = []byte(hashedPassword) + } + + var err error + user, err = db.InsertUser(ctx, params) + if err != nil { + return xerrors.Errorf("create user: %w", err) + } + + privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) + if err != nil { + return xerrors.Errorf("generate user gitsshkey: %w", err) + } + _, err = db.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{ + UserID: user.ID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + PrivateKey: privateKey, + PublicKey: publicKey, + }) + if err != nil { + return xerrors.Errorf("insert user gitsshkey: %w", err) + } + _, err = db.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ + OrganizationID: req.OrganizationID, + UserID: user.ID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + Roles: []string{}, + }) + if err != nil { + return xerrors.Errorf("create organization member: %w", err) + } + return nil + }) +} + func convertUser(user database.User) codersdk.User { return codersdk.User{ ID: user.ID, Email: user.Email, CreatedAt: user.CreatedAt, Username: user.Username, - Name: user.Name, } } + +func convertUsers(users []database.User) []codersdk.User { + converted := make([]codersdk.User, 0, len(users)) + for _, u := range users { + converted = append(converted, convertUser(u)) + } + return converted +} diff --git a/coderd/users_test.go b/coderd/users_test.go index 35bfea01e9..845179bb75 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -2,7 +2,9 @@ package coderd_test import ( "context" + "fmt" "net/http" + "sort" "testing" "github.com/google/uuid" @@ -240,13 +242,14 @@ func TestUpdateUserProfile(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - existentUser, _ := client.CreateUser(context.Background(), codersdk.CreateUserRequest{ + existentUser, err := client.CreateUser(context.Background(), codersdk.CreateUserRequest{ Email: "bruno@coder.com", Username: "bruno", Password: "password", OrganizationID: user.OrganizationID, }) - _, err := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ + require.NoError(t, err) + _, err = client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ Username: existentUser.Username, Email: "newemail@coder.com", }) @@ -281,28 +284,6 @@ func TestUpdateUserProfile(t *testing.T) { require.Equal(t, userProfile.Username, me.Username) require.Equal(t, userProfile.Email, "newemail@coder.com") }) - - t.Run("KeepUserName", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - coderdtest.CreateFirstUser(t, client) - me, _ := client.User(context.Background(), codersdk.Me) - newName := "New Name" - firstProfile, _ := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ - Username: me.Username, - Email: me.Email, - Name: &newName, - }) - t.Log(firstProfile) - userProfile, err := client.UpdateUserProfile(context.Background(), codersdk.Me, codersdk.UpdateUserProfileRequest{ - Username: "newusername", - Email: "newemail@coder.com", - }) - require.NoError(t, err) - require.Equal(t, userProfile.Username, "newusername") - require.Equal(t, userProfile.Email, "newemail@coder.com") - require.Equal(t, userProfile.Name, newName) - }) } func TestUserByName(t *testing.T) { @@ -318,12 +299,13 @@ func TestGetUsers(t *testing.T) { client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) client.CreateUser(context.Background(), codersdk.CreateUserRequest{ - Email: "bruno@coder.com", - Username: "bruno", + Email: "alice@email.com", + Username: "alice", Password: "password", OrganizationID: user.OrganizationID, }) - users, err := client.GetUsers(context.Background()) + // No params is all users + users, err := client.Users(context.Background(), codersdk.UsersRequest{}) require.NoError(t, err) require.Len(t, users, 2) } @@ -546,3 +528,151 @@ func TestWorkspaceByUserAndName(t *testing.T) { require.NoError(t, err) }) } + +// TestPaginatedUsers creates a list of users, then tries to paginate through +// them using different page sizes. +func TestPaginatedUsers(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := coderdtest.New(t, &coderdtest.Options{APIRateLimit: -1}) + coderdtest.CreateFirstUser(t, client) + me, err := client.User(context.Background(), codersdk.Me) + require.NoError(t, err) + + allUsers := make([]codersdk.User, 0) + allUsers = append(allUsers, me) + specialUsers := make([]codersdk.User, 0) + + org, err := client.CreateOrganization(ctx, me.ID, codersdk.CreateOrganizationRequest{ + Name: "default", + }) + require.NoError(t, err) + + // When 100 users exist + total := 100 + // Create users + for i := 0; i < total; i++ { + email := fmt.Sprintf("%d@coder.com", i) + username := fmt.Sprintf("user%d", i) + if i%2 == 0 { + email = fmt.Sprintf("%d@gmail.com", i) + username = fmt.Sprintf("specialuser%d", i) + } + // One side effect of having to use the api vs the db calls directly, is you cannot + // mock time. Ideally I could pass in mocked times and space these users out. + // + // But this also serves as a good test. Postgres has microsecond precision on its timestamps. + // If 2 users share the same created_at, that could cause an issue if you are strictly paginating via + // timestamps. The pagination goes by timestamps and uuids. + newUser, err := client.CreateUser(context.Background(), codersdk.CreateUserRequest{ + Email: email, + Username: username, + Password: "password", + OrganizationID: org.ID, + }) + require.NoError(t, err) + allUsers = append(allUsers, newUser) + if i%2 == 0 { + specialUsers = append(specialUsers, newUser) + } + } + + // Sorting the users will sort by (created_at, uuid). This is to handle + // the off case that created_at is identical for 2 users. + // This is a really rare case in production, but does happen in unit tests + // due to the fake database being in memory and exceptionally quick. + sortUsers(allUsers) + sortUsers(specialUsers) + + assertPagination(ctx, t, client, 10, allUsers, nil) + assertPagination(ctx, t, client, 5, allUsers, nil) + assertPagination(ctx, t, client, 3, allUsers, nil) + assertPagination(ctx, t, client, 1, allUsers, nil) + + // Try a search + gmailSearch := func(request codersdk.UsersRequest) codersdk.UsersRequest { + request.Search = "gmail" + return request + } + assertPagination(ctx, t, client, 3, specialUsers, gmailSearch) + assertPagination(ctx, t, client, 7, specialUsers, gmailSearch) + + usernameSearch := func(request codersdk.UsersRequest) codersdk.UsersRequest { + request.Search = "specialuser" + return request + } + assertPagination(ctx, t, client, 3, specialUsers, usernameSearch) + assertPagination(ctx, t, client, 1, specialUsers, usernameSearch) +} + +// Assert pagination will page through the list of all users using the given +// limit for each page. The 'allUsers' is the expected full list to compare +// against. +func assertPagination(ctx context.Context, t *testing.T, client *codersdk.Client, limit int, allUsers []codersdk.User, + opt func(request codersdk.UsersRequest) codersdk.UsersRequest) { + var count int + if opt == nil { + opt = func(request codersdk.UsersRequest) codersdk.UsersRequest { + return request + } + } + + // Check the first page + page, err := client.Users(ctx, opt(codersdk.UsersRequest{ + Limit: limit, + })) + require.NoError(t, err, "first page") + require.Equalf(t, page, allUsers[:limit], "first page, limit=%d", limit) + count += len(page) + + for { + if len(page) == 0 { + break + } + + afterCursor := page[len(page)-1].ID + // Assert each page is the next expected page + // This is using a cursor, and only works if all users created_at + // is unique. + page, err = client.Users(ctx, opt(codersdk.UsersRequest{ + Limit: limit, + AfterUser: afterCursor, + })) + require.NoError(t, err, "next cursor page") + + // Also check page by offset + offsetPage, err := client.Users(ctx, opt(codersdk.UsersRequest{ + Limit: limit, + Offset: count, + })) + require.NoError(t, err, "next offset page") + + var expected []codersdk.User + if count+limit > len(allUsers) { + expected = allUsers[count:] + } else { + expected = allUsers[count : count+limit] + } + require.Equalf(t, page, expected, "next users, after=%s, limit=%d", afterCursor, limit) + require.Equalf(t, offsetPage, expected, "offset users, offset=%d, limit=%d", count, limit) + + // Also check the before + prevPage, err := client.Users(ctx, opt(codersdk.UsersRequest{ + Offset: count - limit, + Limit: limit, + })) + require.NoError(t, err, "prev page") + require.Equal(t, allUsers[count-limit:count], prevPage, "prev users") + count += len(page) + } +} + +// sortUsers sorts by (created_at, id) +func sortUsers(users []codersdk.User) { + sort.Slice(users, func(i, j int) bool { + if users[i].CreatedAt.Equal(users[j].CreatedAt) { + return users[i].ID.String() < users[j].ID.String() + } + return users[i].CreatedAt.Before(users[j].CreatedAt) + }) +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0ae2e05247..67402cc925 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -166,7 +166,7 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { priorHistory, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) if err == nil { priorJob, err := api.Database.GetProvisionerJobByID(r.Context(), priorHistory.JobID) - if err == nil && !priorJob.CompletedAt.Valid { + if err == nil && convertProvisionerJob(priorJob).Status.Active() { httpapi.Write(rw, http.StatusConflict, httpapi.Response{ Message: "a workspace build is already active", }) diff --git a/codersdk/gitsshkey.go b/codersdk/gitsshkey.go index bae8a4c343..3cc4333e73 100644 --- a/codersdk/gitsshkey.go +++ b/codersdk/gitsshkey.go @@ -19,6 +19,7 @@ type GitSSHKey struct { } type AgentGitSSHKey struct { + PublicKey string `json:"public_key"` PrivateKey string `json:"private_key"` } diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index de59ee9e99..c726f8f255 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -25,6 +25,15 @@ type ProvisionerDaemon database.ProvisionerDaemon // ProvisionerJobStaus represents the at-time state of a job. type ProvisionerJobStatus string +// Active returns whether the job is still active or not. +// It returns true if canceling as well, since the job isn't +// in an entirely inactive state yet. +func (p ProvisionerJobStatus) Active() bool { + return p == ProvisionerJobPending || + p == ProvisionerJobRunning || + p == ProvisionerJobCanceling +} + const ( ProvisionerJobPending ProvisionerJobStatus = "pending" ProvisionerJobRunning ProvisionerJobStatus = "running" @@ -70,8 +79,8 @@ func (c *Client) ListenProvisionerDaemon(ctx context.Context) (proto.DRPCProvisi } return nil, readBodyAsError(res) } - // Allow _somewhat_ large payloads. - conn.SetReadLimit((1 << 20) * 2) + // Align with the frame size of yamux. + conn.SetReadLimit(256 * 1024) config := yamux.DefaultConfig() config.LogOutput = io.Discard diff --git a/codersdk/users.go b/codersdk/users.go index a2939afd9b..da5f8f23fa 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "time" "github.com/google/uuid" @@ -13,13 +14,26 @@ import ( // Me is used as a replacement for your own ID. var Me = uuid.Nil +type UsersRequest struct { + AfterUser uuid.UUID `json:"after_user"` + Search string `json:"search"` + // Limit sets the maximum number of users to be returned + // in a single page. If the limit is <= 0, there is no limit + // and all users are returned. + Limit int `json:"limit"` + // Offset is used to indicate which page to return. An offset of 0 + // returns the first 'limit' number of users. + // To get the next page, use offset=*. + // Offset is 0 indexed, so the first record sits at offset 0. + Offset int `json:"offset"` +} + // User represents a user in Coder. type User struct { ID uuid.UUID `json:"id" validate:"required"` Email string `json:"email" validate:"required"` CreatedAt time.Time `json:"created_at" validate:"required"` Username string `json:"username" validate:"required"` - Name string `json:"name"` } type CreateFirstUserRequest struct { @@ -43,9 +57,8 @@ type CreateUserRequest struct { } type UpdateUserProfileRequest struct { - Email string `json:"email" validate:"required,email"` - Username string `json:"username" validate:"required,username"` - Name *string `json:"name"` + Email string `json:"email" validate:"required,email"` + Username string `json:"username" validate:"required,username"` } // LoginWithPasswordRequest enables callers to authenticate with email and password. @@ -77,6 +90,12 @@ type CreateWorkspaceRequest struct { ParameterValues []CreateParameterRequest `json:"parameter_values"` } +// AuthMethods contains whether authentication types are enabled or not. +type AuthMethods struct { + Password bool `json:"password"` + Github bool `json:"github"` +} + // HasFirstUser returns whether the first user has been created. func (c *Client) HasFirstUser(ctx context.Context) (bool, error) { res, err := c.request(ctx, http.MethodGet, "/api/v2/users/first", nil) @@ -136,19 +155,6 @@ func (c *Client) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req Up return user, json.NewDecoder(res.Body).Decode(&user) } -func (c *Client) GetUsers(ctx context.Context) ([]User, error) { - res, err := c.request(ctx, http.MethodGet, "/api/v2/users", nil) - if err != nil { - return []User{}, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return []User{}, readBodyAsError(res) - } - var users []User - return users, json.NewDecoder(res.Body).Decode(&users) -} - // CreateAPIKey generates an API key for the user ID provided. func (c *Client) CreateAPIKey(ctx context.Context, userID uuid.UUID) (*GenerateAPIKeyResponse, error) { res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", uuidOrMe(userID)), nil) @@ -210,6 +216,34 @@ func (c *Client) User(ctx context.Context, id uuid.UUID) (User, error) { return user, json.NewDecoder(res.Body).Decode(&user) } +// Users returns all users according to the request parameters. If no parameters are set, +// the default behavior is to return all users in a single page. +func (c *Client) Users(ctx context.Context, req UsersRequest) ([]User, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users"), nil, func(r *http.Request) { + q := r.URL.Query() + if req.AfterUser != uuid.Nil { + q.Set("after_user", req.AfterUser.String()) + } + if req.Limit > 0 { + q.Set("limit", strconv.Itoa(req.Limit)) + } + q.Set("offset", strconv.Itoa(req.Offset)) + q.Set("search", req.Search) + r.URL.RawQuery = q.Encode() + }) + if err != nil { + return []User{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return []User{}, readBodyAsError(res) + } + + var users []User + return users, json.NewDecoder(res.Body).Decode(&users) +} + // OrganizationsByUser returns all organizations the user is a member of. func (c *Client) OrganizationsByUser(ctx context.Context, userID uuid.UUID) ([]Organization, error) { res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations", uuidOrMe(userID)), nil) @@ -300,6 +334,22 @@ func (c *Client) WorkspaceByName(ctx context.Context, userID uuid.UUID, name str return workspace, json.NewDecoder(res.Body).Decode(&workspace) } +// AuthMethods returns types of authentication available to the user. +func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) { + res, err := c.request(ctx, http.MethodGet, "/api/v2/users/authmethods", nil) + if err != nil { + return AuthMethods{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return AuthMethods{}, readBodyAsError(res) + } + + var userAuth AuthMethods + return userAuth, json.NewDecoder(res.Body).Decode(&userAuth) +} + // uuidOrMe returns the provided uuid as a string if it's valid, ortherwise // `me`. func uuidOrMe(id uuid.UUID) string { diff --git a/develop.sh b/develop.sh index 2f82c347b8..10a17a8d56 100755 --- a/develop.sh +++ b/develop.sh @@ -14,6 +14,6 @@ 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 --skip-tunnel & + go run cmd/coder/main.go server --dev --skip-tunnel & wait ) diff --git a/docs/README.md b/docs/README.md index ed986f655f..e172ca1c14 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,14 +17,14 @@ Install [the latest release](https://github.com/coder/coder/releases). To tinker, start with dev-mode (all data is in-memory, and is destroyed on exit): ```bash -$ coder start --dev +$ coder server --dev ``` To run a production deployment with PostgreSQL: ```bash $ CODER_PG_CONNECTION_URL="postgres://@/?password=" \ - coder start + coder server ``` To run as a system service, install with `.deb` or `.rpm`: diff --git a/go.mod b/go.mod index 2e98572e65..ac46d958ed 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,9 @@ replace github.com/chzyer/readline => github.com/kylecarbs/readline v0.0.0-20220 // Required until https://github.com/briandowns/spinner/pull/136 is merged. replace github.com/briandowns/spinner => github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e +// Required until https://github.com/storj/drpc/pull/31 is merged. +replace storj.io/drpc => github.com/kylecarbs/drpc v0.0.31-0.20220424193521-8ebbaf48bdff + // opencensus-go leaks a goroutine by default. replace go.opencensus.io => github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b @@ -38,7 +41,7 @@ replace github.com/fatedier/kcp-go => github.com/coder/kcp-go v2.0.4-0.202204091 require ( cdr.dev/slog v1.4.1 - cloud.google.com/go/compute v1.6.0 + cloud.google.com/go/compute v1.6.1 github.com/AlecAivazis/survey/v2 v2.3.4 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/bgentry/speakeasy v0.1.0 @@ -49,7 +52,7 @@ require ( github.com/coder/retry v1.3.0 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/creack/pty v1.1.18 - github.com/fatedier/frp v0.36.2-0.20220414032436-21240ed96251 + github.com/fatedier/frp v0.42.0 github.com/fatedier/golib v0.1.1-0.20220321042308-c306138b83ac github.com/fatih/color v1.13.0 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa @@ -58,9 +61,10 @@ require ( github.com/go-chi/httprate v0.5.3 github.com/go-chi/render v1.0.1 github.com/go-playground/validator/v10 v10.10.1 - github.com/gohugoio/hugo v0.97.2 + github.com/gohugoio/hugo v0.97.3 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-migrate/migrate/v4 v4.15.1 + github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 github.com/google/uuid v1.3.0 github.com/hashicorp/go-version v1.4.0 github.com/hashicorp/hc-install v0.3.1 @@ -74,7 +78,7 @@ require ( github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/lib/pq v1.10.5 github.com/mattn/go-isatty v0.0.14 - github.com/mitchellh/mapstructure v1.4.3 + github.com/mitchellh/mapstructure v1.5.0 github.com/moby/moby v20.10.14+incompatible github.com/open-policy-agent/opa v0.39.0 github.com/ory/dockertest/v3 v3.8.1 @@ -82,7 +86,7 @@ require ( github.com/pion/logging v0.2.2 github.com/pion/transport v0.13.0 github.com/pion/turn/v2 v2.0.8 - github.com/pion/webrtc/v3 v3.1.29 + github.com/pion/webrtc/v3 v3.1.34 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/sftp v1.13.4 github.com/quasilyte/go-ruleguard/dsl v0.3.19 @@ -95,18 +99,18 @@ require ( go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 go.uber.org/atomic v1.9.0 go.uber.org/goleak v1.1.12 - golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd + golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 golang.org/x/exp v0.0.0-20220414153411-bcd21879b8fd golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 - golang.org/x/net v0.0.0-20220401154927-543a649e0bdd - golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a + golang.org/x/net v0.0.0-20220412020605-290c469a71a5 + golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886 + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 - google.golang.org/api v0.74.0 + golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f + google.golang.org/api v0.75.0 google.golang.org/protobuf v1.28.0 - gopkg.in/DataDog/dd-trace-go.v1 v1.37.1 + gopkg.in/DataDog/dd-trace-go.v1 v1.38.0 nhooyr.io/websocket v1.8.7 storj.io/drpc v0.0.30 ) @@ -157,6 +161,7 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-cmp v0.5.7 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -190,7 +195,7 @@ require ( github.com/pelletier/go-toml/v2 v2.0.0-beta.7.0.20220408132554-2377ac4bc04c // indirect github.com/philhofer/fwd v1.1.1 // indirect github.com/pion/dtls/v2 v2.1.3 // indirect - github.com/pion/ice/v2 v2.2.4 // indirect + github.com/pion/ice/v2 v2.2.6 // indirect github.com/pion/interceptor v0.1.10 // indirect github.com/pion/mdns v0.0.5 // indirect github.com/pion/randutil v0.1.0 // indirect @@ -201,7 +206,7 @@ require ( github.com/pion/srtp/v2 v2.0.5 // indirect github.com/pion/stun v0.3.5 // indirect github.com/pion/udp v0.1.1 // indirect - github.com/pires/go-proxyproto v0.5.0 // indirect + github.com/pires/go-proxyproto v0.6.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pquerna/cachecontrol v0.1.0 // indirect @@ -225,7 +230,7 @@ require ( golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9 // indirect + google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3 // indirect google.golang.org/grpc v1.45.0 // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect diff --git a/go.sum b/go.sum index 08b1de6caa..4b24954905 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,9 @@ cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM7 cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= -cloud.google.com/go/compute v1.6.0 h1:XdQIN5mdPTSBVwSIVDuY5e8ZzVAccsHvD3qTEz4zIps= cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= +cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc= +cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= @@ -141,7 +142,6 @@ github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8 github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= -github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= @@ -170,7 +170,6 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= -github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/crlf v0.0.0-20171020200849-670099aa064f/go.mod h1:k8feO4+kXDxro6ErPXBRTJ/ro2mf0SsFG8s7doP9kJE= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -187,7 +186,6 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5 github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= @@ -224,7 +222,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.7.2/go.mod h1:np7TMuJN github.com/aws/aws-sdk-go-v2/service/s3 v1.12.0/go.mod h1:6J++A5xpo7QDsIeSqPK4UHqMSyPOCopa+zKtqAMhqVQ= github.com/aws/aws-sdk-go-v2/service/s3 v1.16.1/go.mod h1:CQe/KvWV1AqRc65KqeJjrLzr5X2ijnFTTVzJW0VBRCI= github.com/aws/aws-sdk-go-v2/service/sqs v1.0.0/go.mod h1:w5BclCU8ptTbagzXS/fHBr+vAyXUjggg/72qDIURKMk= -github.com/aws/aws-sdk-go-v2/service/sso v1.0.0/go.mod h1:qNdDupP6xoM//zL1JmPl2XGbyPL5kKrlsoYVh8XZxzQ= github.com/aws/aws-sdk-go-v2/service/sso v1.3.2/go.mod h1:J21I6kF+d/6XHVk7kp/cx9YVD2TMD2TbLwtRGVcinXo= github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk= github.com/aws/aws-sdk-go-v2/service/sts v1.0.0/go.mod h1:5f+cELGATgill5Pu3/vK3Ebuigstc+qYEHW5MvGWZO4= @@ -258,7 +255,6 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0Bsq github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/bytecodealliance/wasmtime-go v0.35.0 h1:VZjaZ0XOY0qp9TQfh0CQj9zl/AbdeXePVTALy8V1sKs= github.com/bytecodealliance/wasmtime-go v0.35.0/go.mod h1:q320gUxqyI8yB+ZqRuaJOEnGkAnHh6WtJjMaT2CW4wI= -github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo= @@ -343,7 +339,6 @@ github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go. github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.1-0.20191213020239-082f7e3aed57/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.4.0-beta.2.0.20200729163537-40b22ef07410/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= @@ -360,7 +355,6 @@ github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= -github.com/containerd/continuity v0.0.0-20200709052629-daa8e1ccc0bc/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo= github.com/containerd/continuity v0.0.0-20200710164510-efbc4488d8fe/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo= github.com/containerd/continuity v0.0.0-20201208142359-180525291bb7/go.mod h1:kR3BEg7bDFaEddKm54WSmrol1fKWDU1nKYkgrcgZT7Y= github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ= @@ -483,7 +477,6 @@ github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.8.0+incompatible h1:l9EaZDICImO1ngI+uTifW+ZYvvz7fKISBAKpg+MbWbY= github.com/docker/distribution v2.8.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v1.4.2-0.20200319182547-c7ad2b866182/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v20.10.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v20.10.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v20.10.12+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= @@ -519,7 +512,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.8/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= @@ -528,11 +520,10 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/v5 v5.5.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/fatedier/beego v0.0.0-20171024143340-6c6a4f5bd5eb h1:wCrNShQidLmvVWn/0PikGmpdP0vtQmnvyRg3ZBEhczw= github.com/fatedier/beego v0.0.0-20171024143340-6c6a4f5bd5eb/go.mod h1:wx3gB6dbIfBRcucp94PI9Bt3I0F2c/MyNEWuhzpWiwk= -github.com/fatedier/frp v0.36.2-0.20220414032436-21240ed96251 h1:IeF8g33NNx6i2YKporO9FJ8R9FL7n2TXzlkdyiHx+dY= -github.com/fatedier/frp v0.36.2-0.20220414032436-21240ed96251/go.mod h1:3Mp3fWvK8kD3PU8Hy89fKV/655Q5eFSHr+uuUr/lbOg= +github.com/fatedier/frp v0.42.0 h1:IIPCKB5OgGetjIk7vv3MlR3iL8qS0d7uM3kjWs6eymU= +github.com/fatedier/frp v0.42.0/go.mod h1:NahedvXauelo3mcioq3gahG3BdhTfJ4Gia6rRQnE02A= github.com/fatedier/golib v0.1.1-0.20220321042308-c306138b83ac h1:td1FJwN/oz8+9GldeEm3YdBX0Husc0FSPywLesZxi4w= github.com/fatedier/golib v0.1.1-0.20220321042308-c306138b83ac/go.mod h1:fLV0TLwHqrnB/L3jbNl67Gn6PCLggDGHniX1wLrA2Qo= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -578,7 +569,6 @@ github.com/gliderlabs/ssh v0.3.3/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQK github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi v1.5.0/go.mod h1:REp24E+25iKvxgeTfHmdUoL5x15kBiDBlnIl5bCwe2k= -github.com/go-chi/chi/v4 v4.0.0-rc1/go.mod h1:Yfiy+5nynjDc7IMJiguACIro1KxlGW2dLUqcroaEUEY= github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= @@ -607,9 +597,7 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= -github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= github.com/go-ldap/ldap/v3 v3.1.3/go.mod h1:3rbOH3jRS2u6jg2rJnKAMLE/xQyCKIveG2Sa/Cohzb8= -github.com/go-ldap/ldap/v3 v3.1.10/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= @@ -713,8 +701,8 @@ github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/gohugoio/hugo v0.97.2 h1:KpmX9BvVXfuBdJqauSR2bcU1BWlyL4RYkaJVHjProoU= -github.com/gohugoio/hugo v0.97.2/go.mod h1:FRv84nbwdRigH4boW1lxmBvHt2+ZLYu8W9wNuVOBeAo= +github.com/gohugoio/hugo v0.97.3 h1:m8DRdCwdxTxAEGOSemHbapkjxrfo/l+d5pCAc8kz4Y4= +github.com/gohugoio/hugo v0.97.3/go.mod h1:eK1apZTr4Kt04LwYgVFg7h0dXIYJyESgPb4jL2SX8lk= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-migrate/migrate/v4 v4.15.1 h1:Sakl3Nm6+wQKq0Q62tpFMi5a503bgGhceo2icrgQ9vM= @@ -784,7 +772,11 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-github/v35 v35.2.0/go.mod h1:s0515YVTI+IMrDoy9Y4pHt9ShGpzHvHO8rZ7L7acgvs= +github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 h1:DdHws/YnnPrSywrjNYu2lEHqYHWp/LnEx56w59esd54= +github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405/go.mod h1:4RgUDSnsxP19d65zJWqvqJ/poJxBCvmna50eXmIvoR8= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -853,7 +845,6 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.15.2/go.mod h1:vO11I9oWA+KsxmfFQPhLnnIb1VDE24M+pdxZFiuZcA8= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= @@ -861,7 +852,6 @@ github.com/hashicorp/consul/api v1.0.0/go.mod h1:mbFwfRxOTDHZpT3iUsMAFcLNoVm6Xbe github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/internal v0.1.0/go.mod h1:zi9bMZYbiPHyAjgBWo7kCUcy5l2NrTdrkVupCc7Oo6c= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/consul/sdk v0.7.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= @@ -874,7 +864,6 @@ github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/S github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -886,17 +875,10 @@ github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= -github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-retryablehttp v0.6.6/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= -github.com/hashicorp/go-secure-stdlib/mlock v0.1.1/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I= -github.com/hashicorp/go-secure-stdlib/parseutil v0.1.1/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= -github.com/hashicorp/go-secure-stdlib/password v0.1.1/go.mod h1:9hH302QllNwu1o2TGYtSk8I8kTAN0ca1EHpwhm5Mmzo= -github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= -github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.1/go.mod h1:l8slYwnJA26yBz+ErHpp2IRCLr0vuOMGBORIz4rRiAs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= @@ -904,7 +886,6 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.4.0 h1:aAQzgqIrRKRa7w75CKpbBxYsmUoPjzVm1W59ca1L0J4= github.com/hashicorp/go-version v1.4.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -922,7 +903,6 @@ github.com/hashicorp/hcl/v2 v2.11.1 h1:yTyWcXcm9XB0TEkyU/JCRU6rYy4K+mgLtzn2wlrJb github.com/hashicorp/hcl/v2 v2.11.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/memberlist v0.1.6/go.mod h1:5VDNHjqFMgEcclnwmkCnC99IPwxBmIsxwY8qn+Nl0H4= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= @@ -932,7 +912,6 @@ github.com/hashicorp/terraform-json v0.13.0/go.mod h1:y5OdLBCT+rxbwnpxZs9kGL7R9E github.com/hashicorp/vault/api v1.1.0/go.mod h1:R3Umvhlxi2TN7Ex2hzOowyeNb+SfbVWI973N+ctaFMk= github.com/hashicorp/vault/sdk v0.1.14-0.20200519221838-e0cfd64bc267/go.mod h1:WX57W2PwkrOPQ6rVQk+dy5/htHIaB4aBM70EwKThu10= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= -github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hashicorp/yamux v0.0.0-20210707203944-259a57b3608c/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J+x6AzmKuVM/JWCQwkWm6GW/MUR6I= github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= @@ -1013,9 +992,7 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.3.1 h1:aOXiD9oqiuLH8btPQW6SfgtQN5zwhyfzZls8a6sPJ/I= github.com/jedib0t/go-pretty/v6 v6.3.1/go.mod h1:FMkOpgGD3EZ91cW8g/96RfxoV7bdeJyzXPYgz1L1ln0= -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= -github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= github.com/jinzhu/gorm v1.9.1/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= @@ -1082,7 +1059,6 @@ github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwc github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/cpuid/v2 v2.0.6 h1:dQ5ueTiftKxp0gyjKSx5+8BtPWkyQbd95m8Gys/RarI= github.com/klauspost/cpuid/v2 v2.0.6/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/crc32 v1.2.0/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= github.com/klauspost/reedsolomon v1.9.15 h1:g2erWKD2M6rgnPf89fCji6jNlhMKMdXcuNHMW1SYCIo= github.com/klauspost/reedsolomon v1.9.15/go.mod h1:eqPAcE7xar5CIzcdfwydOEdcmchAKAP/qs14y4GCBOk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -1103,6 +1079,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4= +github.com/kylecarbs/drpc v0.0.31-0.20220424193521-8ebbaf48bdff h1:7qg425aXdULnZWCCQNPOzHO7c+M6BpbTfOUJLrk5+3w= +github.com/kylecarbs/drpc v0.0.31-0.20220424193521-8ebbaf48bdff/go.mod h1:6rcOyR/QQkSTX/9L5ZGtlZaE2PtXTTZl8d+ulSeeYEg= 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/readline v0.0.0-20220211054233-0d62993714c8/go.mod h1:n/KX1BZoN1m9EwoXkn/xAV4fd3k8c++gGBsgLONaPOY= @@ -1195,7 +1173,6 @@ github.com/miekg/dns v1.1.45/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7Xn github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -1215,8 +1192,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= -github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= @@ -1356,9 +1333,8 @@ github.com/pion/datachannel v1.5.2 h1:piB93s8LGmbECrpO84DnkIVWasRMk3IimbcXkTQLE6 github.com/pion/datachannel v1.5.2/go.mod h1:FTGQWaHrdCwIJ1rw6xBIfZVkslikjShim5yr05XFuCQ= github.com/pion/dtls/v2 v2.1.3 h1:3UF7udADqous+M2R5Uo2q/YaP4EzUoWKdfX2oscCUio= github.com/pion/dtls/v2 v2.1.3/go.mod h1:o6+WvyLDAlXF7YiPB/RlskRoeK+/JtuaZa5emwQcWus= -github.com/pion/ice/v2 v2.2.3/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE= -github.com/pion/ice/v2 v2.2.4 h1:sTHT39ywr5uqzyEMT7thEhOWsNOcdkHSZBbgQohFuZU= -github.com/pion/ice/v2 v2.2.4/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE= +github.com/pion/ice/v2 v2.2.6 h1:R/vaLlI1J2gCx141L5PEwtuGAGcyS6e7E0hDeJFq5Ig= +github.com/pion/ice/v2 v2.2.6/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE= github.com/pion/interceptor v0.1.10 h1:DJ2GjMGm4XGIQgMJxuEpdaExdY/6RdngT7Uh4oVmquU= github.com/pion/interceptor v0.1.10/go.mod h1:Lh3JSl/cbJ2wP8I3ccrjh1K/deRGRn3UlSPuOTiHb6U= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= @@ -1391,10 +1367,10 @@ github.com/pion/turn/v2 v2.0.8 h1:KEstL92OUN3k5k8qxsXHpr7WWfrdp7iJZHx99ud8muw= github.com/pion/turn/v2 v2.0.8/go.mod h1:+y7xl719J8bAEVpSXBXvTxStjJv3hbz9YFflvkpcGPw= github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o= github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M= -github.com/pion/webrtc/v3 v3.1.29 h1:X/2LbFzBhU2h335azBGmdmrRZIChWTePrg4rwIw91ko= -github.com/pion/webrtc/v3 v3.1.29/go.mod h1:bcD6vrgcflr6lkf3E8VEqnQT7Uf7y1AxcdUWYGKER1w= -github.com/pires/go-proxyproto v0.5.0 h1:A4Jv4ZCaV3AFJeGh5mGwkz4iuWUYMlQ7IoO/GTuSuLo= -github.com/pires/go-proxyproto v0.5.0/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY= +github.com/pion/webrtc/v3 v3.1.34 h1:GUfv2zxWge77x1FhZ6Fge8KQd3bTzvX1CMN1/LmTazM= +github.com/pion/webrtc/v3 v3.1.34/go.mod h1:jClfnbJzt8wtmewGxhPzgE5wZ0U/gNB78XCtmc/uz3k= +github.com/pires/go-proxyproto v0.6.2 h1:KAZ7UteSOt6urjme6ZldyFm4wDe/z0ZUP0Yv0Dos0d8= +github.com/pires/go-proxyproto v0.6.2/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY= github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= @@ -1413,7 +1389,6 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= @@ -1517,14 +1492,12 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= -github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/smartystreets/gunit v1.1.3/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= github.com/snowflakedb/gosnowflake v1.6.3/go.mod h1:6hLajn6yxuJ4xUHZegMekpq9rnQbGJ7TMwXjgTmA6lg= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -1584,25 +1557,20 @@ github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 h1:89CEmDvlq/F7S github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161/go.mod h1:wM7WEvslTq+iOEAMDLSzhVuOt5BRZ05WirO+b09GHQU= github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b h1:fj5tQ8acgNUr6O8LEplsxDhUIe2573iLkJc+PqnzZTI= github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b/go.mod h1:5XA7W9S6mni3h5uvOC75dA3m9CCCaS83lltmc0ukdi4= -github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8= github.com/tidwall/btree v0.3.0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8= github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4= github.com/tidwall/buntdb v1.2.0/go.mod h1:XLza/dhlwzO6dc5o/KWor4kfZSt3BP8QV+77ZMKfI58= -github.com/tidwall/cities v0.1.0/go.mod h1:lV/HDp2gCcRcHJWqgt6Di54GiDrTZwh1aG2ZUPNbqa4= -github.com/tidwall/geoindex v1.4.4/go.mod h1:rvVVNEFfkJVWGUdEfU8QaoOg/9zFX0h9ofWzA60mz1I= github.com/tidwall/gjson v1.6.7/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/grect v0.1.0/go.mod h1:sa5O42oP6jWfTShL9ka6Sgmg3TgIK649veZe05B7+J8= github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= -github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8= github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ= -github.com/tidwall/rtree v1.3.1/go.mod h1:S+JSsqPTI8LfWA4xHBo5eXzie8WJLVFeppAutSegl6M= github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw= github.com/tinylib/msgp v1.1.2 h1:gWmO7n0Ys2RBEb7GPYB9Ujq8Mk5p2U08lRnmMcGy6BQ= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= @@ -1773,8 +1741,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd h1:XcWmESyNjXJMLahc3mqVQJcgSTDxFxhETVlfk9uGc38= -golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA= +golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1831,7 +1799,6 @@ golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdx golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1842,7 +1809,6 @@ golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108155000-395948e2f546/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190225153610-fe579d43d832/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1859,7 +1825,6 @@ golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1910,8 +1875,9 @@ golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220401154927-543a649e0bdd h1:zYlwaUHTmxuf6H7hwO2dgwqozQmH7zf4x+/qql4oVWc= golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1931,8 +1897,9 @@ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM= golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE= +golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -2080,8 +2047,9 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886 h1:eJv7u3ksNXoLbGSKuv2s/SIO4tJVxc/A+MTpzxDgz/Q= golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -2194,8 +2162,9 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= @@ -2238,8 +2207,9 @@ google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tD google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= -google.golang.org/api v0.74.0 h1:ExR2D+5TYIrMphWgs5JCgwRhEDlPDXXrLwHHMgPHTXE= google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= +google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg= +google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= google.golang.org/appengine v1.0.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -2251,7 +2221,6 @@ google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk= -google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -2332,10 +2301,11 @@ google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2 google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9 h1:XGQ6tc+EnM35IAazg4y6AHmUg4oK8NXsXaILte1vRlk= google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3 h1:SeX3QUcBj3fciwnfPT9kt5gBhFy/FCZtYZ+I/RB8agc= +google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= @@ -2390,11 +2360,10 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/DataDog/dd-trace-go.v1 v1.37.1 h1:rgO9oC3Mr7es0zKIsaooL50UY0qboUDdueP7MzH1fUI= -gopkg.in/DataDog/dd-trace-go.v1 v1.37.1/go.mod h1:HMpV5TQ38YAfRJ8OlM7UfLyGg6D01MPNHRFwsnrGMB0= +gopkg.in/DataDog/dd-trace-go.v1 v1.38.0 h1:vm/mYIZCEp5j2MoKPmwM3t6EGthxpvVbCOm2hRl5uDc= +gopkg.in/DataDog/dd-trace-go.v1 v1.38.0/go.mod h1:GBhK4yaMJ1h329ivtKAqRNe1EZ944UnZwtz5lh7CnJc= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -2540,5 +2509,3 @@ sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK sigs.k8s.io/structured-merge-diff/v4 v4.1.0/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= -storj.io/drpc v0.0.30 h1:jqPe4T9KEu3CDBI05A2hCMgMSHLtd/E0N0yTF9QreIE= -storj.io/drpc v0.0.30/go.mod h1:6rcOyR/QQkSTX/9L5ZGtlZaE2PtXTTZl8d+ulSeeYEg= diff --git a/peerbroker/proto/peerbroker.pb.go b/peerbroker/proto/peerbroker.pb.go index b1a880bf8c..931d1c799b 100644 --- a/peerbroker/proto/peerbroker.pb.go +++ b/peerbroker/proto/peerbroker.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.19.4 +// protoc v3.20.0 // source: peerbroker/proto/peerbroker.proto package proto diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index d7d835df22..b9cfade26b 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.19.4 +// protoc v3.20.0 // source: provisionerd/proto/provisionerd.proto package proto diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index f368931224..2fc0225734 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -68,8 +68,8 @@ func New(clientDialer Dialer, opts *Options) *Server { clientDialer: clientDialer, opts: opts, - closeCancel: ctxCancel, - closed: make(chan struct{}), + closeContext: ctx, + closeCancel: ctxCancel, shutdown: make(chan struct{}), @@ -87,13 +87,13 @@ type Server struct { opts *Options clientDialer Dialer - client proto.DRPCProvisionerDaemonClient + clientValue atomic.Value // Locked when closing the daemon. - closeMutex sync.Mutex - closeCancel context.CancelFunc - closed chan struct{} - closeError error + closeMutex sync.Mutex + closeContext context.Context + closeCancel context.CancelFunc + closeError error shutdownMutex sync.Mutex shutdown chan struct{} @@ -108,11 +108,10 @@ type Server struct { // Connect establishes a connection to coderd. func (p *Server) connect(ctx context.Context) { - var err error // An exponential back-off occurs when the connection is failing to dial. // This is to prevent server spam in case of a coderd outage. for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); { - p.client, err = p.clientDialer(ctx) + client, err := p.clientDialer(ctx) if err != nil { if errors.Is(err, context.Canceled) { return @@ -126,6 +125,7 @@ func (p *Server) connect(ctx context.Context) { p.closeMutex.Unlock() continue } + p.clientValue.Store(client) p.opts.Logger.Debug(context.Background(), "connected") break } @@ -139,10 +139,14 @@ func (p *Server) connect(ctx context.Context) { if p.isClosed() { return } - select { - case <-p.closed: + client, ok := p.client() + if !ok { return - case <-p.client.DRPCConn().Closed(): + } + select { + case <-p.closeContext.Done(): + return + case <-client.DRPCConn().Closed(): // We use the update stream to detect when the connection // has been interrupted. This works well, because logs need // to buffer if a job is running in the background. @@ -158,10 +162,14 @@ func (p *Server) connect(ctx context.Context) { ticker := time.NewTicker(p.opts.PollInterval) defer ticker.Stop() for { - select { - case <-p.closed: + client, ok := p.client() + if !ok { return - case <-p.client.DRPCConn().Closed(): + } + select { + case <-p.closeContext.Done(): + return + case <-client.DRPCConn().Closed(): return case <-ticker.C: p.acquireJob(ctx) @@ -170,6 +178,15 @@ func (p *Server) connect(ctx context.Context) { }() } +func (p *Server) client() (proto.DRPCProvisionerDaemonClient, bool) { + rawClient := p.clientValue.Load() + if rawClient == nil { + return nil, false + } + client, ok := rawClient.(proto.DRPCProvisionerDaemonClient) + return client, ok +} + func (p *Server) isRunningJob() bool { select { case <-p.jobRunning: @@ -195,7 +212,11 @@ func (p *Server) acquireJob(ctx context.Context) { return } var err error - job, err := p.client.AcquireJob(ctx, &proto.Empty{}) + client, ok := p.client() + if !ok { + return + } + job, err := client.AcquireJob(ctx, &proto.Empty{}) if err != nil { if errors.Is(err, context.Canceled) { return @@ -226,24 +247,36 @@ func (p *Server) acquireJob(ctx context.Context) { func (p *Server) runJob(ctx context.Context, job *proto.AcquiredJob) { shutdown, shutdownCancel := context.WithCancel(ctx) defer shutdownCancel() + + complete, completeCancel := context.WithCancel(ctx) + defer completeCancel() go func() { ticker := time.NewTicker(p.opts.UpdateInterval) defer ticker.Stop() for { select { - case <-p.closed: + case <-p.closeContext.Done(): return case <-ctx.Done(): return + case <-complete.Done(): + return case <-p.shutdown: p.opts.Logger.Info(ctx, "attempting graceful cancelation") shutdownCancel() return case <-ticker.C: } - resp, err := p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + client, ok := p.client() + if !ok { + continue + } + resp, err := client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.JobId, }) + if errors.Is(err, yamux.ErrSessionShutdown) || errors.Is(err, io.EOF) { + continue + } if err != nil { p.failActiveJobf("send periodic update: %s", err) return @@ -297,7 +330,12 @@ func (p *Server) runJob(ctx context.Context, job *proto.AcquiredJob) { return } - _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + client, ok := p.client() + if !ok { + p.failActiveJobf("client disconnected") + return + } + _, err = client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.GetJobId(), Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, @@ -387,10 +425,14 @@ func (p *Server) runJob(ctx context.Context, job *proto.AcquiredJob) { return } + client, ok = p.client() + if !ok { + return + } // Ensure the job is still running to output. // It's possible the job has failed. if p.isRunningJob() { - _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + _, err = client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.GetJobId(), Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, @@ -409,7 +451,12 @@ func (p *Server) runJob(ctx context.Context, job *proto.AcquiredJob) { } func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sdkproto.DRPCProvisionerClient, job *proto.AcquiredJob) { - _, err := p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + client, ok := p.client() + if !ok { + p.failActiveJobf("client disconnected") + return + } + _, err := client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.GetJobId(), Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, @@ -429,7 +476,7 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd return } - updateResponse, err := p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + updateResponse, err := client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.JobId, ParameterSchemas: parameterSchemas, }) @@ -450,7 +497,7 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd } } - _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + _, err = client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.GetJobId(), Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, @@ -471,7 +518,7 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd p.failActiveJobf("template import provision for start: %s", err) return } - _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + _, err = client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.GetJobId(), Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, @@ -493,7 +540,7 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd return } - _, err = p.client.CompleteJob(ctx, &proto.CompletedJob{ + p.completeJob(&proto.CompletedJob{ JobId: job.JobId, Type: &proto.CompletedJob_TemplateImport_{ TemplateImport: &proto.CompletedJob_TemplateImport{ @@ -502,14 +549,14 @@ func (p *Server) runTemplateImport(ctx, shutdown context.Context, provisioner sd }, }, }) - if err != nil { - p.failActiveJobf("complete job: %s", err) - return - } } // Parses parameter schemas from source. func (p *Server) runTemplateImportParse(ctx context.Context, provisioner sdkproto.DRPCProvisionerClient, job *proto.AcquiredJob) ([]*sdkproto.ParameterSchema, error) { + client, ok := p.client() + if !ok { + return nil, xerrors.New("client disconnected") + } stream, err := provisioner.Parse(ctx, &sdkproto.Parse_Request{ Directory: p.opts.WorkDirectory, }) @@ -529,7 +576,7 @@ func (p *Server) runTemplateImportParse(ctx context.Context, provisioner sdkprot slog.F("output", msgType.Log.Output), ) - _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + _, err = client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.JobId, Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER, @@ -599,8 +646,11 @@ func (p *Server) runTemplateImportProvision(ctx, shutdown context.Context, provi slog.F("level", msgType.Log.Level), slog.F("output", msgType.Log.Output), ) - - _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + client, ok := p.client() + if !ok { + continue + } + _, err = client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.JobId, Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER, @@ -638,7 +688,12 @@ func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sd stage = "Destroying workspace" } - _, err := p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + client, ok := p.client() + if !ok { + p.failActiveJobf("client disconnected") + return + } + _, err := client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.GetJobId(), Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, @@ -699,7 +754,7 @@ func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sd slog.F("workspace_build_id", job.GetWorkspaceBuild().WorkspaceBuildId), ) - _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + _, err = client.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.JobId, Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER, @@ -729,15 +784,7 @@ func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sd return } - p.opts.Logger.Info(context.Background(), "provision successful; marking job as complete", - slog.F("resource_count", len(msgType.Complete.Resources)), - slog.F("resources", msgType.Complete.Resources), - slog.F("state_length", len(msgType.Complete.State)), - ) - - // Complete job may need to be async if we disconnected... - // When we reconnect we can flush any of these cached values. - _, err = p.client.CompleteJob(ctx, &proto.CompletedJob{ + p.completeJob(&proto.CompletedJob{ JobId: job.JobId, Type: &proto.CompletedJob_WorkspaceBuild_{ WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ @@ -746,11 +793,12 @@ func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sd }, }, }) - if err != nil { - p.failActiveJobf("complete job: %s", err) - return - } - // Return so we stop looping! + p.opts.Logger.Info(context.Background(), "provision successful; marked job as complete", + slog.F("resource_count", len(msgType.Complete.Resources)), + slog.F("resources", msgType.Complete.Resources), + slog.F("state_length", len(msgType.Complete.State)), + ) + // Stop looping! return default: p.failActiveJobf("invalid message type %T received from provisioner", msg.Type) @@ -759,6 +807,27 @@ func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sd } } +func (p *Server) completeJob(job *proto.CompletedJob) { + for retrier := retry.New(25*time.Millisecond, 5*time.Second); retrier.Wait(p.closeContext); { + client, ok := p.client() + if !ok { + continue + } + // Complete job may need to be async if we disconnected... + // When we reconnect we can flush any of these cached values. + _, err := client.CompleteJob(p.closeContext, job) + if xerrors.Is(err, yamux.ErrSessionShutdown) || xerrors.Is(err, io.EOF) { + continue + } + if err != nil { + p.opts.Logger.Warn(p.closeContext, "failed to complete job", slog.Error(err)) + p.failActiveJobf(err.Error()) + return + } + break + } +} + func (p *Server) failActiveJobf(format string, args ...interface{}) { p.failActiveJob(&proto.FailedJob{ Error: fmt.Sprintf(format, args...), @@ -786,18 +855,31 @@ func (p *Server) failActiveJob(failedJob *proto.FailedJob) { slog.F("job_id", p.jobID), ) failedJob.JobId = p.jobID - _, err := p.client.FailJob(context.Background(), failedJob) - if err != nil { - p.opts.Logger.Warn(context.Background(), "failed to notify of error; job is no longer running", slog.Error(err)) + for retrier := retry.New(25*time.Millisecond, 5*time.Second); retrier.Wait(p.closeContext); { + client, ok := p.client() + if !ok { + continue + } + _, err := client.FailJob(p.closeContext, failedJob) + if xerrors.Is(err, yamux.ErrSessionShutdown) || xerrors.Is(err, io.EOF) { + continue + } + if err != nil { + if p.isClosed() { + return + } + p.opts.Logger.Warn(context.Background(), "failed to notify of error; job is no longer running", slog.Error(err)) + return + } + p.opts.Logger.Debug(context.Background(), "marked running job as failed") return } - p.opts.Logger.Debug(context.Background(), "marked running job as failed") } // isClosed returns whether the API is closed or not. func (p *Server) isClosed() bool { select { - case <-p.closed: + case <-p.closeContext.Done(): return true default: return false @@ -847,7 +929,6 @@ func (p *Server) closeWithError(err error) error { return p.closeError } p.closeError = err - close(p.closed) errMsg := "provisioner daemon was shutdown gracefully" if err != nil { diff --git a/provisionerd/provisionerd_test.go b/provisionerd/provisionerd_test.go index c56c3a6378..776e3730a4 100644 --- a/provisionerd/provisionerd_test.go +++ b/provisionerd/provisionerd_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/hashicorp/yamux" "github.com/stretchr/testify/require" "go.uber.org/atomic" "go.uber.org/goleak" @@ -126,6 +127,7 @@ func TestProvisionerd(t *testing.T) { // Ensures tars with "../../../etc/passwd" as the path // are not allowed to run, and will fail the job. t.Parallel() + var complete sync.Once completeChan := make(chan struct{}) closer := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { return createProvisionerDaemonClient(t, provisionerDaemonTestServer{ @@ -145,7 +147,9 @@ func TestProvisionerd(t *testing.T) { }, updateJob: noopUpdateJob, failJob: func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error) { - close(completeChan) + complete.Do(func() { + close(completeChan) + }) return &proto.Empty{}, nil }, }), nil @@ -158,6 +162,7 @@ func TestProvisionerd(t *testing.T) { t.Run("RunningPeriodicUpdate", func(t *testing.T) { t.Parallel() + var complete sync.Once completeChan := make(chan struct{}) closer := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { return createProvisionerDaemonClient(t, provisionerDaemonTestServer{ @@ -176,11 +181,9 @@ func TestProvisionerd(t *testing.T) { }, nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { - select { - case <-completeChan: - default: + complete.Do(func() { close(completeChan) - } + }) return &proto.UpdateJobResponse{}, nil }, failJob: func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error) { @@ -207,12 +210,15 @@ func TestProvisionerd(t *testing.T) { didAcquireJob atomic.Bool didDryRun atomic.Bool ) + var complete sync.Once completeChan := make(chan struct{}) closer := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { return createProvisionerDaemonClient(t, provisionerDaemonTestServer{ acquireJob: func(ctx context.Context, _ *proto.Empty) (*proto.AcquiredJob, error) { if didAcquireJob.Load() { - close(completeChan) + complete.Do(func() { + close(completeChan) + }) return &proto.AcquiredJob{}, nil } didAcquireJob.Store(true) @@ -307,12 +313,15 @@ func TestProvisionerd(t *testing.T) { didLog atomic.Bool didAcquireJob atomic.Bool ) + var complete sync.Once completeChan := make(chan struct{}) closer := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { return createProvisionerDaemonClient(t, provisionerDaemonTestServer{ acquireJob: func(ctx context.Context, _ *proto.Empty) (*proto.AcquiredJob, error) { if didAcquireJob.Load() { - close(completeChan) + complete.Do(func() { + close(completeChan) + }) return &proto.AcquiredJob{}, nil } didAcquireJob.Store(true) @@ -492,6 +501,7 @@ func TestProvisionerd(t *testing.T) { t.Run("ShutdownFromJob", func(t *testing.T) { t.Parallel() + var updated sync.Once updateChan := make(chan struct{}) completeChan := make(chan struct{}) server := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { @@ -513,7 +523,9 @@ func TestProvisionerd(t *testing.T) { updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { if len(update.Logs) > 0 && update.Logs[0].Source == proto.LogSource_PROVISIONER { // Close on a log so we know when the job is in progress! - close(updateChan) + updated.Do(func() { + close(updateChan) + }) } return &proto.UpdateJobResponse{ Canceled: true, @@ -558,6 +570,139 @@ func TestProvisionerd(t *testing.T) { <-completeChan require.NoError(t, server.Close()) }) + + t.Run("ReconnectAndFail", func(t *testing.T) { + t.Parallel() + var second atomic.Bool + failChan := make(chan struct{}) + failedChan := make(chan struct{}) + completeChan := make(chan struct{}) + server := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { + client := createProvisionerDaemonClient(t, provisionerDaemonTestServer{ + acquireJob: func(ctx context.Context, _ *proto.Empty) (*proto.AcquiredJob, error) { + if second.Load() { + return &proto.AcquiredJob{}, nil + } + return &proto.AcquiredJob{ + JobId: "test", + Provisioner: "someprovisioner", + TemplateSourceArchive: createTar(t, map[string]string{ + "test.txt": "content", + }), + Type: &proto.AcquiredJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ + Metadata: &sdkproto.Provision_Metadata{}, + }, + }, + }, nil + }, + updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { + return &proto.UpdateJobResponse{}, nil + }, + failJob: func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error) { + if second.Load() { + close(completeChan) + return &proto.Empty{}, nil + } + close(failChan) + <-failedChan + return &proto.Empty{}, nil + }, + }) + if !second.Load() { + go func() { + <-failChan + _ = client.DRPCConn().Close() + second.Store(true) + close(failedChan) + }() + } + return client, nil + }, provisionerd.Provisioners{ + "someprovisioner": createProvisionerClient(t, provisionerTestServer{ + provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { + // Ignore the first provision message! + _, _ = stream.Recv() + return stream.Send(&sdkproto.Provision_Response{ + Type: &sdkproto.Provision_Response_Complete{ + Complete: &sdkproto.Provision_Complete{ + Error: "some error", + }, + }, + }) + }, + }), + }) + <-completeChan + require.NoError(t, server.Close()) + }) + + t.Run("ReconnectAndComplete", func(t *testing.T) { + t.Parallel() + var second atomic.Bool + failChan := make(chan struct{}) + failedChan := make(chan struct{}) + completeChan := make(chan struct{}) + server := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { + client := createProvisionerDaemonClient(t, provisionerDaemonTestServer{ + acquireJob: func(ctx context.Context, _ *proto.Empty) (*proto.AcquiredJob, error) { + if second.Load() { + close(completeChan) + return &proto.AcquiredJob{}, nil + } + return &proto.AcquiredJob{ + JobId: "test", + Provisioner: "someprovisioner", + TemplateSourceArchive: createTar(t, map[string]string{ + "test.txt": "content", + }), + Type: &proto.AcquiredJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ + Metadata: &sdkproto.Provision_Metadata{}, + }, + }, + }, nil + }, + failJob: func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error) { + return nil, yamux.ErrSessionShutdown + }, + updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { + return &proto.UpdateJobResponse{}, nil + }, + completeJob: func(ctx context.Context, job *proto.CompletedJob) (*proto.Empty, error) { + if second.Load() { + return &proto.Empty{}, nil + } + close(failChan) + <-failedChan + return &proto.Empty{}, nil + }, + }) + if !second.Load() { + go func() { + <-failChan + _ = client.DRPCConn().Close() + second.Store(true) + close(failedChan) + }() + } + return client, nil + }, provisionerd.Provisioners{ + "someprovisioner": createProvisionerClient(t, provisionerTestServer{ + provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { + // Ignore the first provision message! + _, _ = stream.Recv() + return stream.Send(&sdkproto.Provision_Response{ + Type: &sdkproto.Provision_Response_Complete{ + Complete: &sdkproto.Provision_Complete{}, + }, + }) + }, + }), + }) + <-completeChan + require.NoError(t, server.Close()) + }) } // Creates an in-memory tar of the files provided. diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index 72d37a0083..c2c7534f13 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.19.4 +// protoc v3.20.0 // source: provisionersdk/proto/provisioner.proto package proto diff --git a/provisionersdk/transport.go b/provisionersdk/transport.go index 3933aeb5ef..8e1a0069cf 100644 --- a/provisionersdk/transport.go +++ b/provisionersdk/transport.go @@ -9,6 +9,12 @@ import ( "storj.io/drpc/drpcconn" ) +const ( + // MaxMessageSize is the maximum payload size that can be + // transported without error. + MaxMessageSize = 4 << 20 +) + // TransportPipe creates an in-memory pipe for dRPC transport. func TransportPipe() (*yamux.Session, *yamux.Session) { clientReader, clientWriter := io.Pipe() diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index fdba8eb4e1..a738e553e0 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -183,7 +183,7 @@ func toTsType(fieldType string) string { switch fieldType { case "bool": return "boolean" - case "uint64", "uint32", "float64": + case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "float32", "float64": return "number" } diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index dd5af571d5..b1d36ee5c6 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 --skip-tunnel`, + command: `go run -tags embed ${path.join(__dirname, "../../cmd/coder/main.go")} server --dev --skip-tunnel`, port: 3000, timeout: 120 * 10000, reuseExistingServer: false, diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 4e2c4c7df9..318bcf9f84 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -2,7 +2,7 @@ import axios, { AxiosRequestHeaders } from "axios" import { mutate } from "swr" import { MockPager, MockUser, MockUser2 } from "../testHelpers/entities" import * as Types from "./types" -import * as GenTypes from "./typesGenerated" +import * as TypesGen from "./typesGenerated" const CONTENT_TYPE_JSON: AxiosRequestHeaders = { "Content-Type": "application/json", @@ -66,6 +66,11 @@ export const getUser = async (): Promise => { return response.data } +export const getAuthMethods = async (): Promise => { + const response = await axios.get("/api/v2/users/authmethods") + return response.data +} + export const getApiKey = async (): Promise => { const response = await axios.post("/api/v2/users/me/keys") return response.data @@ -80,8 +85,8 @@ export const getUsers = async (): Promise => { }) } -export const createUser = async (user: GenTypes.CreateUserRequest): Promise => { - const response = await axios.post("/api/v2/users/", user) +export const createUser = async (user: TypesGen.CreateUserRequest): Promise => { + const response = await axios.post("/api/v2/users/", user) return response.data } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3a0eba1b4b..230e600eee 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -16,6 +16,7 @@ export interface GitSSHKey { // From codersdk/gitsshkey.go:21:6. export interface AgentGitSSHKey { + readonly public_key: string readonly private_key: string } @@ -46,13 +47,13 @@ export interface CreateParameterRequest { readonly source_value: string } -// From codersdk/provisionerdaemons.go:37:6. +// From codersdk/provisionerdaemons.go:46:6. export interface ProvisionerJob { readonly error: string readonly status: ProvisionerJobStatus } -// From codersdk/provisionerdaemons.go:47:6. +// From codersdk/provisionerdaemons.go:56:6. export interface ProvisionerJobLog { readonly stage: string readonly output: string @@ -71,13 +72,19 @@ export interface TemplateVersion { } // From codersdk/users.go:17:6. +export interface UsersRequest { + readonly search: string + readonly limit: number + readonly offset: number +} + +// From codersdk/users.go:32:6. export interface User { readonly email: string readonly username: string - readonly name: string } -// From codersdk/users.go:25:6. +// From codersdk/users.go:39:6. export interface CreateFirstUserRequest { readonly email: string readonly username: string @@ -85,46 +92,51 @@ export interface CreateFirstUserRequest { readonly organization: string } -// From codersdk/users.go:38:6. +// From codersdk/users.go:52:6. export interface CreateUserRequest { readonly email: string readonly username: string readonly password: string } -// From codersdk/users.go:45:6. +// From codersdk/users.go:59:6. export interface UpdateUserProfileRequest { readonly email: string readonly username: string - readonly name?: string } -// From codersdk/users.go:52:6. +// From codersdk/users.go:65:6. export interface LoginWithPasswordRequest { readonly email: string readonly password: string } -// From codersdk/users.go:58:6. +// From codersdk/users.go:71:6. export interface LoginWithPasswordResponse { readonly session_token: string } -// From codersdk/users.go:63:6. +// From codersdk/users.go:76:6. export interface GenerateAPIKeyResponse { readonly key: string } -// From codersdk/users.go:67:6. +// From codersdk/users.go:80:6. export interface CreateOrganizationRequest { readonly name: string } -// From codersdk/users.go:72:6. +// From codersdk/users.go:85:6. export interface CreateWorkspaceRequest { readonly name: string } +// From codersdk/users.go:94:6. +export interface AuthMethods { + readonly password: boolean + readonly github: boolean +} + // From codersdk/workspaceagents.go:31:6. export interface GoogleInstanceIdentityToken { readonly json_web_token: string diff --git a/site/src/components/SignInForm/SignInForm.stories.tsx b/site/src/components/SignInForm/SignInForm.stories.tsx index bc6a80840c..90bdf0a39a 100644 --- a/site/src/components/SignInForm/SignInForm.stories.tsx +++ b/site/src/components/SignInForm/SignInForm.stories.tsx @@ -24,7 +24,26 @@ SignedOut.args = { } export const Loading = Template.bind({}) -Loading.args = { ...SignedOut.args, isLoading: true } +Loading.args = { + ...SignedOut.args, + isLoading: true, + authMethods: { + github: true, + password: true, + }, +} -export const WithError = Template.bind({}) -WithError.args = { ...SignedOut.args, authErrorMessage: "Email or password was invalid" } +export const WithLoginError = Template.bind({}) +WithLoginError.args = { ...SignedOut.args, authErrorMessage: "Email or password was invalid" } + +export const WithAuthMethodsError = Template.bind({}) +WithAuthMethodsError.args = { ...SignedOut.args, methodsErrorMessage: "Failed to fetch auth methods" } + +export const WithGithub = Template.bind({}) +WithGithub.args = { + ...SignedOut.args, + authMethods: { + password: true, + github: true, + }, +} diff --git a/site/src/components/SignInForm/SignInForm.tsx b/site/src/components/SignInForm/SignInForm.tsx index 75e9ba43de..f1c1165911 100644 --- a/site/src/components/SignInForm/SignInForm.tsx +++ b/site/src/components/SignInForm/SignInForm.tsx @@ -1,9 +1,12 @@ +import Button from "@material-ui/core/Button" import FormHelperText from "@material-ui/core/FormHelperText" +import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import { FormikContextType, useFormik } from "formik" import React from "react" import * as Yup from "yup" +import { AuthMethods } from "../../api/typesGenerated" import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" import { Welcome } from "../Welcome/Welcome" import { LoadingButton } from "./../LoadingButton/LoadingButton" @@ -24,7 +27,9 @@ export const Language = { emailInvalid: "Please enter a valid email address.", emailRequired: "Please enter an email address.", authErrorMessage: "Incorrect email or password.", - signIn: "Sign In", + methodsErrorMessage: "Unable to fetch auth methods.", + passwordSignIn: "Sign In", + githubSignIn: "GitHub", } const validationSchema = Yup.object({ @@ -49,10 +54,18 @@ const useStyles = makeStyles((theme) => ({ export interface SignInFormProps { isLoading: boolean authErrorMessage?: string + methodsErrorMessage?: string + authMethods?: AuthMethods onSubmit: ({ email, password }: { email: string; password: string }) => Promise } -export const SignInForm: React.FC = ({ isLoading, authErrorMessage, onSubmit }) => { +export const SignInForm: React.FC = ({ + authMethods, + isLoading, + authErrorMessage, + methodsErrorMessage, + onSubmit, +}) => { const styles = useStyles() const form: FormikContextType = useFormik({ @@ -76,6 +89,7 @@ export const SignInForm: React.FC = ({ isLoading, authErrorMess className={styles.loginTextField} fullWidth label={Language.emailLabel} + type="email" variant="outlined" /> = ({ isLoading, authErrorMess variant="outlined" /> {authErrorMessage && {Language.authErrorMessage}} + {methodsErrorMessage && {Language.methodsErrorMessage}}
- {isLoading ? "" : Language.signIn} + {isLoading ? "" : Language.passwordSignIn}
+ {authMethods?.github && ( +
+ + + +
+ )} ) } diff --git a/site/src/pages/LoginPage/LoginPage.test.tsx b/site/src/pages/LoginPage/LoginPage.test.tsx index f9c4fb8ebe..1d5e8c2abf 100644 --- a/site/src/pages/LoginPage/LoginPage.test.tsx +++ b/site/src/pages/LoginPage/LoginPage.test.tsx @@ -23,7 +23,7 @@ describe("LoginPage", () => { render() // Then - await screen.findByText(Language.signIn) + await screen.findByText(Language.passwordSignIn) }) it("shows an error message if SignIn fails", async () => { @@ -42,7 +42,7 @@ describe("LoginPage", () => { await userEvent.type(email, "test@coder.com") await userEvent.type(password, "password") // Click sign-in - const signInButton = await screen.findByText(Language.signIn) + const signInButton = await screen.findByText(Language.passwordSignIn) act(() => signInButton.click()) // Then @@ -50,4 +50,43 @@ describe("LoginPage", () => { expect(errorMessage).toBeDefined() expect(history.location.pathname).toEqual("/login") }) + + it("shows an error if fetching auth methods fails", async () => { + // Given + server.use( + // Make login fail + rest.get("/api/v2/users/authmethods", async (req, res, ctx) => { + return res(ctx.status(500), ctx.json({ message: "nope" })) + }), + ) + + // When + render() + + // Then + const errorMessage = await screen.findByText(Language.methodsErrorMessage) + expect(errorMessage).toBeDefined() + }) + + it("shows github authentication when enabled", async () => { + // Given + server.use( + rest.get("/api/v2/users/authmethods", async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + password: true, + github: true, + }), + ) + }), + ) + + // When + render() + + // Then + await screen.findByText(Language.passwordSignIn) + await screen.findByText(Language.githubSignIn) + }) }) diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 46b0ab0859..75f9f6d8a0 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -35,6 +35,9 @@ export const LoginPage: React.FC = () => { const isLoading = authState.hasTag("loading") const redirectTo = retrieveRedirect(location.search) const authErrorMessage = authState.context.authError ? (authState.context.authError as Error).message : undefined + const getMethodsError = authState.context.getMethodsError + ? (authState.context.getMethodsError as Error).message + : undefined const onSubmit = async ({ email, password }: { email: string; password: string }) => { authSend({ type: "SIGN_IN", email, password }) @@ -47,7 +50,13 @@ export const LoginPage: React.FC = () => {
- +