diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 90b0ea1cc9..f3d22513ac 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -5,6 +5,8 @@ IST = "IST" MacOS = "macOS" [default.extend-words] +# do as sudo replacement +doas = "doas" [files] extend-exclude = [ diff --git a/.vscode/settings.json b/.vscode/settings.json index e9a32e850c..9771a27a0d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,12 +19,14 @@ "derphttp", "derpmap", "devel", + "dflags", "drpc", "drpcconn", "drpcmux", "drpcserver", "Dsts", "enablements", + "eventsourcemock", "fatih", "Formik", "gitsshkey", @@ -85,8 +87,10 @@ "ptytest", "quickstart", "reconfig", + "replicasync", "retrier", "rpty", + "SCIM", "sdkproto", "sdktrace", "Signup", diff --git a/README.md b/README.md index 157510b19f..ccc01b3fc9 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Once installed, you can start a production deployment1 with a single # Automatically sets up an external access URL on *.try.coder.app coder server -# Requires a PostgreSQL instance and external access URL +# Requires a PostgreSQL instance (version 13 or higher) and external access URL coder server --postgres-url --access-url ``` @@ -95,7 +95,7 @@ Join our community on [Discord](https://coder.com/chat?utm_source=github.com/cod ## Contributing -If you're using Coder in your organization, please try to add your company name to the [ADOPTERS.md](./ADOPTERS.md). It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact. +If you're using Coder in your organization, please try to add your company name to the [ADOPTERS.md](./ADOPTERS.md). It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact. Read the [contributing docs](https://coder.com/docs/coder-oss/latest/CONTRIBUTING). diff --git a/agent/agent.go b/agent/agent.go index 6d0a9a952f..ffaf2ed454 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -170,6 +170,7 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { if a.isClosed() { return } + a.logger.Debug(ctx, "running tailnet with derpmap", slog.F("derpmap", derpMap)) if a.network != nil { a.network.SetDERPMap(derpMap) return @@ -878,12 +879,22 @@ func (r *reconnectingPTY) Close() { // after one or both of them are done writing. If the context is canceled, both // of the connections will be closed. func Bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) { - defer c1.Close() - defer c2.Close() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + defer func() { + _ = c1.Close() + _ = c2.Close() + }() var wg sync.WaitGroup copyFunc := func(dst io.WriteCloser, src io.Reader) { - defer wg.Done() + defer func() { + wg.Done() + // If one side of the copy fails, ensure the other one exits as + // well. + cancel() + }() _, _ = io.Copy(dst, src) } diff --git a/agent/agent_test.go b/agent/agent_test.go index 06a33598b7..e10eee7f11 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -465,7 +465,7 @@ func TestAgent(t *testing.T) { conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0) require.Eventually(t, func() bool { - _, err := conn.Ping() + _, err := conn.Ping(context.Background()) return err == nil }, testutil.WaitMedium, testutil.IntervalFast) conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String()) @@ -483,9 +483,7 @@ func TestAgent(t *testing.T) { t.Run("Speedtest", func(t *testing.T) { t.Parallel() - if testing.Short() { - t.Skip("The minimum duration for a speedtest is hardcoded in Tailscale to 5s!") - } + t.Skip("This test is relatively flakey because of Tailscale's speedtest code...") derpMap := tailnettest.RunDERPAndSTUN(t) conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{ DERPMap: derpMap, diff --git a/agent/apphealth.go b/agent/apphealth.go index d53c76e57a..88e107160d 100644 --- a/agent/apphealth.go +++ b/agent/apphealth.go @@ -60,8 +60,10 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps Worksp continue } app := nextApp - t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second) go func() { + t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second) + defer t.Stop() + for { select { case <-ctx.Done(): @@ -118,6 +120,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps Worksp lastHealth := copyHealth(health) mu.Unlock() reportTicker := time.NewTicker(time.Second) + defer reportTicker.Stop() // every second we check if the health values of the apps have changed // and if there is a change we will report the new values. for { diff --git a/cli/agent_test.go b/cli/agent_test.go index dd0cb1d789..f487ebfc00 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -7,8 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "cdr.dev/slog" - "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/provisioner/echo" @@ -67,11 +65,11 @@ func TestWorkspaceAgent(t *testing.T) { if assert.NotEmpty(t, workspace.LatestBuild.Resources) && assert.NotEmpty(t, resources[0].Agents) { assert.NotEmpty(t, resources[0].Agents[0].Version) } - dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() require.Eventually(t, func() bool { - _, err := dialer.Ping() + _, err := dialer.Ping(ctx) return err == nil }, testutil.WaitMedium, testutil.IntervalFast) cancelFunc() @@ -128,11 +126,11 @@ func TestWorkspaceAgent(t *testing.T) { if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) { assert.NotEmpty(t, resources[0].Agents[0].Version) } - dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() require.Eventually(t, func() bool { - _, err := dialer.Ping() + _, err := dialer.Ping(ctx) return err == nil }, testutil.WaitMedium, testutil.IntervalFast) cancelFunc() @@ -189,11 +187,11 @@ func TestWorkspaceAgent(t *testing.T) { if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) { assert.NotEmpty(t, resources[0].Agents[0].Version) } - dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() require.Eventually(t, func() bool { - _, err := dialer.Ping() + _, err := dialer.Ping(ctx) return err == nil }, testutil.WaitMedium, testutil.IntervalFast) cancelFunc() diff --git a/cli/config/file.go b/cli/config/file.go index a98237afed..388ce0881f 100644 --- a/cli/config/file.go +++ b/cli/config/file.go @@ -13,6 +13,11 @@ func (r Root) Session() File { return File(filepath.Join(string(r), "session")) } +// ReplicaID is a unique identifier for the Coder server. +func (r Root) ReplicaID() File { + return File(filepath.Join(string(r), "replica_id")) +} + func (r Root) URL() File { return File(filepath.Join(string(r), "url")) } diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 3e1512a0c3..4553cbe431 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -19,7 +19,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" @@ -115,7 +114,7 @@ func TestConfigSSH(t *testing.T) { _ = agentCloser.Close() }() resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - agentConn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, resources[0].Agents[0].ID) + agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil) require.NoError(t, err) defer agentConn.Close() diff --git a/cli/deployment/flags.go b/cli/deployment/flags.go index 3a03bea762..792051f805 100644 --- a/cli/deployment/flags.go +++ b/cli/deployment/flags.go @@ -32,7 +32,7 @@ func Flags() *codersdk.DeploymentFlags { Name: "Wildcard Address URL", Flag: "wildcard-access-url", EnvVar: "CODER_WILDCARD_ACCESS_URL", - Description: `Specifies the wildcard hostname to use for workspace applications in the form "*.example.com".`, + Description: `Specifies the wildcard hostname to use for workspace applications in the form "*.example.com" or "*-suffix.example.com". Ports or schemes should not be included. The scheme will be copied from the access URL.`, }, Address: &codersdk.StringFlag{ Name: "Bind Address", @@ -85,6 +85,13 @@ func Flags() *codersdk.DeploymentFlags { Description: "Addresses for STUN servers to establish P2P connections. Set empty to disable P2P connections.", Default: []string{"stun.l.google.com:19302"}, }, + DerpServerRelayAddress: &codersdk.StringFlag{ + Name: "DERP Server Relay Address", + Flag: "derp-server-relay-address", + EnvVar: "CODER_DERP_SERVER_RELAY_URL", + Description: "An HTTP address that is accessible by other replicas to relay DERP traffic. Required for high availability.", + Enterprise: true, + }, DerpConfigURL: &codersdk.StringFlag{ Name: "DERP Config URL", Flag: "derp-config-url", diff --git a/cli/portforward.go b/cli/portforward.go index 476809d601..911e8fb520 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -16,7 +16,6 @@ import ( "github.com/spf13/cobra" "golang.org/x/xerrors" - "cdr.dev/slog" "github.com/coder/coder/agent" "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" @@ -96,7 +95,7 @@ func portForward() *cobra.Command { return xerrors.Errorf("await agent: %w", err) } - conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID) + conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, nil) if err != nil { return err } @@ -139,8 +138,7 @@ func portForward() *cobra.Command { case <-ctx.Done(): closeErr = ctx.Err() case <-sigs: - _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Received signal, closing all listeners and active connections") - closeErr = xerrors.New("signal received") + _, _ = fmt.Fprintln(cmd.OutOrStderr(), "\nReceived signal, closing all listeners and active connections") } cancel() @@ -156,7 +154,7 @@ func portForward() *cobra.Command { case <-ticker.C: } - _, err = conn.Ping() + _, err = conn.Ping(ctx) if err != nil { continue } @@ -214,7 +212,11 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersd for { netConn, err := l.Accept() if err != nil { - _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %+v\n", spec.listenNetwork, spec.listenAddress, err) + // Silently ignore net.ErrClosed errors. + if xerrors.Is(err, net.ErrClosed) { + return + } + _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err) _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Killing listener") return } diff --git a/cli/root.go b/cli/root.go index e7104e6428..ea803cdfa5 100644 --- a/cli/root.go +++ b/cli/root.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "io" "net/http" "net/url" "os" @@ -100,8 +101,9 @@ func Core() []*cobra.Command { } func AGPL() []*cobra.Command { - all := append(Core(), Server(deployment.Flags(), func(_ context.Context, o *coderd.Options) (*coderd.API, error) { - return coderd.New(o), nil + all := append(Core(), Server(deployment.Flags(), func(_ context.Context, o *coderd.Options) (*coderd.API, io.Closer, error) { + api := coderd.New(o) + return api, api, nil })) return all } @@ -607,7 +609,8 @@ func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { // ExperimentalEnabled returns if the experimental feature flag is enabled. func ExperimentalEnabled(cmd *cobra.Command) bool { - return cliflag.IsSetBool(cmd, varExperimental) + enabled, _ := cmd.Flags().GetBool(varExperimental) + return enabled } // EnsureExperimental will ensure that the experimental feature flag is set if the given flag is set. diff --git a/cli/server.go b/cli/server.go index e3cad09ca2..25b435ed6c 100644 --- a/cli/server.go +++ b/cli/server.go @@ -17,6 +17,7 @@ import ( "os/signal" "os/user" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -32,6 +33,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" "go.opentelemetry.io/otel/trace" + "golang.org/x/mod/semver" "golang.org/x/oauth2" xgithub "golang.org/x/oauth2/github" "golang.org/x/sync/errgroup" @@ -53,6 +55,7 @@ import ( "github.com/coder/coder/coderd/database/migrations" "github.com/coder/coder/coderd/devtunnel" "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/prometheusmetrics" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" @@ -67,7 +70,7 @@ import ( ) // nolint:gocyclo -func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *coderd.Options) (*coderd.API, error)) *cobra.Command { +func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *cobra.Command { root := &cobra.Command{ Use: "server", Short: "Start a Coder server", @@ -165,9 +168,10 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code } defer listener.Close() + var tlsConfig *tls.Config if dflags.TLSEnable.Value { - listener, err = configureServerTLS( - listener, dflags.TLSMinVersion.Value, + tlsConfig, err = configureTLS( + dflags.TLSMinVersion.Value, dflags.TLSClientAuth.Value, dflags.TLSCertFiles.Value, dflags.TLSKeyFiles.Value, @@ -176,6 +180,7 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code if err != nil { return xerrors.Errorf("configure tls: %w", err) } + listener = tls.NewListener(listener, tlsConfig) } tcpAddr, valid := listener.Addr().(*net.TCPAddr) @@ -297,13 +302,19 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code return xerrors.Errorf("create derp map: %w", err) } - appHostname := strings.TrimPrefix(dflags.WildcardAccessURL.Value, "http://") - appHostname = strings.TrimPrefix(appHostname, "https://") - appHostname = strings.TrimPrefix(appHostname, "*.") + appHostname := strings.TrimSpace(dflags.WildcardAccessURL.Value) + var appHostnameRegex *regexp.Regexp + if appHostname != "" { + appHostnameRegex, err = httpapi.CompileHostnamePattern(appHostname) + if err != nil { + return xerrors.Errorf("parse wildcard access URL %q: %w", appHostname, err) + } + } options := &coderd.Options{ AccessURL: accessURLParsed, AppHostname: appHostname, + AppHostnameRegex: appHostnameRegex, Logger: logger.Named("coderd"), Database: databasefake.New(), DERPMap: derpMap, @@ -320,6 +331,9 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code Experimental: ExperimentalEnabled(cmd), DeploymentFlags: dflags, } + if tlsConfig != nil { + options.TLSCertificates = tlsConfig.Certificates + } if dflags.OAuth2GithubClientSecret.Value != "" { options.GithubOAuth2Config, err = configureGithubOAuth2(accessURLParsed, @@ -376,6 +390,23 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code return xerrors.Errorf("dial postgres: %w", err) } defer sqlDB.Close() + // Ensure the PostgreSQL version is >=13.0.0! + version, err := sqlDB.QueryContext(ctx, "SHOW server_version;") + if err != nil { + return xerrors.Errorf("get postgres version: %w", err) + } + if !version.Next() { + return xerrors.Errorf("no rows returned for version select") + } + var versionStr string + err = version.Scan(&versionStr) + if err != nil { + return xerrors.Errorf("scan version: %w", err) + } + versionStr = strings.Split(versionStr, " ")[0] + if semver.Compare("v"+versionStr, "v13") < 0 { + return xerrors.New("PostgreSQL version must be v13.0.0 or higher!") + } err = sqlDB.Ping() if err != nil { @@ -463,11 +494,13 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code ), dflags.PromAddress.Value, "prometheus")() } - coderAPI, err := newAPI(ctx, options) + // We use a separate coderAPICloser so the Enterprise API + // can have it's own close functions. This is cleaner + // than abstracting the Coder API itself. + coderAPI, coderAPICloser, err := newAPI(ctx, options) if err != nil { return err } - defer coderAPI.Close() client := codersdk.New(localURL) if dflags.TLSEnable.Value { @@ -647,7 +680,7 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code wg.Wait() cmd.Println("Waiting for WebSocket connections to close...") - _ = coderAPI.Close() + _ = coderAPICloser.Close() cmd.Println("Done waiting for WebSocket connections") // Close tunnel after we no longer have in-flight connections. @@ -885,7 +918,7 @@ func loadCertificates(tlsCertFiles, tlsKeyFiles []string) ([]tls.Certificate, er return certs, nil } -func configureServerTLS(listener net.Listener, tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles []string, tlsClientCAFile string) (net.Listener, error) { +func configureTLS(tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles []string, tlsClientCAFile string) (*tls.Config, error) { tlsConfig := &tls.Config{ MinVersion: tls.VersionTLS12, } @@ -921,6 +954,7 @@ func configureServerTLS(listener net.Listener, tlsMinVersion, tlsClientAuth stri if err != nil { return nil, xerrors.Errorf("load certificates: %w", err) } + tlsConfig.Certificates = certs tlsConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { // If there's only one certificate, return it. if len(certs) == 1 { @@ -955,7 +989,7 @@ func configureServerTLS(listener net.Listener, tlsMinVersion, tlsClientAuth stri tlsConfig.ClientCAs = caPool } - return tls.NewListener(listener, tlsConfig), nil + return tlsConfig, nil } func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, allowSignups bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) { diff --git a/cli/server_test.go b/cli/server_test.go index fe71da9309..d91e2e8889 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -60,6 +60,9 @@ func TestServer(t *testing.T) { "--postgres-url", connectionURL, "--cache-dir", t.TempDir(), ) + pty := ptytest.New(t) + root.SetOutput(pty.Output()) + root.SetErr(pty.Output()) errC := make(chan error, 1) go func() { errC <- root.ExecuteContext(ctx) diff --git a/cli/speedtest.go b/cli/speedtest.go index 357048f63e..f6c06641ec 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -55,7 +55,9 @@ func speedtest() *cobra.Command { if cliflag.IsSetBool(cmd, varVerbose) { logger = logger.Leveled(slog.LevelDebug) } - conn, err := client.DialWorkspaceAgentTailnet(ctx, logger, workspaceAgent.ID) + conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: logger, + }) if err != nil { return err } @@ -68,7 +70,7 @@ func speedtest() *cobra.Command { return ctx.Err() case <-ticker.C: } - dur, err := conn.Ping() + dur, err := conn.Ping(ctx) if err != nil { continue } diff --git a/cli/ssh.go b/cli/ssh.go index ef8538764e..b4d4f6420d 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -20,8 +20,6 @@ import ( "golang.org/x/term" "golang.org/x/xerrors" - "cdr.dev/slog" - "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/autobuild/notify" @@ -86,7 +84,7 @@ func ssh() *cobra.Command { return xerrors.Errorf("await agent: %w", err) } - conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID) + conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, nil) if err != nil { return err } diff --git a/cli/templatecreate.go b/cli/templatecreate.go index a0f4014f71..de0b8eab8f 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -10,6 +10,7 @@ import ( "unicode/utf8" "github.com/briandowns/spinner" + "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -91,7 +92,7 @@ func templateCreate() *cobra.Command { Client: client, Organization: organization, Provisioner: database.ProvisionerType(provisioner), - FileHash: resp.Hash, + FileID: resp.ID, ParameterFile: parameterFile, }) if err != nil { @@ -148,7 +149,7 @@ type createValidTemplateVersionArgs struct { Client *codersdk.Client Organization codersdk.Organization Provisioner database.ProvisionerType - FileHash string + FileID uuid.UUID ParameterFile string // Template is only required if updating a template's active version. Template *codersdk.Template @@ -165,7 +166,7 @@ func createValidTemplateVersion(cmd *cobra.Command, args createValidTemplateVers req := codersdk.CreateTemplateVersionRequest{ Name: args.Name, StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: args.FileHash, + FileID: args.FileID, Provisioner: codersdk.ProvisionerType(args.Provisioner), ParameterValues: parameters, } diff --git a/cli/templatepull.go b/cli/templatepull.go index 5660261c51..09f70c91b8 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -66,7 +66,7 @@ func templatePull() *cobra.Command { latest := versions[0] // Download the tar archive. - raw, ctype, err := client.Download(ctx, latest.Job.StorageSource) + raw, ctype, err := client.Download(ctx, latest.Job.FileID) if err != nil { return xerrors.Errorf("download template: %w", err) } diff --git a/cli/templatepush.go b/cli/templatepush.go index f858c6daec..40bafed0ef 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -80,7 +80,7 @@ func templatePush() *cobra.Command { Client: client, Organization: organization, Provisioner: database.ProvisionerType(provisioner), - FileHash: resp.Hash, + FileID: resp.ID, ParameterFile: parameterFile, Template: &template, ReuseParameters: !alwaysPrompt, diff --git a/cli/tokens.go b/cli/tokens.go index 8719ab3434..4c3cb830cd 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -55,7 +55,7 @@ func createToken() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - res, err := client.CreateToken(cmd.Context(), codersdk.Me) + res, err := client.CreateToken(cmd.Context(), codersdk.Me, codersdk.CreateTokenRequest{}) if err != nil { return xerrors.Errorf("create tokens: %w", err) } diff --git a/coder.service b/coder.service index 697250bf8f..4ff2cc260a 100644 --- a/coder.service +++ b/coder.service @@ -20,11 +20,12 @@ AmbientCapabilities=CAP_IPC_LOCK CAP_NET_BIND_SERVICE CacheDirectory=coder CapabilityBoundingSet=CAP_SYSLOG CAP_IPC_LOCK CAP_NET_BIND_SERVICE KillSignal=SIGINT +KillMode=mixed NoNewPrivileges=yes ExecStart=/usr/bin/coder server Restart=on-failure RestartSec=5 -TimeoutStopSec=30 +TimeoutStopSec=90 [Install] WantedBy=multi-user.target diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index b12c8bc170..e498b98fa0 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -23,7 +23,15 @@ func TestWorkspaceActivityBump(t *testing.T) { setupActivityTest := func(t *testing.T) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { var ttlMillis int64 = 60 * 1000 - client, _, workspace, _ = setupProxyTest(t, func(cwr *codersdk.CreateWorkspaceRequest) { + client = coderdtest.New(t, &coderdtest.Options{ + AppHostname: proxyTestSubdomainRaw, + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + MetricsCacheRefreshInterval: time.Millisecond * 100, + }) + user := coderdtest.CreateFirstUser(t, client) + + workspace = createWorkspaceWithApps(t, client, user.OrganizationID, 1234, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.TTLMillis = &ttlMillis }) @@ -64,7 +72,7 @@ func TestWorkspaceActivityBump(t *testing.T) { "deadline %v never updated", firstDeadline, ) - require.WithinDuration(t, database.Now().Add(time.Hour), workspace.LatestBuild.Deadline.Time, time.Second) + require.WithinDuration(t, database.Now().Add(time.Hour), workspace.LatestBuild.Deadline.Time, 3*time.Second) } } @@ -74,7 +82,9 @@ func TestWorkspaceActivityBump(t *testing.T) { client, workspace, assertBumped := setupActivityTest(t) resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil), resources[0].Agents[0].ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil), + }) require.NoError(t, err) defer conn.Close() diff --git a/coderd/apikey.go b/coderd/apikey.go index 645d660ada..84e936cb22 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -34,12 +34,23 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { return } + var createToken codersdk.CreateTokenRequest + if !httpapi.Read(ctx, rw, r, &createToken) { + return + } + + scope := database.APIKeyScopeAll + if scope != "" { + scope = database.APIKeyScope(createToken.Scope) + } + // tokens last 100 years lifeTime := time.Hour * 876000 cookie, err := api.createAPIKey(ctx, createAPIKeyParams{ UserID: user.ID, LoginType: database.LoginTypeToken, ExpiresAt: database.Now().Add(lifeTime), + Scope: scope, LifetimeSeconds: int64(lifeTime.Seconds()), }) if err != nil { @@ -54,6 +65,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { } // Creates a new session key, used for logging in via the CLI. +// DEPRECATED: use postToken instead. func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) @@ -229,6 +241,11 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h if params.Scope != "" { scope = params.Scope } + switch scope { + case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect: + default: + return nil, xerrors.Errorf("invalid API key scope: %q", scope) + } key, err := api.Database.InsertAPIKey(ctx, database.InsertAPIKeyParams{ ID: keyID, diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index e9163e5c59..f40966b0a2 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -14,30 +14,61 @@ import ( func TestTokens(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - _ = coderdtest.CreateFirstUser(t, client) - keys, err := client.GetTokens(ctx, codersdk.Me) - require.NoError(t, err) - require.Empty(t, keys) - res, err := client.CreateToken(ctx, codersdk.Me) - require.NoError(t, err) - require.Greater(t, len(res.Key), 2) + t.Run("CRUD", func(t *testing.T) { + t.Parallel() - keys, err = client.GetTokens(ctx, codersdk.Me) - require.NoError(t, err) - require.EqualValues(t, len(keys), 1) - require.Contains(t, res.Key, keys[0].ID) - // expires_at must be greater than 50 years - require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300)) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + keys, err := client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.Empty(t, keys) - err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID) - require.NoError(t, err) - keys, err = client.GetTokens(ctx, codersdk.Me) - require.NoError(t, err) - require.Empty(t, keys) + res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{}) + require.NoError(t, err) + require.Greater(t, len(res.Key), 2) + + keys, err = client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.EqualValues(t, len(keys), 1) + require.Contains(t, res.Key, keys[0].ID) + // expires_at must be greater than 50 years + require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300)) + require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope) + + // no update + + err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID) + require.NoError(t, err) + keys, err = client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.Empty(t, keys) + }) + + t.Run("Scoped", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scope: codersdk.APIKeyScopeApplicationConnect, + }) + require.NoError(t, err) + require.Greater(t, len(res.Key), 2) + + keys, err := client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.EqualValues(t, len(keys), 1) + require.Contains(t, res.Key, keys[0].ID) + // expires_at must be greater than 50 years + require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300)) + require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect) + }) } func TestAPIKey(t *testing.T) { diff --git a/coderd/audit.go b/coderd/audit.go index ea1acdb1b1..f76a6565bc 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -221,10 +221,18 @@ func convertAuditLog(dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog { } func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { - return fmt.Sprintf("{user} %s %s {target}", + str := fmt.Sprintf("{user} %s %s", codersdk.AuditAction(alog.Action).FriendlyString(), codersdk.ResourceType(alog.ResourceType).FriendlyString(), ) + + // We don't display the name for git ssh keys. It's fairly long and doesn't + // make too much sense to display. + if alog.ResourceType != database.ResourceTypeGitSshKey { + str += " {target}" + } + + return str } // auditSearchQuery takes a query string and returns the auditLog filter. diff --git a/coderd/audit/audit.go b/coderd/audit/audit.go index 92f4471305..ca343123e9 100644 --- a/coderd/audit/audit.go +++ b/coderd/audit/audit.go @@ -21,7 +21,9 @@ func (nop) Export(context.Context, database.AuditLog) error { return nil } -func (nop) diff(any, any) Map { return Map{} } +func (nop) diff(any, any) Map { + return Map{} +} func NewMock() *MockAuditor { return &MockAuditor{} @@ -36,4 +38,6 @@ func (a *MockAuditor) Export(_ context.Context, alog database.AuditLog) error { return nil } -func (*MockAuditor) diff(any, any) Map { return Map{} } +func (*MockAuditor) diff(any, any) Map { + return Map{} +} diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index 22536583b6..f21fde9a8a 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -276,7 +276,7 @@ func build(ctx context.Context, store database.Store, workspace database.Workspa Provisioner: template.Provisioner, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: priorJob.StorageMethod, - StorageSource: priorJob.StorageSource, + FileID: priorJob.FileID, Input: input, }) if err != nil { diff --git a/coderd/coderd.go b/coderd/coderd.go index 171945f204..5115098cf3 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1,11 +1,13 @@ package coderd import ( + "crypto/tls" "crypto/x509" "io" "net/http" "net/url" "path/filepath" + "regexp" "sync" "sync/atomic" "time" @@ -46,11 +48,16 @@ import ( type Options struct { AccessURL *url.URL // AppHostname should be the wildcard hostname to use for workspace - // applications without the asterisk or leading dot. E.g. "apps.coder.com". + // applications INCLUDING the asterisk, (optional) suffix and leading dot. + // E.g. "*.apps.coder.com" or "*-apps.coder.com". AppHostname string - Logger slog.Logger - Database database.Store - Pubsub database.Pubsub + // AppHostnameRegex contains the regex version of options.AppHostname as + // generated by httpapi.CompileHostnamePattern(). It MUST be set if + // options.AppHostname is set. + AppHostnameRegex *regexp.Regexp + Logger slog.Logger + Database database.Store + Pubsub database.Pubsub // CacheDir is used for caching files served by the API. CacheDir string @@ -76,7 +83,10 @@ type Options struct { TracerProvider trace.TracerProvider AutoImportTemplates []AutoImportTemplate - TailnetCoordinator *tailnet.Coordinator + // TLSCertificates is used to mesh DERP servers securely. + TLSCertificates []tls.Certificate + TailnetCoordinator tailnet.Coordinator + DERPServer *derp.Server DERPMap *tailcfg.DERPMap MetricsCacheRefreshInterval time.Duration @@ -90,6 +100,9 @@ func New(options *Options) *API { if options == nil { options = &Options{} } + if options.AppHostname != "" && options.AppHostnameRegex == nil || options.AppHostname == "" && options.AppHostnameRegex != nil { + panic("coderd: both AppHostname and AppHostnameRegex must be set or unset") + } if options.AgentConnectionUpdateFrequency == 0 { options.AgentConnectionUpdateFrequency = 3 * time.Second } @@ -121,6 +134,9 @@ func New(options *Options) *API { if options.TailnetCoordinator == nil { options.TailnetCoordinator = tailnet.NewCoordinator() } + if options.DERPServer == nil { + options.DERPServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp"))) + } if options.Auditor == nil { options.Auditor = audit.NewNop() } @@ -159,7 +175,7 @@ func New(options *Options) *API { api.Auditor.Store(&options.Auditor) api.WorkspaceQuotaEnforcer.Store(&options.WorkspaceQuotaEnforcer) api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0) - api.derpServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger)) + api.TailnetCoordinator.Store(&options.TailnetCoordinator) oauthConfigs := &httpmw.OAuth2Configs{ Github: options.GithubOAuth2Config, OIDC: options.OIDCConfig, @@ -197,7 +213,7 @@ func New(options *Options) *API { RedirectToLogin: false, Optional: true, }), - httpmw.ExtractUserParam(api.Database), + httpmw.ExtractUserParam(api.Database, false), httpmw.ExtractWorkspaceAndAgentParam(api.Database), ), // Build-Version is helpful for debugging. @@ -214,8 +230,18 @@ func New(options *Options) *API { r.Use( tracing.Middleware(api.TracerProvider), httpmw.RateLimitPerMinute(options.APIRateLimit), - apiKeyMiddlewareRedirect, - httpmw.ExtractUserParam(api.Database), + httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{ + DB: options.Database, + OAuth2Configs: oauthConfigs, + // Optional is true to allow for public apps. If an + // authorization check fails and the user is not authenticated, + // they will be redirected to the login page by the app handler. + RedirectToLogin: false, + Optional: true, + }), + // Redirect to the login page if the user tries to open an app with + // "me" as the username and they are not logged in. + httpmw.ExtractUserParam(api.Database, true), // Extracts the from the url httpmw.ExtractWorkspaceAndAgentParam(api.Database), ) @@ -227,7 +253,7 @@ func New(options *Options) *API { r.Route("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}", apps) r.Route("/@{user}/{workspace_and_agent}/apps/{workspaceapp}", apps) r.Route("/derp", func(r chi.Router) { - r.Get("/", derphttp.Handler(api.derpServer).ServeHTTP) + r.Get("/", derphttp.Handler(api.DERPServer).ServeHTTP) // This is used when UDP is blocked, and latency must be checked via HTTP(s). r.Get("/latency-check", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -280,7 +306,7 @@ func New(options *Options) *API { // file content is expensive so it should be small. httpmw.RateLimitPerMinute(12), ) - r.Get("/{hash}", api.fileByHash) + r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) @@ -310,7 +336,7 @@ func New(options *Options) *API { r.Get("/roles", api.assignableOrgRoles) r.Route("/{user}", func(r chi.Router) { r.Use( - httpmw.ExtractUserParam(options.Database), + httpmw.ExtractUserParam(options.Database, false), httpmw.ExtractOrganizationMemberParam(options.Database), ) r.Put("/roles", api.putMemberRoles) @@ -389,7 +415,7 @@ func New(options *Options) *API { r.Get("/", api.assignableSiteRoles) }) r.Route("/{user}", func(r chi.Router) { - r.Use(httpmw.ExtractUserParam(options.Database)) + r.Use(httpmw.ExtractUserParam(options.Database, false)) r.Delete("/", api.deleteUser) r.Get("/", api.userByName) r.Put("/profile", api.putUserProfile) @@ -532,6 +558,7 @@ type API struct { Auditor atomic.Pointer[audit.Auditor] WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] WorkspaceQuotaEnforcer atomic.Pointer[workspacequota.Enforcer] + TailnetCoordinator atomic.Pointer[tailnet.Coordinator] HTTPAuth *HTTPAuthorizer // APIHandler serves "/api/v2" @@ -539,7 +566,6 @@ type API struct { // RootHandler serves "/" RootHandler chi.Router - derpServer *derp.Server metricsCache *metricscache.Cache siteHandler http.Handler websocketWaitMutex sync.Mutex @@ -554,7 +580,10 @@ func (api *API) Close() error { api.websocketWaitMutex.Unlock() api.metricsCache.Close() - + coordinator := api.TailnetCoordinator.Load() + if coordinator != nil { + _ = (*coordinator).Close() + } return api.workspaceAgentCache.Close() } diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index fa77898864..a5183f2b6e 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -142,7 +142,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "POST:/api/v2/files": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceFile}, - "GET:/api/v2/files/{hash}": { + "GET:/api/v2/files/{fileID}": { AssertAction: rbac.ActionRead, AssertObject: rbac.ResourceFile.WithOwner(a.Admin.UserID.String()), }, @@ -369,7 +369,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a "{workspaceagent}": workspace.LatestBuild.Resources[0].Agents[0].ID.String(), "{buildnumber}": strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10), "{template}": template.ID.String(), - "{hash}": file.Hash, + "{fileID}": file.ID.String(), "{workspaceresource}": workspace.LatestBuild.Resources[0].ID.String(), "{workspaceapp}": workspace.LatestBuild.Resources[0].Agents[0].Apps[0].Name, "{templateversion}": version.ID.String(), diff --git a/coderd/coderdtest/authorize_test.go b/coderd/coderdtest/authorize_test.go index 4d8daa54c8..d4db546454 100644 --- a/coderd/coderdtest/authorize_test.go +++ b/coderd/coderdtest/authorize_test.go @@ -11,7 +11,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { t.Parallel() client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ // Required for any subdomain-based proxy tests to pass. - AppHostname: "test.coder.com", + AppHostname: "*.test.coder.com", Authorizer: &coderdtest.RecordingAuthorizer{}, IncludeProvisionerDaemon: true, }) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d7ac4eb14b..05e3d6a27d 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "crypto/rsa" "crypto/sha256" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/base64" @@ -20,12 +21,15 @@ import ( "net/http" "net/http/httptest" "net/url" + "regexp" "strconv" "strings" + "sync" "testing" "time" "cloud.google.com/go/compute/metadata" + "github.com/coreos/go-oidc/v3/oidc" "github.com/fullsailor/pkcs7" "github.com/golang-jwt/jwt" "github.com/google/uuid" @@ -33,11 +37,14 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "golang.org/x/xerrors" "google.golang.org/api/idtoken" "google.golang.org/api/option" + "tailscale.com/derp" "tailscale.com/net/stun/stuntest" "tailscale.com/tailcfg" + "tailscale.com/types/key" "tailscale.com/types/nettype" "cdr.dev/slog" @@ -49,6 +56,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/util/ptr" @@ -58,6 +66,7 @@ import ( "github.com/coder/coder/provisionerd" "github.com/coder/coder/provisionersdk" "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/tailnet" "github.com/coder/coder/testutil" ) @@ -65,6 +74,7 @@ type Options struct { AppHostname string AWSCertificates awsidentity.Certificates Authorizer rbac.Authorizer + Experimental bool AzureCertificates x509.VerifyOptions GithubOAuth2Config *coderd.GithubOAuth2Config OIDCConfig *coderd.OIDCConfig @@ -75,12 +85,19 @@ type Options struct { AutobuildTicker <-chan time.Time AutobuildStats chan<- executor.Stats Auditor audit.Auditor + TLSCertificates []tls.Certificate // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool MetricsCacheRefreshInterval time.Duration AgentStatsRefreshInterval time.Duration DeploymentFlags *codersdk.DeploymentFlags + + // Overriding the database is heavily discouraged. + // It should only be used in cases where multiple Coder + // test instances are running against the same database. + Database database.Store + Pubsub database.Pubsub } // New constructs a codersdk client connected to an in-memory API instance. @@ -114,7 +131,7 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer) return client, closer } -func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.CancelFunc, *coderd.Options) { +func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.CancelFunc, *coderd.Options) { if options == nil { options = &Options{} } @@ -135,23 +152,40 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance close(options.AutobuildStats) }) } - - db, pubsub := dbtestutil.NewDB(t) + if options.Database == nil { + options.Database, options.Pubsub = dbtestutil.NewDB(t) + } ctx, cancelFunc := context.WithCancel(context.Background()) lifecycleExecutor := executor.New( ctx, - db, + options.Database, slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug), options.AutobuildTicker, ).WithStatsChannel(options.AutobuildStats) lifecycleExecutor.Run() - srv := httptest.NewUnstartedServer(nil) + var mutex sync.RWMutex + var handler http.Handler + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutex.RLock() + defer mutex.RUnlock() + if handler != nil { + handler.ServeHTTP(w, r) + } + })) srv.Config.BaseContext = func(_ net.Listener) context.Context { return ctx } - srv.Start() + if options.TLSCertificates != nil { + srv.TLS = &tls.Config{ + Certificates: options.TLSCertificates, + MinVersion: tls.VersionTLS12, + } + srv.StartTLS() + } else { + srv.Start() + } t.Cleanup(srv.Close) tcpAddr, ok := srv.Listener.Addr().(*net.TCPAddr) @@ -167,57 +201,75 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{}) t.Cleanup(stunCleanup) + derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(slogtest.Make(t, nil).Named("derp"))) + derpServer.SetMeshKey("test-key") + // match default with cli default if options.SSHKeygenAlgorithm == "" { options.SSHKeygenAlgorithm = gitsshkey.AlgorithmEd25519 } - return srv, cancelFunc, &coderd.Options{ - AgentConnectionUpdateFrequency: 150 * time.Millisecond, - // Force a long disconnection timeout to ensure - // agents are not marked as disconnected during slow tests. - AgentInactiveDisconnectTimeout: testutil.WaitShort, - AccessURL: serverURL, - AppHostname: options.AppHostname, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - CacheDir: t.TempDir(), - Database: db, - Pubsub: pubsub, + var appHostnameRegex *regexp.Regexp + if options.AppHostname != "" { + var err error + appHostnameRegex, err = httpapi.CompileHostnamePattern(options.AppHostname) + require.NoError(t, err) + } - Auditor: options.Auditor, - AWSCertificates: options.AWSCertificates, - AzureCertificates: options.AzureCertificates, - GithubOAuth2Config: options.GithubOAuth2Config, - OIDCConfig: options.OIDCConfig, - GoogleTokenValidator: options.GoogleTokenValidator, - SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, - APIRateLimit: options.APIRateLimit, - Authorizer: options.Authorizer, - Telemetry: telemetry.NewNoop(), - DERPMap: &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - EmbeddedRelay: true, - RegionID: 1, - RegionCode: "coder", - RegionName: "Coder", - Nodes: []*tailcfg.DERPNode{{ - Name: "1a", - RegionID: 1, - IPv4: "127.0.0.1", - DERPPort: derpPort, - STUNPort: stunAddr.Port, - InsecureForTests: true, - ForceHTTP: true, - }}, + return func(h http.Handler) { + mutex.Lock() + defer mutex.Unlock() + handler = h + }, cancelFunc, &coderd.Options{ + AgentConnectionUpdateFrequency: 150 * time.Millisecond, + // Force a long disconnection timeout to ensure + // agents are not marked as disconnected during slow tests. + AgentInactiveDisconnectTimeout: testutil.WaitShort, + AccessURL: serverURL, + AppHostname: options.AppHostname, + AppHostnameRegex: appHostnameRegex, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + CacheDir: t.TempDir(), + Database: options.Database, + Pubsub: options.Pubsub, + Experimental: options.Experimental, + + Auditor: options.Auditor, + AWSCertificates: options.AWSCertificates, + AzureCertificates: options.AzureCertificates, + GithubOAuth2Config: options.GithubOAuth2Config, + OIDCConfig: options.OIDCConfig, + GoogleTokenValidator: options.GoogleTokenValidator, + SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, + DERPServer: derpServer, + APIRateLimit: options.APIRateLimit, + Authorizer: options.Authorizer, + Telemetry: telemetry.NewNoop(), + TLSCertificates: options.TLSCertificates, + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + EmbeddedRelay: true, + RegionID: 1, + RegionCode: "coder", + RegionName: "Coder", + Nodes: []*tailcfg.DERPNode{{ + Name: "1a", + RegionID: 1, + IPv4: "127.0.0.1", + DERPPort: derpPort, + STUNPort: stunAddr.Port, + InsecureForTests: true, + ForceHTTP: options.TLSCertificates == nil, + }}, + }, }, }, - }, - AutoImportTemplates: options.AutoImportTemplates, - MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, - AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, - DeploymentFlags: options.DeploymentFlags, - } + AutoImportTemplates: options.AutoImportTemplates, + MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, + AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, + DeploymentFlags: options.DeploymentFlags, + } } // NewWithAPI constructs an in-memory API instance and returns a client to talk to it. @@ -227,10 +279,10 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c if options == nil { options = &Options{} } - srv, cancelFunc, newOptions := NewOptions(t, options) + setHandler, cancelFunc, newOptions := NewOptions(t, options) // We set the handler after server creation for the access URL. coderAPI := coderd.New(newOptions) - srv.Config.Handler = coderAPI.RootHandler + setHandler(coderAPI.RootHandler) var provisionerCloser io.Closer = nopcloser{} if options.IncludeProvisionerDaemon { provisionerCloser = NewProvisionerDaemon(t, coderAPI) @@ -383,7 +435,7 @@ func CreateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID file, err := client.Upload(context.Background(), codersdk.ContentTypeTar, data) require.NoError(t, err) templateVersion, err := client.CreateTemplateVersion(context.Background(), organizationID, codersdk.CreateTemplateVersionRequest{ - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) @@ -431,7 +483,7 @@ func UpdateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID require.NoError(t, err) templateVersion, err := client.CreateTemplateVersion(context.Background(), organizationID, codersdk.CreateTemplateVersionRequest{ TemplateID: templateID, - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) @@ -449,7 +501,7 @@ func AwaitTemplateVersionJob(t *testing.T, client *codersdk.Client, version uuid var err error templateVersion, err = client.TemplateVersion(context.Background(), version) return assert.NoError(t, err) && templateVersion.Job.CompletedAt != nil - }, testutil.WaitShort, testutil.IntervalFast) + }, testutil.WaitMedium, testutil.IntervalFast) return templateVersion } @@ -675,6 +727,80 @@ func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certif } } +type OIDCConfig struct { + key *rsa.PrivateKey + issuer string +} + +func NewOIDCConfig(t *testing.T, issuer string) *OIDCConfig { + t.Helper() + + block, _ := pem.Decode([]byte(testRSAPrivateKey)) + pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + require.NoError(t, err) + + if issuer == "" { + issuer = "https://coder.com" + } + + return &OIDCConfig{ + key: pkey, + issuer: issuer, + } +} + +func (*OIDCConfig) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { + return "/?state=" + url.QueryEscape(state) +} + +func (*OIDCConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + return nil +} + +func (*OIDCConfig) Exchange(_ context.Context, code string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + token, err := base64.StdEncoding.DecodeString(code) + if err != nil { + return nil, xerrors.Errorf("decode code: %w", err) + } + return (&oauth2.Token{ + AccessToken: "token", + }).WithExtra(map[string]interface{}{ + "id_token": string(token), + }), nil +} + +func (o *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + + if _, ok := claims["exp"]; !ok { + claims["exp"] = time.Now().Add(time.Hour).UnixMilli() + } + + if _, ok := claims["iss"]; !ok { + claims["iss"] = o.issuer + } + + if _, ok := claims["sub"]; !ok { + claims["sub"] = "testme" + } + + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(o.key) + require.NoError(t, err) + + return base64.StdEncoding.EncodeToString([]byte(signed)) +} + +func (o *OIDCConfig) OIDCConfig() *coderd.OIDCConfig { + return &coderd.OIDCConfig{ + OAuth2Config: o, + Verifier: oidc.NewVerifier(o.issuer, &oidc.StaticKeySet{ + PublicKeys: []crypto.PublicKey{o.key.Public()}, + }, &oidc.Config{ + SkipClientIDCheck: true, + }), + } +} + // NewAzureInstanceIdentity returns a metadata client and ID token validator for faking // instance authentication for Azure. func NewAzureInstanceIdentity(t *testing.T, instanceID string) (x509.VerifyOptions, *http.Client) { @@ -755,3 +881,19 @@ func SDKError(t *testing.T, err error) *codersdk.Error { require.True(t, errors.As(err, &cerr)) return cerr } + +const testRSAPrivateKey = `-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDLets8+7M+iAQAqN/5BVyCIjhTQ4cmXulL+gm3v0oGMWzLupUS +v8KPA+Tp7dgC/DZPfMLaNH1obBBhJ9DhS6RdS3AS3kzeFrdu8zFHLWF53DUBhS92 +5dCAEuJpDnNizdEhxTfoHrhuCmz8l2nt1pe5eUK2XWgd08Uc93h5ij098wIDAQAB +AoGAHLaZeWGLSaen6O/rqxg2laZ+jEFbMO7zvOTruiIkL/uJfrY1kw+8RLIn+1q0 +wLcWcuEIHgKKL9IP/aXAtAoYh1FBvRPLkovF1NZB0Je/+CSGka6wvc3TGdvppZJe +rKNcUvuOYLxkmLy4g9zuY5qrxFyhtIn2qZzXEtLaVOHzPQECQQDvN0mSajpU7dTB +w4jwx7IRXGSSx65c+AsHSc1Rj++9qtPC6WsFgAfFN2CEmqhMbEUVGPv/aPjdyWk9 +pyLE9xR/AkEA2cGwyIunijE5v2rlZAD7C4vRgdcMyCf3uuPcgzFtsR6ZhyQSgLZ8 +YRPuvwm4cdPJMmO3YwBfxT6XGuSc2k8MjQJBAI0+b8prvpV2+DCQa8L/pjxp+VhR +Xrq2GozrHrgR7NRokTB88hwFRJFF6U9iogy9wOx8HA7qxEbwLZuhm/4AhbECQC2a +d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf +sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u +QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8 +-----END RSA PRIVATE KEY-----` diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index e7a2fbd363..7076377c1f 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -107,11 +107,17 @@ type data struct { workspaceApps []database.WorkspaceApp workspaces []database.Workspace licenses []database.License + replicas []database.Replica deploymentID string + derpMeshKey string lastLicenseID int32 } +func (*fakeQuerier) Ping(_ context.Context) (time.Duration, error) { + return 0, nil +} + // InTx doesn't rollback data properly for in-memory yet. func (q *fakeQuerier) InTx(fn func(database.Store) error) error { q.mutex.Lock() @@ -235,12 +241,58 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) ( return rs, nil } +func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { + var emptyRow database.GetTemplateAverageBuildTimeRow + var ( + startTimes []float64 + stopTimes []float64 + deleteTimes []float64 + ) + for _, wb := range q.workspaceBuilds { + version, err := q.GetTemplateVersionByID(ctx, wb.TemplateVersionID) + if err != nil { + return emptyRow, err + } + if version.TemplateID != arg.TemplateID { + continue + } + + job, err := q.GetProvisionerJobByID(ctx, wb.JobID) + if err != nil { + return emptyRow, err + } + if job.CompletedAt.Valid { + took := job.CompletedAt.Time.Sub(job.StartedAt.Time).Seconds() + if wb.Transition == database.WorkspaceTransitionStart { + startTimes = append(startTimes, took) + } else if wb.Transition == database.WorkspaceTransitionStop { + stopTimes = append(stopTimes, took) + } else if wb.Transition == database.WorkspaceTransitionDelete { + deleteTimes = append(deleteTimes, took) + } + } + } + + tryMedian := func(fs []float64) float64 { + if len(fs) == 0 { + return -1 + } + sort.Float64s(fs) + return fs[len(fs)/2] + } + var row database.GetTemplateAverageBuildTimeRow + row.DeleteMedian = tryMedian(deleteTimes) + row.StopMedian = tryMedian(stopTimes) + row.StartMedian = tryMedian(startTimes) + return row, nil +} + func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database.ParameterValue, error) { q.mutex.Lock() defer q.mutex.Unlock() for _, parameterValue := range q.parameterValues { - if parameterValue.ID.String() != id.String() { + if parameterValue.ID != id { continue } return parameterValue, nil @@ -253,7 +305,7 @@ func (q *fakeQuerier) DeleteParameterValueByID(_ context.Context, id uuid.UUID) defer q.mutex.Unlock() for index, parameterValue := range q.parameterValues { - if parameterValue.ID.String() != id.String() { + if parameterValue.ID != id { continue } q.parameterValues[index] = q.parameterValues[len(q.parameterValues)-1] @@ -316,12 +368,24 @@ func (q *fakeQuerier) DeleteAPIKeyByID(_ context.Context, id string) error { return sql.ErrNoRows } -func (q *fakeQuerier) GetFileByHash(_ context.Context, hash string) (database.File, error) { +func (q *fakeQuerier) GetFileByHashAndCreator(_ context.Context, arg database.GetFileByHashAndCreatorParams) (database.File, error) { q.mutex.RLock() defer q.mutex.RUnlock() for _, file := range q.files { - if file.Hash == hash { + if file.Hash == arg.Hash && file.CreatedBy == arg.CreatedBy { + return file, nil + } + } + return database.File{}, sql.ErrNoRows +} + +func (q *fakeQuerier) GetFileByID(_ context.Context, id uuid.UUID) (database.File, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, file := range q.files { + if file.ID == id { return file, nil } } @@ -495,7 +559,7 @@ func (q *fakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab users := make([]database.User, 0) for _, user := range q.users { for _, id := range ids { - if user.ID.String() != id.String() { + if user.ID != id { continue } users = append(users, user) @@ -866,7 +930,7 @@ func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (databas defer q.mutex.RUnlock() for _, workspace := range q.workspaces { - if workspace.ID.String() == id.String() { + if workspace.ID == id { return workspace, nil } } @@ -937,7 +1001,7 @@ func (q *fakeQuerier) GetWorkspaceAppsByAgentIDs(_ context.Context, ids []uuid.U apps := make([]database.WorkspaceApp, 0) for _, app := range q.workspaceApps { for _, id := range ids { - if app.AgentID.String() == id.String() { + if app.AgentID == id { apps = append(apps, app) break } @@ -986,7 +1050,7 @@ func (q *fakeQuerier) GetWorkspaceBuildByID(_ context.Context, id uuid.UUID) (da defer q.mutex.RUnlock() for _, history := range q.workspaceBuilds { - if history.ID.String() == id.String() { + if history.ID == id { return history, nil } } @@ -998,7 +1062,7 @@ func (q *fakeQuerier) GetWorkspaceCountByUserID(_ context.Context, id uuid.UUID) defer q.mutex.RUnlock() var count int64 for _, workspace := range q.workspaces { - if workspace.OwnerID.String() == id.String() { + if workspace.OwnerID == id { if workspace.Deleted { continue } @@ -1014,7 +1078,7 @@ func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUI defer q.mutex.RUnlock() for _, build := range q.workspaceBuilds { - if build.JobID.String() == jobID.String() { + if build.JobID == jobID { return build, nil } } @@ -1103,7 +1167,7 @@ func (q *fakeQuerier) GetWorkspaceBuildsByWorkspaceID(_ context.Context, if workspaceBuild.CreatedAt.Before(params.Since) { continue } - if workspaceBuild.WorkspaceID.String() == params.WorkspaceID.String() { + if workspaceBuild.WorkspaceID == params.WorkspaceID { history = append(history, workspaceBuild) } } @@ -1156,7 +1220,7 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Con defer q.mutex.RUnlock() for _, workspaceBuild := range q.workspaceBuilds { - if workspaceBuild.WorkspaceID.String() != arg.WorkspaceID.String() { + if workspaceBuild.WorkspaceID != arg.WorkspaceID { continue } if workspaceBuild.BuildNumber != arg.BuildNumber { @@ -1271,7 +1335,7 @@ func (q *fakeQuerier) GetTemplateByID(_ context.Context, id uuid.UUID) (database defer q.mutex.RUnlock() for _, template := range q.templates { - if template.ID.String() == id.String() { + if template.ID == id { return template, nil } } @@ -1367,7 +1431,7 @@ func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg dat defer q.mutex.RUnlock() for _, templateVersion := range q.templateVersions { - if templateVersion.TemplateID.UUID.String() != arg.TemplateID.String() { + if templateVersion.TemplateID.UUID != arg.TemplateID { continue } version = append(version, templateVersion) @@ -1455,7 +1519,7 @@ func (q *fakeQuerier) GetTemplateVersionByID(_ context.Context, templateVersionI defer q.mutex.RUnlock() for _, templateVersion := range q.templateVersions { - if templateVersion.ID.String() != templateVersionID.String() { + if templateVersion.ID != templateVersionID { continue } return templateVersion, nil @@ -1468,7 +1532,7 @@ func (q *fakeQuerier) GetTemplateVersionByJobID(_ context.Context, jobID uuid.UU defer q.mutex.RUnlock() for _, templateVersion := range q.templateVersions { - if templateVersion.JobID.String() != jobID.String() { + if templateVersion.JobID != jobID { continue } return templateVersion, nil @@ -1482,7 +1546,7 @@ func (q *fakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U parameters := make([]database.ParameterSchema, 0) for _, parameterSchema := range q.parameterSchemas { - if parameterSchema.JobID.String() != jobID.String() { + if parameterSchema.JobID != jobID { continue } parameters = append(parameters, parameterSchema) @@ -1835,7 +1899,7 @@ func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID) defer q.mutex.RUnlock() for _, provisionerDaemon := range q.provisionerDaemons { - if provisionerDaemon.ID.String() != id.String() { + if provisionerDaemon.ID != id { continue } return provisionerDaemon, nil @@ -1861,7 +1925,7 @@ func (q *fakeQuerier) GetWorkspaceResourceByID(_ context.Context, id uuid.UUID) defer q.mutex.RUnlock() for _, resource := range q.provisionerJobResources { - if resource.ID.String() == id.String() { + if resource.ID == id { return resource, nil } } @@ -1874,7 +1938,7 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid resources := make([]database.WorkspaceResource, 0) for _, resource := range q.provisionerJobResources { - if resource.JobID.String() != jobID.String() { + if resource.JobID != jobID { continue } resources = append(resources, resource) @@ -1941,7 +2005,7 @@ func (q *fakeQuerier) GetWorkspaceResourceMetadataByResourceID(_ context.Context metadata := make([]database.WorkspaceResourceMetadatum, 0) for _, metadatum := range q.provisionerJobResourceMetadata { - if metadatum.WorkspaceResourceID.String() == id.String() { + if metadatum.WorkspaceResourceID == id { metadata = append(metadata, metadatum) } } @@ -1955,7 +2019,7 @@ func (q *fakeQuerier) GetWorkspaceResourceMetadataByResourceIDs(_ context.Contex metadata := make([]database.WorkspaceResourceMetadatum, 0) for _, metadatum := range q.provisionerJobResourceMetadata { for _, id := range ids { - if metadatum.WorkspaceResourceID.String() == id.String() { + if metadatum.WorkspaceResourceID == id { metadata = append(metadata, metadatum) } } @@ -1970,7 +2034,7 @@ func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID jobs := make([]database.ProvisionerJob, 0) for _, job := range q.provisionerJobs { for _, id := range ids { - if id.String() == job.ID.String() { + if id == job.ID { jobs = append(jobs, job) break } @@ -2002,7 +2066,7 @@ func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg datab logs := make([]database.ProvisionerJobLog, 0) for _, jobLog := range q.provisionerJobLogs { - if jobLog.JobID.String() != arg.JobID.String() { + if jobLog.JobID != arg.JobID { continue } if !arg.CreatedBefore.IsZero() && jobLog.CreatedAt.After(arg.CreatedBefore) { @@ -2051,6 +2115,7 @@ func (q *fakeQuerier) InsertFile(_ context.Context, arg database.InsertFileParam //nolint:gosimple file := database.File{ + ID: arg.ID, Hash: arg.Hash, CreatedAt: arg.CreatedAt, CreatedBy: arg.CreatedBy, @@ -2235,7 +2300,7 @@ func (q *fakeQuerier) InsertProvisionerJob(_ context.Context, arg database.Inser InitiatorID: arg.InitiatorID, Provisioner: arg.Provisioner, StorageMethod: arg.StorageMethod, - StorageSource: arg.StorageSource, + FileID: arg.FileID, Type: arg.Type, Input: arg.Input, } @@ -2306,6 +2371,12 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam q.mutex.Lock() defer q.mutex.Unlock() + for _, user := range q.users { + if user.Username == arg.Username && !user.Deleted { + return database.User{}, errDuplicateKey + } + } + user := database.User{ ID: arg.ID, Email: arg.Email, @@ -2461,6 +2532,10 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW q.mutex.Lock() defer q.mutex.Unlock() + if arg.SharingLevel == "" { + arg.SharingLevel = database.AppSharingLevelOwner + } + // nolint:gosimple workspaceApp := database.WorkspaceApp{ ID: arg.ID, @@ -2471,6 +2546,7 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW Command: arg.Command, Url: arg.Url, Subdomain: arg.Subdomain, + SharingLevel: arg.SharingLevel, HealthcheckUrl: arg.HealthcheckUrl, HealthcheckInterval: arg.HealthcheckInterval, HealthcheckThreshold: arg.HealthcheckThreshold, @@ -2808,21 +2884,21 @@ func (q *fakeQuerier) GetGitSSHKey(_ context.Context, userID uuid.UUID) (databas return database.GitSSHKey{}, sql.ErrNoRows } -func (q *fakeQuerier) UpdateGitSSHKey(_ context.Context, arg database.UpdateGitSSHKeyParams) error { +func (q *fakeQuerier) UpdateGitSSHKey(_ context.Context, arg database.UpdateGitSSHKeyParams) (database.GitSSHKey, error) { q.mutex.Lock() defer q.mutex.Unlock() for index, key := range q.gitSSHKey { - if key.UserID.String() != arg.UserID.String() { + if key.UserID != arg.UserID { continue } key.UpdatedAt = arg.UpdatedAt key.PrivateKey = arg.PrivateKey key.PublicKey = arg.PublicKey q.gitSSHKey[index] = key - return nil + return key, nil } - return sql.ErrNoRows + return database.GitSSHKey{}, sql.ErrNoRows } func (q *fakeQuerier) InsertGroupMember(_ context.Context, arg database.InsertGroupMemberParams) error { @@ -2864,6 +2940,7 @@ func (q *fakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou for i, group := range q.groups { if group.ID == arg.ID { group.Name = arg.Name + group.AvatarURL = arg.AvatarURL q.groups[i] = group return group, nil } @@ -2876,7 +2953,7 @@ func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error defer q.mutex.Unlock() for index, key := range q.gitSSHKey { - if key.UserID.String() != userID.String() { + if key.UserID != userID { continue } q.gitSSHKey[index] = q.gitSSHKey[len(q.gitSSHKey)-1] @@ -3017,6 +3094,21 @@ func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) { return q.deploymentID, nil } +func (q *fakeQuerier) InsertDERPMeshKey(_ context.Context, id string) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.derpMeshKey = id + return nil +} + +func (q *fakeQuerier) GetDERPMeshKey(_ context.Context) (string, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + return q.derpMeshKey, nil +} + func (q *fakeQuerier) InsertLicense( _ context.Context, arg database.InsertLicenseParams, ) (database.License, error) { @@ -3189,7 +3281,7 @@ func (q *fakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar defer q.mutex.RUnlock() for _, group := range q.groups { - if group.OrganizationID.String() == arg.OrganizationID.String() && + if group.OrganizationID == arg.OrganizationID && group.Name == arg.Name { return database.Group{}, errDuplicateKey } @@ -3200,6 +3292,7 @@ func (q *fakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar ID: arg.ID, Name: arg.Name, OrganizationID: arg.OrganizationID, + AvatarURL: arg.AvatarURL, } q.groups = append(q.groups, group) @@ -3282,3 +3375,70 @@ func (q *fakeQuerier) DeleteGroupByID(_ context.Context, id uuid.UUID) error { return sql.ErrNoRows } + +func (q *fakeQuerier) DeleteReplicasUpdatedBefore(_ context.Context, before time.Time) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, replica := range q.replicas { + if replica.UpdatedAt.Before(before) { + q.replicas = append(q.replicas[:i], q.replicas[i+1:]...) + } + } + + return nil +} + +func (q *fakeQuerier) InsertReplica(_ context.Context, arg database.InsertReplicaParams) (database.Replica, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + replica := database.Replica{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + StartedAt: arg.StartedAt, + UpdatedAt: arg.UpdatedAt, + Hostname: arg.Hostname, + RegionID: arg.RegionID, + RelayAddress: arg.RelayAddress, + Version: arg.Version, + DatabaseLatency: arg.DatabaseLatency, + } + q.replicas = append(q.replicas, replica) + return replica, nil +} + +func (q *fakeQuerier) UpdateReplica(_ context.Context, arg database.UpdateReplicaParams) (database.Replica, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, replica := range q.replicas { + if replica.ID != arg.ID { + continue + } + replica.Hostname = arg.Hostname + replica.StartedAt = arg.StartedAt + replica.StoppedAt = arg.StoppedAt + replica.UpdatedAt = arg.UpdatedAt + replica.RelayAddress = arg.RelayAddress + replica.RegionID = arg.RegionID + replica.Version = arg.Version + replica.Error = arg.Error + replica.DatabaseLatency = arg.DatabaseLatency + q.replicas[index] = replica + return replica, nil + } + return database.Replica{}, sql.ErrNoRows +} + +func (q *fakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time.Time) ([]database.Replica, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + replicas := make([]database.Replica, 0) + for _, replica := range q.replicas { + if replica.UpdatedAt.After(updatedAt) && !replica.StoppedAt.Valid { + replicas = append(replicas, replica) + } + } + return replicas, nil +} diff --git a/coderd/database/db.go b/coderd/database/db.go index 4cbbdb399f..020000888f 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -12,6 +12,7 @@ import ( "context" "database/sql" "errors" + "time" "github.com/jmoiron/sqlx" "golang.org/x/xerrors" @@ -24,6 +25,7 @@ type Store interface { // customQuerier contains custom queries that are not generated. customQuerier + Ping(ctx context.Context) (time.Duration, error) InTx(func(Store) error) error } @@ -58,6 +60,13 @@ type sqlQuerier struct { db DBTX } +// Ping returns the time it takes to ping the database. +func (q *sqlQuerier) Ping(ctx context.Context) (time.Duration, error) { + start := time.Now() + err := q.sdb.PingContext(ctx) + return time.Since(start), err +} + // InTx performs database operations inside a transaction. func (q *sqlQuerier) InTx(function func(Store) error) error { if _, ok := q.db.(*sqlx.Tx); ok { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index eb16074e90..f68f8194f6 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -5,6 +5,12 @@ CREATE TYPE api_key_scope AS ENUM ( 'application_connect' ); +CREATE TYPE app_sharing_level AS ENUM ( + 'owner', + 'authenticated', + 'public' +); + CREATE TYPE audit_action AS ENUM ( 'create', 'write', @@ -151,7 +157,8 @@ CREATE TABLE files ( created_at timestamp with time zone NOT NULL, created_by uuid NOT NULL, mimetype character varying(64) NOT NULL, - data bytea NOT NULL + data bytea NOT NULL, + id uuid DEFAULT gen_random_uuid() NOT NULL ); CREATE TABLE gitsshkeys ( @@ -170,7 +177,8 @@ CREATE TABLE group_members ( CREATE TABLE groups ( id uuid NOT NULL, name text NOT NULL, - organization_id uuid NOT NULL + organization_id uuid NOT NULL, + avatar_url text DEFAULT ''::text NOT NULL ); CREATE TABLE licenses ( @@ -245,7 +253,8 @@ CREATE TABLE provisioner_daemons ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone, name character varying(64) NOT NULL, - provisioners provisioner_type[] NOT NULL + provisioners provisioner_type[] NOT NULL, + replica_id uuid ); CREATE TABLE provisioner_job_logs ( @@ -270,10 +279,24 @@ CREATE TABLE provisioner_jobs ( initiator_id uuid NOT NULL, provisioner provisioner_type NOT NULL, storage_method provisioner_storage_method NOT NULL, - storage_source text NOT NULL, type provisioner_job_type NOT NULL, input jsonb NOT NULL, - worker_id uuid + worker_id uuid, + file_id uuid NOT NULL +); + +CREATE TABLE replicas ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + started_at timestamp with time zone NOT NULL, + stopped_at timestamp with time zone, + updated_at timestamp with time zone NOT NULL, + hostname text NOT NULL, + region_id integer NOT NULL, + relay_address text NOT NULL, + database_latency integer NOT NULL, + version text NOT NULL, + error text DEFAULT ''::text NOT NULL ); CREATE TABLE site_configs ( @@ -370,7 +393,8 @@ CREATE TABLE workspace_apps ( healthcheck_interval integer DEFAULT 0 NOT NULL, healthcheck_threshold integer DEFAULT 0 NOT NULL, health workspace_app_health DEFAULT 'disabled'::public.workspace_app_health NOT NULL, - subdomain boolean DEFAULT false NOT NULL + subdomain boolean DEFAULT false NOT NULL, + sharing_level app_sharing_level DEFAULT 'owner'::public.app_sharing_level NOT NULL ); CREATE TABLE workspace_builds ( @@ -432,7 +456,10 @@ ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); ALTER TABLE ONLY files - ADD CONSTRAINT files_pkey PRIMARY KEY (hash); + ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); + +ALTER TABLE ONLY files + ADD CONSTRAINT files_pkey PRIMARY KEY (id); ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); diff --git a/coderd/database/migrations/000059_file_id.down.sql b/coderd/database/migrations/000059_file_id.down.sql new file mode 100644 index 0000000000..56dbb13eeb --- /dev/null +++ b/coderd/database/migrations/000059_file_id.down.sql @@ -0,0 +1,41 @@ +BEGIN; + +-- Add back the storage_source column. This must be nullable temporarily. +ALTER TABLE provisioner_jobs ADD COLUMN storage_source text; + +-- Set the storage_source to the hash of the files.id reference. +UPDATE + provisioner_jobs +SET + storage_source=files.hash +FROM + files +WHERE + provisioner_jobs.file_id = files.id; + +-- Now that we've populated storage_source drop the file_id column. +ALTER TABLE provisioner_jobs DROP COLUMN file_id; +-- We can set the storage_source column as NOT NULL now. +ALTER TABLE provisioner_jobs ALTER COLUMN storage_source SET NOT NULL; + +-- Delete all the duplicate rows where hashes collide. +-- We filter on 'id' to ensure only 1 unique row. +DELETE FROM + files a +USING + files b +WHERE + a.created_by < b.created_by +AND + a.hash = b.hash; + +-- Drop the primary key on files.id. +ALTER TABLE files DROP CONSTRAINT files_pkey; +-- Drop the id column. +ALTER TABLE files DROP COLUMN id; +-- Drop the unique constraint on hash + owner. +ALTER TABLE files DROP CONSTRAINT files_hash_created_by_key; +-- Set the primary key back to hash. +ALTER TABLE files ADD PRIMARY KEY (hash); + +COMMIT; diff --git a/coderd/database/migrations/000059_file_id.up.sql b/coderd/database/migrations/000059_file_id.up.sql new file mode 100644 index 0000000000..f1b6f96edd --- /dev/null +++ b/coderd/database/migrations/000059_file_id.up.sql @@ -0,0 +1,42 @@ +-- This migration updates the files table to move the unique +-- constraint to be hash + created_by. This is necessary to +-- allow regular users who have been granted admin to a specific +-- template to be able to push and read files used for template +-- versions they create. +-- Prior to this collisions on file.hash were not an issue +-- since users who could push files could also read all files. +-- +-- This migration also adds a 'files.id' column as the primary +-- key. As a side effect the provisioner_jobs must now reference +-- the files.id column since the 'hash' column is now ambiguous. +BEGIN; + +-- Drop the primary key on hash. +ALTER TABLE files DROP CONSTRAINT files_pkey; + +-- Add an 'id' column and designate it the primary key. +ALTER TABLE files ADD COLUMN + id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid (); + +-- Update the constraint to include the user who created it. +ALTER TABLE files ADD UNIQUE(hash, created_by); + +-- Update provisioner_jobs to include a file_id column. +-- This must be temporarily nullable. +ALTER TABLE provisioner_jobs ADD COLUMN file_id uuid; + +-- Update all the rows to point to key in the files table. +UPDATE provisioner_jobs +SET + file_id = files.id +FROM + files +WHERE + provisioner_jobs.storage_source = files.hash; + +-- Enforce NOT NULL on file_id now. +ALTER TABLE provisioner_jobs ALTER COLUMN file_id SET NOT NULL; +-- Drop storage_source since it is no longer useful for anything. +ALTER TABLE provisioner_jobs DROP COLUMN storage_source; + +COMMIT; diff --git a/coderd/database/migrations/000060_app_sharing_level.down.sql b/coderd/database/migrations/000060_app_sharing_level.down.sql new file mode 100644 index 0000000000..757a7f8792 --- /dev/null +++ b/coderd/database/migrations/000060_app_sharing_level.down.sql @@ -0,0 +1,5 @@ +-- Drop column sharing_level from workspace_apps +ALTER TABLE workspace_apps DROP COLUMN sharing_level; + +-- Drop type app_sharing_level +DROP TYPE app_sharing_level; diff --git a/coderd/database/migrations/000060_app_sharing_level.up.sql b/coderd/database/migrations/000060_app_sharing_level.up.sql new file mode 100644 index 0000000000..b339ab9726 --- /dev/null +++ b/coderd/database/migrations/000060_app_sharing_level.up.sql @@ -0,0 +1,12 @@ +-- Add enum app_sharing_level +CREATE TYPE app_sharing_level AS ENUM ( + -- only the workspace owner can access the app + 'owner', + -- any authenticated user on the site can access the app + 'authenticated', + -- any user can access the app even if they are not authenticated + 'public' +); + +-- Add sharing_level column to workspace_apps table +ALTER TABLE workspace_apps ADD COLUMN sharing_level app_sharing_level NOT NULL DEFAULT 'owner'::app_sharing_level; diff --git a/coderd/database/migrations/000061_replicas.down.sql b/coderd/database/migrations/000061_replicas.down.sql new file mode 100644 index 0000000000..4cca6615d4 --- /dev/null +++ b/coderd/database/migrations/000061_replicas.down.sql @@ -0,0 +1,2 @@ +DROP TABLE replicas; +ALTER TABLE provisioner_daemons DROP COLUMN replica_id; diff --git a/coderd/database/migrations/000061_replicas.up.sql b/coderd/database/migrations/000061_replicas.up.sql new file mode 100644 index 0000000000..1400662e30 --- /dev/null +++ b/coderd/database/migrations/000061_replicas.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS replicas ( + -- A unique identifier for the replica that is stored on disk. + -- For persistent replicas, this will be reused. + -- For ephemeral replicas, this will be a new UUID for each one. + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + -- The time the replica was created. + started_at timestamp with time zone NOT NULL, + -- The time the replica was last seen. + stopped_at timestamp with time zone, + -- Updated periodically to ensure the replica is still alive. + updated_at timestamp with time zone NOT NULL, + -- Hostname is the hostname of the replica. + hostname text NOT NULL, + -- Region is the region the replica is in. + -- We only DERP mesh to the same region ID of a running replica. + region_id integer NOT NULL, + -- An address that should be accessible to other replicas. + relay_address text NOT NULL, + -- The latency of the replica to the database in microseconds. + database_latency int NOT NULL, + -- Version is the Coder version of the replica. + version text NOT NULL, + error text NOT NULL DEFAULT '' +); + +-- Associates a provisioner daemon with a replica. +ALTER TABLE provisioner_daemons ADD COLUMN replica_id uuid; diff --git a/coderd/database/migrations/000062_group_avatars.down.sql b/coderd/database/migrations/000062_group_avatars.down.sql new file mode 100644 index 0000000000..eb15f35438 --- /dev/null +++ b/coderd/database/migrations/000062_group_avatars.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE groups DROP COLUMN avatar_url; + +COMMIT; diff --git a/coderd/database/migrations/000062_group_avatars.up.sql b/coderd/database/migrations/000062_group_avatars.up.sql new file mode 100644 index 0000000000..b7f033874b --- /dev/null +++ b/coderd/database/migrations/000062_group_avatars.up.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE groups ADD COLUMN avatar_url text NOT NULL DEFAULT ''; + +COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index f669b5e618..7e398552de 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -34,6 +34,26 @@ func (e *APIKeyScope) Scan(src interface{}) error { return nil } +type AppSharingLevel string + +const ( + AppSharingLevelOwner AppSharingLevel = "owner" + AppSharingLevelAuthenticated AppSharingLevel = "authenticated" + AppSharingLevelPublic AppSharingLevel = "public" +) + +func (e *AppSharingLevel) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AppSharingLevel(s) + case string: + *e = AppSharingLevel(s) + default: + return fmt.Errorf("unsupported scan type for AppSharingLevel: %T", src) + } + return nil +} + type AuditAction string const ( @@ -404,6 +424,7 @@ type File struct { CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Mimetype string `db:"mimetype" json:"mimetype"` Data []byte `db:"data" json:"data"` + ID uuid.UUID `db:"id" json:"id"` } type GitSSHKey struct { @@ -418,6 +439,7 @@ type Group struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` } type GroupMember struct { @@ -487,6 +509,7 @@ type ProvisionerDaemon struct { UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` Provisioners []ProvisionerType `db:"provisioners" json:"provisioners"` + ReplicaID uuid.NullUUID `db:"replica_id" json:"replica_id"` } type ProvisionerJob struct { @@ -501,10 +524,10 @@ type ProvisionerJob struct { InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` StorageMethod ProvisionerStorageMethod `db:"storage_method" json:"storage_method"` - StorageSource string `db:"storage_source" json:"storage_source"` Type ProvisionerJobType `db:"type" json:"type"` Input json.RawMessage `db:"input" json:"input"` WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + FileID uuid.UUID `db:"file_id" json:"file_id"` } type ProvisionerJobLog struct { @@ -517,6 +540,20 @@ type ProvisionerJobLog struct { Output string `db:"output" json:"output"` } +type Replica struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + StartedAt time.Time `db:"started_at" json:"started_at"` + StoppedAt sql.NullTime `db:"stopped_at" json:"stopped_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Hostname string `db:"hostname" json:"hostname"` + RegionID int32 `db:"region_id" json:"region_id"` + RelayAddress string `db:"relay_address" json:"relay_address"` + DatabaseLatency int32 `db:"database_latency" json:"database_latency"` + Version string `db:"version" json:"version"` + Error string `db:"error" json:"error"` +} + type SiteConfig struct { Key string `db:"key" json:"key"` Value string `db:"value" json:"value"` @@ -625,6 +662,7 @@ type WorkspaceApp struct { HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"` Health WorkspaceAppHealth `db:"health" json:"health"` Subdomain bool `db:"subdomain" json:"subdomain"` + SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"` } type WorkspaceBuild struct { diff --git a/coderd/database/pubsub_memory.go b/coderd/database/pubsub_memory.go index 148d2f57b1..de5a940414 100644 --- a/coderd/database/pubsub_memory.go +++ b/coderd/database/pubsub_memory.go @@ -47,8 +47,9 @@ func (m *memoryPubsub) Publish(event string, message []byte) error { return nil } for _, listener := range listeners { - listener(context.Background(), message) + go listener(context.Background(), message) } + return nil } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b58f6abbcc..393ab81fdd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -26,6 +26,7 @@ type sqlcQuerier interface { DeleteLicense(ctx context.Context, id int32) (int32, error) DeleteOldAgentStats(ctx context.Context) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error + DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) @@ -38,8 +39,10 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetDERPMeshKey(ctx context.Context) (string, error) GetDeploymentID(ctx context.Context) (string, error) - GetFileByHash(ctx context.Context, hash string) (File, error) + GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) + GetFileByID(ctx context.Context, id uuid.UUID) (File, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) @@ -66,6 +69,8 @@ type sqlcQuerier interface { GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) + GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) + GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) @@ -121,6 +126,7 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertDERPMeshKey(ctx context.Context, value string) error InsertDeploymentID(ctx context.Context, value string) error InsertFile(ctx context.Context, arg InsertFileParams) (File, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) @@ -134,6 +140,7 @@ type sqlcQuerier interface { InsertProvisionerDaemon(ctx context.Context, arg InsertProvisionerDaemonParams) (ProvisionerDaemon, error) InsertProvisionerJob(ctx context.Context, arg InsertProvisionerJobParams) (ProvisionerJob, error) InsertProvisionerJobLogs(ctx context.Context, arg InsertProvisionerJobLogsParams) ([]ProvisionerJobLog, error) + InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) (TemplateVersion, error) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) @@ -147,13 +154,14 @@ type sqlcQuerier interface { ParameterValue(ctx context.Context, id uuid.UUID) (ParameterValue, error) ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error - UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) error + UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) UpdateProvisionerDaemonByID(ctx context.Context, arg UpdateProvisionerDaemonByIDParams) error UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error UpdateProvisionerJobWithCompleteByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteByIDParams) error + UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2ff1805cd4..cb4b43591a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -647,19 +647,26 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam return i, err } -const getFileByHash = `-- name: GetFileByHash :one +const getFileByHashAndCreator = `-- name: GetFileByHashAndCreator :one SELECT - hash, created_at, created_by, mimetype, data + hash, created_at, created_by, mimetype, data, id FROM files WHERE hash = $1 +AND + created_by = $2 LIMIT 1 ` -func (q *sqlQuerier) GetFileByHash(ctx context.Context, hash string) (File, error) { - row := q.db.QueryRowContext(ctx, getFileByHash, hash) +type GetFileByHashAndCreatorParams struct { + Hash string `db:"hash" json:"hash"` + CreatedBy uuid.UUID `db:"created_by" json:"created_by"` +} + +func (q *sqlQuerier) GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) { + row := q.db.QueryRowContext(ctx, getFileByHashAndCreator, arg.Hash, arg.CreatedBy) var i File err := row.Scan( &i.Hash, @@ -667,18 +674,45 @@ func (q *sqlQuerier) GetFileByHash(ctx context.Context, hash string) (File, erro &i.CreatedBy, &i.Mimetype, &i.Data, + &i.ID, + ) + return i, err +} + +const getFileByID = `-- name: GetFileByID :one +SELECT + hash, created_at, created_by, mimetype, data, id +FROM + files +WHERE + id = $1 +LIMIT + 1 +` + +func (q *sqlQuerier) GetFileByID(ctx context.Context, id uuid.UUID) (File, error) { + row := q.db.QueryRowContext(ctx, getFileByID, id) + var i File + err := row.Scan( + &i.Hash, + &i.CreatedAt, + &i.CreatedBy, + &i.Mimetype, + &i.Data, + &i.ID, ) return i, err } const insertFile = `-- name: InsertFile :one INSERT INTO - files (hash, created_at, created_by, mimetype, "data") + files (id, hash, created_at, created_by, mimetype, "data") VALUES - ($1, $2, $3, $4, $5) RETURNING hash, created_at, created_by, mimetype, data + ($1, $2, $3, $4, $5, $6) RETURNING hash, created_at, created_by, mimetype, data, id ` type InsertFileParams struct { + ID uuid.UUID `db:"id" json:"id"` Hash string `db:"hash" json:"hash"` CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` @@ -688,6 +722,7 @@ type InsertFileParams struct { func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) { row := q.db.QueryRowContext(ctx, insertFile, + arg.ID, arg.Hash, arg.CreatedAt, arg.CreatedBy, @@ -701,6 +736,7 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File &i.CreatedBy, &i.Mimetype, &i.Data, + &i.ID, ) return i, err } @@ -779,7 +815,7 @@ func (q *sqlQuerier) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyPar return i, err } -const updateGitSSHKey = `-- name: UpdateGitSSHKey :exec +const updateGitSSHKey = `-- name: UpdateGitSSHKey :one UPDATE gitsshkeys SET @@ -788,6 +824,8 @@ SET public_key = $4 WHERE user_id = $1 +RETURNING + user_id, created_at, updated_at, private_key, public_key ` type UpdateGitSSHKeyParams struct { @@ -797,14 +835,22 @@ type UpdateGitSSHKeyParams struct { PublicKey string `db:"public_key" json:"public_key"` } -func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) error { - _, err := q.db.ExecContext(ctx, updateGitSSHKey, +func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) { + row := q.db.QueryRowContext(ctx, updateGitSSHKey, arg.UserID, arg.UpdatedAt, arg.PrivateKey, arg.PublicKey, ) - return err + var i GitSSHKey + err := row.Scan( + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PrivateKey, + &i.PublicKey, + ) + return i, err } const deleteGroupByID = `-- name: DeleteGroupByID :exec @@ -882,7 +928,7 @@ func (q *sqlQuerier) GetAllOrganizationMembers(ctx context.Context, organization const getGroupByID = `-- name: GetGroupByID :one SELECT - id, name, organization_id + id, name, organization_id, avatar_url FROM groups WHERE @@ -894,13 +940,18 @@ LIMIT func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) { row := q.db.QueryRowContext(ctx, getGroupByID, id) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one SELECT - id, name, organization_id + id, name, organization_id, avatar_url FROM groups WHERE @@ -919,7 +970,12 @@ type GetGroupByOrgAndNameParams struct { func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) { row := q.db.QueryRowContext(ctx, getGroupByOrgAndName, arg.OrganizationID, arg.Name) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } @@ -978,7 +1034,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([] const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many SELECT - id, name, organization_id + id, name, organization_id, avatar_url FROM groups WHERE @@ -996,7 +1052,12 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization var items []Group for rows.Next() { var i Group - if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + if err := rows.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ); err != nil { return nil, err } items = append(items, i) @@ -1012,7 +1073,7 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization const getUserGroups = `-- name: GetUserGroups :many SELECT - groups.id, groups.name, groups.organization_id + groups.id, groups.name, groups.organization_id, groups.avatar_url FROM groups JOIN @@ -1032,7 +1093,12 @@ func (q *sqlQuerier) GetUserGroups(ctx context.Context, userID uuid.UUID) ([]Gro var items []Group for rows.Next() { var i Group - if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + if err := rows.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ); err != nil { return nil, err } items = append(items, i) @@ -1053,7 +1119,7 @@ INSERT INTO groups ( organization_id ) VALUES - ( $1, 'Everyone', $1) RETURNING id, name, organization_id + ( $1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url ` // We use the organization_id as the id @@ -1062,7 +1128,12 @@ VALUES func (q *sqlQuerier) InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) { row := q.db.QueryRowContext(ctx, insertAllUsersGroup, organizationID) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } @@ -1070,22 +1141,34 @@ const insertGroup = `-- name: InsertGroup :one INSERT INTO groups ( id, name, - organization_id + organization_id, + avatar_url ) VALUES - ( $1, $2, $3) RETURNING id, name, organization_id + ( $1, $2, $3, $4) RETURNING id, name, organization_id, avatar_url ` type InsertGroupParams struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` } func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) { - row := q.db.QueryRowContext(ctx, insertGroup, arg.ID, arg.Name, arg.OrganizationID) + row := q.db.QueryRowContext(ctx, insertGroup, + arg.ID, + arg.Name, + arg.OrganizationID, + arg.AvatarURL, + ) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } @@ -1111,21 +1194,28 @@ const updateGroupByID = `-- name: UpdateGroupByID :one UPDATE groups SET - name = $1 + name = $1, + avatar_url = $2 WHERE - id = $2 -RETURNING id, name, organization_id + id = $3 +RETURNING id, name, organization_id, avatar_url ` type UpdateGroupByIDParams struct { - Name string `db:"name" json:"name"` - ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` + ID uuid.UUID `db:"id" json:"id"` } func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) { - row := q.db.QueryRowContext(ctx, updateGroupByID, arg.Name, arg.ID) + row := q.db.QueryRowContext(ctx, updateGroupByID, arg.Name, arg.AvatarURL, arg.ID) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } @@ -1985,7 +2075,7 @@ func (q *sqlQuerier) ParameterValues(ctx context.Context, arg ParameterValuesPar const getProvisionerDaemonByID = `-- name: GetProvisionerDaemonByID :one SELECT - id, created_at, updated_at, name, provisioners + id, created_at, updated_at, name, provisioners, replica_id FROM provisioner_daemons WHERE @@ -2001,13 +2091,14 @@ func (q *sqlQuerier) GetProvisionerDaemonByID(ctx context.Context, id uuid.UUID) &i.UpdatedAt, &i.Name, pq.Array(&i.Provisioners), + &i.ReplicaID, ) return i, err } const getProvisionerDaemons = `-- name: GetProvisionerDaemons :many SELECT - id, created_at, updated_at, name, provisioners + id, created_at, updated_at, name, provisioners, replica_id FROM provisioner_daemons ` @@ -2027,6 +2118,7 @@ func (q *sqlQuerier) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDa &i.UpdatedAt, &i.Name, pq.Array(&i.Provisioners), + &i.ReplicaID, ); err != nil { return nil, err } @@ -2050,7 +2142,7 @@ INSERT INTO provisioners ) VALUES - ($1, $2, $3, $4) RETURNING id, created_at, updated_at, name, provisioners + ($1, $2, $3, $4) RETURNING id, created_at, updated_at, name, provisioners, replica_id ` type InsertProvisionerDaemonParams struct { @@ -2074,6 +2166,7 @@ func (q *sqlQuerier) InsertProvisionerDaemon(ctx context.Context, arg InsertProv &i.UpdatedAt, &i.Name, pq.Array(&i.Provisioners), + &i.ReplicaID, ) return i, err } @@ -2237,7 +2330,7 @@ WHERE SKIP LOCKED LIMIT 1 - ) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id + ) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id ` type AcquireProvisionerJobParams struct { @@ -2267,17 +2360,17 @@ func (q *sqlQuerier) AcquireProvisionerJob(ctx context.Context, arg AcquireProvi &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ) return i, err } const getProvisionerJobByID = `-- name: GetProvisionerJobByID :one SELECT - id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id + id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id FROM provisioner_jobs WHERE @@ -2299,17 +2392,17 @@ func (q *sqlQuerier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (P &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ) return i, err } const getProvisionerJobsByIDs = `-- name: GetProvisionerJobsByIDs :many SELECT - id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id + id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id FROM provisioner_jobs WHERE @@ -2337,10 +2430,10 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ); err != nil { return nil, err } @@ -2356,7 +2449,7 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI } const getProvisionerJobsCreatedAfter = `-- name: GetProvisionerJobsCreatedAfter :many -SELECT id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id FROM provisioner_jobs WHERE created_at > $1 +SELECT id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id FROM provisioner_jobs WHERE created_at > $1 ` func (q *sqlQuerier) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) { @@ -2380,10 +2473,10 @@ func (q *sqlQuerier) GetProvisionerJobsCreatedAfter(ctx context.Context, created &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ); err != nil { return nil, err } @@ -2408,12 +2501,12 @@ INSERT INTO initiator_id, provisioner, storage_method, - storage_source, + file_id, "type", "input" ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id ` type InsertProvisionerJobParams struct { @@ -2424,7 +2517,7 @@ type InsertProvisionerJobParams struct { InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` StorageMethod ProvisionerStorageMethod `db:"storage_method" json:"storage_method"` - StorageSource string `db:"storage_source" json:"storage_source"` + FileID uuid.UUID `db:"file_id" json:"file_id"` Type ProvisionerJobType `db:"type" json:"type"` Input json.RawMessage `db:"input" json:"input"` } @@ -2438,7 +2531,7 @@ func (q *sqlQuerier) InsertProvisionerJob(ctx context.Context, arg InsertProvisi arg.InitiatorID, arg.Provisioner, arg.StorageMethod, - arg.StorageSource, + arg.FileID, arg.Type, arg.Input, ) @@ -2455,10 +2548,10 @@ func (q *sqlQuerier) InsertProvisionerJob(ctx context.Context, arg InsertProvisi &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ) return i, err } @@ -2531,6 +2624,177 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a return err } +const deleteReplicasUpdatedBefore = `-- name: DeleteReplicasUpdatedBefore :exec +DELETE FROM replicas WHERE updated_at < $1 +` + +func (q *sqlQuerier) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error { + _, err := q.db.ExecContext(ctx, deleteReplicasUpdatedBefore, updatedAt) + return err +} + +const getReplicasUpdatedAfter = `-- name: GetReplicasUpdatedAfter :many +SELECT id, created_at, started_at, stopped_at, updated_at, hostname, region_id, relay_address, database_latency, version, error FROM replicas WHERE updated_at > $1 AND stopped_at IS NULL +` + +func (q *sqlQuerier) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) { + rows, err := q.db.QueryContext(ctx, getReplicasUpdatedAfter, updatedAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Replica + for rows.Next() { + var i Replica + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.StartedAt, + &i.StoppedAt, + &i.UpdatedAt, + &i.Hostname, + &i.RegionID, + &i.RelayAddress, + &i.DatabaseLatency, + &i.Version, + &i.Error, + ); 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 insertReplica = `-- name: InsertReplica :one +INSERT INTO replicas ( + id, + created_at, + started_at, + updated_at, + hostname, + region_id, + relay_address, + version, + database_latency +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, started_at, stopped_at, updated_at, hostname, region_id, relay_address, database_latency, version, error +` + +type InsertReplicaParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Hostname string `db:"hostname" json:"hostname"` + RegionID int32 `db:"region_id" json:"region_id"` + RelayAddress string `db:"relay_address" json:"relay_address"` + Version string `db:"version" json:"version"` + DatabaseLatency int32 `db:"database_latency" json:"database_latency"` +} + +func (q *sqlQuerier) InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error) { + row := q.db.QueryRowContext(ctx, insertReplica, + arg.ID, + arg.CreatedAt, + arg.StartedAt, + arg.UpdatedAt, + arg.Hostname, + arg.RegionID, + arg.RelayAddress, + arg.Version, + arg.DatabaseLatency, + ) + var i Replica + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.StartedAt, + &i.StoppedAt, + &i.UpdatedAt, + &i.Hostname, + &i.RegionID, + &i.RelayAddress, + &i.DatabaseLatency, + &i.Version, + &i.Error, + ) + return i, err +} + +const updateReplica = `-- name: UpdateReplica :one +UPDATE replicas SET + updated_at = $2, + started_at = $3, + stopped_at = $4, + relay_address = $5, + region_id = $6, + hostname = $7, + version = $8, + error = $9, + database_latency = $10 +WHERE id = $1 RETURNING id, created_at, started_at, stopped_at, updated_at, hostname, region_id, relay_address, database_latency, version, error +` + +type UpdateReplicaParams struct { + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + StartedAt time.Time `db:"started_at" json:"started_at"` + StoppedAt sql.NullTime `db:"stopped_at" json:"stopped_at"` + RelayAddress string `db:"relay_address" json:"relay_address"` + RegionID int32 `db:"region_id" json:"region_id"` + Hostname string `db:"hostname" json:"hostname"` + Version string `db:"version" json:"version"` + Error string `db:"error" json:"error"` + DatabaseLatency int32 `db:"database_latency" json:"database_latency"` +} + +func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) { + row := q.db.QueryRowContext(ctx, updateReplica, + arg.ID, + arg.UpdatedAt, + arg.StartedAt, + arg.StoppedAt, + arg.RelayAddress, + arg.RegionID, + arg.Hostname, + arg.Version, + arg.Error, + arg.DatabaseLatency, + ) + var i Replica + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.StartedAt, + &i.StoppedAt, + &i.UpdatedAt, + &i.Hostname, + &i.RegionID, + &i.RelayAddress, + &i.DatabaseLatency, + &i.Version, + &i.Error, + ) + return i, err +} + +const getDERPMeshKey = `-- name: GetDERPMeshKey :one +SELECT value FROM site_configs WHERE key = 'derp_mesh_key' +` + +func (q *sqlQuerier) GetDERPMeshKey(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getDERPMeshKey) + var value string + err := row.Scan(&value) + return value, err +} + const getDeploymentID = `-- name: GetDeploymentID :one SELECT value FROM site_configs WHERE key = 'deployment_id' ` @@ -2542,6 +2806,15 @@ func (q *sqlQuerier) GetDeploymentID(ctx context.Context) (string, error) { return value, err } +const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec +INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1) +` + +func (q *sqlQuerier) InsertDERPMeshKey(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, insertDERPMeshKey, value) + return err +} + const insertDeploymentID = `-- name: InsertDeploymentID :exec INSERT INTO site_configs (key, value) VALUES ('deployment_id', $1) ` @@ -2551,6 +2824,53 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error return err } +const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one +WITH build_times AS ( +SELECT + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec, + workspace_builds.transition +FROM + workspace_builds +JOIN template_versions ON + workspace_builds.template_version_id = template_versions.id +JOIN provisioner_jobs pj ON + workspace_builds.job_id = pj.id +WHERE + template_versions.template_id = $1 AND + (pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND + (pj.started_at > $2) AND + (pj.canceled_at IS NULL) AND + ((pj.error IS NULL) OR (pj.error = '')) +ORDER BY + workspace_builds.created_at DESC +) +SELECT + -- Postgres offers no clear way to DRY this short of a function or other + -- complexities. + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'start')), -1)::FLOAT AS start_median, + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'stop')), -1)::FLOAT AS stop_median, + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'delete')), -1)::FLOAT AS delete_median +FROM build_times +` + +type GetTemplateAverageBuildTimeParams struct { + TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` + StartTime sql.NullTime `db:"start_time" json:"start_time"` +} + +type GetTemplateAverageBuildTimeRow struct { + StartMedian float64 `db:"start_median" json:"start_median"` + StopMedian float64 `db:"stop_median" json:"stop_median"` + DeleteMedian float64 `db:"delete_median" json:"delete_median"` +} + +func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) { + row := q.db.QueryRowContext(ctx, getTemplateAverageBuildTime, arg.TemplateID, arg.StartTime) + var i GetTemplateAverageBuildTimeRow + err := row.Scan(&i.StartMedian, &i.StopMedian, &i.DeleteMedian) + return i, err +} + const getTemplateByID = `-- name: GetTemplateByID :one SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl @@ -4288,7 +4608,7 @@ func (q *sqlQuerier) UpdateWorkspaceAgentVersionByID(ctx context.Context, arg Up } const getWorkspaceAppByAgentIDAndName = `-- name: GetWorkspaceAppByAgentIDAndName :one -SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = $1 AND name = $2 +SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = $1 AND name = $2 ` type GetWorkspaceAppByAgentIDAndNameParams struct { @@ -4312,12 +4632,13 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndName(ctx context.Context, arg Ge &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ) return i, err } const getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many -SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC +SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC ` func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) { @@ -4342,6 +4663,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ); err != nil { return nil, err } @@ -4357,7 +4679,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid } const getWorkspaceAppsByAgentIDs = `-- name: GetWorkspaceAppsByAgentIDs :many -SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC +SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC ` func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) { @@ -4382,6 +4704,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid. &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ); err != nil { return nil, err } @@ -4397,7 +4720,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid. } const getWorkspaceAppsCreatedAfter = `-- name: GetWorkspaceAppsCreatedAfter :many -SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC +SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC ` func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) { @@ -4422,6 +4745,7 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ); err != nil { return nil, err } @@ -4447,13 +4771,14 @@ INSERT INTO command, url, subdomain, + sharing_level, healthcheck_url, healthcheck_interval, healthcheck_threshold, health ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level ` type InsertWorkspaceAppParams struct { @@ -4465,6 +4790,7 @@ type InsertWorkspaceAppParams struct { Command sql.NullString `db:"command" json:"command"` Url sql.NullString `db:"url" json:"url"` Subdomain bool `db:"subdomain" json:"subdomain"` + SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"` HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"` HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"` HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"` @@ -4481,6 +4807,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace arg.Command, arg.Url, arg.Subdomain, + arg.SharingLevel, arg.HealthcheckUrl, arg.HealthcheckInterval, arg.HealthcheckThreshold, @@ -4500,6 +4827,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ) return i, err } diff --git a/coderd/database/queries/files.sql b/coderd/database/queries/files.sql index a91513d31a..1f54386bb3 100644 --- a/coderd/database/queries/files.sql +++ b/coderd/database/queries/files.sql @@ -1,15 +1,28 @@ --- name: GetFileByHash :one +-- name: GetFileByID :one +SELECT + * +FROM + files +WHERE + id = $1 +LIMIT + 1; + +-- name: GetFileByHashAndCreator :one SELECT * FROM files WHERE hash = $1 +AND + created_by = $2 LIMIT 1; + -- name: InsertFile :one INSERT INTO - files (hash, created_at, created_by, mimetype, "data") + files (id, hash, created_at, created_by, mimetype, "data") VALUES - ($1, $2, $3, $4, $5) RETURNING *; + ($1, $2, $3, $4, $5, $6) RETURNING *; diff --git a/coderd/database/queries/gitsshkeys.sql b/coderd/database/queries/gitsshkeys.sql index 1fe9c97fa1..4365e3349b 100644 --- a/coderd/database/queries/gitsshkeys.sql +++ b/coderd/database/queries/gitsshkeys.sql @@ -18,7 +18,7 @@ FROM WHERE user_id = $1; --- name: UpdateGitSSHKey :exec +-- name: UpdateGitSSHKey :one UPDATE gitsshkeys SET @@ -26,7 +26,9 @@ SET private_key = $3, public_key = $4 WHERE - user_id = $1; + user_id = $1 +RETURNING + *; -- name: DeleteGitSSHKey :exec DELETE FROM diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 137bf3040a..45c1b8d03c 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -74,10 +74,11 @@ AND INSERT INTO groups ( id, name, - organization_id + organization_id, + avatar_url ) VALUES - ( $1, $2, $3) RETURNING *; + ( $1, $2, $3, $4) RETURNING *; -- We use the organization_id as the id -- for simplicity since all users is @@ -95,9 +96,10 @@ VALUES UPDATE groups SET - name = $1 + name = $1, + avatar_url = $2 WHERE - id = $2 + id = $3 RETURNING *; -- name: InsertGroupMember :exec diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index 4775d574e2..027bd25bc9 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -59,7 +59,7 @@ INSERT INTO initiator_id, provisioner, storage_method, - storage_source, + file_id, "type", "input" ) diff --git a/coderd/database/queries/replicas.sql b/coderd/database/queries/replicas.sql new file mode 100644 index 0000000000..e87c1f4643 --- /dev/null +++ b/coderd/database/queries/replicas.sql @@ -0,0 +1,31 @@ +-- name: GetReplicasUpdatedAfter :many +SELECT * FROM replicas WHERE updated_at > $1 AND stopped_at IS NULL; + +-- name: InsertReplica :one +INSERT INTO replicas ( + id, + created_at, + started_at, + updated_at, + hostname, + region_id, + relay_address, + version, + database_latency +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; + +-- name: UpdateReplica :one +UPDATE replicas SET + updated_at = $2, + started_at = $3, + stopped_at = $4, + relay_address = $5, + region_id = $6, + hostname = $7, + version = $8, + error = $9, + database_latency = $10 +WHERE id = $1 RETURNING *; + +-- name: DeleteReplicasUpdatedBefore :exec +DELETE FROM replicas WHERE updated_at < $1; diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 9d3936e238..b975d2f68c 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -3,3 +3,9 @@ INSERT INTO site_configs (key, value) VALUES ('deployment_id', $1); -- name: GetDeploymentID :one SELECT value FROM site_configs WHERE key = 'deployment_id'; + +-- name: InsertDERPMeshKey :exec +INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1); + +-- name: GetDERPMeshKey :one +SELECT value FROM site_configs WHERE key = 'derp_mesh_key'; diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 4d55244335..06570fffb0 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -105,3 +105,32 @@ WHERE id = $1 RETURNING *; + +-- name: GetTemplateAverageBuildTime :one +WITH build_times AS ( +SELECT + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec, + workspace_builds.transition +FROM + workspace_builds +JOIN template_versions ON + workspace_builds.template_version_id = template_versions.id +JOIN provisioner_jobs pj ON + workspace_builds.job_id = pj.id +WHERE + template_versions.template_id = @template_id AND + (pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND + (pj.started_at > @start_time) AND + (pj.canceled_at IS NULL) AND + ((pj.error IS NULL) OR (pj.error = '')) +ORDER BY + workspace_builds.created_at DESC +) +SELECT + -- Postgres offers no clear way to DRY this short of a function or other + -- complexities. + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'start')), -1)::FLOAT AS start_median, + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'stop')), -1)::FLOAT AS stop_median, + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'delete')), -1)::FLOAT AS delete_median +FROM build_times +; diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index 3336bfda4a..36494a8e9a 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -21,13 +21,14 @@ INSERT INTO command, url, subdomain, + sharing_level, healthcheck_url, healthcheck_interval, healthcheck_threshold, health ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateWorkspaceAppHealthByID :exec UPDATE diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 44cfd89d43..b4263c09b4 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,6 +6,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( + UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); diff --git a/coderd/devtunnel/servers.go b/coderd/devtunnel/servers.go index 092638fc36..7025fb9c71 100644 --- a/coderd/devtunnel/servers.go +++ b/coderd/devtunnel/servers.go @@ -73,6 +73,7 @@ func FindClosestNode() (Node, error) { } pinger.Count = 5 + pinger.Timeout = 5 * time.Second err = pinger.Run() if err != nil { return err diff --git a/coderd/files.go b/coderd/files.go index a0b7be2c8b..2c304921f8 100644 --- a/coderd/files.go +++ b/coderd/files.go @@ -10,6 +10,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" @@ -50,15 +51,20 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { } hashBytes := sha256.Sum256(data) hash := hex.EncodeToString(hashBytes[:]) - file, err := api.Database.GetFileByHash(ctx, hash) + file, err := api.Database.GetFileByHashAndCreator(ctx, database.GetFileByHashAndCreatorParams{ + Hash: hash, + CreatedBy: apiKey.UserID, + }) if err == nil { // The file already exists! httpapi.Write(ctx, rw, http.StatusOK, codersdk.UploadResponse{ - Hash: file.Hash, + ID: file.ID, }) return } + id := uuid.New() file, err = api.Database.InsertFile(ctx, database.InsertFileParams{ + ID: id, Hash: hash, CreatedBy: apiKey.UserID, CreatedAt: database.Now(), @@ -74,20 +80,30 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { } httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UploadResponse{ - Hash: file.Hash, + ID: file.ID, }) } -func (api *API) fileByHash(rw http.ResponseWriter, r *http.Request) { +func (api *API) fileByID(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - hash := chi.URLParam(r, "hash") - if hash == "" { + + fileID := chi.URLParam(r, "fileID") + if fileID == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "File hash must be provided in url.", + Message: "File id must be provided in url.", }) return } - file, err := api.Database.GetFileByHash(ctx, hash) + + id, err := uuid.Parse(fileID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "File id must be a valid UUID.", + }) + return + } + + file, err := api.Database.GetFileByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { httpapi.ResourceNotFound(rw) return diff --git a/coderd/files_test.go b/coderd/files_test.go index 28d4a33104..b3a3953a43 100644 --- a/coderd/files_test.go +++ b/coderd/files_test.go @@ -5,6 +5,7 @@ import ( "net/http" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" @@ -64,7 +65,7 @@ func TestDownload(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, _, err := client.Download(ctx, "something") + _, _, err := client.Download(ctx, uuid.New()) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) @@ -80,7 +81,7 @@ func TestDownload(t *testing.T) { resp, err := client.Upload(ctx, codersdk.ContentTypeTar, make([]byte, 1024)) require.NoError(t, err) - data, contentType, err := client.Download(ctx, resp.Hash) + data, contentType, err := client.Download(ctx, resp.ID) require.NoError(t, err) require.Len(t, data, 1024) require.Equal(t, codersdk.ContentTypeTar, contentType) diff --git a/coderd/gitsshkey.go b/coderd/gitsshkey.go index c65600e0d3..357f5b2e44 100644 --- a/coderd/gitsshkey.go +++ b/coderd/gitsshkey.go @@ -3,6 +3,7 @@ package coderd import ( "net/http" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" @@ -12,14 +13,32 @@ import ( ) func (api *API) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user := httpmw.UserParam(r) + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.GitSSHKey](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceUserData.WithOwner(user.ID.String())) { httpapi.ResourceNotFound(rw) return } + oldKey, err := api.Database.GetGitSSHKey(ctx, user.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + aReq.Old = oldKey + privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -29,7 +48,7 @@ func (api *API) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) { return } - err = api.Database.UpdateGitSSHKey(ctx, database.UpdateGitSSHKeyParams{ + newKey, err := api.Database.UpdateGitSSHKey(ctx, database.UpdateGitSSHKeyParams{ UserID: user.ID, UpdatedAt: database.Now(), PrivateKey: privateKey, @@ -43,14 +62,7 @@ func (api *API) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) { return } - newKey, err := api.Database.GetGitSSHKey(ctx, user.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching user's git SSH key.", - Detail: err.Error(), - }) - return - } + aReq.New = newKey httpapi.Write(ctx, rw, http.StatusOK, codersdk.GitSSHKey{ UserID: newKey.UserID, diff --git a/coderd/gitsshkey_test.go b/coderd/gitsshkey_test.go index d5edc6c10f..b1a60b6039 100644 --- a/coderd/gitsshkey_test.go +++ b/coderd/gitsshkey_test.go @@ -5,9 +5,12 @@ import ( "testing" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" @@ -73,8 +76,10 @@ func TestGitSSHKey(t *testing.T) { }) t.Run("Regenerate", func(t *testing.T) { t.Parallel() + auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{ SSHKeygenAlgorithm: gitsshkey.AlgorithmEd25519, + Auditor: auditor, }) res := coderdtest.CreateFirstUser(t, client) @@ -89,6 +94,9 @@ func TestGitSSHKey(t *testing.T) { require.GreaterOrEqual(t, key2.UpdatedAt, key1.UpdatedAt) require.NotEmpty(t, key2.PublicKey) require.NotEqual(t, key2.PublicKey, key1.PublicKey) + + require.Len(t, auditor.AuditLogs, 1) + assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[0].Action) }) } diff --git a/coderd/httpapi/url.go b/coderd/httpapi/url.go index ac7b81fd42..2de7038c32 100644 --- a/coderd/httpapi/url.go +++ b/coderd/httpapi/url.go @@ -17,21 +17,10 @@ var ( // {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME} `^(?P%[1]s)--(?P%[1]s)--(?P%[1]s)--(?P%[1]s)$`, nameRegex)) + + validHostnameLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) ) -// SplitSubdomain splits a subdomain from the rest of the hostname. E.g.: -// - "foo.bar.com" becomes "foo", "bar.com" -// - "foo.bar.baz.com" becomes "foo", "bar.baz.com" -// - "foo" becomes "foo", "" -func SplitSubdomain(hostname string) (subdomain string, rest string) { - toks := strings.SplitN(hostname, ".", 2) - if len(toks) < 2 { - return toks[0], "" - } - - return toks[0], toks[1] -} - // ApplicationURL is a parsed application URL hostname. type ApplicationURL struct { // Only one of AppName or Port will be set. @@ -111,3 +100,81 @@ func HostnamesMatch(a, b string) bool { return strings.EqualFold(aHost, bHost) } + +// CompileHostnamePattern compiles a hostname pattern into a regular expression. +// A hostname pattern is a string that may contain a single wildcard character +// at the beginning. The wildcard character matches any number of hostname-safe +// characters excluding periods. The pattern is case-insensitive. +// +// The supplied pattern: +// - must not start or end with a period +// - must contain exactly one asterisk at the beginning +// - must not contain any other wildcard characters +// - must not contain any other characters that are not hostname-safe (including +// whitespace) +// - must contain at least two hostname labels/segments (i.e. "foo" or "*" are +// not valid patterns, but "foo.bar" and "*.bar" are). +// +// The returned regular expression will match an entire hostname with optional +// trailing periods and whitespace. The first submatch will be the wildcard +// match. +func CompileHostnamePattern(pattern string) (*regexp.Regexp, error) { + pattern = strings.ToLower(pattern) + if strings.Contains(pattern, "http:") || strings.Contains(pattern, "https:") { + return nil, xerrors.Errorf("hostname pattern must not contain a scheme: %q", pattern) + } + if strings.Contains(pattern, ":") { + return nil, xerrors.Errorf("hostname pattern must not contain a port: %q", pattern) + } + if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") { + return nil, xerrors.Errorf("hostname pattern must not start or end with a period: %q", pattern) + } + if strings.Count(pattern, ".") < 1 { + return nil, xerrors.Errorf("hostname pattern must contain at least two labels/segments: %q", pattern) + } + if strings.Count(pattern, "*") != 1 { + return nil, xerrors.Errorf("hostname pattern must contain exactly one asterisk: %q", pattern) + } + if !strings.HasPrefix(pattern, "*") { + return nil, xerrors.Errorf("hostname pattern must only contain an asterisk at the beginning: %q", pattern) + } + for i, label := range strings.Split(pattern, ".") { + if i == 0 { + // We have to allow the asterisk to be a valid hostname label. + label = strings.TrimPrefix(label, "*") + label = "a" + label + } + if !validHostnameLabelRegex.MatchString(label) { + return nil, xerrors.Errorf("hostname pattern contains invalid label %q: %q", label, pattern) + } + } + + // Replace periods with escaped periods. + regexPattern := strings.ReplaceAll(pattern, ".", "\\.") + + // Capture wildcard match. + regexPattern = strings.Replace(regexPattern, "*", "([^.]+)", 1) + + // Allow trailing period. + regexPattern = regexPattern + "\\.?" + + // Allow optional port number. + regexPattern += "(:\\d+)?" + + // Allow leading and trailing whitespace. + regexPattern = `^\s*` + regexPattern + `\s*$` + + return regexp.Compile(regexPattern) +} + +// ExecuteHostnamePattern executes a pattern generated by CompileHostnamePattern +// and returns the wildcard match. If the pattern does not match the hostname, +// returns false. +func ExecuteHostnamePattern(pattern *regexp.Regexp, hostname string) (string, bool) { + matches := pattern.FindStringSubmatch(hostname) + if len(matches) < 2 { + return "", false + } + + return matches[1], true +} diff --git a/coderd/httpapi/url_test.go b/coderd/httpapi/url_test.go index 91d232ece2..2843c5efdd 100644 --- a/coderd/httpapi/url_test.go +++ b/coderd/httpapi/url_test.go @@ -1,6 +1,7 @@ package httpapi_test import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -8,64 +9,6 @@ import ( "github.com/coder/coder/coderd/httpapi" ) -func TestSplitSubdomain(t *testing.T) { - t.Parallel() - testCases := []struct { - Name string - Host string - ExpectedSubdomain string - ExpectedRest string - }{ - { - Name: "Empty", - Host: "", - ExpectedSubdomain: "", - ExpectedRest: "", - }, - { - Name: "NoSubdomain", - Host: "com", - ExpectedSubdomain: "com", - ExpectedRest: "", - }, - { - Name: "Domain", - Host: "coder.com", - ExpectedSubdomain: "coder", - ExpectedRest: "com", - }, - { - Name: "Subdomain", - Host: "subdomain.coder.com", - ExpectedSubdomain: "subdomain", - ExpectedRest: "coder.com", - }, - { - Name: "DoubleSubdomain", - Host: "subdomain1.subdomain2.coder.com", - ExpectedSubdomain: "subdomain1", - ExpectedRest: "subdomain2.coder.com", - }, - { - Name: "WithPort", - Host: "subdomain.coder.com:8080", - ExpectedSubdomain: "subdomain", - ExpectedRest: "coder.com:8080", - }, - } - - for _, c := range testCases { - c := c - t.Run(c.Name, func(t *testing.T) { - t.Parallel() - - subdomain, rest := httpapi.SplitSubdomain(c.Host) - require.Equal(t, c.ExpectedSubdomain, subdomain) - require.Equal(t, c.ExpectedRest, rest) - }) - } -} - func TestApplicationURLString(t *testing.T) { t.Parallel() @@ -214,3 +157,239 @@ func TestParseSubdomainAppURL(t *testing.T) { }) } } + +func TestCompileHostnamePattern(t *testing.T) { + t.Parallel() + + type matchCase struct { + input string + // empty string denotes no match + match string + } + + type testCase struct { + name string + pattern string + errorContains string + // expectedRegex only needs to contain the inner part of the regex, not + // the prefix and suffix checks. + expectedRegex string + matchCases []matchCase + } + + testCases := []testCase{ + { + name: "Invalid_ContainsHTTP", + pattern: "http://*.hi.com", + errorContains: "must not contain a scheme", + }, + { + name: "Invalid_ContainsHTTPS", + pattern: "https://*.hi.com", + errorContains: "must not contain a scheme", + }, + { + name: "Invalid_ContainsPort", + pattern: "*.hi.com:8080", + errorContains: "must not contain a port", + }, + { + name: "Invalid_StartPeriod", + pattern: ".hi.com", + errorContains: "must not start or end with a period", + }, + { + name: "Invalid_EndPeriod", + pattern: "hi.com.", + errorContains: "must not start or end with a period", + }, + { + name: "Invalid_Empty", + pattern: "", + errorContains: "must contain at least two labels", + }, + { + name: "Invalid_SingleLabel", + pattern: "hi", + errorContains: "must contain at least two labels", + }, + { + name: "Invalid_NoWildcard", + pattern: "hi.com", + errorContains: "must contain exactly one asterisk", + }, + { + name: "Invalid_MultipleWildcards", + pattern: "**.hi.com", + errorContains: "must contain exactly one asterisk", + }, + { + name: "Invalid_WildcardNotFirst", + pattern: "hi.*.com", + errorContains: "must only contain an asterisk at the beginning", + }, + { + name: "Invalid_BadLabel1", + pattern: "*.h_i.com", + errorContains: "contains invalid label", + }, + { + name: "Invalid_BadLabel2", + pattern: "*.hi-.com", + errorContains: "contains invalid label", + }, + { + name: "Invalid_BadLabel3", + pattern: "*.-hi.com", + errorContains: "contains invalid label", + }, + + { + name: "Valid_Simple", + pattern: "*.hi", + expectedRegex: `([^.]+)\.hi`, + matchCases: []matchCase{ + { + input: "hi", + match: "", + }, + { + input: "hi.com", + match: "", + }, + { + input: "hi.hi.hi", + match: "", + }, + { + input: "abcd.hi", + match: "abcd", + }, + { + input: "abcd.hi.", + match: "abcd", + }, + { + input: " abcd.hi. ", + match: "abcd", + }, + { + input: "abcd.hi:8080", + match: "abcd", + }, + { + input: "ab__invalid__cd-.hi", + // Invalid subdomains still match the pattern because they + // managed to make it to the webserver anyways. + match: "ab__invalid__cd-", + }, + }, + }, + { + name: "Valid_MultiLevel", + pattern: "*.hi.com", + expectedRegex: `([^.]+)\.hi\.com`, + matchCases: []matchCase{ + { + input: "hi.com", + match: "", + }, + { + input: "abcd.hi.com", + match: "abcd", + }, + { + input: "ab__invalid__cd-.hi.com", + match: "ab__invalid__cd-", + }, + }, + }, + { + name: "Valid_WildcardSuffix1", + pattern: `*a.hi.com`, + expectedRegex: `([^.]+)a\.hi\.com`, + matchCases: []matchCase{ + { + input: "hi.com", + match: "", + }, + { + input: "abcd.hi.com", + match: "", + }, + { + input: "ab__invalid__cd-.hi.com", + match: "", + }, + { + input: "abcda.hi.com", + match: "abcd", + }, + { + input: "ab__invalid__cd-a.hi.com", + match: "ab__invalid__cd-", + }, + }, + }, + { + name: "Valid_WildcardSuffix2", + pattern: `*-test.hi.com`, + expectedRegex: `([^.]+)-test\.hi\.com`, + matchCases: []matchCase{ + { + input: "hi.com", + match: "", + }, + { + input: "abcd.hi.com", + match: "", + }, + { + input: "ab__invalid__cd-.hi.com", + match: "", + }, + { + input: "abcd-test.hi.com", + match: "abcd", + }, + { + input: "ab__invalid__cd-test.hi.com", + match: "ab__invalid__cd", + }, + }, + }, + } + + for _, c := range testCases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + regex, err := httpapi.CompileHostnamePattern(c.pattern) + if c.errorContains == "" { + require.NoError(t, err) + + expected := `^\s*` + c.expectedRegex + `\.?(:\d+)?\s*$` + require.Equal(t, expected, regex.String(), "generated regex does not match") + + for i, m := range c.matchCases { + m := m + t.Run(fmt.Sprintf("MatchCase%d", i), func(t *testing.T) { + t.Parallel() + + match, ok := httpapi.ExecuteHostnamePattern(regex, m.input) + if m.match == "" { + require.False(t, ok) + } else { + require.True(t, ok) + require.Equal(t, m.match, match) + } + }) + } + } else { + require.Error(t, err) + require.ErrorContains(t, err, c.errorContains) + } + }) + } +} diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 5e409a58d7..cc331983ce 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -83,8 +83,8 @@ type OAuth2Configs struct { } const ( - signedOutErrorMessage string = "You are signed out or your session has expired. Please sign in again to continue." - internalErrorMessage string = "An internal error occurred. Please try again or contact the system administrator." + SignedOutErrorMessage = "You are signed out or your session has expired. Please sign in again to continue." + internalErrorMessage = "An internal error occurred. Please try again or contact the system administrator." ) type ExtractAPIKeyConfig struct { @@ -119,21 +119,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { // like workspace applications. write := func(code int, response codersdk.Response) { if cfg.RedirectToLogin { - path := r.URL.Path - if r.URL.RawQuery != "" { - path += "?" + r.URL.RawQuery - } - - q := url.Values{} - q.Add("message", response.Message) - q.Add("redirect", path) - - u := &url.URL{ - Path: "/login", - RawQuery: q.Encode(), - } - - http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) + RedirectToLogin(rw, r, response.Message) return } @@ -157,7 +143,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { token := apiTokenFromRequest(r) if token == "" { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenKey), }) return @@ -166,7 +152,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { keyID, keySecret, err := SplitAPIToken(token) if err != nil { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: "Invalid API key format: " + err.Error(), }) return @@ -176,7 +162,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { if err != nil { if errors.Is(err, sql.ErrNoRows) { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: "API key is invalid.", }) return @@ -192,7 +178,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { hashedSecret := sha256.Sum256([]byte(keySecret)) if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: "API key secret is invalid.", }) return @@ -255,7 +241,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { // Checking if the key is expired. if key.ExpiresAt.Before(now) { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()), }) return @@ -422,3 +408,23 @@ func SplitAPIToken(token string) (id string, secret string, err error) { return keyID, keySecret, nil } + +// RedirectToLogin redirects the user to the login page with the `message` and +// `redirect` query parameters set. +func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) { + path := r.URL.Path + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + + q := url.Values{} + q.Add("message", message) + q.Add("redirect", path) + + u := &url.URL{ + Path: "/login", + RawQuery: q.Encode(), + } + + http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) +} diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index faab86228f..9ad91e7d2c 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -148,7 +148,7 @@ func TestOrganizationParam(t *testing.T) { DB: db, RedirectToLogin: false, }), - httpmw.ExtractUserParam(db), + httpmw.ExtractUserParam(db, false), httpmw.ExtractOrganizationParam(db), httpmw.ExtractOrganizationMemberParam(db), ) @@ -189,7 +189,7 @@ func TestOrganizationParam(t *testing.T) { RedirectToLogin: false, }), httpmw.ExtractOrganizationParam(db), - httpmw.ExtractUserParam(db), + httpmw.ExtractUserParam(db, false), httpmw.ExtractOrganizationMemberParam(db), ) rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/httpmw/userparam.go b/coderd/httpmw/userparam.go index 6b852408c2..74119d503a 100644 --- a/coderd/httpmw/userparam.go +++ b/coderd/httpmw/userparam.go @@ -33,8 +33,11 @@ func UserParam(r *http.Request) database.User { return user } -// ExtractUserParam extracts a user from an ID/username in the {user} URL parameter. -func ExtractUserParam(db database.Store) func(http.Handler) http.Handler { +// ExtractUserParam extracts a user from an ID/username in the {user} URL +// parameter. +// +//nolint:revive +func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { var ( @@ -53,7 +56,19 @@ func ExtractUserParam(db database.Store) func(http.Handler) http.Handler { } if userQuery == "me" { - user, err = db.GetUserByID(ctx, APIKey(r).UserID) + apiKey, ok := APIKeyOptional(r) + if !ok { + if redirectToLoginOnMe { + RedirectToLogin(rw, r, SignedOutErrorMessage) + return + } + + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Cannot use \"me\" without a valid session.", + }) + return + } + user, err = db.GetUserByID(ctx, apiKey.UserID) if xerrors.Is(err, sql.ErrNoRows) { httpapi.ResourceNotFound(rw) return diff --git a/coderd/httpmw/userparam_test.go b/coderd/httpmw/userparam_test.go index edd7faf128..9d283f1ea4 100644 --- a/coderd/httpmw/userparam_test.go +++ b/coderd/httpmw/userparam_test.go @@ -63,7 +63,7 @@ func TestUserParam(t *testing.T) { r = returnedRequest })).ServeHTTP(rw, r) - httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) })).ServeHTTP(rw, r) res := rw.Result() @@ -85,7 +85,7 @@ func TestUserParam(t *testing.T) { routeContext := chi.NewRouteContext() routeContext.URLParams.Add("user", "ben") r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext)) - httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) })).ServeHTTP(rw, r) res := rw.Result() @@ -107,7 +107,7 @@ func TestUserParam(t *testing.T) { routeContext := chi.NewRouteContext() routeContext.URLParams.Add("user", "me") r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext)) - httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { _ = httpmw.UserParam(r) rw.WriteHeader(http.StatusOK) })).ServeHTTP(rw, r) diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index bc040b98bc..b44a80c391 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -305,7 +305,7 @@ func TestWorkspaceAgentByNameParam(t *testing.T) { DB: db, RedirectToLogin: true, }), - httpmw.ExtractUserParam(db), + httpmw.ExtractUserParam(db, false), httpmw.ExtractWorkspaceAndAgentParam(db), ) rtr.Get("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index da38f54c84..0adf509596 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -2,6 +2,7 @@ package metricscache import ( "context" + "database/sql" "sync/atomic" "time" @@ -17,7 +18,7 @@ import ( "github.com/coder/retry" ) -// Cache holds the template DAU cache. +// Cache holds the template metrics. // The aggregation queries responsible for these values can take up to a minute // on large deployments. Even in small deployments, aggregation queries can // take a few hundred milliseconds, which would ruin page load times and @@ -26,8 +27,9 @@ type Cache struct { database database.Store log slog.Logger - templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] - templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] + templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] + templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] + templateAverageBuildTime atomic.Pointer[map[uuid.UUID]database.GetTemplateAverageBuildTimeRow] done chan struct{} cancel func() @@ -128,8 +130,9 @@ func (c *Cache) refresh(ctx context.Context) error { } var ( - templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) - templateUniqueUsers = make(map[uuid.UUID]int) + templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) + templateUniqueUsers = make(map[uuid.UUID]int) + templateAverageBuildTimes = make(map[uuid.UUID]database.GetTemplateAverageBuildTimeRow) ) for _, template := range templates { rows, err := c.database.GetTemplateDAUs(ctx, template.ID) @@ -138,9 +141,26 @@ func (c *Cache) refresh(ctx context.Context) error { } templateDAUs[template.ID] = convertDAUResponse(rows) templateUniqueUsers[template.ID] = countUniqueUsers(rows) + + templateAvgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + StartTime: sql.NullTime{ + Time: database.Time(time.Now().AddDate(0, -30, 0)), + Valid: true, + }, + }) + + if err != nil { + return err + } + templateAverageBuildTimes[template.ID] = templateAvgBuildTime } c.templateDAUResponses.Store(&templateDAUs) c.templateUniqueUsers.Store(&templateUniqueUsers) + c.templateAverageBuildTime.Store(&templateAverageBuildTimes) return nil } @@ -220,3 +240,33 @@ func (c *Cache) TemplateUniqueUsers(id uuid.UUID) (int, bool) { } return resp, true } + +func (c *Cache) TemplateBuildTimeStats(id uuid.UUID) codersdk.TemplateBuildTimeStats { + var unknown codersdk.TemplateBuildTimeStats + + m := c.templateAverageBuildTime.Load() + if m == nil { + // Data loading. + return unknown + } + + resp, ok := (*m)[id] + if !ok { + // No data or not enough builds. + return unknown + } + + convertMedian := func(m float64) *int64 { + if m <= 0 { + return nil + } + i := int64(m * 1000) + return &i + } + + return codersdk.TemplateBuildTimeStats{ + StartMillis: convertMedian(resp.StartMedian), + StopMillis: convertMedian(resp.StopMedian), + DeleteMillis: convertMedian(resp.DeleteMedian), + } +} diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 70d926702e..8e2c1dba06 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -2,10 +2,12 @@ package metricscache_test import ( "context" + "database/sql" "testing" "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" @@ -20,7 +22,7 @@ func date(year, month, day int) time.Time { return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) } -func TestCache(t *testing.T) { +func TestCache_TemplateUsers(t *testing.T) { t.Parallel() var ( @@ -197,3 +199,167 @@ func TestCache(t *testing.T) { }) } } + +func clockTime(t time.Time, hour, minute, sec int) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), hour, minute, sec, t.Nanosecond(), t.Location()) +} + +func TestCache_BuildTime(t *testing.T) { + t.Parallel() + + someDay := date(2022, 10, 1) + + type jobParams struct { + startedAt time.Time + completedAt time.Time + } + + type args struct { + rows []jobParams + transition database.WorkspaceTransition + } + type want struct { + buildTimeMs int64 + loads bool + } + tests := []struct { + name string + args args + want want + }{ + {"empty", args{}, want{-1, false}}, + {"one/start", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + }, + transition: database.WorkspaceTransitionStart, + }, want{10 * 1000, true}, + }, + {"two/stop", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 50), + }, + }, + transition: database.WorkspaceTransitionStop, + }, want{50 * 1000, true}, + }, + {"three/delete", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 50), + }, { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 20), + }, + }, + transition: database.WorkspaceTransitionDelete, + }, want{20 * 1000, true}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + var ( + db = databasefake.New() + cache = metricscache.New(db, slogtest.Make(t, nil), testutil.IntervalFast) + ) + + defer cache.Close() + + template, err := db.InsertTemplate(ctx, database.InsertTemplateParams{ + ID: uuid.New(), + }) + require.NoError(t, err) + + templateVersion, err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + ID: uuid.New(), + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + }) + require.NoError(t, err) + + gotStats := cache.TemplateBuildTimeStats(template.ID) + require.Empty(t, gotStats, "should not have loaded yet") + + for _, row := range tt.args.rows { + _, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: uuid.New(), + Provisioner: database.ProvisionerTypeEcho, + }) + require.NoError(t, err) + + job, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + StartedAt: sql.NullTime{Time: row.startedAt, Valid: true}, + Types: []database.ProvisionerType{ + database.ProvisionerTypeEcho, + }, + }) + require.NoError(t, err) + + _, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + TemplateVersionID: templateVersion.ID, + JobID: job.ID, + Transition: tt.args.transition, + }) + require.NoError(t, err) + + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: job.ID, + CompletedAt: sql.NullTime{Time: row.completedAt, Valid: true}, + }) + require.NoError(t, err) + } + + if tt.want.loads { + require.Eventuallyf(t, func() bool { + stats := cache.TemplateBuildTimeStats(template.ID) + return assert.NotEmpty(t, stats) + }, testutil.WaitLong, testutil.IntervalMedium, + "BuildTime never populated", + ) + + gotStats = cache.TemplateBuildTimeStats(template.ID) + + if tt.args.transition == database.WorkspaceTransitionDelete { + require.Nil(t, gotStats.StopMillis) + require.Nil(t, gotStats.StartMillis) + require.Equal(t, tt.want.buildTimeMs, *gotStats.DeleteMillis) + } + if tt.args.transition == database.WorkspaceTransitionStart { + require.Nil(t, gotStats.StopMillis) + require.Nil(t, gotStats.DeleteMillis) + require.Equal(t, tt.want.buildTimeMs, *gotStats.StartMillis) + } + if tt.args.transition == database.WorkspaceTransitionStop { + require.Nil(t, gotStats.StartMillis) + require.Nil(t, gotStats.DeleteMillis) + require.Equal(t, tt.want.buildTimeMs, *gotStats.StopMillis) + } + } else { + require.Never(t, func() bool { + stats := cache.TemplateBuildTimeStats(template.ID) + return !assert.Empty(t, stats) + }, testutil.WaitShort/2, testutil.IntervalMedium, + "BuildTimeStats populated", + ) + } + }) + } +} diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index bef1110ec3..adb5cb2edf 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -315,7 +315,7 @@ func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty } switch job.StorageMethod { case database.ProvisionerStorageMethodFile: - file, err := server.Database.GetFileByHash(ctx, job.StorageSource) + file, err := server.Database.GetFileByID(ctx, job.FileID) if err != nil { return nil, failJob(fmt.Sprintf("get file by hash: %s", err)) } @@ -814,6 +814,14 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. health = database.WorkspaceAppHealthInitializing } + sharingLevel := database.AppSharingLevelOwner + switch app.SharingLevel { + case sdkproto.AppSharingLevel_AUTHENTICATED: + sharingLevel = database.AppSharingLevelAuthenticated + case sdkproto.AppSharingLevel_PUBLIC: + sharingLevel = database.AppSharingLevelPublic + } + dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ ID: uuid.New(), CreatedAt: database.Now(), @@ -829,6 +837,7 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. Valid: app.Url != "", }, Subdomain: app.Subdomain, + SharingLevel: sharingLevel, HealthcheckUrl: app.Healthcheck.Url, HealthcheckInterval: app.Healthcheck.Interval, HealthcheckThreshold: app.Healthcheck.Threshold, diff --git a/coderd/provisionerdaemons_test.go b/coderd/provisionerdaemons_test.go index 8fe4831480..d3b0be35cd 100644 --- a/coderd/provisionerdaemons_test.go +++ b/coderd/provisionerdaemons_test.go @@ -33,11 +33,11 @@ func TestProvisionerDaemons(t *testing.T) { resp, err := client.Upload(ctx, codersdk.ContentTypeTar, data) require.NoError(t, err) - t.Log(resp.Hash) + t.Log(resp.ID) version, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: resp.Hash, + FileID: resp.ID, Provisioner: codersdk.ProvisionerTypeEcho, }) require.NoError(t, err) diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index e278411224..04f050f0c5 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -270,7 +270,7 @@ func (api *API) provisionerJobResources(rw http.ResponseWriter, r *http.Request, } } - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, agent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), agent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading job agent.", @@ -316,10 +316,10 @@ func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) code func convertProvisionerJob(provisionerJob database.ProvisionerJob) codersdk.ProvisionerJob { job := codersdk.ProvisionerJob{ - ID: provisionerJob.ID, - CreatedAt: provisionerJob.CreatedAt, - Error: provisionerJob.Error.String, - StorageSource: provisionerJob.StorageSource, + ID: provisionerJob.ID, + CreatedAt: provisionerJob.CreatedAt, + Error: provisionerJob.Error.String, + FileID: provisionerJob.FileID, } // Applying values optional to the struct. if provisionerJob.StartedAt.Valid { diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 5492e4397d..1a8861c984 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -146,6 +146,10 @@ var ( ResourceDeploymentFlags = Object{ Type: "deployment_flags", } + + ResourceReplicas = Object{ + Type: "replicas", + } ) // Object is used to create objects for authz checks when you have none in diff --git a/coderd/templates.go b/coderd/templates.go index 3366e02b14..7b08303dea 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -600,6 +600,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO now = database.Now() ) file, err := tx.InsertFile(ctx, database.InsertFileParams{ + ID: uuid.New(), Hash: hex.EncodeToString(hash[:]), CreatedAt: now, CreatedBy: opts.userID, @@ -639,7 +640,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO InitiatorID: opts.userID, Provisioner: database.ProvisionerTypeTerraform, StorageMethod: database.ProvisionerStorageMethodFile, - StorageSource: file.Hash, + FileID: file.ID, Type: database.ProvisionerJobTypeTemplateVersionImport, Input: []byte{'{', '}'}, }) @@ -772,6 +773,9 @@ func (api *API) convertTemplate( template database.Template, workspaceOwnerCount uint32, createdByName string, ) codersdk.Template { activeCount, _ := api.metricsCache.TemplateUniqueUsers(template.ID) + + buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID) + return codersdk.Template{ ID: template.ID, CreatedAt: template.CreatedAt, @@ -782,6 +786,7 @@ func (api *API) convertTemplate( ActiveVersionID: template.ActiveVersionID, WorkspaceOwnerCount: workspaceOwnerCount, ActiveUserCount: activeCount, + BuildTimeStats: buildTimeStats, Description: template.Description, Icon: template.Icon, MaxTTLMillis: time.Duration(template.MaxTtl).Milliseconds(), diff --git a/coderd/templates_test.go b/coderd/templates_test.go index bf547c4d0e..7fb1f0c025 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -561,7 +561,7 @@ func TestDeleteTemplate(t *testing.T) { }) } -func TestTemplateDAUs(t *testing.T) { +func TestTemplateMetrics(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ @@ -594,6 +594,7 @@ func TestTemplateDAUs(t *testing.T) { }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.Equal(t, -1, template.ActiveUserCount) + require.Empty(t, template.BuildTimeStats) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -626,7 +627,9 @@ func TestTemplateDAUs(t *testing.T) { require.NoError(t, err) assert.Zero(t, workspaces[0].LastUsedAt) - conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil).Named("tailnet"), resources[0].Agents[0].ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil).Named("tailnet"), + }) require.NoError(t, err) defer func() { _ = conn.Close() @@ -661,6 +664,16 @@ func TestTemplateDAUs(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, template.ActiveUserCount) + require.Eventuallyf(t, func() bool { + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + startMs := template.BuildTimeStats.StartMillis + return startMs != nil && *startMs > 1 + }, + testutil.WaitShort, testutil.IntervalFast, + "BuildTimeStats never loaded", + ) + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) assert.WithinDuration(t, diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 4d5b54b993..ccc1cd9761 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -285,7 +285,7 @@ func (api *API) postTemplateVersionDryRun(rw http.ResponseWriter, r *http.Reques InitiatorID: apiKey.UserID, Provisioner: job.Provisioner, StorageMethod: job.StorageMethod, - StorageSource: job.StorageSource, + FileID: job.FileID, Type: database.ProvisionerJobTypeTemplateVersionDryRun, Input: input, }) @@ -717,7 +717,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return } - file, err := api.Database.GetFileByHash(ctx, req.StorageSource) + file, err := api.Database.GetFileByID(ctx, req.FileID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ Message: "File not found.", @@ -732,12 +732,10 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return } - // TODO(JonA): Readd this check once we update the unique constraint - // on files to be owner + hash. - // if !api.Authorize(r, rbac.ActionRead, file) { - // httpapi.ResourceNotFound(rw) - // return - // } + if !api.Authorize(r, rbac.ActionRead, file) { + httpapi.ResourceNotFound(rw) + return + } var templateVersion database.TemplateVersion var provisionerJob database.ProvisionerJob @@ -814,7 +812,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht InitiatorID: apiKey.UserID, Provisioner: database.ProvisionerType(req.Provisioner), StorageMethod: database.ProvisionerStorageMethodFile, - StorageSource: file.Hash, + FileID: file.ID, Type: database.ProvisionerJobTypeTemplateVersionImport, Input: []byte{'{', '}'}, }) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 5521be8ec3..1bcb5ba8a0 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -66,7 +66,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { _, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ TemplateID: templateID, StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: "hash", + FileID: uuid.New(), Provisioner: codersdk.ProvisionerTypeEcho, }) var apiErr *codersdk.Error @@ -84,7 +84,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { _, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: "hash", + FileID: uuid.New(), Provisioner: codersdk.ProvisionerTypeEcho, }) var apiErr *codersdk.Error @@ -112,7 +112,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { version, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "bananas", StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: file.Hash, + FileID: file.ID, Provisioner: codersdk.ProvisionerTypeEcho, ParameterValues: []codersdk.CreateParameterRequest{{ Name: "example", @@ -842,7 +842,7 @@ func TestPaginatedTemplateVersions(t *testing.T) { eg.Go(func() error { templateVersion, err := client.CreateTemplateVersion(egCtx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ TemplateID: template.ID, - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) diff --git a/coderd/tracing/exporter.go b/coderd/tracing/exporter.go index b516e196d7..56a8d7dcc6 100644 --- a/coderd/tracing/exporter.go +++ b/coderd/tracing/exporter.go @@ -3,6 +3,7 @@ package tracing import ( "context" + "github.com/go-logr/logr" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" @@ -59,12 +60,15 @@ func TracerProvider(ctx context.Context, service string, opts TracerOpts) (*sdkt tracerProvider := sdktrace.NewTracerProvider(tracerOpts...) otel.SetTracerProvider(tracerProvider) + // Ignore otel errors! + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {})) otel.SetTextMapPropagator( propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, ), ) + otel.SetLogger(logr.Discard()) return tracerProvider, func(ctx context.Context) error { for _, close := range closers { diff --git a/coderd/userauth.go b/coderd/userauth.go index 4dd67844cf..30c66a0c58 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "net/http" + "net/mail" "strconv" "strings" "github.com/coreos/go-oidc/v3/oidc" "github.com/google/go-github/v43/github" "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -219,12 +221,25 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { }) return } + usernameRaw, ok := claims["preferred_username"] + var username string + if ok { + username, _ = usernameRaw.(string) + } emailRaw, ok := claims["email"] if !ok { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No email found in OIDC payload!", - }) - return + // Email is an optional claim in OIDC and + // instead the email is frequently sent in + // "preferred_username". See: + // https://github.com/coder/coder/issues/4472 + _, err = mail.ParseAddress(username) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "No email found in OIDC payload!", + }) + return + } + emailRaw = username } email, ok := emailRaw.(string) if !ok { @@ -243,11 +258,6 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { return } } - usernameRaw, ok := claims["preferred_username"] - var username string - if ok { - username, _ = usernameRaw.(string) - } // The username is a required property in Coder. We make a best-effort // attempt at using what the claims provide, but if that fails we will // generate a random username. @@ -381,6 +391,38 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook organizationID = organizations[0].ID } + _, err := tx.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{ + Username: params.Username, + }) + if err == nil { + var ( + original = params.Username + validUsername bool + ) + for i := 0; i < 10; i++ { + alternate := fmt.Sprintf("%s-%s", original, namesgenerator.GetRandomName(1)) + + params.Username = httpapi.UsernameFrom(alternate) + + _, err := tx.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{ + Username: params.Username, + }) + if xerrors.Is(err, sql.ErrNoRows) { + validUsername = true + break + } + if err != nil { + return xerrors.Errorf("get user by email/username: %w", err) + } + } + if !validUsername { + return httpError{ + code: http.StatusConflict, + msg: fmt.Sprintf("exhausted alternatives for taken username %q", original), + } + } + } + user, _, err = api.CreateUser(ctx, tx, CreateUserRequest{ CreateUserRequest: codersdk.CreateUserRequest{ Email: params.Email, diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 42ac974d4e..ba4b437bdb 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -3,13 +3,12 @@ package coderd_test import ( "context" "crypto" - "crypto/rand" - "crypto/rsa" + "fmt" "io" "net/http" "net/url" + "strings" "testing" - "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt" @@ -425,6 +424,15 @@ func TestUserOIDC(t *testing.T) { Username: "kyle", AllowSignups: true, StatusCode: http.StatusTemporaryRedirect, + }, { + // See: https://github.com/coder/coder/issues/4472 + Name: "UsernameIsEmail", + Claims: jwt.MapClaims{ + "preferred_username": "kyle@kwc.io", + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, }, { Name: "WithPicture", Claims: jwt.MapClaims{ @@ -441,17 +449,19 @@ func TestUserOIDC(t *testing.T) { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() - config := createOIDCConfig(t, tc.Claims) + conf := coderdtest.NewOIDCConfig(t, "") + + config := conf.OIDCConfig() config.AllowSignups = tc.AllowSignups config.EmailDomain = tc.EmailDomain + client := coderdtest.New(t, &coderdtest.Options{ OIDCConfig: config, }) - resp := oidcCallback(t, client) + resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.Claims)) assert.Equal(t, tc.StatusCode, resp.StatusCode) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx, _ := testutil.Context(t) if tc.Username != "" { client.SessionToken = authCookieValue(resp.Cookies()) @@ -469,10 +479,50 @@ func TestUserOIDC(t *testing.T) { }) } + t.Run("AlternateUsername", func(t *testing.T) { + t.Parallel() + + conf := coderdtest.NewOIDCConfig(t, "") + + config := conf.OIDCConfig() + config.AllowSignups = true + + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: config, + }) + + code := conf.EncodeClaims(t, jwt.MapClaims{ + "email": "jon@coder.com", + }) + resp := oidcCallback(t, client, code) + assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + ctx, _ := testutil.Context(t) + + client.SessionToken = authCookieValue(resp.Cookies()) + user, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, "jon", user.Username) + + // Pass a different subject field so that we prompt creating a + // new user. + code = conf.EncodeClaims(t, jwt.MapClaims{ + "email": "jon@example2.com", + "sub": "diff", + }) + resp = oidcCallback(t, client, code) + assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + client.SessionToken = authCookieValue(resp.Cookies()) + user, err = client.User(ctx, "me") + require.NoError(t, err) + require.True(t, strings.HasPrefix(user.Username, "jon-"), "username %q should have prefix %q", user.Username, "jon-") + }) + t.Run("Disabled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - resp := oidcCallback(t, client) + resp := oidcCallback(t, client, "asdf") require.Equal(t, http.StatusPreconditionRequired, resp.StatusCode) }) @@ -483,7 +533,7 @@ func TestUserOIDC(t *testing.T) { OAuth2Config: &oauth2Config{}, }, }) - resp := oidcCallback(t, client) + resp := oidcCallback(t, client, "asdf") require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) @@ -505,48 +555,16 @@ func TestUserOIDC(t *testing.T) { Verifier: verifier, }, }) - resp := oidcCallback(t, client) + resp := oidcCallback(t, client, "asdf") require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) } -// createOIDCConfig generates a new OIDCConfig that returns a static token -// with the claims provided. -func createOIDCConfig(t *testing.T, claims jwt.MapClaims) *coderd.OIDCConfig { - t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 - claims["exp"] = time.Now().Add(time.Hour).UnixMilli() - claims["iss"] = "https://coder.com" - claims["sub"] = "hello" - - signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key) - require.NoError(t, err) - - verifier := oidc.NewVerifier("https://coder.com", &oidc.StaticKeySet{ - PublicKeys: []crypto.PublicKey{key.Public()}, - }, &oidc.Config{ - SkipClientIDCheck: true, - }) - - return &coderd.OIDCConfig{ - OAuth2Config: &oauth2Config{ - token: (&oauth2.Token{ - AccessToken: "token", - }).WithExtra(map[string]interface{}{ - "id_token": signed, - }), - }, - Verifier: verifier, - } -} - 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) @@ -564,19 +582,18 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { return res } -func oidcCallback(t *testing.T, client *codersdk.Client) *http.Response { +func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { t.Helper() client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } - state := "somestate" - oauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback?code=asd&state=" + state) + oauthURL, err := client.URL.Parse(fmt.Sprintf("/api/v2/users/oidc/callback?code=%s&state=somestate", code)) require.NoError(t, err) req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) require.NoError(t, err) req.AddCookie(&http.Cookie{ Name: codersdk.OAuth2StateKey, - Value: state, + Value: "somestate", }) res, err := client.HTTPClient.Do(req) require.NoError(t, err) diff --git a/coderd/users.go b/coderd/users.go index f48708e9b5..5b56509786 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1207,6 +1207,7 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { CreatedAt: k.CreatedAt, UpdatedAt: k.UpdatedAt, LoginType: codersdk.LoginType(k.LoginType), + Scope: codersdk.APIKeyScope(k.Scope), LifetimeSeconds: k.LifetimeSeconds, } } diff --git a/coderd/users_test.go b/coderd/users_test.go index 3a7a11b670..e4e8b7d661 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -286,7 +286,7 @@ func TestPostLogin(t *testing.T) { require.Equal(t, int64(86400), key.LifetimeSeconds, "default should be 86400") // tokens have a longer life - token, err := client.CreateToken(ctx, codersdk.Me) + token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{}) require.NoError(t, err, "make new token api key") split = strings.Split(token.Key, "-") apiKey, err := client.GetAPIKey(ctx, admin.UserID.String(), split[0]) @@ -1202,7 +1202,7 @@ func TestPostTokens(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - apiKey, err := client.CreateToken(ctx, codersdk.Me) + apiKey, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{}) require.NotNil(t, apiKey) require.GreaterOrEqual(t, len(apiKey.Key), 2) require.NoError(t, err) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index f854fc1c29..250b4c73ae 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -49,7 +49,7 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { }) return } - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -78,7 +78,7 @@ func (api *API) workspaceAgentApps(rw http.ResponseWriter, r *http.Request) { func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgent(r) - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -98,7 +98,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) func (api *API) postWorkspaceAgentVersion(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgent(r) - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -152,7 +152,7 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { httpapi.ResourceNotFound(rw) return } - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -229,7 +229,7 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req return } - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -376,8 +376,9 @@ func (api *API) dialWorkspaceAgentTailnet(r *http.Request, agentID uuid.UUID) (* }) conn.SetNodeCallback(sendNodes) go func() { - err := api.TailnetCoordinator.ServeClient(serverConn, uuid.New(), agentID) + err := (*api.TailnetCoordinator.Load()).ServeClient(serverConn, uuid.New(), agentID) if err != nil { + api.Logger.Warn(r.Context(), "tailnet coordinator client error", slog.Error(err)) _ = conn.Close() } }() @@ -514,8 +515,9 @@ func (api *API) workspaceAgentCoordinate(rw http.ResponseWriter, r *http.Request closeChan := make(chan struct{}) go func() { defer close(closeChan) - err := api.TailnetCoordinator.ServeAgent(wsNetConn, workspaceAgent.ID) + err := (*api.TailnetCoordinator.Load()).ServeAgent(wsNetConn, workspaceAgent.ID) if err != nil { + api.Logger.Warn(ctx, "tailnet coordinator agent error", slog.Error(err)) _ = conn.Close(websocket.StatusInternalError, err.Error()) return } @@ -583,7 +585,7 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R go httpapi.Heartbeat(ctx, conn) defer conn.Close(websocket.StatusNormalClosure, "") - err = api.TailnetCoordinator.ServeClient(websocket.NetConn(ctx, conn, websocket.MessageBinary), uuid.New(), workspaceAgent.ID) + err = (*api.TailnetCoordinator.Load()).ServeClient(websocket.NetConn(ctx, conn, websocket.MessageBinary), uuid.New(), workspaceAgent.ID) if err != nil { _ = conn.Close(websocket.StatusInternalError, err.Error()) return @@ -594,11 +596,12 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { apps := make([]codersdk.WorkspaceApp, 0) for _, dbApp := range dbApps { apps = append(apps, codersdk.WorkspaceApp{ - ID: dbApp.ID, - Name: dbApp.Name, - Command: dbApp.Command.String, - Icon: dbApp.Icon, - Subdomain: dbApp.Subdomain, + ID: dbApp.ID, + Name: dbApp.Name, + Command: dbApp.Command.String, + Icon: dbApp.Icon, + Subdomain: dbApp.Subdomain, + SharingLevel: codersdk.WorkspaceAppSharingLevel(dbApp.SharingLevel), Healthcheck: codersdk.Healthcheck{ URL: dbApp.HealthcheckUrl, Interval: dbApp.HealthcheckInterval, @@ -610,7 +613,7 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { return apps } -func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator *tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration) (codersdk.WorkspaceAgent, error) { +func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration) (codersdk.WorkspaceAgent, error) { var envs map[string]string if dbAgent.EnvironmentVariables.Valid { err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs) @@ -754,18 +757,30 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques // Allow overriding the stat interval for debugging and testing purposes. timer := time.NewTicker(api.AgentStatsRefreshInterval) - for { - err := wsjson.Write(ctx, conn, codersdk.AgentStatsReportRequest{}) - if err != nil { - api.Logger.Debug(ctx, "write report request", slog.Error(err)) - conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("write report request: %s", err)) - return - } - var rep codersdk.AgentStatsReportResponse + defer timer.Stop() + go func() { + for { + err := wsjson.Write(ctx, conn, codersdk.AgentStatsReportRequest{}) + if err != nil { + conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("write report request: %s", err)) + return + } + + select { + case <-timer.C: + continue + case <-ctx.Done(): + conn.Close(websocket.StatusNormalClosure, "") + return + } + } + }() + + for { + var rep codersdk.AgentStatsReportResponse err = wsjson.Read(ctx, conn, &rep) if err != nil { - api.Logger.Debug(ctx, "read report response", slog.Error(err)) conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("read report response: %s", err)) return } @@ -824,14 +839,6 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques return } } - - select { - case <-timer.C: - continue - case <-ctx.Done(): - conn.Close(websocket.StatusNormalClosure, "") - return - } } } diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 6bd569dde9..e8dd772095 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -123,13 +123,13 @@ func TestWorkspaceAgentListen(t *testing.T) { defer cancel() resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer func() { _ = conn.Close() }() require.Eventually(t, func() bool { - _, err := conn.Ping() + _, err := conn.Ping(ctx) return err == nil }, testutil.WaitLong, testutil.IntervalFast) }) @@ -253,7 +253,9 @@ func TestWorkspaceAgentTailnet(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), resources[0].Agents[0].ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), + }) require.NoError(t, err) defer conn.Close() sshClient, err := conn.SSHClient() diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index 1e3e416861..5a7c192602 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -16,10 +16,12 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" jose "gopkg.in/square/go-jose.v2" + "cdr.dev/slog" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -32,8 +34,6 @@ import ( const ( // This needs to be a super unique query parameter because we don't want to // conflict with query parameters that users may use. - // TODO: this will make dogfooding harder so come up with a more unique - // solution //nolint:gosec subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783" redirectURIQueryParam = "redirect_uri" @@ -51,8 +51,32 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) workspace := httpmw.WorkspaceParam(r) agent := httpmw.WorkspaceAgentParam(r) - if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) { - httpapi.ResourceNotFound(rw) + // We do not support port proxying on paths, so lookup the app by name. + appName := chi.URLParam(r, "workspaceapp") + app, ok := api.lookupWorkspaceApp(rw, r, agent.ID, appName) + if !ok { + return + } + + appSharingLevel := database.AppSharingLevelOwner + if app.SharingLevel != "" { + appSharingLevel = app.SharingLevel + } + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) + if !ok { + return + } + if !authed { + _, hasAPIKey := httpmw.APIKeyOptional(r) + if hasAPIKey { + // The request has a valid API key but insufficient permissions. + renderApplicationNotFound(rw, r, api.AccessURL) + return + } + + // Redirect to login as they don't have permission to access the app and + // they aren't signed in. + httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage) return } @@ -67,10 +91,9 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) api.proxyWorkspaceApplication(proxyApplication{ Workspace: workspace, Agent: agent, - // We do not support port proxying for paths. - AppName: chi.URLParam(r, "workspaceapp"), - Port: 0, - Path: chiPath, + App: &app, + Port: 0, + Path: chiPath, }, rw, r) } @@ -116,7 +139,7 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht // Step 1: Pass on if subdomain-based application proxying is not // configured. - if api.AppHostname == "" { + if api.AppHostname == "" || api.AppHostnameRegex == nil { next.ServeHTTP(rw, r) return } @@ -156,16 +179,30 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht workspace := httpmw.WorkspaceParam(r) agent := httpmw.WorkspaceAgentParam(r) + var workspaceAppPtr *database.WorkspaceApp + if app.AppName != "" { + workspaceApp, ok := api.lookupWorkspaceApp(rw, r, agent.ID, app.AppName) + if !ok { + return + } + + workspaceAppPtr = &workspaceApp + } + // Verify application auth. This function will redirect or // return an error page if the user doesn't have permission. - if !api.verifyWorkspaceApplicationAuth(rw, r, workspace, host) { + sharingLevel := database.AppSharingLevelOwner + if workspaceAppPtr != nil && workspaceAppPtr.SharingLevel != "" { + sharingLevel = workspaceAppPtr.SharingLevel + } + if !api.verifyWorkspaceApplicationSubdomainAuth(rw, r, host, workspace, sharingLevel) { return } api.proxyWorkspaceApplication(proxyApplication{ Workspace: workspace, Agent: agent, - AppName: app.AppName, + App: workspaceAppPtr, Port: app.Port, Path: r.URL.Path, }, rw, r) @@ -182,28 +219,25 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt return httpapi.ApplicationURL{}, false } + // If there are no periods in the hostname, then it can't be a valid + // application URL. + if !strings.Contains(host, ".") { + next.ServeHTTP(rw, r) + return httpapi.ApplicationURL{}, false + } + // Split the subdomain so we can parse the application details and verify it // matches the configured app hostname later. - subdomain, rest := httpapi.SplitSubdomain(host) - if rest == "" { - // If there are no periods in the hostname, then it can't be a valid - // application URL. + subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, host) + if !ok { + // Doesn't match the regex, so it's not a valid application URL. next.ServeHTTP(rw, r) return httpapi.ApplicationURL{}, false } - matchingBaseHostname := httpapi.HostnamesMatch(api.AppHostname, rest) // Parse the application URL from the subdomain. app, err := httpapi.ParseSubdomainAppURL(subdomain) if err != nil { - // If it isn't a valid app URL and the base domain doesn't match the - // configured app hostname, this request was probably destined for the - // dashboard/API router. - if !matchingBaseHostname { - next.ServeHTTP(rw, r) - return httpapi.ApplicationURL{}, false - } - site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, Title: "Invalid application URL", @@ -214,39 +248,142 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt return httpapi.ApplicationURL{}, false } - // At this point we've verified that the subdomain looks like a valid - // application URL, so the base hostname should match the configured app - // hostname. - if !matchingBaseHostname { + return app, true +} + +// lookupWorkspaceApp looks up the workspace application by name in the given +// agent and returns it. If the application is not found or there was a server +// error while looking it up, an HTML error page is returned and false is +// returned so the caller can return early. +func (api *API) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agentID uuid.UUID, appName string) (database.WorkspaceApp, bool) { + app, err := api.Database.GetWorkspaceAppByAgentIDAndName(r.Context(), database.GetWorkspaceAppByAgentIDAndNameParams{ + AgentID: agentID, + Name: appName, + }) + if xerrors.Is(err, sql.ErrNoRows) { + renderApplicationNotFound(rw, r, api.AccessURL) + return database.WorkspaceApp{}, false + } + if err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ - Status: http.StatusNotFound, - Title: "Not Found", - Description: "The server does not accept application requests on this hostname.", - RetryEnabled: false, + Status: http.StatusInternalServerError, + Title: "Internal Server Error", + Description: "Could not fetch workspace application: " + err.Error(), + RetryEnabled: true, DashboardURL: api.AccessURL.String(), }) - return httpapi.ApplicationURL{}, false + return database.WorkspaceApp{}, false } return app, true } -// verifyWorkspaceApplicationAuth checks that the request is authorized to -// access the given application. If the user does not have a app session key, +func (api *API) authorizeWorkspaceApp(r *http.Request, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) { + ctx := r.Context() + + // Short circuit if not authenticated. + roles, ok := httpmw.UserAuthorizationOptional(r) + if !ok { + // The user is not authenticated, so they can only access the app if it + // is public. + return sharingLevel == database.AppSharingLevelPublic, nil + } + + // Do a standard RBAC check. This accounts for share level "owner" and any + // other RBAC rules that may be in place. + // + // Regardless of share level or whether it's enabled or not, the owner of + // the workspace can always access applications (as long as their API key's + // scope allows it). + err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) + if err == nil { + return true, nil + } + + switch sharingLevel { + case database.AppSharingLevelOwner: + // We essentially already did this above with the regular RBAC check. + // Owners can always access their own apps according to RBAC rules, so + // they have already been returned from this function. + case database.AppSharingLevelAuthenticated: + // The user is authenticated at this point, but we need to make sure + // that they have ApplicationConnect permissions to their own + // workspaces. This ensures that the key's scope has permission to + // connect to workspace apps. + object := rbac.ResourceWorkspaceApplicationConnect.WithOwner(roles.ID.String()) + err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, object) + if err == nil { + return true, nil + } + case database.AppSharingLevelPublic: + // We don't really care about scopes and stuff if it's public anyways. + // Someone with a restricted-scope API key could just not submit the + // API key cookie in the request and access the page. + return true, nil + } + + // No checks were successful. + return false, nil +} + +// fetchWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer +// for a given app share level in the given workspace. The user's authorization +// status is returned. If a server error occurs, a HTML error page is rendered +// and false is returned so the caller can return early. +func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) { + ok, err := api.authorizeWorkspaceApp(r, appSharingLevel, workspace) + if err != nil { + api.Logger.Error(r.Context(), "authorize workspace app", slog.Error(err)) + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusInternalServerError, + Title: "Internal Server Error", + Description: "Could not verify authorization. Please try again or contact an administrator.", + RetryEnabled: true, + DashboardURL: api.AccessURL.String(), + }) + return false, false + } + + return ok, true +} + +// checkWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer +// for a given app share level in the given workspace. If the user is not +// authorized or a server error occurs, a discrete HTML error page is rendered +// and false is returned so the caller can return early. +func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) + if !ok { + return false + } + if !authed { + renderApplicationNotFound(rw, r, api.AccessURL) + return false + } + + return true +} + +// verifyWorkspaceApplicationSubdomainAuth checks that the request is authorized +// to access the given application. If the user does not have a app session key, // they will be redirected to the route below. If the user does have a session // key but insufficient permissions a static error page will be rendered. -func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, host string) bool { - _, ok := httpmw.APIKeyOptional(r) - if ok { - if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) { - renderApplicationNotFound(rw, r, api.AccessURL) - return false - } - - // Request should be all good to go! +func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, r *http.Request, host string, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) + if !ok { + return false + } + if authed { return true } + _, hasAPIKey := httpmw.APIKeyOptional(r) + if hasAPIKey { + // The request has a valid API key but insufficient permissions. + renderApplicationNotFound(rw, r, api.AccessURL) + return false + } + // If the request has the special query param then we need to set a cookie // and strip that query parameter. if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" { @@ -352,8 +489,8 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request // Ensure that the redirect URI is a subdomain of api.AppHostname and is a // valid app subdomain. - subdomain, rest := httpapi.SplitSubdomain(u.Hostname()) - if !httpapi.HostnamesMatch(api.AppHostname, rest) { + subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host) + if !ok { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "The redirect_uri query parameter must be a valid app subdomain.", }) @@ -421,58 +558,49 @@ type proxyApplication struct { Workspace database.Workspace Agent database.WorkspaceAgent - // Either AppName or Port must be set, but not both. - AppName string - Port uint16 + // Either App or Port must be set, but not both. + App *database.WorkspaceApp + Port uint16 + + // SharingLevel MUST be set to database.AppSharingLevelOwner by default for + // ports. + SharingLevel database.AppSharingLevel // Path must either be empty or have a leading slash. Path string } func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, rbac.ActionCreate, proxyApp.Workspace.ApplicationConnectRBAC()) { - httpapi.ResourceNotFound(rw) + + sharingLevel := database.AppSharingLevelOwner + if proxyApp.App != nil && proxyApp.App.SharingLevel != "" { + sharingLevel = proxyApp.App.SharingLevel + } + if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.Workspace, sharingLevel) { return } // If the app does not exist, but the app name is a port number, then // route to the port as an "anonymous app". We only support HTTP for // port-based URLs. + // + // This is only supported for subdomain-based applications. internalURL := fmt.Sprintf("http://127.0.0.1:%d", proxyApp.Port) // If the app name was used instead, fetch the app from the database so we // can get the internal URL. - if proxyApp.AppName != "" { - app, err := api.Database.GetWorkspaceAppByAgentIDAndName(ctx, database.GetWorkspaceAppByAgentIDAndNameParams{ - AgentID: proxyApp.Agent.ID, - Name: proxyApp.AppName, - }) - if xerrors.Is(err, sql.ErrNoRows) { - renderApplicationNotFound(rw, r, api.AccessURL) - return - } - if err != nil { - site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ - Status: http.StatusInternalServerError, - Title: "Internal Server Error", - Description: "Could not fetch workspace application: " + err.Error(), - RetryEnabled: true, - DashboardURL: api.AccessURL.String(), - }) - return - } - - if !app.Url.Valid { + if proxyApp.App != nil { + if !proxyApp.App.Url.Valid { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, Title: "Bad Request", - Description: fmt.Sprintf("Application %q does not have a URL set.", app.Name), + Description: fmt.Sprintf("Application %q does not have a URL set.", proxyApp.App.Name), RetryEnabled: true, DashboardURL: api.AccessURL.String(), }) return } - internalURL = app.Url.String + internalURL = proxyApp.App.Url.String } appURL, err := url.Parse(internalURL) @@ -692,8 +820,8 @@ func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey strin func renderApplicationNotFound(rw http.ResponseWriter, r *http.Request, accessURL *url.URL) { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusNotFound, - Title: "Application not found", - Description: "The application or workspace you are trying to access does not exist.", + Title: "Application Not Found", + Description: "The application or workspace you are trying to access does not exist or you do not have permission to access it.", RetryEnabled: false, DashboardURL: accessURL.String(), }) diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index c5bc98666f..bbf80746d7 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -7,6 +7,7 @@ import ( "io" "net" "net/http" + "net/http/httputil" "net/url" "strings" "testing" @@ -28,19 +29,22 @@ import ( ) const ( - proxyTestAgentName = "agent-name" - proxyTestAppName = "example" - proxyTestAppQuery = "query=true" - proxyTestAppBody = "hello world" - proxyTestFakeAppName = "fake" + proxyTestAgentName = "agent-name" + proxyTestAppNameFake = "test-app-fake" + proxyTestAppNameOwner = "test-app-owner" + proxyTestAppNameAuthenticated = "test-app-authenticated" + proxyTestAppNamePublic = "test-app-public" + proxyTestAppQuery = "query=true" + proxyTestAppBody = "hello world from apps test" - proxyTestSubdomain = "test.coder.com" + proxyTestSubdomainRaw = "*.test.coder.com" + proxyTestSubdomain = "test.coder.com" ) func TestGetAppHost(t *testing.T) { t.Parallel() - cases := []string{"", "test.coder.com"} + cases := []string{"", proxyTestSubdomainRaw} for _, c := range cases { c := c name := c @@ -72,7 +76,7 @@ func TestGetAppHost(t *testing.T) { // setupProxyTest creates a workspace with an agent and some apps. It returns a // codersdk client, the first user, the workspace, and the port number the test // listener is running on. -func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (*codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.Workspace, uint16) { +func setupProxyTest(t *testing.T, customAppHost ...string) (*codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.Workspace, uint16) { // #nosec ln, err := net.Listen("tcp", ":0") require.NoError(t, err) @@ -93,62 +97,20 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork tcpAddr, ok := ln.Addr().(*net.TCPAddr) require.True(t, ok) + appHost := proxyTestSubdomainRaw + if len(customAppHost) > 0 { + appHost = customAppHost[0] + } + client := coderdtest.New(t, &coderdtest.Options{ - AppHostname: proxyTestSubdomain, + AppHostname: appHost, IncludeProvisionerDaemon: true, AgentStatsRefreshInterval: time.Millisecond * 100, MetricsCacheRefreshInterval: time.Millisecond * 100, }) user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionDryRun: echo.ProvisionComplete, - Provision: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Name: proxyTestAgentName, - Auth: &proto.Agent_Token{ - Token: authToken, - }, - Apps: []*proto.App{ - { - Name: proxyTestAppName, - Url: fmt.Sprintf("http://127.0.0.1:%d?%s", tcpAddr.Port, proxyTestAppQuery), - }, { - Name: proxyTestFakeAppName, - // Hopefully this IP and port doesn't exist. - Url: "http://127.1.0.1:65535", - }, - }, - }}, - }}, - }, - }, - }}, - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, workspaceMutators...) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = authToken - agentCloser := agent.New(agent.Options{ - FetchMetadata: agentClient.WorkspaceAgentMetadata, - CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, - Logger: slogtest.Make(t, nil).Named("agent"), - StatsReporter: agentClient.AgentReportStats, - }) - t.Cleanup(func() { - _ = agentCloser.Close() - }) - coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + workspace := createWorkspaceWithApps(t, client, user.OrganizationID, uint16(tcpAddr.Port)) // Configure the HTTP client to not follow redirects and to route all // requests regardless of hostname to the coderd test server. @@ -166,6 +128,75 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork return client, user, workspace, uint16(tcpAddr.Port) } +func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { + authToken := uuid.NewString() + + appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", port, proxyTestAppQuery) + version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: proxyTestAgentName, + Auth: &proto.Agent_Token{ + Token: authToken, + }, + Apps: []*proto.App{ + { + Name: proxyTestAppNameFake, + SharingLevel: proto.AppSharingLevel_OWNER, + // Hopefully this IP and port doesn't exist. + Url: "http://127.1.0.1:65535", + }, + { + Name: proxyTestAppNameOwner, + SharingLevel: proto.AppSharingLevel_OWNER, + Url: appURL, + }, + { + Name: proxyTestAppNameAuthenticated, + SharingLevel: proto.AppSharingLevel_AUTHENTICATED, + Url: appURL, + }, + { + Name: proxyTestAppNamePublic, + SharingLevel: proto.AppSharingLevel_PUBLIC, + Url: appURL, + }, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, orgID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID, workspaceMutators...) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agent.Options{ + FetchMetadata: agentClient.WorkspaceAgentMetadata, + CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, + Logger: slogtest.Make(t, nil).Named("agent"), + StatsReporter: agentClient.AgentReportStats, + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + return workspace +} + func TestWorkspaceAppsProxyPath(t *testing.T) { t.Parallel() client, firstUser, workspace, _ := setupProxyTest(t) @@ -180,7 +211,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() @@ -201,7 +232,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := userClient.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil) + resp, err := userClient.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusNotFound, resp.StatusCode) @@ -213,7 +244,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) @@ -225,7 +256,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example/", nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) @@ -240,7 +271,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example/?"+proxyTestAppQuery, nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -255,7 +286,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/fake/", nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameFake), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusBadGateway, resp.StatusCode) @@ -281,7 +312,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) { require.NoError(t, err) // Try to load the application without authentication. - subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppName, proxyTestAgentName, workspace.Name, user.Username) + subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, workspace.Name, user.Username) u, err := url.Parse(fmt.Sprintf("http://%s.%s/test", subdomain, proxyTestSubdomain)) require.NoError(t, err) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -510,28 +541,9 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) { return client } - t.Run("NotMatchingHostname", func(t *testing.T) { - t.Parallel() - client := setup(t, "test."+proxyTestSubdomain) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - uri := fmt.Sprintf("http://app--agent--workspace--username.%s/api/v2/users/me", proxyTestSubdomain) - resp, err := client.Request(ctx, http.MethodGet, uri, nil) - require.NoError(t, err) - defer resp.Body.Close() - - // Should have an error response. - require.Equal(t, http.StatusNotFound, resp.StatusCode) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - require.Contains(t, string(body), "does not accept application requests on this hostname") - }) - t.Run("InvalidSubdomain", func(t *testing.T) { t.Parallel() - client := setup(t, proxyTestSubdomain) + client := setup(t, proxyTestSubdomainRaw) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -551,11 +563,11 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) { func TestWorkspaceAppsProxySubdomain(t *testing.T) { t.Parallel() - client, firstUser, workspace, port := setupProxyTest(t) + client, firstUser, _, port := setupProxyTest(t) // proxyURL generates a URL for the proxy subdomain. The default path is a // slash. - proxyURL := func(t *testing.T, appNameOrPort interface{}, pathAndQuery ...string) string { + proxyURL := func(t *testing.T, client *codersdk.Client, appNameOrPort interface{}, pathAndQuery ...string) string { t.Helper() var ( @@ -569,16 +581,30 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { require.True(t, ok) } - me, err := client.User(context.Background(), codersdk.Me) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + me, err := client.User(ctx, codersdk.Me) require.NoError(t, err, "get current user details") - hostname := httpapi.ApplicationURL{ + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Owner: codersdk.Me, + }) + require.NoError(t, err, "get workspaces") + require.Len(t, workspaces, 1, "expected 1 workspace") + + appHost, err := client.GetAppHost(ctx) + require.NoError(t, err, "get app host") + + subdomain := httpapi.ApplicationURL{ AppName: appName, Port: port, AgentName: proxyTestAgentName, - WorkspaceName: workspace.Name, + WorkspaceName: workspaces[0].Name, Username: me.Username, - }.String() + "." + proxyTestSubdomain + }.String() + + hostname := strings.Replace(appHost.Host, "*", subdomain, 1) actualPath := "/" query := "" @@ -607,7 +633,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := userClient.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppName), nil) + resp, err := userClient.Request(ctx, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusNotFound, resp.StatusCode) @@ -619,7 +645,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - slashlessURL := proxyURL(t, proxyTestAppName, "") + slashlessURL := proxyURL(t, client, proxyTestAppNameOwner, "") resp, err := client.Request(ctx, http.MethodGet, slashlessURL, nil) require.NoError(t, err) defer resp.Body.Close() @@ -636,7 +662,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - querylessURL := proxyURL(t, proxyTestAppName, "/", "") + querylessURL := proxyURL(t, client, proxyTestAppNameOwner, "/", "") resp, err := client.Request(ctx, http.MethodGet, querylessURL, nil) require.NoError(t, err) defer resp.Body.Close() @@ -653,7 +679,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppName, "/", proxyTestAppQuery), nil) + resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -668,7 +694,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, port, "/", proxyTestAppQuery), nil) + resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, port, "/", proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -683,7 +709,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestFakeAppName, "/", ""), nil) + resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, proxyTestAppNameFake, "/", ""), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusBadGateway, resp.StatusCode) @@ -696,7 +722,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { defer cancel() port := uint16(codersdk.MinimumListeningPort - 1) - resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, port, "/", proxyTestAppQuery), nil) + resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, port, "/", proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() @@ -707,4 +733,235 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { require.NoError(t, err) require.Contains(t, resBody.Message, "Coder reserves ports less than") }) + + t.Run("SuffixWildcardOK", func(t *testing.T) { + t.Parallel() + + client, _, _, _ := setupProxyTest(t, "*-suffix.test.coder.com") + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery) + t.Logf("url: %s", u) + + resp, err := client.Request(ctx, http.MethodGet, u, nil) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, proxyTestAppBody, string(body)) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("SuffixWildcardNotMatch", func(t *testing.T) { + t.Parallel() + + client, _, _, _ := setupProxyTest(t, "*-suffix.test.coder.com") + + t.Run("NoSuffix", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery) + // Replace the -suffix with nothing. + u = strings.Replace(u, "-suffix", "", 1) + + resp, err := client.Request(ctx, http.MethodGet, u, nil) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // It's probably rendering the dashboard, so only ensure that the body + // doesn't match. + require.NotContains(t, string(body), proxyTestAppBody) + }) + + t.Run("DifferentSuffix", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery) + // Replace the -suffix with something else. + u = strings.Replace(u, "-suffix", "-not-suffix", 1) + + resp, err := client.Request(ctx, http.MethodGet, u, nil) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // It's probably rendering the dashboard, so only ensure that the body + // doesn't match. + require.NotContains(t, string(body), proxyTestAppBody) + }) + }) +} + +func TestAppSharing(t *testing.T) { + t.Parallel() + + setup := func(t *testing.T) (workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) { + //nolint:gosec + const password = "password" + + client, _, workspace, _ = setupProxyTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + user, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + + // Verify that the apps have the correct sharing levels set. + workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + agnt = workspaceBuild.Resources[0].Agents[0] + found := map[string]codersdk.WorkspaceAppSharingLevel{} + expected := map[string]codersdk.WorkspaceAppSharingLevel{ + proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner, + proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner, + proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated, + proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic, + } + for _, app := range agnt.Apps { + found[app.Name] = app.SharingLevel + } + require.Equal(t, expected, found, "apps have incorrect sharing levels") + + // Create a user in a different org. + otherOrg, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "a-different-org", + }) + require.NoError(t, err) + userInOtherOrg, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "no-template-access@coder.com", + Username: "no-template-access", + Password: password, + OrganizationID: otherOrg.ID, + }) + require.NoError(t, err) + + clientInOtherOrg = codersdk.New(client.URL) + loginRes, err := clientInOtherOrg.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: userInOtherOrg.Email, + Password: password, + }) + require.NoError(t, err) + clientInOtherOrg.SessionToken = loginRes.SessionToken + clientInOtherOrg.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + // Create an unauthenticated codersdk client. + clientWithNoAuth = codersdk.New(client.URL) + clientWithNoAuth.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + return workspace, agnt, user, client, clientInOtherOrg, clientWithNoAuth + } + + verifyAccess := func(t *testing.T, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // If the client has a session token, we also want to check that a + // scoped key works. + clients := []*codersdk.Client{client} + if client.SessionToken != "" { + token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scope: codersdk.APIKeyScopeApplicationConnect, + }) + require.NoError(t, err) + + scopedClient := codersdk.New(client.URL) + scopedClient.SessionToken = token.Key + scopedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect + + clients = append(clients, scopedClient) + } + + for i, client := range clients { + msg := fmt.Sprintf("client %d", i) + + appPath := fmt.Sprintf("/@%s/%s.%s/apps/%s/?%s", username, workspaceName, agentName, appName, proxyTestAppQuery) + res, err := client.Request(ctx, http.MethodGet, appPath, nil) + require.NoError(t, err, msg) + + dump, err := httputil.DumpResponse(res, true) + res.Body.Close() + require.NoError(t, err, msg) + t.Logf("response dump: %s", dump) + + if !shouldHaveAccess { + if shouldRedirectToLogin { + assert.Equal(t, http.StatusTemporaryRedirect, res.StatusCode, "should not have access, expected temporary redirect. "+msg) + location, err := res.Location() + require.NoError(t, err, msg) + assert.Equal(t, "/login", location.Path, "should not have access, expected redirect to /login. "+msg) + } else { + // If the user doesn't have access we return 404 to avoid + // leaking information about the existence of the app. + assert.Equal(t, http.StatusNotFound, res.StatusCode, "should not have access, expected not found. "+msg) + } + } + + if shouldHaveAccess { + assert.Equal(t, http.StatusOK, res.StatusCode, "should have access, expected ok. "+msg) + assert.Contains(t, string(dump), "hello world", "should have access, expected hello world. "+msg) + } + } + } + + t.Run("Level", func(t *testing.T) { + t.Parallel() + + workspace, agent, user, client, clientInOtherOrg, clientWithNoAuth := setup(t) + + t.Run("Owner", func(t *testing.T) { + t.Parallel() + + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, client, true, false) + + // Authenticated users should not have access to a workspace that + // they do not own. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) + + // Unauthenticated user should not have any access. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) + }) + + t.Run("Authenticated", func(t *testing.T) { + t.Parallel() + + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, client, true, false) + + // Authenticated users should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, true, false) + + // Unauthenticated user should not have any access. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) + }) + + t.Run("Public", func(t *testing.T) { + t.Parallel() + + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, client, true, false) + + // Authenticated users should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, true, false) + + // Unauthenticated user should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, true, false) + }) + }) } diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index b2d8424907..dc89f576b5 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -15,6 +15,7 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -277,6 +278,22 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } + // we only want to create audit logs for delete builds right now + if action == rbac.ActionDelete { + var ( + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.Workspace](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionDelete, + }) + ) + + defer commitAudit() + aReq.Old = workspace + } + if createBuild.TemplateVersionID == uuid.Nil { latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) if err != nil { @@ -458,7 +475,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { Provisioner: template.Provisioner, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: templateVersionJob.StorageMethod, - StorageSource: templateVersionJob.StorageSource, + FileID: templateVersionJob.FileID, Input: input, }) if err != nil { @@ -844,7 +861,7 @@ func (api *API) convertWorkspaceBuild( apiAgents := make([]codersdk.WorkspaceAgent, 0) for _, agent := range agents { apps := appsByAgentID[agent.ID] - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, agent, convertApps(apps), api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), agent, convertApps(apps), api.AgentInactiveDisconnectTimeout) if err != nil { return codersdk.WorkspaceBuild{}, xerrors.Errorf("converting workspace agent: %w", err) } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index c4c3f7d364..983063a190 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" @@ -534,7 +535,8 @@ func TestWorkspaceBuildStatus(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - client, closeDaemon, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + auditor := audit.NewMock() + client, closeDaemon, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -575,4 +577,8 @@ func TestWorkspaceBuildStatus(t *testing.T) { workspace, err = client.DeletedWorkspace(ctx, workspace.ID) require.NoError(t, err) require.EqualValues(t, codersdk.WorkspaceStatusDeleted, workspace.LatestBuild.Status) + + // assert an audit log has been created for deletion + require.Len(t, auditor.AuditLogs, 5) + assert.Equal(t, database.AuditActionDelete, auditor.AuditLogs[4].Action) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index e089673597..0295dc29d5 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -487,7 +487,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req Provisioner: template.Provisioner, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: templateVersionJob.StorageMethod, - StorageSource: templateVersionJob.StorageSource, + FileID: templateVersionJob.FileID, Input: input, }) if err != nil { diff --git a/coderd/wsconncache/wsconncache_test.go b/coderd/wsconncache/wsconncache_test.go index 003d3cddb8..d4345ce9d5 100644 --- a/coderd/wsconncache/wsconncache_test.go +++ b/coderd/wsconncache/wsconncache_test.go @@ -128,7 +128,9 @@ func TestCache(t *testing.T) { return } defer release() - proxy.Transport = conn.HTTPTransport() + transport := conn.HTTPTransport() + defer transport.CloseIdleConnections() + proxy.Transport = transport res := httptest.NewRecorder() proxy.ServeHTTP(res, req) resp := res.Result() diff --git a/codersdk/agentconn.go b/codersdk/agentconn.go index b11c440ce3..ddfb9541a1 100644 --- a/codersdk/agentconn.go +++ b/codersdk/agentconn.go @@ -132,10 +132,10 @@ type AgentConn struct { CloseFunc func() } -func (c *AgentConn) Ping() (time.Duration, error) { +func (c *AgentConn) Ping(ctx context.Context) (time.Duration, error) { errCh := make(chan error, 1) durCh := make(chan time.Duration, 1) - c.Conn.Ping(TailnetIP, tailcfg.PingDisco, func(pr *ipnstate.PingResult) { + go c.Conn.Ping(TailnetIP, tailcfg.PingDisco, func(pr *ipnstate.PingResult) { if pr.Err != "" { errCh <- xerrors.New(pr.Err) return @@ -145,6 +145,8 @@ func (c *AgentConn) Ping() (time.Duration, error) { select { case err := <-errCh: return 0, err + case <-ctx.Done(): + return 0, ctx.Err() case dur := <-durCh: return dur, nil } diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 44782dde85..1d22cba345 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -13,13 +13,14 @@ import ( type APIKey struct { ID string `json:"id" validate:"required"` // NOTE: do not ever return the HashedSecret - UserID uuid.UUID `json:"user_id" validate:"required"` - LastUsed time.Time `json:"last_used" validate:"required"` - ExpiresAt time.Time `json:"expires_at" validate:"required"` - CreatedAt time.Time `json:"created_at" validate:"required"` - UpdatedAt time.Time `json:"updated_at" validate:"required"` - LoginType LoginType `json:"login_type" validate:"required"` - LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` + UserID uuid.UUID `json:"user_id" validate:"required"` + LastUsed time.Time `json:"last_used" validate:"required"` + ExpiresAt time.Time `json:"expires_at" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + LoginType LoginType `json:"login_type" validate:"required"` + Scope APIKeyScope `json:"scope" validate:"required"` + LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` } type LoginType string @@ -31,32 +32,51 @@ const ( LoginTypeToken LoginType = "token" ) +type APIKeyScope string + +const ( + APIKeyScopeAll APIKeyScope = "all" + APIKeyScopeApplicationConnect APIKeyScope = "application_connect" +) + +type CreateTokenRequest struct { + Scope APIKeyScope `json:"scope"` +} + +// GenerateAPIKeyResponse contains an API key for a user. +type GenerateAPIKeyResponse struct { + Key string `json:"key"` +} + // CreateToken generates an API key that doesn't expire. -func (c *Client) CreateToken(ctx context.Context, userID string) (*GenerateAPIKeyResponse, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil) +func (c *Client) CreateToken(ctx context.Context, userID string, req CreateTokenRequest) (GenerateAPIKeyResponse, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), req) if err != nil { - return nil, err + return GenerateAPIKeyResponse{}, err } defer res.Body.Close() if res.StatusCode > http.StatusCreated { - return nil, readBodyAsError(res) + return GenerateAPIKeyResponse{}, readBodyAsError(res) } - apiKey := &GenerateAPIKeyResponse{} - return apiKey, json.NewDecoder(res.Body).Decode(apiKey) + + var apiKey GenerateAPIKeyResponse + return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } // CreateAPIKey generates an API key for the user ID provided. -func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKeyResponse, error) { +// DEPRECATED: use CreateToken instead. +func (c *Client) CreateAPIKey(ctx context.Context, user string) (GenerateAPIKeyResponse, error) { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", user), nil) if err != nil { - return nil, err + return GenerateAPIKeyResponse{}, err } defer res.Body.Close() if res.StatusCode > http.StatusCreated { - return nil, readBodyAsError(res) + return GenerateAPIKeyResponse{}, readBodyAsError(res) } - apiKey := &GenerateAPIKeyResponse{} - return apiKey, json.NewDecoder(res.Body).Decode(apiKey) + + var apiKey GenerateAPIKeyResponse + return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } // GetTokens list machine API keys. diff --git a/codersdk/features.go b/codersdk/features.go index 291b5575a7..862411de62 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -15,12 +15,13 @@ const ( ) const ( - FeatureUserLimit = "user_limit" - FeatureAuditLog = "audit_log" - FeatureBrowserOnly = "browser_only" - FeatureSCIM = "scim" - FeatureWorkspaceQuota = "workspace_quota" - FeatureTemplateRBAC = "template_rbac" + FeatureUserLimit = "user_limit" + FeatureAuditLog = "audit_log" + FeatureBrowserOnly = "browser_only" + FeatureSCIM = "scim" + FeatureWorkspaceQuota = "workspace_quota" + FeatureTemplateRBAC = "template_rbac" + FeatureHighAvailability = "high_availability" ) var FeatureNames = []string{ @@ -30,6 +31,7 @@ var FeatureNames = []string{ FeatureSCIM, FeatureWorkspaceQuota, FeatureTemplateRBAC, + FeatureHighAvailability, } type Feature struct { @@ -42,6 +44,7 @@ type Feature struct { type Entitlements struct { Features map[string]Feature `json:"features"` Warnings []string `json:"warnings"` + Errors []string `json:"errors"` HasLicense bool `json:"has_license"` Experimental bool `json:"experimental"` Trial bool `json:"trial"` diff --git a/codersdk/files.go b/codersdk/files.go index 52fcf02150..6d728fbfbc 100644 --- a/codersdk/files.go +++ b/codersdk/files.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + + "github.com/google/uuid" ) const ( @@ -14,7 +16,7 @@ const ( // UploadResponse contains the hash to reference the uploaded file. type UploadResponse struct { - Hash string `json:"hash"` + ID uuid.UUID `json:"hash"` } // Upload uploads an arbitrary file with the content type provided. @@ -35,8 +37,8 @@ func (c *Client) Upload(ctx context.Context, contentType string, content []byte) } // Download fetches a file by uploaded hash. -func (c *Client) Download(ctx context.Context, hash string) ([]byte, string, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/files/%s", hash), nil) +func (c *Client) Download(ctx context.Context, id uuid.UUID) ([]byte, string, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/files/%s", id.String()), nil) if err != nil { return nil, "", err } diff --git a/codersdk/flags.go b/codersdk/flags.go index 92f02941a5..bf407760bb 100644 --- a/codersdk/flags.go +++ b/codersdk/flags.go @@ -19,6 +19,7 @@ type DeploymentFlags struct { DerpServerRegionCode *StringFlag `json:"derp_server_region_code" typescript:",notnull"` DerpServerRegionName *StringFlag `json:"derp_server_region_name" typescript:",notnull"` DerpServerSTUNAddresses *StringArrayFlag `json:"derp_server_stun_address" typescript:",notnull"` + DerpServerRelayAddress *StringFlag `json:"derp_server_relay_address" typescript:",notnull"` DerpConfigURL *StringFlag `json:"derp_config_url" typescript:",notnull"` DerpConfigPath *StringFlag `json:"derp_config_path" typescript:",notnull"` PromEnabled *BoolFlag `json:"prom_enabled" typescript:",notnull"` @@ -37,7 +38,7 @@ type DeploymentFlags struct { OAuth2GithubEnterpriseBaseURL *StringFlag `json:"oauth2_github_enterprise_base_url" typescript:",notnull"` OIDCAllowSignups *BoolFlag `json:"oidc_allow_signups" typescript:",notnull"` OIDCClientID *StringFlag `json:"oidc_client_id" typescript:",notnull"` - OIDCClientSecret *StringFlag `json:"oidc_cliet_secret" typescript:",notnull"` + OIDCClientSecret *StringFlag `json:"oidc_client_secret" typescript:",notnull"` OIDCEmailDomain *StringFlag `json:"oidc_email_domain" typescript:",notnull"` OIDCIssuerURL *StringFlag `json:"oidc_issuer_url" typescript:",notnull"` OIDCScopes *StringArrayFlag `json:"oidc_scopes" typescript:",notnull"` @@ -48,7 +49,7 @@ type DeploymentFlags struct { TLSCertFiles *StringArrayFlag `json:"tls_cert_files" typescript:",notnull"` TLSClientCAFile *StringFlag `json:"tls_client_ca_file" typescript:",notnull"` TLSClientAuth *StringFlag `json:"tls_client_auth" typescript:",notnull"` - TLSKeyFiles *StringArrayFlag `json:"tls_key_tiles" typescript:",notnull"` + TLSKeyFiles *StringArrayFlag `json:"tls_key_files" typescript:",notnull"` TLSMinVersion *StringFlag `json:"tls_min_version" typescript:",notnull"` TraceEnable *BoolFlag `json:"trace_enable" typescript:",notnull"` SecureAuthCookie *BoolFlag `json:"secure_auth_cookie" typescript:",notnull"` diff --git a/codersdk/groups.go b/codersdk/groups.go index b4b9759a02..a84f8560b2 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -11,7 +11,8 @@ import ( ) type CreateGroupRequest struct { - Name string `json:"name"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` } type Group struct { @@ -19,6 +20,7 @@ type Group struct { Name string `json:"name"` OrganizationID uuid.UUID `json:"organization_id"` Members []User `json:"members"` + AvatarURL string `json:"avatar_url"` } func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGroupRequest) (Group, error) { @@ -77,6 +79,7 @@ type PatchGroupRequest struct { AddUsers []string `json:"add_users"` RemoveUsers []string `json:"remove_users"` Name string `json:"name"` + AvatarURL *string `json:"avatar_url"` } func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroupRequest) (Group, error) { diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 003b9156dd..de5e42122c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -39,7 +39,7 @@ type CreateTemplateVersionRequest struct { TemplateID uuid.UUID `json:"template_id,omitempty"` StorageMethod ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"` - StorageSource string `json:"storage_source" validate:"required"` + FileID uuid.UUID `json:"file_id" validate:"required"` Provisioner ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"` // ParameterValues allows for additional parameters to be provided // during the dry-run provision stage. diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 5eb8872fe6..adce0321be 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -64,15 +64,15 @@ const ( ) type ProvisionerJob struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - CanceledAt *time.Time `json:"canceled_at,omitempty"` - Error string `json:"error,omitempty"` - Status ProvisionerJobStatus `json:"status"` - WorkerID *uuid.UUID `json:"worker_id,omitempty"` - StorageSource string `json:"storage_source"` + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CanceledAt *time.Time `json:"canceled_at,omitempty"` + Error string `json:"error,omitempty"` + Status ProvisionerJobStatus `json:"status"` + WorkerID *uuid.UUID `json:"worker_id,omitempty"` + FileID uuid.UUID `json:"file_id"` } type ProvisionerJobLog struct { diff --git a/codersdk/replicas.go b/codersdk/replicas.go new file mode 100644 index 0000000000..e74af021ee --- /dev/null +++ b/codersdk/replicas.go @@ -0,0 +1,44 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +type Replica struct { + // ID is the unique identifier for the replica. + ID uuid.UUID `json:"id"` + // Hostname is the hostname of the replica. + Hostname string `json:"hostname"` + // CreatedAt is when the replica was first seen. + CreatedAt time.Time `json:"created_at"` + // RelayAddress is the accessible address to relay DERP connections. + RelayAddress string `json:"relay_address"` + // RegionID is the region of the replica. + RegionID int32 `json:"region_id"` + // Error is the error. + Error string `json:"error"` + // DatabaseLatency is the latency in microseconds to the database. + DatabaseLatency int32 `json:"database_latency"` +} + +// Replicas fetches the list of replicas. +func (c *Client) Replicas(ctx context.Context) ([]Replica, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/replicas", nil) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + + var replicas []Replica + return replicas, json.NewDecoder(res.Body).Decode(&replicas) +} diff --git a/codersdk/templates.go b/codersdk/templates.go index 7065fed2bd..22e707050a 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -23,13 +23,20 @@ type Template struct { ActiveVersionID uuid.UUID `json:"active_version_id"` WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` // ActiveUserCount is set to -1 when loading. - ActiveUserCount int `json:"active_user_count"` - Description string `json:"description"` - Icon string `json:"icon"` - MaxTTLMillis int64 `json:"max_ttl_ms"` - MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"` - CreatedByID uuid.UUID `json:"created_by_id"` - CreatedByName string `json:"created_by_name"` + ActiveUserCount int `json:"active_user_count"` + BuildTimeStats TemplateBuildTimeStats `json:"build_time_stats"` + Description string `json:"description"` + Icon string `json:"icon"` + MaxTTLMillis int64 `json:"max_ttl_ms"` + MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"` + CreatedByID uuid.UUID `json:"created_by_id"` + CreatedByName string `json:"created_by_name"` +} + +type TemplateBuildTimeStats struct { + StartMillis *int64 `json:"start_ms"` + StopMillis *int64 `json:"stop_ms"` + DeleteMillis *int64 `json:"delete_ms"` } type UpdateActiveTemplateVersion struct { diff --git a/codersdk/users.go b/codersdk/users.go index a37e41d84e..b2452284a2 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -96,11 +96,6 @@ type LoginWithPasswordResponse struct { SessionToken string `json:"session_token" validate:"required"` } -// GenerateAPIKeyResponse contains an API key for a user. -type GenerateAPIKeyResponse struct { - Key string `json:"key"` -} - type CreateOrganizationRequest struct { Name string `json:"name" validate:"required,username"` } diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 253e8713fd..c86944ae2b 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -21,7 +21,6 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" - "github.com/coder/coder/tailnet" "github.com/coder/retry" ) @@ -316,7 +315,8 @@ func (c *Client) ListenWorkspaceAgentTailnet(ctx context.Context) (net.Conn, err Value: c.SessionToken, }}) httpClient := &http.Client{ - Jar: jar, + Jar: jar, + Transport: c.HTTPClient.Transport, } // nolint:bodyclose conn, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{ @@ -332,7 +332,17 @@ func (c *Client) ListenWorkspaceAgentTailnet(ctx context.Context) (net.Conn, err return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil } -func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logger, agentID uuid.UUID) (*AgentConn, error) { +// @typescript-ignore DialWorkspaceAgentOptions +type DialWorkspaceAgentOptions struct { + Logger slog.Logger + // BlockEndpoints forced a direct connection through DERP. + BlockEndpoints bool +} + +func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *DialWorkspaceAgentOptions) (*AgentConn, error) { + if options == nil { + options = &DialWorkspaceAgentOptions{} + } res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/connection", agentID), nil) if err != nil { return nil, err @@ -349,9 +359,10 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg ip := tailnet.IP() conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, - DERPMap: connInfo.DERPMap, - Logger: logger, + Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, + DERPMap: connInfo.DERPMap, + Logger: options.Logger, + BlockEndpoints: options.BlockEndpoints, }) if err != nil { return nil, xerrors.Errorf("create tailnet: %w", err) @@ -370,7 +381,8 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg Value: c.SessionToken, }}) httpClient := &http.Client{ - Jar: jar, + Jar: jar, + Transport: c.HTTPClient.Transport, } ctx, cancelFunc := context.WithCancel(ctx) closed := make(chan struct{}) @@ -379,7 +391,7 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg defer close(closed) isFirst := true for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); { - logger.Debug(ctx, "connecting") + options.Logger.Debug(ctx, "connecting") // nolint:bodyclose ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{ HTTPClient: httpClient, @@ -398,21 +410,21 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg if errors.Is(err, context.Canceled) { return } - logger.Debug(ctx, "failed to dial", slog.Error(err)) + options.Logger.Debug(ctx, "failed to dial", slog.Error(err)) continue } sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error { return conn.UpdateNodes(node) }) conn.SetNodeCallback(sendNode) - logger.Debug(ctx, "serving coordinator") + options.Logger.Debug(ctx, "serving coordinator") err = <-errChan if errors.Is(err, context.Canceled) { _ = ws.Close(websocket.StatusGoingAway, "") return } if err != nil { - logger.Debug(ctx, "error serving coordinator", slog.Error(err)) + options.Logger.Debug(ctx, "error serving coordinator", slog.Error(err)) _ = ws.Close(websocket.StatusGoingAway, "") continue } diff --git a/codersdk/workspaceapps.go b/codersdk/workspaceapps.go index 7de4217bac..6faf4bd3c3 100644 --- a/codersdk/workspaceapps.go +++ b/codersdk/workspaceapps.go @@ -13,6 +13,14 @@ const ( WorkspaceAppHealthUnhealthy WorkspaceAppHealth = "unhealthy" ) +type WorkspaceAppSharingLevel string + +const ( + WorkspaceAppSharingLevelOwner WorkspaceAppSharingLevel = "owner" + WorkspaceAppSharingLevelAuthenticated WorkspaceAppSharingLevel = "authenticated" + WorkspaceAppSharingLevelPublic WorkspaceAppSharingLevel = "public" +) + type WorkspaceApp struct { ID uuid.UUID `json:"id"` // Name is a unique identifier attached to an agent. @@ -25,7 +33,8 @@ type WorkspaceApp struct { // `coder server` or via a hostname-based dev URL. If this is set to true // and there is no app wildcard configured on the server, the app will not // be accessible in the UI. - Subdomain bool `json:"subdomain"` + Subdomain bool `json:"subdomain"` + SharingLevel WorkspaceAppSharingLevel `json:"sharing_level"` // Healthcheck specifies the configuration for checking app health. Healthcheck Healthcheck `json:"healthcheck"` Health WorkspaceAppHealth `json:"health"` diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index ec749caabb..a227ff6511 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -7,6 +7,7 @@ their deployment. We track **create, update and delete** events for the following resources: +- GitSSHKey - Template - TemplateVersion - Workspace diff --git a/docs/admin/configure.md b/docs/admin/configure.md index 8e70475fe2..e1607d9672 100644 --- a/docs/admin/configure.md +++ b/docs/admin/configure.md @@ -29,6 +29,8 @@ Coder uses a PostgreSQL database to store users, workspace metadata, and other d Use `CODER_PG_CONNECTION_URL` to set the database that Coder connects to. If unset, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. +> Postgres 13 is the minimum supported version. + ## System packages If you've installed Coder via a [system package](../install/packages.md) Coder, you can diff --git a/docs/admin/enterprise.md b/docs/admin/enterprise.md index 5524660bc8..45ecf3dfe4 100644 --- a/docs/admin/enterprise.md +++ b/docs/admin/enterprise.md @@ -3,28 +3,32 @@ Coder is free to use and includes some features that are only accessible with a paid license. Contact sales@coder.com to obtain a license. -These features are available in the enterprise edition: - -- [Audit Logging](./audit-logs.md) -- [Browser Only Connections](../networking.md#browser-only-connections) +### User Management - [Groups](./groups.md) - [Template RBAC](./rbac.md) -- [Quotas](./quotas.md) - [SCIM](./auth.md#scim) -And we're releasing these imminently: +### Networking & Deployment +- [High Availability](./high-availability.md) +- [Browser Only Connections](../networking.md#browser-only-connections) + +### Other +- [Audit Logging](./audit-logs.md) +- [Quotas](./quotas.md) + +### Coming soon -- High Availability - Multiple Git Provider Authentication +- Max Workspace Auto-Stop ## Adding your license key -### You will need: +### Requirements - Your license key (contact sales@coder.com if you don't have yours) - Coder CLI installed -### Steps: +### Instructions 1. Save your license key to disk and make note of the path 2. Open a terminal diff --git a/docs/admin/high-availability.md b/docs/admin/high-availability.md new file mode 100644 index 0000000000..4c56d67752 --- /dev/null +++ b/docs/admin/high-availability.md @@ -0,0 +1,64 @@ +# High Availability + +High Availability (HA) mode solves for horizontal scalability and automatic failover +within a single region. When in HA mode, Coder continues using a single Postgres +endpoint. [GCP](https://cloud.google.com/sql/docs/postgres/high-availability), [AWS](https://docs.aws.amazon.com/prescriptive-guidance/latest/saas-multitenant-managed-postgresql/availability.html), +and other cloud vendors offer fully-managed HA Postgres services that pair +nicely with Coder. + +For Coder to operate correctly, every node must be within 10ms of each other +and Postgres. We make a best-effort attempt to warn the user when inter-coder +latency is too high, but if requests start dropping, this is one metric to investigate. +Note that this latency requirement applies _only_ to coder services. Coder will +operate correctly even with few seconds of latency on +workspace <-> coder and user <-> coder connections. + +## Setup + +> We're stress testing High Availability this week before we enable it by default. To try HA beforehand, set `CODER_EXPERIMENTAL=true` for the Coder server as well as the additional configuration options below. + +Coder automatically enters HA mode when multiple instances simultaneously connect +to the same Postgres endpoint. + +HA brings one configuration variable to set in each Coder +node: `CODER_DERP_SERVER_RELAY_URL`. The HA nodes use these URLs to communicate +with each other. Inter-node communication is only required while using the +embedded relay (default). If you're using [custom relays](../networking.md#custom-relays), Coder ignores `CODER_DERP_SERVER_RELAY_URL` since Postgres is the sole rendezvous for the Coder nodes. + +`CODER_DERP_SERVER_RELAY_URL` will never be `CODER_ACCESS_URL` because +`CODER_ACCESS_URL` is a load balancer to all Coder nodes. + +Here's an example 3-node network configuration setup: + +| Name | `CODER_ADDRESS` | `CODER_DERP_SERVER_RELAY_URL` | `CODER_ACCESS_URL` | +| --------- | --------------- | ----------------------------- | ------------------------ | +| `coder-1` | `*:80` | `http://10.0.0.1:80` | `https://coder.big.corp` | +| `coder-2` | `*:80` | `http://10.0.0.2:80` | `https://coder.big.corp` | +| `coder-3` | `*:80` | `http://10.0.0.3:80` | `https://coder.big.corp` | + +## Kubernetes + +If you installed Coder via +[our Helm Chart](../install/kubernetes.md#install-coder-with-helm), just +increase `coder.replicaCount` in `values.yaml`. + +If you installed Coder into Kubernetes by some other means, insert the relay URL +via the environment like so: + +```yaml +env: + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(POD_IP) +``` + +Then, increase the number of pods. + +## Up next + +- [Networking](../networking.md) +- [Kubernetes](../install/kubernetes.md) +- [Enterprise](./enterprise.md) diff --git a/docs/images/icons/hydra.svg b/docs/images/icons/hydra.svg new file mode 100644 index 0000000000..4b6d0938ed --- /dev/null +++ b/docs/images/icons/hydra.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/install/binary.md b/docs/install/binary.md index bde79b0422..20cd3f1643 100644 --- a/docs/install/binary.md +++ b/docs/install/binary.md @@ -17,7 +17,7 @@ Coder publishes self-contained .zip and .tar.gz archives in [GitHub releases](ht # Automatically sets up an external access URL on *.try.coder.app coder server - # Requires a PostgreSQL instance and external access URL + # Requires a PostgreSQL instance (version 13 or higher) and external access URL coder server --postgres-url --access-url ``` diff --git a/docs/install/docker.md b/docs/install/docker.md index 565bdd2f59..73124a13fc 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -30,9 +30,9 @@ Learn more about Coder's [configuration options](../admin/configure.md). ## Run Coder with access URL and external PostgreSQL (recommended) -For production deployments, we recommend using an external PostgreSQL database. -Set `ACCESS_URL` to the external URL that users and workspaces will use to -connect to Coder. +For production deployments, we recommend using an external PostgreSQL database +(version 13 or higher). Set `ACCESS_URL` to the external URL that users and +workspaces will use to connect to Coder. ```sh docker run --rm -it \ diff --git a/docs/install/index.md b/docs/install/index.md index 973936080a..b08bfdaab7 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -1,5 +1,5 @@ There are a number of different methods to install and run Coder: - This page is rendered on https://coder.com/docs/coder-oss/install. Refer to the other documents in the `install/` directory for per-platform instructions. + This page is rendered on https://coder.com/docs/coder-oss/latest/install. Refer to the other documents in the `install/` directory for per-platform instructions. diff --git a/docs/manifest.json b/docs/manifest.json index 1cf16bd323..4ff1536a6b 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -27,17 +27,17 @@ "children": [ { "title": "Install script", - "description": "One-line install script for macOS and Linux.", + "description": "One-line install script for macOS and Linux", "path": "./install/install.sh.md" }, { "title": "System packages", - "description": "System packages for Debian, Ubuntu, Fedora, CentOS, RHEL, SUSE, and Alpine.", + "description": "System packages for Debian, Ubuntu, Fedora, CentOS, RHEL, SUSE, and Alpine", "path": "./install/packages.md" }, { "title": "Kubernetes", - "description": "Install Coder with Kubernetes via Helm.", + "description": "Install Coder with Kubernetes via Helm", "path": "./install/kubernetes.md" }, { @@ -47,12 +47,12 @@ }, { "title": "Standalone binaries", - "description": "Download binaries for macOS, Windows, and Linux.", + "description": "Download binaries for macOS, Windows, and Linux", "path": "./install/binary.md" }, { "title": "Uninstall", - "description": "Learn how to uninstall Coder.", + "description": "Learn how to uninstall Coder", "path": "./install/uninstall.md" } ] @@ -182,7 +182,7 @@ "children": [ { "title": "Authentication", - "description": "Learn how to set up authentication using GitHub or OpenID Connect.", + "description": "Learn how to set up authentication using GitHub or OpenID Connect", "icon_path": "./images/icons/key.svg", "path": "./admin/auth.md" }, @@ -208,13 +208,13 @@ }, { "title": "Configuration", - "description": "Learn how to configure Coder.", + "description": "Learn how to configure Coder", "path": "./admin/configure.md", "icon_path": "./images/icons/toggle_on.svg" }, { "title": "Upgrading", - "description": "Learn how to upgrade Coder.", + "description": "Learn how to upgrade Coder", "icon_path": "./images/icons/upgrade.svg", "path": "./admin/upgrade.md" }, @@ -226,27 +226,34 @@ }, { "title": "Audit Logs", - "description": "Learn how to use Audit Logs in your Coder deployment.", + "description": "Learn how to use Audit Logs in your Coder deployment", "icon_path": "./images/icons/radar.svg", "path": "./admin/audit-logs.md", "state": "enterprise" }, { "title": "Quotas", - "description": "Learn how to use Workspace Quotas in Coder.", + "description": "Learn how to use Workspace Quotas in Coder", "icon_path": "./images/icons/dollar.svg", "path": "./admin/quotas.md", "state": "enterprise" }, + { + "title": "High Availability", + "description": "Learn how to configure Coder for High Availability", + "icon_path": "./images/icons/hydra.svg", + "path": "./admin/high-availability.md", + "state": "enterprise" + }, { "title": "Enterprise", - "description": "Learn how to enable Enterprise features.", + "description": "Learn how to enable Enterprise features", "icon_path": "./images/icons/group.svg", "path": "./admin/enterprise.md" }, { "title": "Telemetry", - "description": "Learn what usage telemetry Coder collects.", + "description": "Learn what usage telemetry Coder collects", "icon_path": "./images/icons/science.svg", "path": "./admin/telemetry.md" } diff --git a/dogfood/main.tf b/dogfood/main.tf index 5635223c52..cc65f6b1c6 100644 --- a/dogfood/main.tf +++ b/dogfood/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" @@ -38,10 +38,12 @@ resource "coder_agent" "dev" { } resource "coder_app" "code-server" { - agent_id = coder_agent.dev.id - name = "code-server" - url = "http://localhost:13337/" - icon = "/icon/code.svg" + agent_id = coder_agent.dev.id + name = "code-server" + url = "http://localhost:13337/" + icon = "/icon/code.svg" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" @@ -50,7 +52,6 @@ resource "coder_app" "code-server" { } } - resource "docker_volume" "home_volume" { name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-home" } diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 215809c173..78b94a6509 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -57,7 +57,7 @@ func TestFeaturesList(t *testing.T) { var entitlements codersdk.Entitlements err := json.Unmarshal(buf.Bytes(), &entitlements) require.NoError(t, err, "unmarshal JSON output") - assert.Len(t, entitlements.Features, 6) + assert.Len(t, entitlements.Features, 7) assert.Empty(t, entitlements.Warnings) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureUserLimit].Entitlement) @@ -71,6 +71,8 @@ func TestFeaturesList(t *testing.T) { entitlements.Features[codersdk.FeatureTemplateRBAC].Entitlement) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureSCIM].Entitlement) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureHighAvailability].Entitlement) assert.False(t, entitlements.HasLicense) assert.False(t, entitlements.Experimental) }) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 62af6f2888..a65b8e8faa 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -2,11 +2,20 @@ package cli import ( "context" + "database/sql" + "errors" + "io" + "net/url" "github.com/spf13/cobra" + "golang.org/x/xerrors" + "tailscale.com/derp" + "tailscale.com/types/key" "github.com/coder/coder/cli/deployment" + "github.com/coder/coder/cryptorand" "github.com/coder/coder/enterprise/coderd" + "github.com/coder/coder/tailnet" agpl "github.com/coder/coder/cli" agplcoderd "github.com/coder/coder/coderd" @@ -14,23 +23,49 @@ import ( func server() *cobra.Command { dflags := deployment.Flags() - cmd := agpl.Server(dflags, func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) { + cmd := agpl.Server(dflags, func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, io.Closer, error) { + if dflags.DerpServerRelayAddress.Value != "" { + _, err := url.Parse(dflags.DerpServerRelayAddress.Value) + if err != nil { + return nil, nil, xerrors.Errorf("derp-server-relay-address must be a valid HTTP URL: %w", err) + } + } + + options.DERPServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp"))) + meshKey, err := options.Database.GetDERPMeshKey(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, nil, xerrors.Errorf("get mesh key: %w", err) + } + meshKey, err = cryptorand.String(32) + if err != nil { + return nil, nil, xerrors.Errorf("generate mesh key: %w", err) + } + err = options.Database.InsertDERPMeshKey(ctx, meshKey) + if err != nil { + return nil, nil, xerrors.Errorf("insert mesh key: %w", err) + } + } + options.DERPServer.SetMeshKey(meshKey) + o := &coderd.Options{ - AuditLogging: dflags.AuditLogging.Value, - BrowserOnly: dflags.BrowserOnly.Value, - SCIMAPIKey: []byte(dflags.SCIMAuthHeader.Value), - UserWorkspaceQuota: dflags.UserWorkspaceQuota.Value, - RBACEnabled: true, - Options: options, + AuditLogging: dflags.AuditLogging.Value, + BrowserOnly: dflags.BrowserOnly.Value, + SCIMAPIKey: []byte(dflags.SCIMAuthHeader.Value), + UserWorkspaceQuota: dflags.UserWorkspaceQuota.Value, + RBAC: true, + DERPServerRelayAddress: dflags.DerpServerRelayAddress.Value, + DERPServerRegionID: dflags.DerpServerRegionID.Value, + + Options: options, } api, err := coderd.New(ctx, o) if err != nil { - return nil, err + return nil, nil, err } - return api.AGPL, nil + return api.AGPL, api, nil }) deployment.AttachFlags(cmd.Flags(), dflags, true) - return cmd } diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go index 72cc4c5f38..9195387632 100644 --- a/enterprise/coderd/authorize_test.go +++ b/enterprise/coderd/authorize_test.go @@ -28,7 +28,7 @@ func TestCheckACLPermissions(t *testing.T) { // Create adminClient, member, and org adminClient adminUser := coderdtest.CreateFirstUser(t, adminClient) _ = coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) memberClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 488d5dc033..a9c8c3b590 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -3,6 +3,8 @@ package coderd import ( "context" "crypto/ed25519" + "crypto/tls" + "crypto/x509" "net/http" "sync" "time" @@ -23,6 +25,10 @@ import ( "github.com/coder/coder/enterprise/audit" "github.com/coder/coder/enterprise/audit/backends" "github.com/coder/coder/enterprise/coderd/license" + "github.com/coder/coder/enterprise/derpmesh" + "github.com/coder/coder/enterprise/replicasync" + "github.com/coder/coder/enterprise/tailnet" + agpltailnet "github.com/coder/coder/tailnet" ) // New constructs an Enterprise coderd API instance. @@ -35,12 +41,19 @@ func New(ctx context.Context, options *Options) (*API, error) { if options.Keys == nil { options.Keys = Keys } + if options.Options == nil { + options.Options = &coderd.Options{} + } + if options.Options.Authorizer == nil { + options.Options.Authorizer = rbac.NewAuthorizer() + } ctx, cancelFunc := context.WithCancel(ctx) api := &API{ AGPL: coderd.New(options.Options), Options: options, cancelEntitlementsLoop: cancelFunc, } + oauthConfigs := &httpmw.OAuth2Configs{ Github: options.GithubOAuth2Config, OIDC: options.OIDCConfig, @@ -53,6 +66,10 @@ func New(ctx context.Context, options *Options) (*API, error) { api.AGPL.APIHandler.Group(func(r chi.Router) { r.Get("/entitlements", api.serveEntitlements) + r.Route("/replicas", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", api.replicas) + }) r.Route("/licenses", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Post("/", api.postLicense) @@ -92,7 +109,7 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Route("/workspace-quota", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Route("/{user}", func(r chi.Router) { - r.Use(httpmw.ExtractUserParam(options.Database)) + r.Use(httpmw.ExtractUserParam(options.Database, false)) r.Get("/", api.workspaceQuota) }) }) @@ -111,7 +128,40 @@ func New(ctx context.Context, options *Options) (*API, error) { }) } - err := api.updateEntitlements(ctx) + meshRootCA := x509.NewCertPool() + for _, certificate := range options.TLSCertificates { + for _, certificatePart := range certificate.Certificate { + certificate, err := x509.ParseCertificate(certificatePart) + if err != nil { + return nil, xerrors.Errorf("parse certificate %s: %w", certificate.Subject.CommonName, err) + } + meshRootCA.AddCert(certificate) + } + } + // This TLS configuration spoofs access from the access URL hostname + // assuming that the certificates provided will cover that hostname. + // + // Replica sync and DERP meshing require accessing replicas via their + // internal IP addresses, and if TLS is configured we use the same + // certificates. + meshTLSConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: options.TLSCertificates, + RootCAs: meshRootCA, + ServerName: options.AccessURL.Hostname(), + } + var err error + api.replicaManager, err = replicasync.New(ctx, options.Logger, options.Database, options.Pubsub, &replicasync.Options{ + RelayAddress: options.DERPServerRelayAddress, + RegionID: int32(options.DERPServerRegionID), + TLSConfig: meshTLSConfig, + }) + if err != nil { + return nil, xerrors.Errorf("initialize replica: %w", err) + } + api.derpMesh = derpmesh.New(options.Logger.Named("derpmesh"), api.DERPServer, meshTLSConfig) + + err = api.updateEntitlements(ctx) if err != nil { return nil, xerrors.Errorf("update entitlements: %w", err) } @@ -123,13 +173,17 @@ func New(ctx context.Context, options *Options) (*API, error) { type Options struct { *coderd.Options - RBACEnabled bool + RBAC bool AuditLogging bool // Whether to block non-browser connections. BrowserOnly bool SCIMAPIKey []byte UserWorkspaceQuota int + // Used for high availability. + DERPServerRelayAddress string + DERPServerRegionID int + EntitlementsUpdateInterval time.Duration Keys map[string]ed25519.PublicKey } @@ -138,6 +192,11 @@ type API struct { AGPL *coderd.API *Options + // Detects multiple Coder replicas running at the same time. + replicaManager *replicasync.Manager + // Meshes DERP connections from multiple replicas. + derpMesh *derpmesh.Mesh + cancelEntitlementsLoop func() entitlementsMu sync.RWMutex entitlements codersdk.Entitlements @@ -145,6 +204,8 @@ type API struct { func (api *API) Close() error { api.cancelEntitlementsLoop() + _ = api.replicaManager.Close() + _ = api.derpMesh.Close() return api.AGPL.Close() } @@ -152,16 +213,18 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.entitlementsMu.Lock() defer api.entitlementsMu.Unlock() - entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, api.Keys, map[string]bool{ - codersdk.FeatureAuditLog: api.AuditLogging, - codersdk.FeatureBrowserOnly: api.BrowserOnly, - codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, - codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0, - codersdk.FeatureTemplateRBAC: api.RBACEnabled, + entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, len(api.replicaManager.All()), api.Keys, map[string]bool{ + codersdk.FeatureAuditLog: api.AuditLogging, + codersdk.FeatureBrowserOnly: api.BrowserOnly, + codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, + codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0, + codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "", + codersdk.FeatureTemplateRBAC: api.RBAC, }) if err != nil { return err } + entitlements.Experimental = api.Experimental featureChanged := func(featureName string) (changed bool, enabled bool) { if api.entitlements.Features == nil { @@ -203,6 +266,46 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer) } + if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed { + coordinator := agpltailnet.NewCoordinator() + if api.Experimental && enabled { + haCoordinator, err := tailnet.NewCoordinator(api.Logger, api.Pubsub) + if err != nil { + api.Logger.Error(ctx, "unable to set up high availability coordinator", slog.Error(err)) + // If we try to setup the HA coordinator and it fails, nothing + // is actually changing. + changed = false + } else { + coordinator = haCoordinator + } + + api.replicaManager.SetCallback(func() { + addresses := make([]string, 0) + for _, replica := range api.replicaManager.Regional() { + addresses = append(addresses, replica.RelayAddress) + } + api.derpMesh.SetAddresses(addresses, false) + _ = api.updateEntitlements(ctx) + }) + } else { + api.derpMesh.SetAddresses([]string{}, false) + api.replicaManager.SetCallback(func() { + // If the amount of replicas change, so should our entitlements. + // This is to display a warning in the UI if the user is unlicensed. + _ = api.updateEntitlements(ctx) + }) + } + + // Recheck changed in case the HA coordinator failed to set up. + if changed { + oldCoordinator := *api.AGPL.TailnetCoordinator.Swap(&coordinator) + err := oldCoordinator.Close() + if err != nil { + api.Logger.Error(ctx, "close old tailnet coordinator", slog.Error(err)) + } + } + } + api.entitlements = entitlements return nil diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 050cad5f9b..7b51845ff3 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -41,9 +41,9 @@ func TestEntitlements(t *testing.T) { }) _ = coderdtest.CreateFirstUser(t, client) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - UserLimit: 100, - AuditLog: true, - TemplateRBACEnabled: true, + UserLimit: 100, + AuditLog: true, + TemplateRBAC: true, }) res, err := client.Entitlements(context.Background()) require.NoError(t, err) @@ -85,7 +85,7 @@ func TestEntitlements(t *testing.T) { assert.False(t, res.HasLicense) al = res.Features[codersdk.FeatureAuditLog] assert.Equal(t, codersdk.EntitlementNotEntitled, al.Entitlement) - assert.True(t, al.Enabled) + assert.False(t, al.Enabled) }) t.Run("Pubsub", func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 84b5422982..a8595b5bc6 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -4,7 +4,9 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/tls" "io" + "net/http" "testing" "time" @@ -60,19 +62,21 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c if options.Options == nil { options.Options = &coderdtest.Options{} } - srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options) + setHandler, cancelFunc, oop := coderdtest.NewOptions(t, options.Options) coderAPI, err := coderd.New(context.Background(), &coderd.Options{ - RBACEnabled: true, + RBAC: true, AuditLogging: options.AuditLogging, BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, + DERPServerRelayAddress: oop.AccessURL.String(), + DERPServerRegionID: oop.DERPMap.RegionIDs()[0], UserWorkspaceQuota: options.UserWorkspaceQuota, Options: oop, EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, Keys: Keys, }) assert.NoError(t, err) - srv.Config.Handler = coderAPI.AGPL.RootHandler + setHandler(coderAPI.AGPL.RootHandler) var provisionerCloser io.Closer = nopcloser{} if options.IncludeProvisionerDaemon { provisionerCloser = coderdtest.NewProvisionerDaemon(t, coderAPI.AGPL) @@ -83,22 +87,32 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c _ = provisionerCloser.Close() _ = coderAPI.Close() }) - return codersdk.New(coderAPI.AccessURL), provisionerCloser, coderAPI + client := codersdk.New(coderAPI.AccessURL) + client.HTTPClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + //nolint:gosec + InsecureSkipVerify: true, + }, + }, + } + return client, provisionerCloser, coderAPI } type LicenseOptions struct { - AccountType string - AccountID string - Trial bool - AllFeatures bool - GraceAt time.Time - ExpiresAt time.Time - UserLimit int64 - AuditLog bool - BrowserOnly bool - SCIM bool - WorkspaceQuota bool - TemplateRBACEnabled bool + AccountType string + AccountID string + Trial bool + AllFeatures bool + GraceAt time.Time + ExpiresAt time.Time + UserLimit int64 + AuditLog bool + BrowserOnly bool + SCIM bool + WorkspaceQuota bool + TemplateRBAC bool + HighAvailability bool } // AddLicense generates a new license with the options provided and inserts it. @@ -118,25 +132,29 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { if options.GraceAt.IsZero() { options.GraceAt = time.Now().Add(time.Hour) } - auditLog := int64(0) + var auditLog int64 if options.AuditLog { auditLog = 1 } - browserOnly := int64(0) + var browserOnly int64 if options.BrowserOnly { browserOnly = 1 } - scim := int64(0) + var scim int64 if options.SCIM { scim = 1 } - workspaceQuota := int64(0) + var workspaceQuota int64 if options.WorkspaceQuota { workspaceQuota = 1 } + highAvailability := int64(0) + if options.HighAvailability { + highAvailability = 1 + } rbacEnabled := int64(0) - if options.TemplateRBACEnabled { + if options.TemplateRBAC { rbacEnabled = 1 } @@ -154,12 +172,13 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { Version: license.CurrentVersion, AllFeatures: options.AllFeatures, Features: license.Features{ - UserLimit: options.UserLimit, - AuditLog: auditLog, - BrowserOnly: browserOnly, - SCIM: scim, - WorkspaceQuota: workspaceQuota, - TemplateRBAC: rbacEnabled, + UserLimit: options.UserLimit, + AuditLog: auditLog, + BrowserOnly: browserOnly, + SCIM: scim, + WorkspaceQuota: workspaceQuota, + HighAvailability: highAvailability, + TemplateRBAC: rbacEnabled, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index c21a412617..e8ad88cd02 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -25,7 +25,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { client, _, api := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ Options: &coderdtest.Options{ // Required for any subdomain-based proxy tests to pass. - AppHostname: "test.coder.com", + AppHostname: "*.test.coder.com", Authorizer: &coderdtest.RecordingAuthorizer{}, IncludeProvisionerDaemon: true, }, @@ -33,7 +33,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { ctx, _ := testutil.Context(t) admin := coderdtest.CreateFirstUser(t, client) license := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ Name: "testgroup", @@ -58,6 +58,10 @@ func TestAuthorizeAllEndpoints(t *testing.T) { AssertAction: rbac.ActionRead, AssertObject: rbac.ResourceLicense, } + assertRoute["GET:/api/v2/replicas"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceReplicas, + } assertRoute["DELETE:/api/v2/licenses/{id}"] = coderdtest.RouteCheck{ AssertAction: rbac.ActionDelete, AssertObject: rbac.ResourceLicense, diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 4c81c4a5ef..7c1cb90706 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -43,6 +43,7 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) ID: uuid.New(), Name: req.Name, OrganizationID: org.ID, + AvatarURL: req.AvatarURL, }) if database.IsUniqueViolation(err) { httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ @@ -81,6 +82,12 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } + // If the name matches the existing group name pretend we aren't + // updating the name at all. + if req.Name == group.Name { + req.Name = "" + } + users := make([]string, 0, len(req.AddUsers)+len(req.RemoveUsers)) users = append(users, req.AddUsers...) users = append(users, req.RemoveUsers...) @@ -109,7 +116,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } } - if req.Name != "" { + if req.Name != "" && req.Name != group.Name { _, err := api.Database.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ OrganizationID: group.OrganizationID, Name: req.Name, @@ -123,16 +130,29 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { } err := api.Database.InTx(func(tx database.Store) error { - if req.Name != "" { - var err error - group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ - ID: group.ID, - Name: req.Name, - }) - if err != nil { - return xerrors.Errorf("update group by ID: %w", err) - } + var err error + group, err = tx.GetGroupByID(ctx, group.ID) + if err != nil { + return xerrors.Errorf("get group by ID: %w", err) } + + // TODO: Do we care about validating this? + if req.AvatarURL != nil { + group.AvatarURL = *req.AvatarURL + } + if req.Name != "" { + group.Name = req.Name + } + + group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ + ID: group.ID, + Name: group.Name, + AvatarURL: group.AvatarURL, + }) + if err != nil { + return xerrors.Errorf("update group by ID: %w", err) + } + for _, id := range req.AddUsers { err := tx.InsertGroupMember(ctx, database.InsertGroupMemberParams{ GroupID: group.ID, @@ -276,6 +296,7 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group { ID: g.ID, Name: g.Name, OrganizationID: g.OrganizationID, + AvatarURL: g.AvatarURL, Members: convertUsers(users, orgs), } } diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 2661da6bcc..8019b956d4 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "k8s.io/utils/pointer" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" @@ -24,14 +25,16 @@ func TestCreateGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ - Name: "hi", + Name: "hi", + AvatarURL: "https://example.com", }) require.NoError(t, err) require.Equal(t, "hi", group.Name) + require.Equal(t, "https://example.com", group.AvatarURL) require.Empty(t, group.Members) require.NotEqual(t, uuid.Nil.String(), group.ID.String()) }) @@ -43,7 +46,7 @@ func TestCreateGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -67,7 +70,7 @@ func TestCreateGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -83,14 +86,42 @@ func TestCreateGroup(t *testing.T) { func TestPatchGroup(t *testing.T) { t.Parallel() - t.Run("Name", func(t *testing.T) { + t.Run("OK", func(t *testing.T) { t.Parallel() client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + AvatarURL: "https://example.com", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + Name: "bye", + AvatarURL: pointer.String("https://google.com"), + }) + require.NoError(t, err) + require.Equal(t, "bye", group.Name) + require.Equal(t, "https://google.com", group.AvatarURL) + }) + + // The FE sends a request from the edit page where the old name == new name. + // This should pass since it's not really an error to update a group name + // to itself. + t.Run("SameNameOK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -99,10 +130,10 @@ func TestPatchGroup(t *testing.T) { require.NoError(t, err) group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ - Name: "bye", + Name: "hi", }) require.NoError(t, err) - require.Equal(t, "bye", group.Name) + require.Equal(t, "hi", group.Name) }) t.Run("AddUsers", func(t *testing.T) { @@ -112,7 +143,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -138,7 +169,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -166,6 +197,37 @@ func TestPatchGroup(t *testing.T) { require.Contains(t, group.Members, user4) }) + t.Run("NameConflict", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + ctx, _ := testutil.Context(t) + group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + AvatarURL: "https://example.com", + }) + require.NoError(t, err) + + group2, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "bye", + }) + require.NoError(t, err) + + group1, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{ + Name: group2.Name, + AvatarURL: pointer.String("https://google.com"), + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusConflict, cerr.StatusCode()) + }) + t.Run("UserNotExist", func(t *testing.T) { t.Parallel() @@ -173,7 +235,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -197,7 +259,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -221,7 +283,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) ctx, _ := testutil.Context(t) @@ -247,7 +309,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -276,7 +338,7 @@ func TestGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -296,7 +358,7 @@ func TestGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -326,7 +388,7 @@ func TestGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -347,7 +409,7 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -380,7 +442,7 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -421,7 +483,7 @@ func TestGroups(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -467,7 +529,7 @@ func TestDeleteGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -492,7 +554,7 @@ func TestDeleteGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) err := client.DeleteGroup(ctx, user.OrganizationID) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index ce9e5d1d59..c5bb689db6 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -17,12 +17,20 @@ import ( ) // Entitlements processes licenses to return whether features are enabled or not. -func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, keys map[string]ed25519.PublicKey, enablements map[string]bool) (codersdk.Entitlements, error) { +func Entitlements( + ctx context.Context, + db database.Store, + logger slog.Logger, + replicaCount int, + keys map[string]ed25519.PublicKey, + enablements map[string]bool, +) (codersdk.Entitlements, error) { now := time.Now() // Default all entitlements to be disabled. entitlements := codersdk.Entitlements{ Features: map[string]codersdk.Feature{}, Warnings: []string{}, + Errors: []string{}, } for _, featureName := range codersdk.FeatureNames { entitlements.Features[featureName] = codersdk.Feature{ @@ -96,6 +104,12 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke Enabled: enablements[codersdk.FeatureWorkspaceQuota], } } + if claims.Features.HighAvailability > 0 { + entitlements.Features[codersdk.FeatureHighAvailability] = codersdk.Feature{ + Entitlement: entitlement, + Enabled: enablements[codersdk.FeatureHighAvailability], + } + } if claims.Features.TemplateRBAC > 0 { entitlements.Features[codersdk.FeatureTemplateRBAC] = codersdk.Feature{ Entitlement: entitlement, @@ -132,6 +146,10 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke if featureName == codersdk.FeatureUserLimit { continue } + // High availability has it's own warnings based on replica count! + if featureName == codersdk.FeatureHighAvailability { + continue + } feature := entitlements.Features[featureName] if !feature.Enabled { continue @@ -141,9 +159,6 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke case codersdk.EntitlementNotEntitled: entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf("%s is enabled but your license is not entitled to this feature.", niceName)) - // Disable the feature and add a warning... - feature.Enabled = false - entitlements.Features[featureName] = feature case codersdk.EntitlementGracePeriod: entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf("%s is enabled but your license for this feature is expired.", niceName)) @@ -152,6 +167,32 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke } } + if replicaCount > 1 { + feature := entitlements.Features[codersdk.FeatureHighAvailability] + + switch feature.Entitlement { + case codersdk.EntitlementNotEntitled: + if entitlements.HasLicense { + entitlements.Errors = append(entitlements.Warnings, + "You have multiple replicas but your license is not entitled to high availability. You will be unable to connect to workspaces.") + } else { + entitlements.Errors = append(entitlements.Warnings, + "You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.") + } + case codersdk.EntitlementGracePeriod: + entitlements.Warnings = append(entitlements.Warnings, + "You have multiple replicas but your license for high availability is expired. Reduce to one replica or workspace connections will stop working.") + } + } + + for _, featureName := range codersdk.FeatureNames { + feature := entitlements.Features[featureName] + if feature.Entitlement == codersdk.EntitlementNotEntitled { + feature.Enabled = false + entitlements.Features[featureName] = feature + } + } + return entitlements, nil } @@ -171,12 +212,13 @@ var ( ) type Features struct { - UserLimit int64 `json:"user_limit"` - AuditLog int64 `json:"audit_log"` - BrowserOnly int64 `json:"browser_only"` - SCIM int64 `json:"scim"` - WorkspaceQuota int64 `json:"workspace_quota"` - TemplateRBAC int64 `json:"template_rbac"` + UserLimit int64 `json:"user_limit"` + AuditLog int64 `json:"audit_log"` + BrowserOnly int64 `json:"browser_only"` + SCIM int64 `json:"scim"` + WorkspaceQuota int64 `json:"workspace_quota"` + TemplateRBAC int64 `json:"template_rbac"` + HighAvailability int64 `json:"high_availability"` } type Claims struct { diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 8f15c5c009..a47dd83c98 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -20,17 +20,18 @@ import ( func TestEntitlements(t *testing.T) { t.Parallel() all := map[string]bool{ - codersdk.FeatureAuditLog: true, - codersdk.FeatureBrowserOnly: true, - codersdk.FeatureSCIM: true, - codersdk.FeatureWorkspaceQuota: true, - codersdk.FeatureTemplateRBAC: true, + codersdk.FeatureAuditLog: true, + codersdk.FeatureBrowserOnly: true, + codersdk.FeatureSCIM: true, + codersdk.FeatureWorkspaceQuota: true, + codersdk.FeatureHighAvailability: true, + codersdk.FeatureTemplateRBAC: true, } t.Run("Defaults", func(t *testing.T) { t.Parallel() db := databasefake.New() - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -46,7 +47,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -60,16 +61,17 @@ func TestEntitlements(t *testing.T) { db := databasefake.New() db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - UserLimit: 100, - AuditLog: true, - BrowserOnly: true, - SCIM: true, - WorkspaceQuota: true, - TemplateRBACEnabled: true, + UserLimit: 100, + AuditLog: true, + BrowserOnly: true, + SCIM: true, + WorkspaceQuota: true, + HighAvailability: true, + TemplateRBAC: true, }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -82,18 +84,19 @@ func TestEntitlements(t *testing.T) { db := databasefake.New() db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - UserLimit: 100, - AuditLog: true, - BrowserOnly: true, - SCIM: true, - WorkspaceQuota: true, - TemplateRBACEnabled: true, - GraceAt: time.Now().Add(-time.Hour), - ExpiresAt: time.Now().Add(time.Hour), + UserLimit: 100, + AuditLog: true, + BrowserOnly: true, + SCIM: true, + WorkspaceQuota: true, + HighAvailability: true, + TemplateRBAC: true, + GraceAt: time.Now().Add(-time.Hour), + ExpiresAt: time.Now().Add(time.Hour), }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -101,6 +104,9 @@ func TestEntitlements(t *testing.T) { if featureName == codersdk.FeatureUserLimit { continue } + if featureName == codersdk.FeatureHighAvailability { + continue + } niceName := strings.Title(strings.ReplaceAll(featureName, "_", " ")) require.Equal(t, codersdk.EntitlementGracePeriod, entitlements.Features[featureName].Entitlement) require.Contains(t, entitlements.Warnings, fmt.Sprintf("%s is enabled but your license for this feature is expired.", niceName)) @@ -113,7 +119,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -121,6 +127,9 @@ func TestEntitlements(t *testing.T) { if featureName == codersdk.FeatureUserLimit { continue } + if featureName == codersdk.FeatureHighAvailability { + continue + } niceName := strings.Title(strings.ReplaceAll(featureName, "_", " ")) // Ensures features that are not entitled are properly disabled. require.False(t, entitlements.Features[featureName].Enabled) @@ -131,15 +140,19 @@ func TestEntitlements(t *testing.T) { t.Run("TooManyUsers", func(t *testing.T) { t.Parallel() db := databasefake.New() - db.InsertUser(context.Background(), database.InsertUserParams{}) - db.InsertUser(context.Background(), database.InsertUserParams{}) + db.InsertUser(context.Background(), database.InsertUserParams{ + Username: "test1", + }) + db.InsertUser(context.Background(), database.InsertUserParams{ + Username: "test2", + }) db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ UserLimit: 1, }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Contains(t, entitlements.Warnings, "Your deployment has 2 active users but is only licensed for 1.") @@ -161,7 +174,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Empty(t, entitlements.Warnings) @@ -184,7 +197,7 @@ func TestEntitlements(t *testing.T) { }), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -199,7 +212,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -211,4 +224,52 @@ func TestEntitlements(t *testing.T) { require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement) } }) + + t.Run("MultipleReplicasNoLicense", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, all) + require.NoError(t, err) + require.False(t, entitlements.HasLicense) + require.Len(t, entitlements.Errors, 1) + require.Equal(t, "You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.", entitlements.Errors[0]) + }) + + t.Run("MultipleReplicasNotEntitled", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + Exp: time.Now().Add(time.Hour), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + AuditLog: true, + }), + }) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, map[string]bool{ + codersdk.FeatureHighAvailability: true, + }) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Len(t, entitlements.Errors, 1) + require.Equal(t, "You have multiple replicas but your license is not entitled to high availability. You will be unable to connect to workspaces.", entitlements.Errors[0]) + }) + + t.Run("MultipleReplicasGrace", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + HighAvailability: true, + GraceAt: time.Now().Add(-time.Hour), + ExpiresAt: time.Now().Add(time.Hour), + }), + Exp: time.Now().Add(time.Hour), + }) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, map[string]bool{ + codersdk.FeatureHighAvailability: true, + }) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Len(t, entitlements.Warnings, 1) + require.Equal(t, "You have multiple replicas but your license for high availability is expired. Reduce to one replica or workspace connections will stop working.", entitlements.Warnings[0]) + }) } diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index f7c1c63999..aa4dddf1fd 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -78,21 +78,21 @@ func TestGetLicense(t *testing.T) { defer cancel() coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - AccountID: "testing", - AuditLog: true, - SCIM: true, - BrowserOnly: true, - TemplateRBACEnabled: true, + AccountID: "testing", + AuditLog: true, + SCIM: true, + BrowserOnly: true, + TemplateRBAC: true, }) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - AccountID: "testing2", - AuditLog: true, - SCIM: true, - BrowserOnly: true, - Trial: true, - UserLimit: 200, - TemplateRBACEnabled: false, + AccountID: "testing2", + AuditLog: true, + SCIM: true, + BrowserOnly: true, + Trial: true, + UserLimit: 200, + TemplateRBAC: false, }) licenses, err := client.Licenses(ctx) @@ -101,23 +101,25 @@ func TestGetLicense(t *testing.T) { assert.Equal(t, int32(1), licenses[0].ID) assert.Equal(t, "testing", licenses[0].Claims["account_id"]) assert.Equal(t, map[string]interface{}{ - codersdk.FeatureUserLimit: json.Number("0"), - codersdk.FeatureAuditLog: json.Number("1"), - codersdk.FeatureSCIM: json.Number("1"), - codersdk.FeatureBrowserOnly: json.Number("1"), - codersdk.FeatureWorkspaceQuota: json.Number("0"), - codersdk.FeatureTemplateRBAC: json.Number("1"), + codersdk.FeatureUserLimit: json.Number("0"), + codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureBrowserOnly: json.Number("1"), + codersdk.FeatureWorkspaceQuota: json.Number("0"), + codersdk.FeatureHighAvailability: json.Number("0"), + codersdk.FeatureTemplateRBAC: json.Number("1"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) assert.Equal(t, true, licenses[1].Claims["trial"]) assert.Equal(t, map[string]interface{}{ - codersdk.FeatureUserLimit: json.Number("200"), - codersdk.FeatureAuditLog: json.Number("1"), - codersdk.FeatureSCIM: json.Number("1"), - codersdk.FeatureBrowserOnly: json.Number("1"), - codersdk.FeatureWorkspaceQuota: json.Number("0"), - codersdk.FeatureTemplateRBAC: json.Number("0"), + codersdk.FeatureUserLimit: json.Number("200"), + codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureBrowserOnly: json.Number("1"), + codersdk.FeatureWorkspaceQuota: json.Number("0"), + codersdk.FeatureHighAvailability: json.Number("0"), + codersdk.FeatureTemplateRBAC: json.Number("0"), }, licenses[1].Claims["features"]) }) } diff --git a/enterprise/coderd/replicas.go b/enterprise/coderd/replicas.go new file mode 100644 index 0000000000..906597f257 --- /dev/null +++ b/enterprise/coderd/replicas.go @@ -0,0 +1,37 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" +) + +// replicas returns the number of replicas that are active in Coder. +func (api *API) replicas(rw http.ResponseWriter, r *http.Request) { + if !api.AGPL.Authorize(r, rbac.ActionRead, rbac.ResourceReplicas) { + httpapi.ResourceNotFound(rw) + return + } + + replicas := api.replicaManager.All() + res := make([]codersdk.Replica, 0, len(replicas)) + for _, replica := range replicas { + res = append(res, convertReplica(replica)) + } + httpapi.Write(r.Context(), rw, http.StatusOK, res) +} + +func convertReplica(replica database.Replica) codersdk.Replica { + return codersdk.Replica{ + ID: replica.ID, + Hostname: replica.Hostname, + CreatedAt: replica.CreatedAt, + RelayAddress: replica.RelayAddress, + RegionID: replica.RegionID, + Error: replica.Error, + DatabaseLatency: replica.DatabaseLatency, + } +} diff --git a/enterprise/coderd/replicas_test.go b/enterprise/coderd/replicas_test.go new file mode 100644 index 0000000000..b14e52dd58 --- /dev/null +++ b/enterprise/coderd/replicas_test.go @@ -0,0 +1,143 @@ +package coderd_test + +import ( + "context" + "crypto/tls" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" +) + +func TestReplicas(t *testing.T) { + t.Parallel() + t.Run("ErrorWithoutLicense", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + firstClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Experimental: true, + IncludeProvisionerDaemon: true, + Database: db, + Pubsub: pubsub, + }, + }) + _ = coderdtest.CreateFirstUser(t, firstClient) + secondClient, _, secondAPI := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + }) + secondClient.SessionToken = firstClient.SessionToken + ents, err := secondClient.Entitlements(context.Background()) + require.NoError(t, err) + require.Len(t, ents.Errors, 1) + _ = secondAPI.Close() + + ents, err = firstClient.Entitlements(context.Background()) + require.NoError(t, err) + require.Len(t, ents.Warnings, 0) + }) + t.Run("ConnectAcrossMultiple", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + firstClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Database: db, + Pubsub: pubsub, + Experimental: true, + }, + }) + firstUser := coderdtest.CreateFirstUser(t, firstClient) + coderdenttest.AddLicense(t, firstClient, coderdenttest.LicenseOptions{ + HighAvailability: true, + }) + + secondClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Experimental: true, + Database: db, + Pubsub: pubsub, + }, + }) + secondClient.SessionToken = firstClient.SessionToken + replicas, err := secondClient.Replicas(context.Background()) + require.NoError(t, err) + require.Len(t, replicas, 2) + + _, agent := setupWorkspaceAgent(t, firstClient, firstUser, 0) + conn, err := secondClient.DialWorkspaceAgent(context.Background(), agent.ID, &codersdk.DialWorkspaceAgentOptions{ + BlockEndpoints: true, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + require.Eventually(t, func() bool { + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancelFunc() + _, err = conn.Ping(ctx) + return err == nil + }, testutil.WaitLong, testutil.IntervalFast) + _ = conn.Close() + }) + t.Run("ConnectAcrossMultipleTLS", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + certificates := []tls.Certificate{testutil.GenerateTLSCertificate(t, "localhost")} + firstClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Experimental: true, + IncludeProvisionerDaemon: true, + Database: db, + Pubsub: pubsub, + TLSCertificates: certificates, + }, + }) + firstUser := coderdtest.CreateFirstUser(t, firstClient) + coderdenttest.AddLicense(t, firstClient, coderdenttest.LicenseOptions{ + HighAvailability: true, + }) + + secondClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Experimental: true, + Database: db, + Pubsub: pubsub, + TLSCertificates: certificates, + }, + }) + secondClient.SessionToken = firstClient.SessionToken + replicas, err := secondClient.Replicas(context.Background()) + require.NoError(t, err) + require.Len(t, replicas, 2) + + _, agent := setupWorkspaceAgent(t, firstClient, firstUser, 0) + conn, err := secondClient.DialWorkspaceAgent(context.Background(), agent.ID, &codersdk.DialWorkspaceAgentOptions{ + BlockEndpoints: true, + Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + require.Eventually(t, func() bool { + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.IntervalSlow) + defer cancelFunc() + _, err = conn.Ping(ctx) + return err == nil + }, testutil.WaitLong, testutil.IntervalFast) + _ = conn.Close() + replicas, err = secondClient.Replicas(context.Background()) + require.NoError(t, err) + require.Len(t, replicas, 2) + for _, replica := range replicas { + require.Empty(t, replica.Error) + } + }) +} diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index b4f96eb610..87aa5a4ca8 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -23,7 +23,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -64,7 +64,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -88,7 +88,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -138,7 +138,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -176,7 +176,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -214,7 +214,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -262,7 +262,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -286,7 +286,7 @@ func TestTemplateACL(t *testing.T) { _, err = client1.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "testme", TemplateID: template.ID, - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) @@ -302,7 +302,7 @@ func TestTemplateACL(t *testing.T) { _, err = client1.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "testme", TemplateID: template.ID, - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) @@ -318,7 +318,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -361,7 +361,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -422,7 +422,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -447,7 +447,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -472,7 +472,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -498,7 +498,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -533,7 +533,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -575,7 +575,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -597,7 +597,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -662,7 +662,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index 3bb40b75b0..18285bcb94 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -2,13 +2,14 @@ package coderd_test import ( "context" + "crypto/tls" + "fmt" "net/http" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" - "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" @@ -16,6 +17,14 @@ import ( "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/testutil" +) + +// App names for each app sharing level. +const ( + testAppNameOwner = "test-app-owner" + testAppNameAuthenticated = "test-app-authenticated" + testAppNamePublic = "test-app-public" ) func TestBlockNonBrowser(t *testing.T) { @@ -32,8 +41,8 @@ func TestBlockNonBrowser(t *testing.T) { coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ BrowserOnly: true, }) - id := setupWorkspaceAgent(t, client, user) - _, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id) + _, agent := setupWorkspaceAgent(t, client, user, 0) + _, err := client.DialWorkspaceAgent(context.Background(), agent.ID, nil) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusConflict, apiErr.StatusCode()) @@ -49,14 +58,14 @@ func TestBlockNonBrowser(t *testing.T) { coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ BrowserOnly: false, }) - id := setupWorkspaceAgent(t, client, user) - conn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id) + _, agent := setupWorkspaceAgent(t, client, user, 0) + conn, err := client.DialWorkspaceAgent(context.Background(), agent.ID, nil) require.NoError(t, err) _ = conn.Close() }) } -func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) uuid.UUID { +func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse, appPort uint16) (codersdk.Workspace, codersdk.WorkspaceAgent) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -72,6 +81,23 @@ func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.Cr Auth: &proto.Agent_Token{ Token: authToken, }, + Apps: []*proto.App{ + { + Name: testAppNameOwner, + SharingLevel: proto.AppSharingLevel_OWNER, + Url: fmt.Sprintf("http://localhost:%d", appPort), + }, + { + Name: testAppNameAuthenticated, + SharingLevel: proto.AppSharingLevel_AUTHENTICATED, + Url: fmt.Sprintf("http://localhost:%d", appPort), + }, + { + Name: testAppNamePublic, + SharingLevel: proto.AppSharingLevel_PUBLIC, + Url: fmt.Sprintf("http://localhost:%d", appPort), + }, + }, }}, }}, }, @@ -83,15 +109,30 @@ func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.Cr workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) agentClient := codersdk.New(client.URL) + agentClient.HTTPClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + //nolint:gosec + InsecureSkipVerify: true, + }, + }, + } agentClient.SessionToken = authToken agentCloser := agent.New(agent.Options{ FetchMetadata: agentClient.WorkspaceAgentMetadata, CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, Logger: slogtest.Make(t, nil).Named("agent"), }) - defer func() { + t.Cleanup(func() { _ = agentCloser.Close() - }() + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - return resources[0].Agents[0].ID + agnt, err := client.WorkspaceAgent(ctx, resources[0].Agents[0].ID) + require.NoError(t, err) + + return workspace, agnt } diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 33984e970d..824b3febb1 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -26,7 +26,7 @@ func TestCreateWorkspace(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) diff --git a/enterprise/derpmesh/derpmesh.go b/enterprise/derpmesh/derpmesh.go new file mode 100644 index 0000000000..3982542167 --- /dev/null +++ b/enterprise/derpmesh/derpmesh.go @@ -0,0 +1,165 @@ +package derpmesh + +import ( + "context" + "crypto/tls" + "net" + "net/url" + "sync" + + "golang.org/x/xerrors" + "tailscale.com/derp" + "tailscale.com/derp/derphttp" + "tailscale.com/types/key" + + "github.com/coder/coder/tailnet" + + "cdr.dev/slog" +) + +// New constructs a new mesh for DERP servers. +func New(logger slog.Logger, server *derp.Server, tlsConfig *tls.Config) *Mesh { + return &Mesh{ + logger: logger, + server: server, + tlsConfig: tlsConfig, + ctx: context.Background(), + closed: make(chan struct{}), + active: make(map[string]context.CancelFunc), + } +} + +type Mesh struct { + logger slog.Logger + server *derp.Server + ctx context.Context + tlsConfig *tls.Config + + mutex sync.Mutex + closed chan struct{} + active map[string]context.CancelFunc +} + +// SetAddresses performs a diff of the incoming addresses and adds +// or removes DERP clients from the mesh. +// +// Connect is only used for testing to ensure DERPs are meshed before +// exchanging messages. +// nolint:revive +func (m *Mesh) SetAddresses(addresses []string, connect bool) { + total := make(map[string]struct{}, 0) + for _, address := range addresses { + addressURL, err := url.Parse(address) + if err != nil { + m.logger.Error(m.ctx, "invalid address", slog.F("address", err), slog.Error(err)) + continue + } + derpURL, err := addressURL.Parse("/derp") + if err != nil { + m.logger.Error(m.ctx, "parse derp", slog.F("address", err), slog.Error(err)) + continue + } + address = derpURL.String() + + total[address] = struct{}{} + added, err := m.addAddress(address, connect) + if err != nil { + m.logger.Error(m.ctx, "failed to add address", slog.F("address", address), slog.Error(err)) + continue + } + if added { + m.logger.Debug(m.ctx, "added mesh address", slog.F("address", address)) + } + } + + m.mutex.Lock() + for address := range m.active { + _, found := total[address] + if found { + continue + } + removed := m.removeAddress(address) + if removed { + m.logger.Debug(m.ctx, "removed mesh address", slog.F("address", address)) + } + } + m.mutex.Unlock() +} + +// addAddress begins meshing with a new address. It returns false if the address is already being meshed with. +// It's expected that this is a full HTTP address with a path. +// e.g. http://127.0.0.1:8080/derp +// nolint:revive +func (m *Mesh) addAddress(address string, connect bool) (bool, error) { + m.mutex.Lock() + defer m.mutex.Unlock() + if m.isClosed() { + return false, nil + } + _, isActive := m.active[address] + if isActive { + return false, nil + } + client, err := derphttp.NewClient(m.server.PrivateKey(), address, tailnet.Logger(m.logger.Named("client"))) + if err != nil { + return false, xerrors.Errorf("create derp client: %w", err) + } + client.TLSConfig = m.tlsConfig + client.MeshKey = m.server.MeshKey() + client.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, network, addr) + }) + if connect { + _ = client.Connect(m.ctx) + } + ctx, cancelFunc := context.WithCancel(m.ctx) + closed := make(chan struct{}) + closeFunc := func() { + cancelFunc() + _ = client.Close() + <-closed + } + m.active[address] = closeFunc + go func() { + defer close(closed) + client.RunWatchConnectionLoop(ctx, m.server.PublicKey(), tailnet.Logger(m.logger.Named("loop")), func(np key.NodePublic) { + m.server.AddPacketForwarder(np, client) + }, func(np key.NodePublic) { + m.server.RemovePacketForwarder(np, client) + }) + }() + return true, nil +} + +// removeAddress stops meshing with a given address. +func (m *Mesh) removeAddress(address string) bool { + cancelFunc, isActive := m.active[address] + if isActive { + cancelFunc() + } + return isActive +} + +// Close ends all active meshes with the DERP server. +func (m *Mesh) Close() error { + m.mutex.Lock() + defer m.mutex.Unlock() + if m.isClosed() { + return nil + } + close(m.closed) + for _, cancelFunc := range m.active { + cancelFunc() + } + return nil +} + +func (m *Mesh) isClosed() bool { + select { + case <-m.closed: + return true + default: + } + return false +} diff --git a/enterprise/derpmesh/derpmesh_test.go b/enterprise/derpmesh/derpmesh_test.go new file mode 100644 index 0000000000..7c7331d21c --- /dev/null +++ b/enterprise/derpmesh/derpmesh_test.go @@ -0,0 +1,222 @@ +package derpmesh_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "tailscale.com/derp" + "tailscale.com/derp/derphttp" + "tailscale.com/types/key" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/enterprise/derpmesh" + "github.com/coder/coder/tailnet" + "github.com/coder/coder/testutil" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestDERPMesh(t *testing.T) { + t.Parallel() + commonName := "something.org" + rawCert := testutil.GenerateTLSCertificate(t, commonName) + certificate, err := x509.ParseCertificate(rawCert.Certificate[0]) + require.NoError(t, err) + pool := x509.NewCertPool() + pool.AddCert(certificate) + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: commonName, + RootCAs: pool, + Certificates: []tls.Certificate{rawCert}, + } + + t.Run("ExchangeMessages", func(t *testing.T) { + // This tests messages passing through multiple DERP servers. + t.Parallel() + firstServer, firstServerURL := startDERP(t, tlsConfig) + defer firstServer.Close() + secondServer, secondServerURL := startDERP(t, tlsConfig) + firstMesh := derpmesh.New(slogtest.Make(t, nil).Named("first").Leveled(slog.LevelDebug), firstServer, tlsConfig) + firstMesh.SetAddresses([]string{secondServerURL}, true) + secondMesh := derpmesh.New(slogtest.Make(t, nil).Named("second").Leveled(slog.LevelDebug), secondServer, tlsConfig) + secondMesh.SetAddresses([]string{firstServerURL}, true) + defer firstMesh.Close() + defer secondMesh.Close() + + first := key.NewNode() + second := key.NewNode() + firstClient, err := derphttp.NewClient(first, secondServerURL, tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + firstClient.TLSConfig = tlsConfig + secondClient, err := derphttp.NewClient(second, firstServerURL, tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + secondClient.TLSConfig = tlsConfig + err = secondClient.Connect(context.Background()) + require.NoError(t, err) + + closed := make(chan struct{}) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + sent := []byte("hello world") + go func() { + defer close(closed) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + err = firstClient.Send(second.Public(), sent) + require.NoError(t, err) + } + }() + + got := recvData(t, secondClient) + require.Equal(t, sent, got) + cancelFunc() + <-closed + }) + t.Run("RemoveAddress", func(t *testing.T) { + // This tests messages passing through multiple DERP servers. + t.Parallel() + server, serverURL := startDERP(t, tlsConfig) + mesh := derpmesh.New(slogtest.Make(t, nil).Named("first").Leveled(slog.LevelDebug), server, tlsConfig) + mesh.SetAddresses([]string{"http://fake.com"}, false) + // This should trigger a removal... + mesh.SetAddresses([]string{}, false) + defer mesh.Close() + + first := key.NewNode() + second := key.NewNode() + firstClient, err := derphttp.NewClient(first, serverURL, tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + firstClient.TLSConfig = tlsConfig + secondClient, err := derphttp.NewClient(second, serverURL, tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + secondClient.TLSConfig = tlsConfig + err = secondClient.Connect(context.Background()) + require.NoError(t, err) + + closed := make(chan struct{}) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + sent := []byte("hello world") + go func() { + defer close(closed) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + err = firstClient.Send(second.Public(), sent) + require.NoError(t, err) + } + }() + got := recvData(t, secondClient) + require.Equal(t, sent, got) + cancelFunc() + <-closed + }) + t.Run("TwentyMeshes", func(t *testing.T) { + t.Parallel() + meshes := make([]*derpmesh.Mesh, 0, 20) + serverURLs := make([]string, 0, 20) + for i := 0; i < 20; i++ { + server, url := startDERP(t, tlsConfig) + mesh := derpmesh.New(slogtest.Make(t, nil).Named("mesh").Leveled(slog.LevelDebug), server, tlsConfig) + t.Cleanup(func() { + _ = server.Close() + _ = mesh.Close() + }) + serverURLs = append(serverURLs, url) + meshes = append(meshes, mesh) + } + for _, mesh := range meshes { + mesh.SetAddresses(serverURLs, true) + } + + first := key.NewNode() + second := key.NewNode() + firstClient, err := derphttp.NewClient(first, serverURLs[9], tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + firstClient.TLSConfig = tlsConfig + secondClient, err := derphttp.NewClient(second, serverURLs[16], tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + secondClient.TLSConfig = tlsConfig + err = secondClient.Connect(context.Background()) + require.NoError(t, err) + + closed := make(chan struct{}) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + sent := []byte("hello world") + go func() { + defer close(closed) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + err = firstClient.Send(second.Public(), sent) + require.NoError(t, err) + } + }() + + got := recvData(t, secondClient) + require.Equal(t, sent, got) + cancelFunc() + <-closed + }) +} + +func recvData(t *testing.T, client *derphttp.Client) []byte { + for { + msg, err := client.Recv() + if errors.Is(err, io.EOF) { + return nil + } + assert.NoError(t, err) + t.Logf("derp: %T", msg) + switch msg := msg.(type) { + case derp.ReceivedPacket: + return msg.Data + default: + // Drop all others! + } + } +} + +func startDERP(t *testing.T, tlsConfig *tls.Config) (*derp.Server, string) { + logf := tailnet.Logger(slogtest.Make(t, nil)) + d := derp.NewServer(key.NewNode(), logf) + d.SetMeshKey("some-key") + server := httptest.NewUnstartedServer(derphttp.Handler(d)) + server.TLS = tlsConfig + server.StartTLS() + t.Cleanup(func() { + _ = d.Close() + }) + t.Cleanup(server.Close) + return d, server.URL +} diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go new file mode 100644 index 0000000000..68344c91f7 --- /dev/null +++ b/enterprise/replicasync/replicasync.go @@ -0,0 +1,390 @@ +package replicasync + +import ( + "context" + "crypto/tls" + "database/sql" + "errors" + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/database" +) + +var ( + PubsubEvent = "replica" +) + +type Options struct { + CleanupInterval time.Duration + UpdateInterval time.Duration + PeerTimeout time.Duration + RelayAddress string + RegionID int32 + TLSConfig *tls.Config +} + +// New registers the replica with the database and periodically updates to ensure +// it's healthy. It contacts all other alive replicas to ensure they are reachable. +func New(ctx context.Context, logger slog.Logger, db database.Store, pubsub database.Pubsub, options *Options) (*Manager, error) { + if options == nil { + options = &Options{} + } + if options.PeerTimeout == 0 { + options.PeerTimeout = 3 * time.Second + } + if options.UpdateInterval == 0 { + options.UpdateInterval = 5 * time.Second + } + if options.CleanupInterval == 0 { + // The cleanup interval can be quite long, because it's + // primary purpose is to clean up dead replicas. + options.CleanupInterval = 30 * time.Minute + } + hostname, err := os.Hostname() + if err != nil { + return nil, xerrors.Errorf("get hostname: %w", err) + } + databaseLatency, err := db.Ping(ctx) + if err != nil { + return nil, xerrors.Errorf("ping database: %w", err) + } + id := uuid.New() + replica, err := db.InsertReplica(ctx, database.InsertReplicaParams{ + ID: id, + CreatedAt: database.Now(), + StartedAt: database.Now(), + UpdatedAt: database.Now(), + Hostname: hostname, + RegionID: options.RegionID, + RelayAddress: options.RelayAddress, + Version: buildinfo.Version(), + DatabaseLatency: int32(databaseLatency.Microseconds()), + }) + if err != nil { + return nil, xerrors.Errorf("insert replica: %w", err) + } + err = pubsub.Publish(PubsubEvent, []byte(id.String())) + if err != nil { + return nil, xerrors.Errorf("publish new replica: %w", err) + } + ctx, cancelFunc := context.WithCancel(ctx) + manager := &Manager{ + id: id, + options: options, + db: db, + pubsub: pubsub, + self: replica, + logger: logger, + closed: make(chan struct{}), + closeCancel: cancelFunc, + } + err = manager.syncReplicas(ctx) + if err != nil { + return nil, xerrors.Errorf("run replica: %w", err) + } + err = manager.subscribe(ctx) + if err != nil { + return nil, xerrors.Errorf("subscribe: %w", err) + } + manager.closeWait.Add(1) + go manager.loop(ctx) + return manager, nil +} + +// Manager keeps the replica up to date and in sync with other replicas. +type Manager struct { + id uuid.UUID + options *Options + db database.Store + pubsub database.Pubsub + logger slog.Logger + + closeWait sync.WaitGroup + closeMutex sync.Mutex + closed chan (struct{}) + closeCancel context.CancelFunc + + self database.Replica + mutex sync.Mutex + peers []database.Replica + callback func() +} + +// updateInterval is used to determine a replicas state. +// If the replica was updated > the time, it's considered healthy. +// If the replica was updated < the time, it's considered stale. +func (m *Manager) updateInterval() time.Time { + return database.Now().Add(-3 * m.options.UpdateInterval) +} + +// loop runs the replica update sequence on an update interval. +func (m *Manager) loop(ctx context.Context) { + defer m.closeWait.Done() + updateTicker := time.NewTicker(m.options.UpdateInterval) + defer updateTicker.Stop() + deleteTicker := time.NewTicker(m.options.CleanupInterval) + defer deleteTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-deleteTicker.C: + err := m.db.DeleteReplicasUpdatedBefore(ctx, m.updateInterval()) + if err != nil { + m.logger.Warn(ctx, "delete old replicas", slog.Error(err)) + } + continue + case <-updateTicker.C: + } + err := m.syncReplicas(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + m.logger.Warn(ctx, "run replica update loop", slog.Error(err)) + } + } +} + +// subscribe listens for new replica information! +func (m *Manager) subscribe(ctx context.Context) error { + var ( + needsUpdate = false + updating = false + updateMutex = sync.Mutex{} + ) + + // This loop will continually update nodes as updates are processed. + // The intent is to always be up to date without spamming the run + // function, so if a new update comes in while one is being processed, + // it will reprocess afterwards. + var update func() + update = func() { + err := m.syncReplicas(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + m.logger.Warn(ctx, "run replica from subscribe", slog.Error(err)) + } + updateMutex.Lock() + if needsUpdate { + needsUpdate = false + updateMutex.Unlock() + update() + return + } + updating = false + updateMutex.Unlock() + } + cancelFunc, err := m.pubsub.Subscribe(PubsubEvent, func(ctx context.Context, message []byte) { + updateMutex.Lock() + defer updateMutex.Unlock() + id, err := uuid.Parse(string(message)) + if err != nil { + return + } + // Don't process updates for ourself! + if id == m.id { + return + } + if updating { + needsUpdate = true + return + } + updating = true + go update() + }) + if err != nil { + return err + } + go func() { + <-ctx.Done() + cancelFunc() + }() + return nil +} + +func (m *Manager) syncReplicas(ctx context.Context) error { + m.closeMutex.Lock() + m.closeWait.Add(1) + m.closeMutex.Unlock() + defer m.closeWait.Done() + // Expect replicas to update once every three times the interval... + // If they don't, assume death! + replicas, err := m.db.GetReplicasUpdatedAfter(ctx, m.updateInterval()) + if err != nil { + return xerrors.Errorf("get replicas: %w", err) + } + + m.mutex.Lock() + m.peers = make([]database.Replica, 0, len(replicas)) + for _, replica := range replicas { + if replica.ID == m.id { + continue + } + m.peers = append(m.peers, replica) + } + m.mutex.Unlock() + + client := http.Client{ + Timeout: m.options.PeerTimeout, + Transport: &http.Transport{ + TLSClientConfig: m.options.TLSConfig, + }, + } + defer client.CloseIdleConnections() + var wg sync.WaitGroup + var mu sync.Mutex + failed := make([]string, 0) + for _, peer := range m.Regional() { + wg.Add(1) + go func(peer database.Replica) { + defer wg.Done() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, peer.RelayAddress, nil) + if err != nil { + m.logger.Warn(ctx, "create http request for relay probe", + slog.F("relay_address", peer.RelayAddress), slog.Error(err)) + return + } + res, err := client.Do(req) + if err != nil { + mu.Lock() + failed = append(failed, fmt.Sprintf("relay %s (%s): %s", peer.Hostname, peer.RelayAddress, err)) + mu.Unlock() + return + } + _ = res.Body.Close() + }(peer) + } + wg.Wait() + replicaError := "" + if len(failed) > 0 { + replicaError = fmt.Sprintf("Failed to dial peers: %s", strings.Join(failed, ", ")) + } + + databaseLatency, err := m.db.Ping(ctx) + if err != nil { + return xerrors.Errorf("ping database: %w", err) + } + + replica, err := m.db.UpdateReplica(ctx, database.UpdateReplicaParams{ + ID: m.self.ID, + UpdatedAt: database.Now(), + StartedAt: m.self.StartedAt, + StoppedAt: m.self.StoppedAt, + RelayAddress: m.self.RelayAddress, + RegionID: m.self.RegionID, + Hostname: m.self.Hostname, + Version: m.self.Version, + Error: replicaError, + DatabaseLatency: int32(databaseLatency.Microseconds()), + }) + if err != nil { + return xerrors.Errorf("update replica: %w", err) + } + m.mutex.Lock() + defer m.mutex.Unlock() + if m.self.Error != replica.Error { + // Publish an update occurred! + err = m.pubsub.Publish(PubsubEvent, []byte(m.self.ID.String())) + if err != nil { + return xerrors.Errorf("publish replica update: %w", err) + } + } + m.self = replica + if m.callback != nil { + go m.callback() + } + return nil +} + +// Self represents the current replica. +func (m *Manager) Self() database.Replica { + m.mutex.Lock() + defer m.mutex.Unlock() + return m.self +} + +// All returns every replica, including itself. +func (m *Manager) All() []database.Replica { + m.mutex.Lock() + defer m.mutex.Unlock() + replicas := make([]database.Replica, 0, len(m.peers)) + for _, replica := range append(m.peers, m.self) { + // When we assign the non-pointer to a + // variable it loses the reference. + replica := replica + replicas = append(replicas, replica) + } + return replicas +} + +// Regional returns all replicas in the same region excluding itself. +func (m *Manager) Regional() []database.Replica { + m.mutex.Lock() + defer m.mutex.Unlock() + replicas := make([]database.Replica, 0) + for _, replica := range m.peers { + if replica.RegionID != m.self.RegionID { + continue + } + replicas = append(replicas, replica) + } + return replicas +} + +// SetCallback sets a function to execute whenever new peers +// are refreshed or updated. +func (m *Manager) SetCallback(callback func()) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.callback = callback + // Instantly call the callback to inform replicas! + go callback() +} + +func (m *Manager) Close() error { + m.closeMutex.Lock() + select { + case <-m.closed: + m.closeMutex.Unlock() + return nil + default: + } + close(m.closed) + m.closeCancel() + m.closeWait.Wait() + m.closeMutex.Unlock() + m.mutex.Lock() + defer m.mutex.Unlock() + ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFunc() + _, err := m.db.UpdateReplica(ctx, database.UpdateReplicaParams{ + ID: m.self.ID, + UpdatedAt: database.Now(), + StartedAt: m.self.StartedAt, + StoppedAt: sql.NullTime{ + Time: database.Now(), + Valid: true, + }, + RelayAddress: m.self.RelayAddress, + RegionID: m.self.RegionID, + Hostname: m.self.Hostname, + Version: m.self.Version, + Error: m.self.Error, + }) + if err != nil { + return xerrors.Errorf("update replica: %w", err) + } + err = m.pubsub.Publish(PubsubEvent, []byte(m.self.ID.String())) + if err != nil { + return xerrors.Errorf("publish replica update: %w", err) + } + return nil +} diff --git a/enterprise/replicasync/replicasync_test.go b/enterprise/replicasync/replicasync_test.go new file mode 100644 index 0000000000..7538b48a38 --- /dev/null +++ b/enterprise/replicasync/replicasync_test.go @@ -0,0 +1,222 @@ +package replicasync_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/enterprise/replicasync" + "github.com/coder/coder/testutil" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestReplica(t *testing.T) { + t.Parallel() + t.Run("CreateOnNew", func(t *testing.T) { + // This ensures that a new replica is created on New. + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + closeChan := make(chan struct{}, 1) + cancel, err := pubsub.Subscribe(replicasync.PubsubEvent, func(ctx context.Context, message []byte) { + closeChan <- struct{}{} + }) + require.NoError(t, err) + defer cancel() + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, nil) + require.NoError(t, err) + <-closeChan + _ = server.Close() + require.NoError(t, err) + }) + t.Run("ConnectsToPeerReplica", func(t *testing.T) { + // Ensures that the replica reports a successful status for + // accessing all of its peers. + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + db, pubsub := dbtestutil.NewDB(t) + peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + StartedAt: database.Now(), + UpdatedAt: database.Now(), + Hostname: "something", + RelayAddress: srv.URL, + }) + require.NoError(t, err) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, &replicasync.Options{ + RelayAddress: "http://169.254.169.254", + }) + require.NoError(t, err) + require.Len(t, server.Regional(), 1) + require.Equal(t, peer.ID, server.Regional()[0].ID) + require.Empty(t, server.Self().Error) + _ = server.Close() + }) + t.Run("ConnectsToPeerReplicaTLS", func(t *testing.T) { + // Ensures that the replica reports a successful status for + // accessing all of its peers. + t.Parallel() + rawCert := testutil.GenerateTLSCertificate(t, "hello.org") + certificate, err := x509.ParseCertificate(rawCert.Certificate[0]) + require.NoError(t, err) + pool := x509.NewCertPool() + pool.AddCert(certificate) + // nolint:gosec + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{rawCert}, + ServerName: "hello.org", + RootCAs: pool, + } + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.TLS = tlsConfig + srv.StartTLS() + defer srv.Close() + db, pubsub := dbtestutil.NewDB(t) + peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + StartedAt: database.Now(), + UpdatedAt: database.Now(), + Hostname: "something", + RelayAddress: srv.URL, + }) + require.NoError(t, err) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, &replicasync.Options{ + RelayAddress: "http://169.254.169.254", + TLSConfig: tlsConfig, + }) + require.NoError(t, err) + require.Len(t, server.Regional(), 1) + require.Equal(t, peer.ID, server.Regional()[0].ID) + require.Empty(t, server.Self().Error) + _ = server.Close() + }) + t.Run("ConnectsToFakePeerWithError", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + CreatedAt: database.Now().Add(time.Minute), + StartedAt: database.Now().Add(time.Minute), + UpdatedAt: database.Now().Add(time.Minute), + Hostname: "something", + // Fake address to dial! + RelayAddress: "http://127.0.0.1:1", + }) + require.NoError(t, err) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, &replicasync.Options{ + PeerTimeout: 1 * time.Millisecond, + RelayAddress: "http://127.0.0.1:1", + }) + require.NoError(t, err) + require.Len(t, server.Regional(), 1) + require.Equal(t, peer.ID, server.Regional()[0].ID) + require.NotEmpty(t, server.Self().Error) + require.Contains(t, server.Self().Error, "Failed to dial peers") + _ = server.Close() + }) + t.Run("RefreshOnPublish", func(t *testing.T) { + // Refresh when a new replica appears! + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, nil) + require.NoError(t, err) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + RelayAddress: srv.URL, + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + // Publish multiple times to ensure it can handle that case. + err = pubsub.Publish(replicasync.PubsubEvent, []byte(peer.ID.String())) + require.NoError(t, err) + err = pubsub.Publish(replicasync.PubsubEvent, []byte(peer.ID.String())) + require.NoError(t, err) + require.Eventually(t, func() bool { + return len(server.Regional()) == 1 + }, testutil.WaitShort, testutil.IntervalFast) + _ = server.Close() + }) + t.Run("DeletesOld", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + _, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + UpdatedAt: database.Now().Add(-time.Hour), + }) + require.NoError(t, err) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, &replicasync.Options{ + RelayAddress: "google.com", + CleanupInterval: time.Millisecond, + }) + require.NoError(t, err) + defer server.Close() + require.Eventually(t, func() bool { + return len(server.Regional()) == 0 + }, testutil.WaitShort, testutil.IntervalFast) + }) + t.Run("TwentyConcurrent", func(t *testing.T) { + // Ensures that twenty concurrent replicas can spawn and all + // discover each other in parallel! + t.Parallel() + // This doesn't use the database fake because creating + // this many PostgreSQL connections takes some + // configuration tweaking. + db := databasefake.New() + pubsub := database.NewPubsubInMemory() + logger := slogtest.Make(t, nil) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + var wg sync.WaitGroup + count := 20 + wg.Add(count) + for i := 0; i < count; i++ { + server, err := replicasync.New(context.Background(), logger, db, pubsub, &replicasync.Options{ + RelayAddress: srv.URL, + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = server.Close() + }) + done := false + server.SetCallback(func() { + if len(server.All()) != count { + return + } + if done { + return + } + done = true + wg.Done() + }) + } + wg.Wait() + }) +} diff --git a/enterprise/tailnet/coordinator.go b/enterprise/tailnet/coordinator.go new file mode 100644 index 0000000000..c24107fe2a --- /dev/null +++ b/enterprise/tailnet/coordinator.go @@ -0,0 +1,575 @@ +package tailnet + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/database" + agpl "github.com/coder/coder/tailnet" +) + +// NewCoordinator creates a new high availability coordinator +// that uses PostgreSQL pubsub to exchange handshakes. +func NewCoordinator(logger slog.Logger, pubsub database.Pubsub) (agpl.Coordinator, error) { + ctx, cancelFunc := context.WithCancel(context.Background()) + coord := &haCoordinator{ + id: uuid.New(), + log: logger, + pubsub: pubsub, + closeFunc: cancelFunc, + close: make(chan struct{}), + nodes: map[uuid.UUID]*agpl.Node{}, + agentSockets: map[uuid.UUID]net.Conn{}, + agentToConnectionSockets: map[uuid.UUID]map[uuid.UUID]net.Conn{}, + } + + if err := coord.runPubsub(ctx); err != nil { + return nil, xerrors.Errorf("run coordinator pubsub: %w", err) + } + + return coord, nil +} + +type haCoordinator struct { + id uuid.UUID + log slog.Logger + mutex sync.RWMutex + pubsub database.Pubsub + close chan struct{} + closeFunc context.CancelFunc + + // nodes maps agent and connection IDs their respective node. + nodes map[uuid.UUID]*agpl.Node + // agentSockets maps agent IDs to their open websocket. + agentSockets map[uuid.UUID]net.Conn + // agentToConnectionSockets maps agent IDs to connection IDs of conns that + // are subscribed to updates for that agent. + agentToConnectionSockets map[uuid.UUID]map[uuid.UUID]net.Conn +} + +// Node returns an in-memory node by ID. +func (c *haCoordinator) Node(id uuid.UUID) *agpl.Node { + c.mutex.Lock() + defer c.mutex.Unlock() + node := c.nodes[id] + return node +} + +// ServeClient accepts a WebSocket connection that wants to connect to an agent +// with the specified ID. +func (c *haCoordinator) ServeClient(conn net.Conn, id uuid.UUID, agent uuid.UUID) error { + c.mutex.Lock() + // When a new connection is requested, we update it with the latest + // node of the agent. This allows the connection to establish. + node, ok := c.nodes[agent] + c.mutex.Unlock() + if ok { + data, err := json.Marshal([]*agpl.Node{node}) + if err != nil { + return xerrors.Errorf("marshal node: %w", err) + } + _, err = conn.Write(data) + if err != nil { + return xerrors.Errorf("write nodes: %w", err) + } + } else { + err := c.publishClientHello(agent) + if err != nil { + return xerrors.Errorf("publish client hello: %w", err) + } + } + + c.mutex.Lock() + connectionSockets, ok := c.agentToConnectionSockets[agent] + if !ok { + connectionSockets = map[uuid.UUID]net.Conn{} + c.agentToConnectionSockets[agent] = connectionSockets + } + + // Insert this connection into a map so the agent can publish node updates. + connectionSockets[id] = conn + c.mutex.Unlock() + + defer func() { + c.mutex.Lock() + defer c.mutex.Unlock() + // Clean all traces of this connection from the map. + delete(c.nodes, id) + connectionSockets, ok := c.agentToConnectionSockets[agent] + if !ok { + return + } + delete(connectionSockets, id) + if len(connectionSockets) != 0 { + return + } + delete(c.agentToConnectionSockets, agent) + }() + + decoder := json.NewDecoder(conn) + // Indefinitely handle messages from the client websocket. + for { + err := c.handleNextClientMessage(id, agent, decoder) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + return nil + } + return xerrors.Errorf("handle next client message: %w", err) + } + } +} + +func (c *haCoordinator) handleNextClientMessage(id, agent uuid.UUID, decoder *json.Decoder) error { + var node agpl.Node + err := decoder.Decode(&node) + if err != nil { + return xerrors.Errorf("read json: %w", err) + } + + c.mutex.Lock() + // Update the node of this client in our in-memory map. If an agent entirely + // shuts down and reconnects, it needs to be aware of all clients attempting + // to establish connections. + c.nodes[id] = &node + // Write the new node from this client to the actively connected agent. + agentSocket, ok := c.agentSockets[agent] + c.mutex.Unlock() + if !ok { + // If we don't own the agent locally, send it over pubsub to a node that + // owns the agent. + err := c.publishNodesToAgent(agent, []*agpl.Node{&node}) + if err != nil { + return xerrors.Errorf("publish node to agent") + } + return nil + } + + // Write the new node from this client to the actively + // connected agent. + data, err := json.Marshal([]*agpl.Node{&node}) + if err != nil { + return xerrors.Errorf("marshal nodes: %w", err) + } + + _, err = agentSocket.Write(data) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + return nil + } + return xerrors.Errorf("write json: %w", err) + } + + return nil +} + +// ServeAgent accepts a WebSocket connection to an agent that listens to +// incoming connections and publishes node updates. +func (c *haCoordinator) ServeAgent(conn net.Conn, id uuid.UUID) error { + // Tell clients on other instances to send a callmemaybe to us. + err := c.publishAgentHello(id) + if err != nil { + return xerrors.Errorf("publish agent hello: %w", err) + } + + // Publish all nodes on this instance that want to connect to this agent. + nodes := c.nodesSubscribedToAgent(id) + if len(nodes) > 0 { + data, err := json.Marshal(nodes) + if err != nil { + return xerrors.Errorf("marshal json: %w", err) + } + _, err = conn.Write(data) + if err != nil { + return xerrors.Errorf("write nodes: %w", err) + } + } + + // If an old agent socket is connected, we close it + // to avoid any leaks. This shouldn't ever occur because + // we expect one agent to be running. + c.mutex.Lock() + oldAgentSocket, ok := c.agentSockets[id] + if ok { + _ = oldAgentSocket.Close() + } + c.agentSockets[id] = conn + c.mutex.Unlock() + defer func() { + c.mutex.Lock() + defer c.mutex.Unlock() + delete(c.agentSockets, id) + delete(c.nodes, id) + }() + + decoder := json.NewDecoder(conn) + for { + node, err := c.handleAgentUpdate(id, decoder) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, context.Canceled) { + return nil + } + return xerrors.Errorf("handle next agent message: %w", err) + } + + err = c.publishAgentToNodes(id, node) + if err != nil { + return xerrors.Errorf("publish agent to nodes: %w", err) + } + } +} + +func (c *haCoordinator) nodesSubscribedToAgent(agentID uuid.UUID) []*agpl.Node { + c.mutex.Lock() + defer c.mutex.Unlock() + sockets, ok := c.agentToConnectionSockets[agentID] + if !ok { + return nil + } + + nodes := make([]*agpl.Node, 0, len(sockets)) + for targetID := range sockets { + node, ok := c.nodes[targetID] + if !ok { + continue + } + nodes = append(nodes, node) + } + + return nodes +} + +func (c *haCoordinator) handleClientHello(id uuid.UUID) error { + c.mutex.Lock() + node, ok := c.nodes[id] + c.mutex.Unlock() + if !ok { + return nil + } + return c.publishAgentToNodes(id, node) +} + +func (c *haCoordinator) handleAgentUpdate(id uuid.UUID, decoder *json.Decoder) (*agpl.Node, error) { + var node agpl.Node + err := decoder.Decode(&node) + if err != nil { + return nil, xerrors.Errorf("read json: %w", err) + } + + c.mutex.Lock() + oldNode := c.nodes[id] + if oldNode != nil { + if oldNode.AsOf.After(node.AsOf) { + c.mutex.Unlock() + return oldNode, nil + } + } + c.nodes[id] = &node + connectionSockets, ok := c.agentToConnectionSockets[id] + if !ok { + c.mutex.Unlock() + return &node, nil + } + + data, err := json.Marshal([]*agpl.Node{&node}) + if err != nil { + c.mutex.Unlock() + return nil, xerrors.Errorf("marshal nodes: %w", err) + } + + // Publish the new node to every listening socket. + var wg sync.WaitGroup + wg.Add(len(connectionSockets)) + for _, connectionSocket := range connectionSockets { + connectionSocket := connectionSocket + go func() { + defer wg.Done() + _ = connectionSocket.SetWriteDeadline(time.Now().Add(5 * time.Second)) + _, _ = connectionSocket.Write(data) + }() + } + c.mutex.Unlock() + wg.Wait() + return &node, nil +} + +// Close closes all of the open connections in the coordinator and stops the +// coordinator from accepting new connections. +func (c *haCoordinator) Close() error { + c.mutex.Lock() + defer c.mutex.Unlock() + select { + case <-c.close: + return nil + default: + } + close(c.close) + c.closeFunc() + + wg := sync.WaitGroup{} + + wg.Add(len(c.agentSockets)) + for _, socket := range c.agentSockets { + socket := socket + go func() { + _ = socket.Close() + wg.Done() + }() + } + + for _, connMap := range c.agentToConnectionSockets { + wg.Add(len(connMap)) + for _, socket := range connMap { + socket := socket + go func() { + _ = socket.Close() + wg.Done() + }() + } + } + + wg.Wait() + return nil +} + +func (c *haCoordinator) publishNodesToAgent(recipient uuid.UUID, nodes []*agpl.Node) error { + msg, err := c.formatCallMeMaybe(recipient, nodes) + if err != nil { + return xerrors.Errorf("format publish message: %w", err) + } + + err = c.pubsub.Publish("wireguard_peers", msg) + if err != nil { + return xerrors.Errorf("publish message: %w", err) + } + + return nil +} + +func (c *haCoordinator) publishAgentHello(id uuid.UUID) error { + msg, err := c.formatAgentHello(id) + if err != nil { + return xerrors.Errorf("format publish message: %w", err) + } + + err = c.pubsub.Publish("wireguard_peers", msg) + if err != nil { + return xerrors.Errorf("publish message: %w", err) + } + + return nil +} + +func (c *haCoordinator) publishClientHello(id uuid.UUID) error { + msg, err := c.formatClientHello(id) + if err != nil { + return xerrors.Errorf("format client hello: %w", err) + } + err = c.pubsub.Publish("wireguard_peers", msg) + if err != nil { + return xerrors.Errorf("publish client hello: %w", err) + } + return nil +} + +func (c *haCoordinator) publishAgentToNodes(id uuid.UUID, node *agpl.Node) error { + msg, err := c.formatAgentUpdate(id, node) + if err != nil { + return xerrors.Errorf("format publish message: %w", err) + } + + err = c.pubsub.Publish("wireguard_peers", msg) + if err != nil { + return xerrors.Errorf("publish message: %w", err) + } + + return nil +} + +func (c *haCoordinator) runPubsub(ctx context.Context) error { + messageQueue := make(chan []byte, 64) + cancelSub, err := c.pubsub.Subscribe("wireguard_peers", func(ctx context.Context, message []byte) { + select { + case messageQueue <- message: + case <-ctx.Done(): + return + } + }) + if err != nil { + return xerrors.Errorf("subscribe wireguard peers") + } + go func() { + for { + var message []byte + select { + case <-ctx.Done(): + return + case message = <-messageQueue: + } + c.handlePubsubMessage(ctx, message) + } + }() + + go func() { + defer cancelSub() + <-c.close + }() + + return nil +} + +func (c *haCoordinator) handlePubsubMessage(ctx context.Context, message []byte) { + sp := bytes.Split(message, []byte("|")) + if len(sp) != 4 { + c.log.Error(ctx, "invalid wireguard peer message", slog.F("msg", string(message))) + return + } + + var ( + coordinatorID = sp[0] + eventType = sp[1] + agentID = sp[2] + nodeJSON = sp[3] + ) + + sender, err := uuid.ParseBytes(coordinatorID) + if err != nil { + c.log.Error(ctx, "invalid sender id", slog.F("id", string(coordinatorID)), slog.F("msg", string(message))) + return + } + + // We sent this message! + if sender == c.id { + return + } + + switch string(eventType) { + case "callmemaybe": + agentUUID, err := uuid.ParseBytes(agentID) + if err != nil { + c.log.Error(ctx, "invalid agent id", slog.F("id", string(agentID))) + return + } + + c.mutex.Lock() + agentSocket, ok := c.agentSockets[agentUUID] + if !ok { + c.mutex.Unlock() + return + } + c.mutex.Unlock() + + // We get a single node over pubsub, so turn into an array. + _, err = agentSocket.Write(nodeJSON) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + return + } + c.log.Error(ctx, "send callmemaybe to agent", slog.Error(err)) + return + } + case "clienthello": + agentUUID, err := uuid.ParseBytes(agentID) + if err != nil { + c.log.Error(ctx, "invalid agent id", slog.F("id", string(agentID))) + return + } + + err = c.handleClientHello(agentUUID) + if err != nil { + c.log.Error(ctx, "handle agent request node", slog.Error(err)) + return + } + case "agenthello": + agentUUID, err := uuid.ParseBytes(agentID) + if err != nil { + c.log.Error(ctx, "invalid agent id", slog.F("id", string(agentID))) + return + } + + nodes := c.nodesSubscribedToAgent(agentUUID) + if len(nodes) > 0 { + err := c.publishNodesToAgent(agentUUID, nodes) + if err != nil { + c.log.Error(ctx, "publish nodes to agent", slog.Error(err)) + return + } + } + case "agentupdate": + agentUUID, err := uuid.ParseBytes(agentID) + if err != nil { + c.log.Error(ctx, "invalid agent id", slog.F("id", string(agentID))) + return + } + + decoder := json.NewDecoder(bytes.NewReader(nodeJSON)) + _, err = c.handleAgentUpdate(agentUUID, decoder) + if err != nil { + c.log.Error(ctx, "handle agent update", slog.Error(err)) + return + } + default: + c.log.Error(ctx, "unknown peer event", slog.F("name", string(eventType))) + } +} + +// format: |callmemaybe|| +func (c *haCoordinator) formatCallMeMaybe(recipient uuid.UUID, nodes []*agpl.Node) ([]byte, error) { + buf := bytes.Buffer{} + + buf.WriteString(c.id.String() + "|") + buf.WriteString("callmemaybe|") + buf.WriteString(recipient.String() + "|") + err := json.NewEncoder(&buf).Encode(nodes) + if err != nil { + return nil, xerrors.Errorf("encode node: %w", err) + } + + return buf.Bytes(), nil +} + +// format: |agenthello|| +func (c *haCoordinator) formatAgentHello(id uuid.UUID) ([]byte, error) { + buf := bytes.Buffer{} + + buf.WriteString(c.id.String() + "|") + buf.WriteString("agenthello|") + buf.WriteString(id.String() + "|") + + return buf.Bytes(), nil +} + +// format: |clienthello|| +func (c *haCoordinator) formatClientHello(id uuid.UUID) ([]byte, error) { + buf := bytes.Buffer{} + + buf.WriteString(c.id.String() + "|") + buf.WriteString("clienthello|") + buf.WriteString(id.String() + "|") + + return buf.Bytes(), nil +} + +// format: |agentupdate|| +func (c *haCoordinator) formatAgentUpdate(id uuid.UUID, node *agpl.Node) ([]byte, error) { + buf := bytes.Buffer{} + + buf.WriteString(c.id.String() + "|") + buf.WriteString("agentupdate|") + buf.WriteString(id.String() + "|") + err := json.NewEncoder(&buf).Encode(node) + if err != nil { + return nil, xerrors.Errorf("encode node: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/enterprise/tailnet/coordinator_test.go b/enterprise/tailnet/coordinator_test.go new file mode 100644 index 0000000000..86cee94dbd --- /dev/null +++ b/enterprise/tailnet/coordinator_test.go @@ -0,0 +1,261 @@ +package tailnet_test + +import ( + "net" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/enterprise/tailnet" + agpl "github.com/coder/coder/tailnet" + "github.com/coder/coder/testutil" +) + +func TestCoordinatorSingle(t *testing.T) { + t.Parallel() + t.Run("ClientWithoutAgent", func(t *testing.T) { + t.Parallel() + coordinator, err := tailnet.NewCoordinator(slogtest.Make(t, nil), database.NewPubsubInMemory()) + require.NoError(t, err) + defer coordinator.Close() + + client, server := net.Pipe() + sendNode, errChan := agpl.ServeCoordinator(client, func(node []*agpl.Node) error { + return nil + }) + id := uuid.New() + closeChan := make(chan struct{}) + go func() { + err := coordinator.ServeClient(server, id, uuid.New()) + assert.NoError(t, err) + close(closeChan) + }() + sendNode(&agpl.Node{}) + require.Eventually(t, func() bool { + return coordinator.Node(id) != nil + }, testutil.WaitShort, testutil.IntervalFast) + + err = client.Close() + require.NoError(t, err) + <-errChan + <-closeChan + }) + + t.Run("AgentWithoutClients", func(t *testing.T) { + t.Parallel() + coordinator, err := tailnet.NewCoordinator(slogtest.Make(t, nil), database.NewPubsubInMemory()) + require.NoError(t, err) + defer coordinator.Close() + + client, server := net.Pipe() + sendNode, errChan := agpl.ServeCoordinator(client, func(node []*agpl.Node) error { + return nil + }) + id := uuid.New() + closeChan := make(chan struct{}) + go func() { + err := coordinator.ServeAgent(server, id) + assert.NoError(t, err) + close(closeChan) + }() + sendNode(&agpl.Node{}) + require.Eventually(t, func() bool { + return coordinator.Node(id) != nil + }, testutil.WaitShort, testutil.IntervalFast) + err = client.Close() + require.NoError(t, err) + <-errChan + <-closeChan + }) + + t.Run("AgentWithClient", func(t *testing.T) { + t.Parallel() + + coordinator, err := tailnet.NewCoordinator(slogtest.Make(t, nil), database.NewPubsubInMemory()) + require.NoError(t, err) + defer coordinator.Close() + + agentWS, agentServerWS := net.Pipe() + defer agentWS.Close() + agentNodeChan := make(chan []*agpl.Node) + sendAgentNode, agentErrChan := agpl.ServeCoordinator(agentWS, func(nodes []*agpl.Node) error { + agentNodeChan <- nodes + return nil + }) + agentID := uuid.New() + closeAgentChan := make(chan struct{}) + go func() { + err := coordinator.ServeAgent(agentServerWS, agentID) + assert.NoError(t, err) + close(closeAgentChan) + }() + sendAgentNode(&agpl.Node{}) + require.Eventually(t, func() bool { + return coordinator.Node(agentID) != nil + }, testutil.WaitShort, testutil.IntervalFast) + + clientWS, clientServerWS := net.Pipe() + defer clientWS.Close() + defer clientServerWS.Close() + clientNodeChan := make(chan []*agpl.Node) + sendClientNode, clientErrChan := agpl.ServeCoordinator(clientWS, func(nodes []*agpl.Node) error { + clientNodeChan <- nodes + return nil + }) + clientID := uuid.New() + closeClientChan := make(chan struct{}) + go func() { + err := coordinator.ServeClient(clientServerWS, clientID, agentID) + assert.NoError(t, err) + close(closeClientChan) + }() + agentNodes := <-clientNodeChan + require.Len(t, agentNodes, 1) + sendClientNode(&agpl.Node{}) + clientNodes := <-agentNodeChan + require.Len(t, clientNodes, 1) + + // Ensure an update to the agent node reaches the client! + sendAgentNode(&agpl.Node{}) + agentNodes = <-clientNodeChan + require.Len(t, agentNodes, 1) + + // Close the agent WebSocket so a new one can connect. + err = agentWS.Close() + require.NoError(t, err) + <-agentErrChan + <-closeAgentChan + + // Create a new agent connection. This is to simulate a reconnect! + agentWS, agentServerWS = net.Pipe() + defer agentWS.Close() + agentNodeChan = make(chan []*agpl.Node) + _, agentErrChan = agpl.ServeCoordinator(agentWS, func(nodes []*agpl.Node) error { + agentNodeChan <- nodes + return nil + }) + closeAgentChan = make(chan struct{}) + go func() { + err := coordinator.ServeAgent(agentServerWS, agentID) + assert.NoError(t, err) + close(closeAgentChan) + }() + // Ensure the existing listening client sends it's node immediately! + clientNodes = <-agentNodeChan + require.Len(t, clientNodes, 1) + + err = agentWS.Close() + require.NoError(t, err) + <-agentErrChan + <-closeAgentChan + + err = clientWS.Close() + require.NoError(t, err) + <-clientErrChan + <-closeClientChan + }) +} + +func TestCoordinatorHA(t *testing.T) { + t.Parallel() + + t.Run("AgentWithClient", func(t *testing.T) { + t.Parallel() + + _, pubsub := dbtestutil.NewDB(t) + + coordinator1, err := tailnet.NewCoordinator(slogtest.Make(t, nil), pubsub) + require.NoError(t, err) + defer coordinator1.Close() + + agentWS, agentServerWS := net.Pipe() + defer agentWS.Close() + agentNodeChan := make(chan []*agpl.Node) + sendAgentNode, agentErrChan := agpl.ServeCoordinator(agentWS, func(nodes []*agpl.Node) error { + agentNodeChan <- nodes + return nil + }) + agentID := uuid.New() + closeAgentChan := make(chan struct{}) + go func() { + err := coordinator1.ServeAgent(agentServerWS, agentID) + assert.NoError(t, err) + close(closeAgentChan) + }() + sendAgentNode(&agpl.Node{}) + require.Eventually(t, func() bool { + return coordinator1.Node(agentID) != nil + }, testutil.WaitShort, testutil.IntervalFast) + + coordinator2, err := tailnet.NewCoordinator(slogtest.Make(t, nil), pubsub) + require.NoError(t, err) + defer coordinator2.Close() + + clientWS, clientServerWS := net.Pipe() + defer clientWS.Close() + defer clientServerWS.Close() + clientNodeChan := make(chan []*agpl.Node) + sendClientNode, clientErrChan := agpl.ServeCoordinator(clientWS, func(nodes []*agpl.Node) error { + clientNodeChan <- nodes + return nil + }) + clientID := uuid.New() + closeClientChan := make(chan struct{}) + go func() { + err := coordinator2.ServeClient(clientServerWS, clientID, agentID) + assert.NoError(t, err) + close(closeClientChan) + }() + agentNodes := <-clientNodeChan + require.Len(t, agentNodes, 1) + sendClientNode(&agpl.Node{}) + _ = sendClientNode + clientNodes := <-agentNodeChan + require.Len(t, clientNodes, 1) + + // Ensure an update to the agent node reaches the client! + sendAgentNode(&agpl.Node{}) + agentNodes = <-clientNodeChan + require.Len(t, agentNodes, 1) + + // Close the agent WebSocket so a new one can connect. + require.NoError(t, agentWS.Close()) + require.NoError(t, agentServerWS.Close()) + <-agentErrChan + <-closeAgentChan + + // Create a new agent connection. This is to simulate a reconnect! + agentWS, agentServerWS = net.Pipe() + defer agentWS.Close() + agentNodeChan = make(chan []*agpl.Node) + _, agentErrChan = agpl.ServeCoordinator(agentWS, func(nodes []*agpl.Node) error { + agentNodeChan <- nodes + return nil + }) + closeAgentChan = make(chan struct{}) + go func() { + err := coordinator1.ServeAgent(agentServerWS, agentID) + assert.NoError(t, err) + close(closeAgentChan) + }() + // Ensure the existing listening client sends it's node immediately! + clientNodes = <-agentNodeChan + require.Len(t, clientNodes, 1) + + err = agentWS.Close() + require.NoError(t, err) + <-agentErrChan + <-closeAgentChan + + err = clientWS.Close() + require.NoError(t, err) + <-clientErrChan + <-closeClientChan + }) +} diff --git a/examples/templates/aws-ecs-container/main.tf b/examples/templates/aws-ecs-container/main.tf index e21381ad54..394bbed6dc 100644 --- a/examples/templates/aws-ecs-container/main.tf +++ b/examples/templates/aws-ecs-container/main.tf @@ -6,7 +6,7 @@ terraform { } coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } @@ -110,6 +110,7 @@ resource "coder_app" "code-server" { icon = "/icon/code.svg" url = "http://localhost:13337?folder=/home/coder" subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/aws-linux/main.tf b/examples/templates/aws-linux/main.tf index 549afaeccb..89b69be247 100644 --- a/examples/templates/aws-linux/main.tf +++ b/examples/templates/aws-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } @@ -86,10 +86,12 @@ resource "coder_agent" "main" { } resource "coder_app" "code-server" { - agent_id = coder_agent.main.id - name = "code-server" - url = "http://localhost:13337/?folder=/home/coder" - icon = "/icon/code.svg" + agent_id = coder_agent.main.id + name = "code-server" + url = "http://localhost:13337/?folder=/home/coder" + icon = "/icon/code.svg" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/aws-windows/main.tf b/examples/templates/aws-windows/main.tf index 960f1d88aa..a01ee9a7eb 100644 --- a/examples/templates/aws-windows/main.tf +++ b/examples/templates/aws-windows/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/examples/templates/azure-linux/main.tf b/examples/templates/azure-linux/main.tf index e40fb4f510..aa6698e6bc 100644 --- a/examples/templates/azure-linux/main.tf +++ b/examples/templates/azure-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } azurerm = { source = "hashicorp/azurerm" diff --git a/examples/templates/bare/main.tf b/examples/templates/bare/main.tf index 24bf79b337..b51b3e777c 100644 --- a/examples/templates/bare/main.tf +++ b/examples/templates/bare/main.tf @@ -43,10 +43,12 @@ resource "null_resource" "fake-disk" { resource "coder_app" "fake-app" { # Access :8080 in the workspace from the Coder dashboard. - name = "VS Code" - icon = "/icon/code.svg" - agent_id = "fake-compute" - url = "http://localhost:8080" + name = "VS Code" + icon = "/icon/code.svg" + agent_id = "fake-compute" + url = "http://localhost:8080" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:8080/healthz" diff --git a/examples/templates/do-linux/main.tf b/examples/templates/do-linux/main.tf index 375d61c3a1..9f54de8957 100644 --- a/examples/templates/do-linux/main.tf +++ b/examples/templates/do-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } digitalocean = { source = "digitalocean/digitalocean" diff --git a/examples/templates/docker-code-server/main.tf b/examples/templates/docker-code-server/main.tf index 3ac932a1d0..2e4f4f5b48 100644 --- a/examples/templates/docker-code-server/main.tf +++ b/examples/templates/docker-code-server/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" @@ -38,9 +38,12 @@ resource "coder_agent" "main" { } resource "coder_app" "code-server" { - agent_id = coder_agent.main.id - url = "http://localhost:8080/?folder=/home/coder" - icon = "/icon/code.svg" + agent_id = coder_agent.main.id + name = "code-server" + url = "http://localhost:8080/?folder=/home/coder" + icon = "/icon/code.svg" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:8080/healthz" diff --git a/examples/templates/docker-image-builds/main.tf b/examples/templates/docker-image-builds/main.tf index 4849253a7b..f5290efdfe 100644 --- a/examples/templates/docker-image-builds/main.tf +++ b/examples/templates/docker-image-builds/main.tf @@ -3,7 +3,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" @@ -34,10 +34,12 @@ resource "coder_agent" "main" { } resource "coder_app" "code-server" { - agent_id = coder_agent.main.id - name = "code-server" - url = "http://localhost:13337/?folder=/home/coder" - icon = "/icon/code.svg" + agent_id = coder_agent.main.id + name = "code-server" + url = "http://localhost:13337/?folder=/home/coder" + icon = "/icon/code.svg" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/docker-with-dotfiles/main.tf b/examples/templates/docker-with-dotfiles/main.tf index 73ab639f69..750dbed2e0 100644 --- a/examples/templates/docker-with-dotfiles/main.tf +++ b/examples/templates/docker-with-dotfiles/main.tf @@ -9,7 +9,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" diff --git a/examples/templates/docker/main.tf b/examples/templates/docker/main.tf index 2463eeef9f..677dace7f4 100644 --- a/examples/templates/docker/main.tf +++ b/examples/templates/docker/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" @@ -43,10 +43,13 @@ resource "coder_agent" "main" { } resource "coder_app" "code-server" { - agent_id = coder_agent.main.id - name = "code-server" - url = "http://localhost:13337/?folder=/home/coder" - icon = "/icon/code.svg" + agent_id = coder_agent.main.id + name = "code-server" + url = "http://localhost:13337/?folder=/home/coder" + icon = "/icon/code.svg" + subdomain = false + share = "owner" + healthcheck { url = "http://localhost:13337/healthz" interval = 5 diff --git a/examples/templates/gcp-linux/main.tf b/examples/templates/gcp-linux/main.tf index 29e516790c..8e184b17c3 100644 --- a/examples/templates/gcp-linux/main.tf +++ b/examples/templates/gcp-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } google = { source = "hashicorp/google" @@ -65,6 +65,7 @@ resource "coder_app" "code-server" { icon = "/icon/code.svg" url = "http://localhost:13337?folder=/home/coder" subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/gcp-vm-container/main.tf b/examples/templates/gcp-vm-container/main.tf index 508bf4a344..753a2535fe 100644 --- a/examples/templates/gcp-vm-container/main.tf +++ b/examples/templates/gcp-vm-container/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } google = { source = "hashicorp/google" @@ -55,6 +55,7 @@ resource "coder_app" "code-server" { icon = "/icon/code.svg" url = "http://localhost:13337?folder=/home/coder" subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/gcp-windows/main.tf b/examples/templates/gcp-windows/main.tf index e9f65d332c..5f9a65ac1a 100644 --- a/examples/templates/gcp-windows/main.tf +++ b/examples/templates/gcp-windows/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } google = { source = "hashicorp/google" diff --git a/examples/templates/kubernetes/main.tf b/examples/templates/kubernetes/main.tf index a3656df07e..b9d6ebd0ba 100644 --- a/examples/templates/kubernetes/main.tf +++ b/examples/templates/kubernetes/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } kubernetes = { source = "hashicorp/kubernetes" @@ -76,6 +76,7 @@ resource "coder_app" "code-server" { icon = "/icon/code.svg" url = "http://localhost:13337?folder=/home/coder" subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/go.mod b/go.mod index 9834e27e5f..8fec641bc9 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ replace github.com/tcnksm/go-httpstat => github.com/kylecarbs/go-httpstat v0.0.0 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20220926024748-50f068456c6c +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20221015033036-5861cbbf7bf5 // Switch to our fork that imports fixes from http://github.com/tailscale/ssh. // See: https://github.com/coder/coder/issues/3371 @@ -125,12 +125,12 @@ require ( github.com/u-root/u-root v0.9.0 github.com/unrolled/secure v1.13.0 go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 - go.opentelemetry.io/otel v1.10.0 + go.opentelemetry.io/otel v1.11.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.10.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0 go.opentelemetry.io/otel/sdk v1.10.0 - go.opentelemetry.io/otel/trace v1.10.0 + go.opentelemetry.io/otel/trace v1.11.0 go.uber.org/atomic v1.10.0 go.uber.org/goleak v1.2.0 golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e diff --git a/go.sum b/go.sum index 13fdc5724f..099a4d96cb 100644 --- a/go.sum +++ b/go.sum @@ -351,8 +351,8 @@ github.com/coder/retry v1.3.0 h1:5lAAwt/2Cm6lVmnfBY7sOMXcBOwcwJhmV5QGSELIVWY= github.com/coder/retry v1.3.0/go.mod h1:tXuRgZgWjUnU5LZPT4lJh4ew2elUhexhlnXzrJWdyFY= github.com/coder/ssh v0.0.0-20220811105153-fcea99919338 h1:tN5GKFT68YLVzJoA8AHuiMNJ0qlhoD3pGN3JY9gxSko= github.com/coder/ssh v0.0.0-20220811105153-fcea99919338/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= -github.com/coder/tailscale v1.1.1-0.20220926024748-50f068456c6c h1:xa6lr5Pj87Is26tgpzwBsEGKL7aVz7/fRGgY9QIbf3E= -github.com/coder/tailscale v1.1.1-0.20220926024748-50f068456c6c/go.mod h1:5amxy08qijEa8bcTW2SeIy4MIqcmd7LMsuOxqOlj2Ak= +github.com/coder/tailscale v1.1.1-0.20221015033036-5861cbbf7bf5 h1:WVH6e/qK3Wpl0wbmpORD2oQ1qLJborF3fsFHyO1ps0Y= +github.com/coder/tailscale v1.1.1-0.20221015033036-5861cbbf7bf5/go.mod h1:5amxy08qijEa8bcTW2SeIy4MIqcmd7LMsuOxqOlj2Ak= github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE= github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU= github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= @@ -1859,8 +1859,8 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.2 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0/go.mod h1:2AboqHi0CiIZU0qwhtUfCYD1GeUzvvIXWNkhDt7ZMG4= go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo= go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs= -go.opentelemetry.io/otel v1.10.0 h1:Y7DTJMR6zs1xkS/upamJYk0SxxN4C9AqRd77jmZnyY4= -go.opentelemetry.io/otel v1.10.0/go.mod h1:NbvWjCthWHKBEUMpf0/v8ZRZlni86PpGFEMA9pnQSnQ= +go.opentelemetry.io/otel v1.11.0 h1:kfToEGMDq6TrVrJ9Vht84Y8y9enykSZzDDZglV0kIEk= +go.opentelemetry.io/otel v1.11.0/go.mod h1:H2KtuEphyMvlhZ+F7tg9GRhAOe60moNx61Ex+WmiKkk= go.opentelemetry.io/otel/exporters/otlp v0.20.0/go.mod h1:YIieizyaN77rtLJra0buKiNBOm9XQfkPEKBeuhoMwAM= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.3.0/go.mod h1:VpP4/RMn8bv8gNo9uK7/IMY4mtWLELsS+JIP0inH0h4= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 h1:TaB+1rQhddO1sF71MpZOZAuSPW1klK2M8XxfrBMfK7Y= @@ -1889,8 +1889,8 @@ go.opentelemetry.io/otel/sdk/metric v0.20.0/go.mod h1:knxiS8Xd4E/N+ZqKmUPf3gTTZ4 go.opentelemetry.io/otel/sdk/metric v0.31.0 h1:2sZx4R43ZMhJdteKAlKoHvRgrMp53V1aRxvEf5lCq8Q= go.opentelemetry.io/otel/trace v0.20.0/go.mod h1:6GjCW8zgDjwGHGa6GkyeB8+/5vjT16gUEi0Nf1iBdgw= go.opentelemetry.io/otel/trace v1.3.0/go.mod h1:c/VDhno8888bvQYmbYLqe41/Ldmr/KKunbvWM4/fEjk= -go.opentelemetry.io/otel/trace v1.10.0 h1:npQMbR8o7mum8uF95yFbOEJffhs1sbCOfDh8zAJiH5E= -go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM= +go.opentelemetry.io/otel/trace v1.11.0 h1:20U/Vj42SX+mASlXLmSGBg6jpI1jQtv682lZtTAOVFI= +go.opentelemetry.io/otel/trace v1.11.0/go.mod h1:nyYjis9jy0gytE9LXGU+/m1sHTKbRY0fX0hulNNDP1U= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.11.0/go.mod h1:QpEjXPrNQzrFDZgoTo49dgHR9RYRSrg3NAKnUGl9YpQ= go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw= diff --git a/helm/Makefile b/helm/Makefile index 2d88f4ff09..a3f689b163 100644 --- a/helm/Makefile +++ b/helm/Makefile @@ -14,5 +14,5 @@ lint: lint/helm .PHONY: lint lint/helm: - helm lint --strict . + helm lint --strict --set coder.image.tag=v0.0.1 . .PHONY: lint/helm diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl index 95ba09a886..b0b04baacb 100644 --- a/helm/templates/_helpers.tpl +++ b/helm/templates/_helpers.tpl @@ -138,6 +138,24 @@ Coder TLS environment variables. {{- end }} {{- end }} +{{/* +Coder ingress wildcard hostname with the wildcard suffix stripped. +*/}} +{{- define "coder.ingressWildcardHost" -}} +{{/* This regex replace is required as the original input including the suffix + * is not a legal ingress host. We need to remove the suffix and keep the + * wildcard '*'. + * + * - '\\*' Starts with '*' + * - '[^.]*' Suffix is 0 or more characters, '-suffix' + * - '(' Start domain capture group + * - '\\.' The domain should be separated with a '.' from the subdomain + * - '.*' Rest of the domain. + * - ')' $1 is the ''.example.com' + */}} +{{- regexReplaceAll "\\*[^.]*(\\..*)" .Values.coder.ingress.wildcardHost "*${1}" -}} +{{- end }} + {{/* Fail on fully deprecated values or deprecated value combinations. This is included at the top of coder.yaml. diff --git a/helm/templates/coder.yaml b/helm/templates/coder.yaml index 45f3f6e29a..02badc277d 100644 --- a/helm/templates/coder.yaml +++ b/helm/templates/coder.yaml @@ -14,10 +14,7 @@ metadata: {{- include "coder.labels" . | nindent 4 }} annotations: {{ toYaml .Values.coder.annotations | nindent 4}} spec: - # NOTE: this is currently not used as coder v2 does not support high - # availability yet. - # replicas: {{ .Values.coder.replicaCount }} - replicas: 1 + replicas: {{ .Values.coder.replicaCount }} selector: matchLabels: {{- include "coder.selectorLabels" . | nindent 6 }} @@ -38,6 +35,13 @@ spec: env: - name: CODER_ADDRESS value: "0.0.0.0:{{ include "coder.port" . }}" + # Used for inter-pod communication with high-availability. + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: "{{ include "coder.portName" . }}://$(KUBE_POD_IP):{{ include "coder.port" . }}" {{- include "coder.tlsEnv" . | nindent 12 }} {{- with .Values.coder.env -}} {{ toYaml . | nindent 12 }} diff --git a/helm/templates/ingress.yaml b/helm/templates/ingress.yaml index 594bda6747..4644ae836c 100644 --- a/helm/templates/ingress.yaml +++ b/helm/templates/ingress.yaml @@ -26,8 +26,9 @@ spec: name: coder port: name: {{ include "coder.portName" . | quote }} + {{- if .Values.coder.ingress.wildcardHost }} - - host: {{ .Values.coder.ingress.wildcardHost | quote }} + - host: {{ include "coder.ingressWildcardHost" . | quote }} http: paths: - path: / @@ -46,7 +47,7 @@ spec: secretName: {{ .Values.coder.ingress.tls.secretName | quote}} {{- if .Values.coder.ingress.tls.wildcardSecretName }} - hosts: - - {{ .Values.coder.ingress.wildcardHost | quote }} + - {{ include "coder.ingressWildcardHost" . | quote }} secretName: {{ .Values.coder.ingress.tls.wildcardSecretName | quote}} {{- end }} {{- end }} diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml index 28fe0e9f9a..b9a7e9a2f0 100644 --- a/helm/templates/service.yaml +++ b/helm/templates/service.yaml @@ -10,6 +10,7 @@ metadata: {{- toYaml .Values.coder.service.annotations | nindent 4 }} spec: type: {{ .Values.coder.service.type }} + sessionAffinity: ClientIP ports: - name: {{ include "coder.portName" . | quote }} port: {{ include "coder.servicePort" . }} diff --git a/helm/values.yaml b/helm/values.yaml index cfba214ee6..392a53c187 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -1,9 +1,9 @@ # coder -- Primary configuration for `coder server`. coder: - # NOTE: this is currently not used as coder v2 does not support high - # availability yet. - # # coder.replicaCount -- The number of Kubernetes deployment replicas. - # replicaCount: 1 + # coder.replicaCount -- The number of Kubernetes deployment replicas. + # This should only be increased if High Availability is enabled. + # This is an Enterprise feature. Contact sales@coder.com. + replicaCount: 1 # coder.image -- The image to use for Coder. image: @@ -101,8 +101,10 @@ coder: # coder.ingress.host -- The hostname to match on. host: "" # coder.ingress.wildcardHost -- The wildcard hostname to match on. Should be - # in the form "*.example.com". Optional if not using applications over - # subdomains. + # in the form "*.example.com" or "*-suffix.example.com". If you are using a + # suffix after the wildcard, the suffix will be stripped from the created + # ingress to ensure that it is a legal ingress host. Optional if not using + # applications over subdomains. wildcardHost: "" # coder.ingress.annotations -- The ingress annotations. annotations: {} diff --git a/install.sh b/install.sh index bbecb4d598..bac333cf93 100755 --- a/install.sh +++ b/install.sh @@ -531,6 +531,8 @@ sh_c() { sudo_sh_c() { if [ "$(id -u)" = 0 ]; then sh_c "$@" + elif command_exists doas; then + sh_c "doas $*" elif command_exists sudo; then sh_c "sudo $*" elif command_exists su; then @@ -539,7 +541,7 @@ sudo_sh_c() { echoh echoerr "This script needs to run the following command as root." echoerr " $*" - echoerr "Please install sudo or su." + echoerr "Please install sudo, su, or doas." exit 1 fi } diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index e629625323..604c99c7fb 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -30,6 +30,7 @@ type agentAppAttributes struct { Icon string `mapstructure:"icon"` URL string `mapstructure:"url"` Command string `mapstructure:"command"` + Share string `mapstructure:"share"` Subdomain bool `mapstructure:"subdomain"` Healthcheck []appHealthcheckAttributes `mapstructure:"healthcheck"` } @@ -235,6 +236,16 @@ func ConvertResources(module *tfjson.StateModule, rawGraph string) ([]*proto.Res } } + sharingLevel := proto.AppSharingLevel_OWNER + switch strings.ToLower(attrs.Share) { + case "owner": + sharingLevel = proto.AppSharingLevel_OWNER + case "authenticated": + sharingLevel = proto.AppSharingLevel_AUTHENTICATED + case "public": + sharingLevel = proto.AppSharingLevel_PUBLIC + } + for _, agents := range resourceAgents { for _, agent := range agents { // Find agents with the matching ID and associate them! @@ -242,12 +253,13 @@ func ConvertResources(module *tfjson.StateModule, rawGraph string) ([]*proto.Res continue } agent.Apps = append(agent.Apps, &proto.App{ - Name: attrs.Name, - Command: attrs.Command, - Url: attrs.URL, - Icon: attrs.Icon, - Subdomain: attrs.Subdomain, - Healthcheck: healthcheck, + Name: attrs.Name, + Command: attrs.Command, + Url: attrs.URL, + Icon: attrs.Icon, + Subdomain: attrs.Subdomain, + SharingLevel: sharingLevel, + Healthcheck: healthcheck, }) } } diff --git a/provisioner/terraform/testdata/calling-module/calling-module.tf b/provisioner/terraform/testdata/calling-module/calling-module.tf index 6c6289c30d..6bde4e1fd0 100644 --- a/provisioner/terraform/testdata/calling-module/calling-module.tf +++ b/provisioner/terraform/testdata/calling-module/calling-module.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tf b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tf index 3f7a212667..ce8eea33b1 100644 --- a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tf +++ b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tf b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tf index 7ae15e8673..2ec5614cd1 100644 --- a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tf +++ b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/instance-id/instance-id.tf b/provisioner/terraform/testdata/instance-id/instance-id.tf index f474e4993a..767ed45a63 100644 --- a/provisioner/terraform/testdata/instance-id/instance-id.tf +++ b/provisioner/terraform/testdata/instance-id/instance-id.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tf b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tf index 379612d2f3..cae9aac261 100644 --- a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tf +++ b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tf b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tf index 678c600616..446183a9db 100644 --- a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tf +++ b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf index 26734569b6..ab94dcfbf7 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisionersdk/archive.go b/provisionersdk/archive.go index e69f57f0f3..2df6b457c2 100644 --- a/provisionersdk/archive.go +++ b/provisionersdk/archive.go @@ -87,7 +87,8 @@ func Tar(directory string, limit int64) ([]byte, error) { // Don't store tfstate! return err } - header.Name = rel + // Use unix paths in the tar archive. + header.Name = filepath.ToSlash(rel) if err := tarWriter.WriteHeader(header); err != nil { return err } @@ -131,7 +132,7 @@ func Untar(directory string, archive []byte) error { return err } // #nosec - target := filepath.Join(directory, header.Name) + target := filepath.Join(directory, filepath.FromSlash(header.Name)) switch header.Typeflag { case tar.TypeDir: if _, err := os.Stat(target); err != nil { diff --git a/provisionersdk/archive_test.go b/provisionersdk/archive_test.go index 9fefa90ca6..4d37dd7ac5 100644 --- a/provisionersdk/archive_test.go +++ b/provisionersdk/archive_test.go @@ -1,7 +1,6 @@ package provisionersdk_test import ( - "fmt" "os" "path/filepath" "testing" @@ -59,6 +58,7 @@ func TestTar(t *testing.T) { }} for _, file := range files { newDir := dir + file.Name = filepath.FromSlash(file.Name) if filepath.Base(file.Name) != file.Name { newDir = filepath.Join(newDir, filepath.Dir(file.Name)) err := os.MkdirAll(newDir, 0755) @@ -70,7 +70,6 @@ func TestTar(t *testing.T) { _ = tmpFile.Close() file.Name, err = filepath.Rel(dir, tmpFile.Name()) require.NoError(t, err) - fmt.Printf("rel") } content, err := provisionersdk.Tar(dir, 1024) require.NoError(t, err) diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index b88fc0ac1d..0e70c8f919 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -76,6 +76,55 @@ func (LogLevel) EnumDescriptor() ([]byte, []int) { return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{0} } +type AppSharingLevel int32 + +const ( + AppSharingLevel_OWNER AppSharingLevel = 0 + AppSharingLevel_AUTHENTICATED AppSharingLevel = 1 + AppSharingLevel_PUBLIC AppSharingLevel = 2 +) + +// Enum value maps for AppSharingLevel. +var ( + AppSharingLevel_name = map[int32]string{ + 0: "OWNER", + 1: "AUTHENTICATED", + 2: "PUBLIC", + } + AppSharingLevel_value = map[string]int32{ + "OWNER": 0, + "AUTHENTICATED": 1, + "PUBLIC": 2, + } +) + +func (x AppSharingLevel) Enum() *AppSharingLevel { + p := new(AppSharingLevel) + *p = x + return p +} + +func (x AppSharingLevel) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AppSharingLevel) Descriptor() protoreflect.EnumDescriptor { + return file_provisionersdk_proto_provisioner_proto_enumTypes[1].Descriptor() +} + +func (AppSharingLevel) Type() protoreflect.EnumType { + return &file_provisionersdk_proto_provisioner_proto_enumTypes[1] +} + +func (x AppSharingLevel) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AppSharingLevel.Descriptor instead. +func (AppSharingLevel) EnumDescriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{1} +} + type WorkspaceTransition int32 const ( @@ -109,11 +158,11 @@ func (x WorkspaceTransition) String() string { } func (WorkspaceTransition) Descriptor() protoreflect.EnumDescriptor { - return file_provisionersdk_proto_provisioner_proto_enumTypes[1].Descriptor() + return file_provisionersdk_proto_provisioner_proto_enumTypes[2].Descriptor() } func (WorkspaceTransition) Type() protoreflect.EnumType { - return &file_provisionersdk_proto_provisioner_proto_enumTypes[1] + return &file_provisionersdk_proto_provisioner_proto_enumTypes[2] } func (x WorkspaceTransition) Number() protoreflect.EnumNumber { @@ -122,7 +171,7 @@ func (x WorkspaceTransition) Number() protoreflect.EnumNumber { // Deprecated: Use WorkspaceTransition.Descriptor instead. func (WorkspaceTransition) EnumDescriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{1} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{2} } type ParameterSource_Scheme int32 @@ -152,11 +201,11 @@ func (x ParameterSource_Scheme) String() string { } func (ParameterSource_Scheme) Descriptor() protoreflect.EnumDescriptor { - return file_provisionersdk_proto_provisioner_proto_enumTypes[2].Descriptor() + return file_provisionersdk_proto_provisioner_proto_enumTypes[3].Descriptor() } func (ParameterSource_Scheme) Type() protoreflect.EnumType { - return &file_provisionersdk_proto_provisioner_proto_enumTypes[2] + return &file_provisionersdk_proto_provisioner_proto_enumTypes[3] } func (x ParameterSource_Scheme) Number() protoreflect.EnumNumber { @@ -198,11 +247,11 @@ func (x ParameterDestination_Scheme) String() string { } func (ParameterDestination_Scheme) Descriptor() protoreflect.EnumDescriptor { - return file_provisionersdk_proto_provisioner_proto_enumTypes[3].Descriptor() + return file_provisionersdk_proto_provisioner_proto_enumTypes[4].Descriptor() } func (ParameterDestination_Scheme) Type() protoreflect.EnumType { - return &file_provisionersdk_proto_provisioner_proto_enumTypes[3] + return &file_provisionersdk_proto_provisioner_proto_enumTypes[4] } func (x ParameterDestination_Scheme) Number() protoreflect.EnumNumber { @@ -244,11 +293,11 @@ func (x ParameterSchema_TypeSystem) String() string { } func (ParameterSchema_TypeSystem) Descriptor() protoreflect.EnumDescriptor { - return file_provisionersdk_proto_provisioner_proto_enumTypes[4].Descriptor() + return file_provisionersdk_proto_provisioner_proto_enumTypes[5].Descriptor() } func (ParameterSchema_TypeSystem) Type() protoreflect.EnumType { - return &file_provisionersdk_proto_provisioner_proto_enumTypes[4] + return &file_provisionersdk_proto_provisioner_proto_enumTypes[5] } func (x ParameterSchema_TypeSystem) Number() protoreflect.EnumNumber { @@ -850,12 +899,13 @@ type App struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` - Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` - Icon string `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"` - Subdomain bool `protobuf:"varint,5,opt,name=subdomain,proto3" json:"subdomain,omitempty"` - Healthcheck *Healthcheck `protobuf:"bytes,6,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` + Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` + Icon string `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"` + Subdomain bool `protobuf:"varint,5,opt,name=subdomain,proto3" json:"subdomain,omitempty"` + Healthcheck *Healthcheck `protobuf:"bytes,6,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"` + SharingLevel AppSharingLevel `protobuf:"varint,7,opt,name=sharing_level,json=sharingLevel,proto3,enum=provisioner.AppSharingLevel" json:"sharing_level,omitempty"` } func (x *App) Reset() { @@ -932,6 +982,13 @@ func (x *App) GetHealthcheck() *Healthcheck { return nil } +func (x *App) GetSharingLevel() AppSharingLevel { + if x != nil { + return x.SharingLevel + } + return AppSharingLevel_OWNER +} + // Healthcheck represents configuration for checking for app readiness. type Healthcheck struct { state protoimpl.MessageState @@ -1952,7 +2009,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x22, 0xb3, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x22, 0xf6, 0x01, 0x0a, 0x03, 0x41, 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, @@ -1964,110 +2021,118 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, - 0x68, 0x65, 0x63, 0x6b, 0x22, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, - 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, - 0xad, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, - 0x12, 0x3a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, - 0x68, 0x69, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x68, 0x69, 0x64, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x69, 0x63, 0x6f, 0x6e, 0x1a, 0x69, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, - 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, - 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6e, 0x75, 0x6c, - 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, - 0xfc, 0x01, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, - 0x72, 0x79, 0x1a, 0x55, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x49, - 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, - 0x6d, 0x61, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, - 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, - 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x1a, 0x73, 0x0a, 0x08, 0x52, 0x65, 0x73, + 0x68, 0x65, 0x63, 0x6b, 0x12, 0x41, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x5f, + 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, + 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x0c, 0x73, 0x68, 0x61, 0x72, 0x69, + 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x22, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x76, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, + 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, + 0x6c, 0x64, 0x22, 0xad, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, + 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, + 0x12, 0x0a, 0x04, 0x68, 0x69, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x68, + 0x69, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x1a, 0x69, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, + 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, + 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, + 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, + 0x6c, 0x6c, 0x22, 0xfc, 0x01, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x79, 0x1a, 0x55, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, + 0x65, 0x12, 0x49, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, + 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, + 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, 0x10, 0x70, 0x61, 0x72, 0x61, + 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x1a, 0x73, 0x0a, 0x08, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x39, + 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, + 0x61, 0x72, 0x73, 0x65, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, + 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, + 0x65, 0x22, 0xae, 0x07, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, + 0xd1, 0x02, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, + 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, + 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, + 0x0a, 0x0c, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, + 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, + 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x32, 0x0a, 0x15, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, + 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, + 0x61, 0x69, 0x6c, 0x1a, 0xd9, 0x01, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x1c, 0x0a, + 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x46, 0x0a, 0x10, 0x70, + 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x79, 0x5f, 0x72, 0x75, + 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x1a, + 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0x80, 0x01, 0x0a, 0x07, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, + 0x72, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x06, 0x63, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, + 0x6e, 0x63, 0x65, 0x6c, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x1a, 0x6b, 0x0a, 0x08, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x39, 0x0a, 0x08, 0x63, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, - 0x65, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, - 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xae, - 0x07, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xd1, 0x02, 0x0a, - 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, - 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, - 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2c, - 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, - 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x15, - 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, - 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, - 0x1a, 0xd9, 0x01, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, - 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72, 0x61, - 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, - 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, - 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, - 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, - 0x61, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x79, 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x1a, 0x08, 0x0a, 0x06, - 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0x80, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x48, - 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, - 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, - 0x6c, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x1a, 0x6b, 0x0a, 0x08, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, - 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a, - 0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54, - 0x52, 0x41, 0x43, 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, - 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57, - 0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, + 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, + 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x2a, 0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, + 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, + 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, + 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, + 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, 0x0f, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, + 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, + 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x2a, 0x37, 0x0a, 0x13, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x54, 0x4f, 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, @@ -2100,72 +2165,74 @@ func file_provisionersdk_proto_provisioner_proto_rawDescGZIP() []byte { return file_provisionersdk_proto_provisioner_proto_rawDescData } -var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 6) var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ (LogLevel)(0), // 0: provisioner.LogLevel - (WorkspaceTransition)(0), // 1: provisioner.WorkspaceTransition - (ParameterSource_Scheme)(0), // 2: provisioner.ParameterSource.Scheme - (ParameterDestination_Scheme)(0), // 3: provisioner.ParameterDestination.Scheme - (ParameterSchema_TypeSystem)(0), // 4: provisioner.ParameterSchema.TypeSystem - (*Empty)(nil), // 5: provisioner.Empty - (*ParameterSource)(nil), // 6: provisioner.ParameterSource - (*ParameterDestination)(nil), // 7: provisioner.ParameterDestination - (*ParameterValue)(nil), // 8: provisioner.ParameterValue - (*ParameterSchema)(nil), // 9: provisioner.ParameterSchema - (*Log)(nil), // 10: provisioner.Log - (*InstanceIdentityAuth)(nil), // 11: provisioner.InstanceIdentityAuth - (*Agent)(nil), // 12: provisioner.Agent - (*App)(nil), // 13: provisioner.App - (*Healthcheck)(nil), // 14: provisioner.Healthcheck - (*Resource)(nil), // 15: provisioner.Resource - (*Parse)(nil), // 16: provisioner.Parse - (*Provision)(nil), // 17: provisioner.Provision - nil, // 18: provisioner.Agent.EnvEntry - (*Resource_Metadata)(nil), // 19: provisioner.Resource.Metadata - (*Parse_Request)(nil), // 20: provisioner.Parse.Request - (*Parse_Complete)(nil), // 21: provisioner.Parse.Complete - (*Parse_Response)(nil), // 22: provisioner.Parse.Response - (*Provision_Metadata)(nil), // 23: provisioner.Provision.Metadata - (*Provision_Start)(nil), // 24: provisioner.Provision.Start - (*Provision_Cancel)(nil), // 25: provisioner.Provision.Cancel - (*Provision_Request)(nil), // 26: provisioner.Provision.Request - (*Provision_Complete)(nil), // 27: provisioner.Provision.Complete - (*Provision_Response)(nil), // 28: provisioner.Provision.Response + (AppSharingLevel)(0), // 1: provisioner.AppSharingLevel + (WorkspaceTransition)(0), // 2: provisioner.WorkspaceTransition + (ParameterSource_Scheme)(0), // 3: provisioner.ParameterSource.Scheme + (ParameterDestination_Scheme)(0), // 4: provisioner.ParameterDestination.Scheme + (ParameterSchema_TypeSystem)(0), // 5: provisioner.ParameterSchema.TypeSystem + (*Empty)(nil), // 6: provisioner.Empty + (*ParameterSource)(nil), // 7: provisioner.ParameterSource + (*ParameterDestination)(nil), // 8: provisioner.ParameterDestination + (*ParameterValue)(nil), // 9: provisioner.ParameterValue + (*ParameterSchema)(nil), // 10: provisioner.ParameterSchema + (*Log)(nil), // 11: provisioner.Log + (*InstanceIdentityAuth)(nil), // 12: provisioner.InstanceIdentityAuth + (*Agent)(nil), // 13: provisioner.Agent + (*App)(nil), // 14: provisioner.App + (*Healthcheck)(nil), // 15: provisioner.Healthcheck + (*Resource)(nil), // 16: provisioner.Resource + (*Parse)(nil), // 17: provisioner.Parse + (*Provision)(nil), // 18: provisioner.Provision + nil, // 19: provisioner.Agent.EnvEntry + (*Resource_Metadata)(nil), // 20: provisioner.Resource.Metadata + (*Parse_Request)(nil), // 21: provisioner.Parse.Request + (*Parse_Complete)(nil), // 22: provisioner.Parse.Complete + (*Parse_Response)(nil), // 23: provisioner.Parse.Response + (*Provision_Metadata)(nil), // 24: provisioner.Provision.Metadata + (*Provision_Start)(nil), // 25: provisioner.Provision.Start + (*Provision_Cancel)(nil), // 26: provisioner.Provision.Cancel + (*Provision_Request)(nil), // 27: provisioner.Provision.Request + (*Provision_Complete)(nil), // 28: provisioner.Provision.Complete + (*Provision_Response)(nil), // 29: provisioner.Provision.Response } var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ - 2, // 0: provisioner.ParameterSource.scheme:type_name -> provisioner.ParameterSource.Scheme - 3, // 1: provisioner.ParameterDestination.scheme:type_name -> provisioner.ParameterDestination.Scheme - 3, // 2: provisioner.ParameterValue.destination_scheme:type_name -> provisioner.ParameterDestination.Scheme - 6, // 3: provisioner.ParameterSchema.default_source:type_name -> provisioner.ParameterSource - 7, // 4: provisioner.ParameterSchema.default_destination:type_name -> provisioner.ParameterDestination - 4, // 5: provisioner.ParameterSchema.validation_type_system:type_name -> provisioner.ParameterSchema.TypeSystem + 3, // 0: provisioner.ParameterSource.scheme:type_name -> provisioner.ParameterSource.Scheme + 4, // 1: provisioner.ParameterDestination.scheme:type_name -> provisioner.ParameterDestination.Scheme + 4, // 2: provisioner.ParameterValue.destination_scheme:type_name -> provisioner.ParameterDestination.Scheme + 7, // 3: provisioner.ParameterSchema.default_source:type_name -> provisioner.ParameterSource + 8, // 4: provisioner.ParameterSchema.default_destination:type_name -> provisioner.ParameterDestination + 5, // 5: provisioner.ParameterSchema.validation_type_system:type_name -> provisioner.ParameterSchema.TypeSystem 0, // 6: provisioner.Log.level:type_name -> provisioner.LogLevel - 18, // 7: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry - 13, // 8: provisioner.Agent.apps:type_name -> provisioner.App - 14, // 9: provisioner.App.healthcheck:type_name -> provisioner.Healthcheck - 12, // 10: provisioner.Resource.agents:type_name -> provisioner.Agent - 19, // 11: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata - 9, // 12: provisioner.Parse.Complete.parameter_schemas:type_name -> provisioner.ParameterSchema - 10, // 13: provisioner.Parse.Response.log:type_name -> provisioner.Log - 21, // 14: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete - 1, // 15: provisioner.Provision.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition - 8, // 16: provisioner.Provision.Start.parameter_values:type_name -> provisioner.ParameterValue - 23, // 17: provisioner.Provision.Start.metadata:type_name -> provisioner.Provision.Metadata - 24, // 18: provisioner.Provision.Request.start:type_name -> provisioner.Provision.Start - 25, // 19: provisioner.Provision.Request.cancel:type_name -> provisioner.Provision.Cancel - 15, // 20: provisioner.Provision.Complete.resources:type_name -> provisioner.Resource - 10, // 21: provisioner.Provision.Response.log:type_name -> provisioner.Log - 27, // 22: provisioner.Provision.Response.complete:type_name -> provisioner.Provision.Complete - 20, // 23: provisioner.Provisioner.Parse:input_type -> provisioner.Parse.Request - 26, // 24: provisioner.Provisioner.Provision:input_type -> provisioner.Provision.Request - 22, // 25: provisioner.Provisioner.Parse:output_type -> provisioner.Parse.Response - 28, // 26: provisioner.Provisioner.Provision:output_type -> provisioner.Provision.Response - 25, // [25:27] is the sub-list for method output_type - 23, // [23:25] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name + 19, // 7: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry + 14, // 8: provisioner.Agent.apps:type_name -> provisioner.App + 15, // 9: provisioner.App.healthcheck:type_name -> provisioner.Healthcheck + 1, // 10: provisioner.App.sharing_level:type_name -> provisioner.AppSharingLevel + 13, // 11: provisioner.Resource.agents:type_name -> provisioner.Agent + 20, // 12: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata + 10, // 13: provisioner.Parse.Complete.parameter_schemas:type_name -> provisioner.ParameterSchema + 11, // 14: provisioner.Parse.Response.log:type_name -> provisioner.Log + 22, // 15: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete + 2, // 16: provisioner.Provision.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition + 9, // 17: provisioner.Provision.Start.parameter_values:type_name -> provisioner.ParameterValue + 24, // 18: provisioner.Provision.Start.metadata:type_name -> provisioner.Provision.Metadata + 25, // 19: provisioner.Provision.Request.start:type_name -> provisioner.Provision.Start + 26, // 20: provisioner.Provision.Request.cancel:type_name -> provisioner.Provision.Cancel + 16, // 21: provisioner.Provision.Complete.resources:type_name -> provisioner.Resource + 11, // 22: provisioner.Provision.Response.log:type_name -> provisioner.Log + 28, // 23: provisioner.Provision.Response.complete:type_name -> provisioner.Provision.Complete + 21, // 24: provisioner.Provisioner.Parse:input_type -> provisioner.Parse.Request + 27, // 25: provisioner.Provisioner.Provision:input_type -> provisioner.Provision.Request + 23, // 26: provisioner.Provisioner.Parse:output_type -> provisioner.Parse.Response + 29, // 27: provisioner.Provisioner.Provision:output_type -> provisioner.Provision.Response + 26, // [26:28] is the sub-list for method output_type + 24, // [24:26] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name } func init() { file_provisionersdk_proto_provisioner_proto_init() } @@ -2472,7 +2539,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_provisionersdk_proto_provisioner_proto_rawDesc, - NumEnums: 5, + NumEnums: 6, NumMessages: 24, NumExtensions: 0, NumServices: 1, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 26af34f280..bc6ab711a4 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -87,6 +87,12 @@ message Agent { } } +enum AppSharingLevel { + OWNER = 0; + AUTHENTICATED = 1; + PUBLIC = 2; +} + // App represents a dev-accessible application on the workspace. message App { string name = 1; @@ -95,6 +101,7 @@ message App { string icon = 4; bool subdomain = 5; Healthcheck healthcheck = 6; + AppSharingLevel sharing_level = 7; } // Healthcheck represents configuration for checking for app readiness. diff --git a/site/package.json b/site/package.json index 8618c728e6..b38494f51d 100644 --- a/site/package.json +++ b/site/package.json @@ -9,7 +9,7 @@ "check:all": "yarn format:check && yarn lint && yarn test", "chromatic": "chromatic", "dev": "vite", - "format:check": "prettier --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'", + "format:check": "prettier --cache --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'", "format:types": "prettier --write 'src/api/typesGenerated.ts'", "format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'", "lint": "jest --selectProjects lint", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index ea078c17c6..c0759aecaf 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -21,6 +21,7 @@ import { XServiceContext } from "xServices/StateContext" import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame" import { RequireAuth } from "./components/RequireAuth/RequireAuth" import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout" +import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout" // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed @@ -67,6 +68,18 @@ const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage")) const SettingsGroupPage = lazy( () => import("./pages/GroupsPage/SettingsGroupPage"), ) +const GeneralSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/GeneralSettingsPage"), +) +const SecuritySettingsPage = lazy( + () => import("./pages/DeploySettingsPage/SecuritySettingsPage"), +) +const AuthSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/AuthSettingsPage"), +) +const NetworkSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/NetworkSettingsPage"), +) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -237,6 +250,65 @@ export const AppRouter: FC = () => { /> + + + + + + + + + } + /> + + + + + + + + } + /> + + + + + + + + } + /> + + + + + + + + } + /> + + }> } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index fef3d2a7ef..ded0d3d3c8 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -28,6 +28,7 @@ export const defaultEntitlements = (): TypesGen.Entitlements => { return { features: features, has_license: false, + errors: [], warnings: [], experimental: false, trial: false, @@ -665,3 +666,14 @@ export const getAgentListeningPorts = async ( ) return response.data } + +export const getDeploymentFlags = + async (): Promise => { + const response = await axios.get(`/api/v2/flags/deployment`) + return response.data + } + +export const getReplicas = async (): Promise => { + const response = await axios.get(`/api/v2/replicas`) + return response.data +} diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 19807dccfd..1663e0333d 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -23,8 +23,7 @@ export type ApiError = AxiosError & { response: AxiosResponse } -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -export const isApiError = (err: any): err is ApiError => { +export const isApiError = (err: unknown): err is ApiError => { if (axios.isAxiosError(err)) { const response = err.response?.data if (!response) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 44c8df3bd1..ec2da89fce 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -9,6 +9,7 @@ export interface APIKey { readonly created_at: string readonly updated_at: string readonly login_type: LoginType + readonly scope: APIKeyScope readonly lifetime_seconds: number } @@ -168,6 +169,7 @@ export interface CreateFirstUserResponse { // From codersdk/groups.go export interface CreateGroupRequest { readonly name: string + readonly avatar_url: string } // From codersdk/users.go @@ -206,7 +208,7 @@ export interface CreateTemplateVersionRequest { readonly name?: string readonly template_id?: string readonly storage_method: ProvisionerStorageMethod - readonly storage_source: string + readonly file_id: string readonly provisioner: ProvisionerType readonly parameter_values?: CreateParameterRequest[] } @@ -218,6 +220,11 @@ export interface CreateTestAuditLogRequest { readonly resource_id?: string } +// From codersdk/apikey.go +export interface CreateTokenRequest { + readonly scope: APIKeyScope +} + // From codersdk/users.go export interface CreateUserRequest { readonly email: string @@ -268,6 +275,7 @@ export interface DeploymentFlags { readonly derp_server_region_code: StringFlag readonly derp_server_region_name: StringFlag readonly derp_server_stun_address: StringArrayFlag + readonly derp_server_relay_address: StringFlag readonly derp_config_url: StringFlag readonly derp_config_path: StringFlag readonly prom_enabled: BoolFlag @@ -286,7 +294,7 @@ export interface DeploymentFlags { readonly oauth2_github_enterprise_base_url: StringFlag readonly oidc_allow_signups: BoolFlag readonly oidc_client_id: StringFlag - readonly oidc_cliet_secret: StringFlag + readonly oidc_client_secret: StringFlag readonly oidc_email_domain: StringFlag readonly oidc_issuer_url: StringFlag readonly oidc_scopes: StringArrayFlag @@ -297,7 +305,7 @@ export interface DeploymentFlags { readonly tls_cert_files: StringArrayFlag readonly tls_client_ca_file: StringFlag readonly tls_client_auth: StringFlag - readonly tls_key_tiles: StringArrayFlag + readonly tls_key_files: StringArrayFlag readonly tls_min_version: StringFlag readonly trace_enable: BoolFlag readonly secure_auth_cookie: BoolFlag @@ -331,6 +339,7 @@ export interface DurationFlag { export interface Entitlements { readonly features: Record readonly warnings: string[] + readonly errors: string[] readonly has_license: boolean readonly experimental: boolean readonly trial: boolean @@ -344,7 +353,7 @@ export interface Feature { readonly actual?: number } -// From codersdk/users.go +// From codersdk/apikey.go export interface GenerateAPIKeyResponse { readonly key: string } @@ -368,6 +377,7 @@ export interface Group { readonly name: string readonly organization_id: string readonly members: User[] + readonly avatar_url: string } // From codersdk/workspaceapps.go @@ -483,6 +493,7 @@ export interface PatchGroupRequest { readonly add_users: string[] readonly remove_users: string[] readonly name: string + readonly avatar_url?: string } // From codersdk/provisionerdaemons.go @@ -504,7 +515,7 @@ export interface ProvisionerJob { readonly error?: string readonly status: ProvisionerJobStatus readonly worker_id?: string - readonly storage_source: string + readonly file_id: string } // From codersdk/provisionerdaemons.go @@ -522,6 +533,17 @@ export interface PutExtendWorkspaceRequest { readonly deadline: string } +// From codersdk/replicas.go +export interface Replica { + readonly id: string + readonly hostname: string + readonly created_at: string + readonly relay_address: string + readonly region_id: number + readonly error: string + readonly database_latency: number +} + // From codersdk/error.go export interface Response { readonly message: string @@ -580,6 +602,7 @@ export interface Template { readonly active_version_id: string readonly workspace_owner_count: number readonly active_user_count: number + readonly build_time_stats: TemplateBuildTimeStats readonly description: string readonly icon: string readonly max_ttl_ms: number @@ -594,6 +617,13 @@ export interface TemplateACL { readonly group: TemplateGroup[] } +// From codersdk/templates.go +export interface TemplateBuildTimeStats { + readonly start_ms?: number + readonly stop_ms?: number + readonly delete_ms?: number +} + // From codersdk/templates.go export interface TemplateDAUsResponse { readonly entries: DAUEntry[] @@ -783,6 +813,7 @@ export interface WorkspaceApp { readonly command?: string readonly icon?: string readonly subdomain: boolean + readonly sharing_level: WorkspaceAppSharingLevel readonly healthcheck: Healthcheck readonly health: WorkspaceAppHealth } @@ -865,6 +896,8 @@ export interface WorkspaceResourceMetadata { export interface WorkspacesRequest extends Pagination { readonly q?: string } +// From codersdk/apikey.go +export type APIKeyScope = "all" | "application_connect" // From codersdk/audit.go export type AuditAction = "create" | "delete" | "write" @@ -946,6 +979,9 @@ export type WorkspaceAppHealth = | "initializing" | "unhealthy" +// From codersdk/workspaceapps.go +export type WorkspaceAppSharingLevel = "authenticated" | "owner" | "public" + // From codersdk/workspacebuilds.go export type WorkspaceStatus = | "canceled" diff --git a/site/src/components/AppLink/AppLink.stories.tsx b/site/src/components/AppLink/AppLink.stories.tsx index f232870999..845d1e350a 100644 --- a/site/src/components/AppLink/AppLink.stories.tsx +++ b/site/src/components/AppLink/AppLink.stories.tsx @@ -15,6 +15,7 @@ WithIcon.args = { workspaceName: MockWorkspace.name, appName: "code-server", appIcon: "/icon/code.svg", + appSharingLevel: "owner", health: "healthy", } @@ -23,6 +24,7 @@ WithoutIcon.args = { username: "developer", workspaceName: MockWorkspace.name, appName: "code-server", + appSharingLevel: "owner", health: "healthy", } @@ -31,6 +33,7 @@ HealthDisabled.args = { username: "developer", workspaceName: MockWorkspace.name, appName: "code-server", + appSharingLevel: "owner", health: "disabled", } diff --git a/site/src/components/AppLink/AppLink.tsx b/site/src/components/AppLink/AppLink.tsx index 61a413d61b..50477c0836 100644 --- a/site/src/components/AppLink/AppLink.tsx +++ b/site/src/components/AppLink/AppLink.tsx @@ -4,6 +4,9 @@ import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" import Tooltip from "@material-ui/core/Tooltip" import ComputerIcon from "@material-ui/icons/Computer" +import PublicOutlinedIcon from "@material-ui/icons/PublicOutlined" +import LockOutlinedIcon from "@material-ui/icons/LockOutlined" +import GroupOutlinedIcon from "@material-ui/icons/GroupOutlined" import ErrorOutlineIcon from "@material-ui/icons/ErrorOutline" import { FC, PropsWithChildren } from "react" import * as TypesGen from "../../api/typesGenerated" @@ -23,6 +26,7 @@ export interface AppLinkProps { appIcon?: TypesGen.WorkspaceApp["icon"] appCommand?: TypesGen.WorkspaceApp["command"] appSubdomain: TypesGen.WorkspaceApp["subdomain"] + appSharingLevel: TypesGen.WorkspaceApp["sharing_level"] health: TypesGen.WorkspaceApp["health"] } @@ -35,6 +39,7 @@ export const AppLink: FC> = ({ appIcon, appCommand, appSubdomain, + appSharingLevel, health, }) => { const styles = useStyles() @@ -51,7 +56,7 @@ export const AppLink: FC> = ({ } if (appsHost && appSubdomain) { const subdomain = `${appName}--${agentName}--${workspaceName}--${username}` - href = `${window.location.protocol}//${subdomain}.${appsHost}/` + href = `${window.location.protocol}//${appsHost}/`.replace("*", subdomain) } let canClick = true @@ -60,36 +65,50 @@ export const AppLink: FC> = ({ ) : ( ) - let tooltip = "" + + let shareIcon = + let shareTooltip = "Private, only accessible by you" + if (appSharingLevel === "authenticated") { + shareIcon = + shareTooltip = "Shared with all authenticated users" + } + if (appSharingLevel === "public") { + shareIcon = + shareTooltip = "Shared publicly" + } + + let primaryTooltip = "" if (health === "initializing") { canClick = false icon = - tooltip = "Initializing..." + primaryTooltip = "Initializing..." } if (health === "unhealthy") { canClick = false icon = - tooltip = "Unhealthy" + primaryTooltip = "Unhealthy" } if (!appsHost && appSubdomain) { canClick = false icon = - tooltip = "Your admin has not configured subdomain application access" + primaryTooltip = + "Your admin has not configured subdomain application access" } const button = ( ) return ( - + ({ notConfiguredIcon: { color: theme.palette.grey[300], }, + + appName: { + marginRight: theme.spacing(1), + }, })) diff --git a/site/src/components/CopyButton/CopyButton.tsx b/site/src/components/CopyButton/CopyButton.tsx index 107ccc862d..ac8bc3cb87 100644 --- a/site/src/components/CopyButton/CopyButton.tsx +++ b/site/src/components/CopyButton/CopyButton.tsx @@ -2,7 +2,7 @@ import IconButton from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" import Tooltip from "@material-ui/core/Tooltip" import Check from "@material-ui/icons/Check" -import React, { useState } from "react" +import { useClipboard } from "hooks/useClipboard" import { combineClasses } from "../../util/combineClasses" import { FileCopyIcon } from "../Icons/FileCopyIcon" @@ -30,39 +30,7 @@ export const CopyButton: React.FC> = ({ tooltipTitle = Language.tooltipTitle, }) => { const styles = useStyles() - const [isCopied, setIsCopied] = useState(false) - - const copyToClipboard = async (): Promise => { - try { - await window.navigator.clipboard.writeText(text) - setIsCopied(true) - window.setTimeout(() => { - setIsCopied(false) - }, 1000) - } catch (err) { - const input = document.createElement("input") - input.value = text - document.body.appendChild(input) - input.focus() - input.select() - const result = document.execCommand("copy") - document.body.removeChild(input) - if (result) { - setIsCopied(true) - window.setTimeout(() => { - setIsCopied(false) - }, 1000) - } else { - const wrappedErr = new Error( - "copyToClipboard: failed to copy text to clipboard", - ) - if (err instanceof Error) { - wrappedErr.stack = err.stack - } - console.error(wrappedErr) - } - } - } + const { isCopied, copy: copyToClipboard } = useClipboard(text) return ( diff --git a/site/src/components/CopyableValue/CopyableValue.tsx b/site/src/components/CopyableValue/CopyableValue.tsx new file mode 100644 index 0000000000..15bd72d84c --- /dev/null +++ b/site/src/components/CopyableValue/CopyableValue.tsx @@ -0,0 +1,39 @@ +import { makeStyles } from "@material-ui/core/styles" +import Tooltip from "@material-ui/core/Tooltip" +import { useClickable } from "hooks/useClickable" +import { useClipboard } from "hooks/useClipboard" +import React, { HTMLProps } from "react" +import { combineClasses } from "util/combineClasses" + +interface CopyableValueProps extends HTMLProps { + value: string +} + +export const CopyableValue: React.FC = ({ + value, + className, + ...props +}) => { + const { isCopied, copy } = useClipboard(value) + const clickableProps = useClickable(copy) + const styles = useStyles() + + return ( + + + + ) +} + +const useStyles = makeStyles(() => ({ + value: { + cursor: "pointer", + }, +})) diff --git a/site/src/components/DeploySettingsLayout/Badges.tsx b/site/src/components/DeploySettingsLayout/Badges.tsx new file mode 100644 index 0000000000..7dd2600901 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Badges.tsx @@ -0,0 +1,79 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Stack } from "components/Stack/Stack" +import React, { PropsWithChildren } from "react" +import { combineClasses } from "util/combineClasses" + +export const EnabledBadge: React.FC = () => { + const styles = useStyles() + return ( + + Enabled + + ) +} + +export const DisabledBadge: React.FC = () => { + const styles = useStyles() + return ( + + Disabled + + ) +} + +export const EnterpriseBadge: React.FC = () => { + const styles = useStyles() + return ( + + Enterprise + + ) +} + +export const Badges: React.FC = ({ children }) => { + const styles = useStyles() + return ( + + {children} + + ) +} + +const useStyles = makeStyles((theme) => ({ + badges: { + margin: theme.spacing(0, 0, 2), + }, + + badge: { + fontSize: 10, + height: 24, + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.085em", + padding: theme.spacing(0, 1.5), + borderRadius: 9999, + display: "flex", + alignItems: "center", + width: "fit-content", + }, + + enterpriseBadge: { + backgroundColor: theme.palette.info.dark, + border: `1px solid ${theme.palette.info.light}`, + }, + + enabledBadge: { + border: `1px solid ${theme.palette.success.light}`, + backgroundColor: theme.palette.success.dark, + }, + + disabledBadge: { + border: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.paper, + }, +})) diff --git a/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx b/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx new file mode 100644 index 0000000000..b6abb27841 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx @@ -0,0 +1,73 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Margins } from "components/Margins/Margins" +import { Stack } from "components/Stack/Stack" +import { Sidebar } from "./Sidebar" +import React, { + createContext, + PropsWithChildren, + useContext, + useEffect, +} from "react" +import { useActor } from "@xstate/react" +import { XServiceContext } from "xServices/StateContext" +import { Loader } from "components/Loader/Loader" +import { DeploymentFlags } from "api/typesGenerated" + +type DeploySettingsContextValue = { deploymentFlags: DeploymentFlags } + +const DeploySettingsContext = createContext< + DeploySettingsContextValue | undefined +>(undefined) + +export const useDeploySettings = (): DeploySettingsContextValue => { + const context = useContext(DeploySettingsContext) + if (!context) { + throw new Error( + "useDeploySettings should be used inside of DeploySettingsLayout", + ) + } + return context +} + +export const DeploySettingsLayout: React.FC = ({ + children, +}) => { + const xServices = useContext(XServiceContext) + const [state, send] = useActor(xServices.deploymentFlagsXService) + const styles = useStyles() + const { deploymentFlags } = state.context + + useEffect(() => { + if (state.matches("idle")) { + send("LOAD") + } + }, [send, state]) + + return ( + + + +
+ {deploymentFlags ? ( + + {children} + + ) : ( + + )} +
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + wrapper: { + padding: theme.spacing(6, 0), + }, + + content: { + maxWidth: 800, + width: "100%", + }, +})) diff --git a/site/src/components/DeploySettingsLayout/Header.tsx b/site/src/components/DeploySettingsLayout/Header.tsx new file mode 100644 index 0000000000..638725fbd1 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Header.tsx @@ -0,0 +1,69 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import LaunchOutlined from "@material-ui/icons/LaunchOutlined" +import { Stack } from "components/Stack/Stack" +import React from "react" + +export const Header: React.FC<{ + title: string | JSX.Element + description?: string | JSX.Element + secondary?: boolean + docsHref?: string +}> = ({ title, description, docsHref, secondary }) => { + const styles = useStyles() + + return ( + +
+

+ {title} +

+ {description && ( + {description} + )} +
+ + {docsHref && ( + + )} +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + headingGroup: { + maxWidth: 420, + marginBottom: theme.spacing(3), + }, + + title: { + fontSize: 32, + fontWeight: 700, + display: "flex", + alignItems: "center", + lineHeight: "initial", + margin: 0, + marginBottom: theme.spacing(0.5), + gap: theme.spacing(1), + + "&.secondary": { + fontSize: 24, + fontWeight: 500, + }, + }, + + description: { + fontSize: 14, + color: theme.palette.text.secondary, + lineHeight: "160%", + }, +})) diff --git a/site/src/components/DeploySettingsLayout/Option.tsx b/site/src/components/DeploySettingsLayout/Option.tsx new file mode 100644 index 0000000000..d82aeff1d4 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Option.tsx @@ -0,0 +1,85 @@ +import { makeStyles } from "@material-ui/core/styles" +import React, { PropsWithChildren } from "react" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import { DisabledBadge, EnabledBadge } from "./Badges" + +export const OptionName: React.FC = ({ children }) => { + const styles = useStyles() + return {children} +} + +export const OptionDescription: React.FC = ({ + children, +}) => { + const styles = useStyles() + return {children} +} + +const NotSet: React.FC = () => { + const styles = useStyles() + + return Not set +} + +export const OptionValue: React.FC = ({ children }) => { + const styles = useStyles() + + if (typeof children === "boolean") { + return children ? : + } + + if (Array.isArray(children)) { + if (children.length === 0) { + return + } + + return ( +
    + {children.map((item) => ( +
  • + {item} +
  • + ))} +
+ ) + } + + if (children === "") { + return + } + + return {children} +} + +const useStyles = makeStyles((theme) => ({ + optionName: { + display: "block", + }, + + optionDescription: { + display: "block", + color: theme.palette.text.secondary, + fontSize: 14, + marginTop: theme.spacing(0.5), + }, + + optionValue: { + fontSize: 14, + fontFamily: MONOSPACE_FONT_FAMILY, + overflowWrap: "anywhere", + userSelect: "all", + + "& ul": { + padding: theme.spacing(2), + }, + }, + + optionValueList: { + margin: 0, + padding: 0, + listStylePosition: "inside", + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5), + }, +})) diff --git a/site/src/components/DeploySettingsLayout/OptionsTable.tsx b/site/src/components/DeploySettingsLayout/OptionsTable.tsx new file mode 100644 index 0000000000..d54c614b48 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/OptionsTable.tsx @@ -0,0 +1,64 @@ +import { makeStyles } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { DeploymentFlags } from "api/typesGenerated" +import { + OptionDescription, + OptionName, + OptionValue, +} from "components/DeploySettingsLayout/Option" +import React from "react" + +const OptionsTable: React.FC<{ options: Partial }> = ({ + options, +}) => { + const styles = useStyles() + + return ( + + + + + Option + Value + + + + {Object.values(options).map((option) => { + return ( + + + {option.name} + {option.description} + + + + {option.value} + + + ) + })} + +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + table: { + "& td": { + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), + }, + + "& td:last-child, & th:last-child": { + paddingLeft: theme.spacing(4), + }, + }, +})) + +export default OptionsTable diff --git a/site/src/components/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx new file mode 100644 index 0000000000..ad195acbdc --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx @@ -0,0 +1,114 @@ +import { makeStyles } from "@material-ui/core/styles" +import LaunchOutlined from "@material-ui/icons/LaunchOutlined" +import LockRounded from "@material-ui/icons/LockRounded" +import Globe from "@material-ui/icons/Public" +import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined" +import { Stack } from "components/Stack/Stack" +import React, { ElementType, PropsWithChildren, ReactNode } from "react" +import { NavLink } from "react-router-dom" +import { combineClasses } from "util/combineClasses" + +const SidebarNavItem: React.FC< + PropsWithChildren<{ href: string; icon: ReactNode }> +> = ({ children, href, icon }) => { + const styles = useStyles() + return ( + + combineClasses([ + styles.sidebarNavItem, + isActive ? styles.sidebarNavItemActive : undefined, + ]) + } + > + + {icon} + {children} + + + ) +} + +const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({ + icon: Icon, +}) => { + const styles = useStyles() + return +} + +export const Sidebar: React.FC = () => { + const styles = useStyles() + + return ( + + ) +} + +const useStyles = makeStyles((theme) => ({ + sidebar: { + width: 245, + }, + + sidebarNavItem: { + color: "inherit", + display: "block", + fontSize: 16, + textDecoration: "none", + padding: theme.spacing(1.5, 1.5, 1.5, 3), + borderRadius: theme.shape.borderRadius / 2, + transition: "background-color 0.15s ease-in-out", + marginBottom: 1, + position: "relative", + + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + }, + + sidebarNavItemActive: { + backgroundColor: theme.palette.action.hover, + + "&:before": { + content: '""', + display: "block", + width: 3, + height: "100%", + position: "absolute", + left: 0, + top: 0, + backgroundColor: theme.palette.secondary.dark, + borderRadius: theme.shape.borderRadius, + }, + }, + + sidebarNavItemIcon: { + width: theme.spacing(2), + height: theme.spacing(2), + }, +})) diff --git a/site/src/components/GroupAvatar/GroupAvatar.stories.tsx b/site/src/components/GroupAvatar/GroupAvatar.stories.tsx index 315a179c3c..8fda557362 100644 --- a/site/src/components/GroupAvatar/GroupAvatar.stories.tsx +++ b/site/src/components/GroupAvatar/GroupAvatar.stories.tsx @@ -11,4 +11,5 @@ const Template: Story = (args) => export const Example = Template.bind({}) Example.args = { name: "My Group", + avatarURL: "", } diff --git a/site/src/components/GroupAvatar/GroupAvatar.tsx b/site/src/components/GroupAvatar/GroupAvatar.tsx index 6f3b043db0..ab9762050a 100644 --- a/site/src/components/GroupAvatar/GroupAvatar.tsx +++ b/site/src/components/GroupAvatar/GroupAvatar.tsx @@ -25,9 +25,10 @@ const StyledBadge = withStyles((theme) => ({ export type GroupAvatarProps = { name: string + avatarURL?: string } -export const GroupAvatar: FC = ({ name }) => { +export const GroupAvatar: FC = ({ name, avatarURL }) => { return ( = ({ name }) => { }} badgeContent={} > - {firstLetter(name)} + {firstLetter(name)} ) } diff --git a/site/src/components/LicenseBanner/LicenseBanner.tsx b/site/src/components/LicenseBanner/LicenseBanner.tsx index 8532bfca2e..7ecfc2a2a2 100644 --- a/site/src/components/LicenseBanner/LicenseBanner.tsx +++ b/site/src/components/LicenseBanner/LicenseBanner.tsx @@ -8,15 +8,15 @@ export const LicenseBanner: React.FC = () => { const [entitlementsState, entitlementsSend] = useActor( xServices.entitlementsXService, ) - const { warnings } = entitlementsState.context.entitlements + const { errors, warnings } = entitlementsState.context.entitlements /** Gets license data on app mount because LicenseBanner is mounted in App */ useEffect(() => { entitlementsSend("GET_ENTITLEMENTS") }, [entitlementsSend]) - if (warnings.length > 0) { - return + if (errors.length > 0 || warnings.length > 0) { + return } else { return null } diff --git a/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx index c37653eff7..c7ee69c261 100644 --- a/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx +++ b/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx @@ -12,13 +12,23 @@ const Template: Story = (args) => ( export const OneWarning = Template.bind({}) OneWarning.args = { + errors: [], warnings: ["You have exceeded the number of seats in your license."], } export const TwoWarnings = Template.bind({}) TwoWarnings.args = { + errors: [], warnings: [ "You have exceeded the number of seats in your license.", "You are flying too close to the sun.", ], } + +export const OneError = Template.bind({}) +OneError.args = { + errors: [ + "You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.", + ], + warnings: [], +} diff --git a/site/src/components/LicenseBanner/LicenseBannerView.tsx b/site/src/components/LicenseBanner/LicenseBannerView.tsx index 49276b1f0d..792bc191a0 100644 --- a/site/src/components/LicenseBanner/LicenseBannerView.tsx +++ b/site/src/components/LicenseBanner/LicenseBannerView.tsx @@ -2,47 +2,56 @@ import { makeStyles } from "@material-ui/core/styles" import { Expander } from "components/Expander/Expander" import { Pill } from "components/Pill/Pill" import { useState } from "react" +import { colors } from "theme/colors" export const Language = { licenseIssue: "License Issue", licenseIssues: (num: number): string => `${num} License Issues`, - upgrade: "Contact us to upgrade your license.", + upgrade: "Contact sales@coder.com.", exceeded: "It looks like you've exceeded some limits of your license.", lessDetails: "Less", moreDetails: "More", } export interface LicenseBannerViewProps { + errors: string[] warnings: string[] } export const LicenseBannerView: React.FC = ({ + errors, warnings, }) => { const styles = useStyles() const [showDetails, setShowDetails] = useState(false) - if (warnings.length === 1) { + const isError = errors.length > 0 + const messages = [...errors, ...warnings] + const type = isError ? "error" : "warning" + + if (messages.length === 1) { return ( -
- - {warnings[0]} -   - - {Language.upgrade} - +
+ +
+ {messages[0]} +   + + {Language.upgrade} + +
) } else { return ( -
-
-
- - {Language.exceeded} +
+ +
+
    - {warnings.map((warning) => ( -
  • - {warning} + {messages.map((message) => ( +
  • + {message}
  • ))}
@@ -67,14 +76,18 @@ const useStyles = makeStyles((theme) => ({ container: { padding: theme.spacing(1.5), backgroundColor: theme.palette.warning.main, + display: "flex", + alignItems: "center", + + "&.error": { + backgroundColor: colors.red[12], + }, }, flex: { - display: "flex", + display: "column", }, leftContent: { marginRight: theme.spacing(1), - }, - text: { marginLeft: theme.spacing(1), }, link: { @@ -83,9 +96,10 @@ const useStyles = makeStyles((theme) => ({ fontWeight: "bold", }, list: { - margin: theme.spacing(1.5), + padding: theme.spacing(1), + margin: 0, }, listItem: { - margin: theme.spacing(1), + margin: theme.spacing(0.5), }, })) diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index c9c51b6a7a..95355e5a1d 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -14,9 +14,15 @@ export const Navbar: React.FC = () => { selectFeatureVisibility, shallowEqual, ) + const experimental = useSelector( + xServices.entitlementsXService, + (state) => state.context.entitlements.experimental, + ) const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) + const canViewDeployment = + experimental && Boolean(permissions?.viewDeploymentFlags) const onSignOut = () => authSend("SIGN_OUT") return ( @@ -24,6 +30,7 @@ export const Navbar: React.FC = () => { user={me} onSignOut={onSignOut} canViewAuditLog={canViewAuditLog} + canViewDeployment={canViewDeployment} /> ) } diff --git a/site/src/components/NavbarView/NavbarView.test.tsx b/site/src/components/NavbarView/NavbarView.test.tsx index 58ffd82766..2b65a81b00 100644 --- a/site/src/components/NavbarView/NavbarView.test.tsx +++ b/site/src/components/NavbarView/NavbarView.test.tsx @@ -22,26 +22,54 @@ describe("NavbarView", () => { it("renders content", async () => { // When - render() + render( + , + ) // Then await screen.findAllByText("Coder", { exact: false }) }) it("workspaces nav link has the correct href", async () => { - render() + render( + , + ) const workspacesLink = await screen.findByText(navLanguage.workspaces) expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces") }) it("templates nav link has the correct href", async () => { - render() + render( + , + ) const templatesLink = await screen.findByText(navLanguage.templates) expect((templatesLink as HTMLAnchorElement).href).toContain("/templates") }) it("users nav link has the correct href", async () => { - render() + render( + , + ) const userLink = await screen.findByText(navLanguage.users) expect((userLink as HTMLAnchorElement).href).toContain("/users") }) @@ -55,7 +83,14 @@ describe("NavbarView", () => { } // When - render() + render( + , + ) // Then // There should be a 'B' avatar! @@ -64,16 +99,56 @@ describe("NavbarView", () => { }) it("audit nav link has the correct href", async () => { - render() + render( + , + ) const auditLink = await screen.findByText(navLanguage.audit) expect((auditLink as HTMLAnchorElement).href).toContain("/audit") }) it("audit nav link is hidden for members", async () => { render( - , + , ) const auditLink = screen.queryByText(navLanguage.audit) expect(auditLink).not.toBeInTheDocument() }) + + it("deployment nav link has the correct href", async () => { + render( + , + ) + const auditLink = await screen.findByText(navLanguage.deployment) + expect((auditLink as HTMLAnchorElement).href).toContain( + "/settings/deployment/general", + ) + }) + + it("deployment nav link is hidden for members", async () => { + render( + , + ) + const auditLink = screen.queryByText(navLanguage.deployment) + expect(auditLink).not.toBeInTheDocument() + }) }) diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 03a4451314..c7d2260344 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -17,6 +17,7 @@ export interface NavbarViewProps { user?: TypesGen.User onSignOut: () => void canViewAuditLog: boolean + canViewDeployment: boolean } export const Language = { @@ -24,11 +25,16 @@ export const Language = { templates: "Templates", users: "Users", audit: "Audit", + deployment: "Deployment", } const NavItems: React.FC< - React.PropsWithChildren<{ className?: string; canViewAuditLog: boolean }> -> = ({ className, canViewAuditLog }) => { + React.PropsWithChildren<{ + className?: string + canViewAuditLog: boolean + canViewDeployment: boolean + }> +> = ({ className, canViewAuditLog, canViewDeployment }) => { const styles = useStyles() const location = useLocation() @@ -65,6 +71,13 @@ const NavItems: React.FC< )} + {canViewDeployment && ( + + + {Language.deployment} + + + )} ) } @@ -72,6 +85,7 @@ export const NavbarView: React.FC> = ({ user, onSignOut, canViewAuditLog, + canViewDeployment, }) => { const styles = useStyles() const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -98,7 +112,10 @@ export const NavbarView: React.FC> = ({
- +
@@ -109,6 +126,7 @@ export const NavbarView: React.FC> = ({
@@ -192,7 +210,7 @@ const useStyles = makeStyles((theme) => ({ fontSize: 16, padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, textDecoration: "none", - transition: "background-color 0.3s ease", + transition: "background-color 0.15s ease-in-out", "&:hover": { backgroundColor: theme.palette.action.hover, diff --git a/site/src/components/PageHeader/PageHeader.tsx b/site/src/components/PageHeader/PageHeader.tsx index c7f0e49bba..3944f34050 100644 --- a/site/src/components/PageHeader/PageHeader.tsx +++ b/site/src/components/PageHeader/PageHeader.tsx @@ -15,14 +15,17 @@ export const PageHeader: React.FC> = ({ const styles = useStyles({}) return ( -
+
{children}
{actions && ( {actions} )} -
+ ) } diff --git a/site/src/components/PortForwardButton/PortForwardButton.tsx b/site/src/components/PortForwardButton/PortForwardButton.tsx index eef1d65b27..4a18681f2f 100644 --- a/site/src/components/PortForwardButton/PortForwardButton.tsx +++ b/site/src/components/PortForwardButton/PortForwardButton.tsx @@ -27,12 +27,33 @@ export interface PortForwardButtonProps { agentId: string } +const portForwardURL = ( + host: string, + port: number, + agentName: string, + workspaceName: string, + username: string, +): string => { + const { location } = window + + const subdomain = `${ + isNaN(port) ? 3000 : port + }--${agentName}--${workspaceName}--${username}` + return `${location.protocol}//${host}`.replace("*", subdomain) +} + const EnabledView: React.FC = (props) => { const { host, workspaceName, agentName, agentId, username } = props const styles = useStyles() const [port, setPort] = useState("3000") - const { location } = window - const urlExample = `${location.protocol}//${port}--${agentName}--${workspaceName}--${username}.${host}` + const urlExample = portForwardURL( + host, + parseInt(port), + agentName, + workspaceName, + username, + ) + const [state] = useMachine(portForwardMachine, { context: { agentId: agentId }, }) @@ -43,7 +64,8 @@ const EnabledView: React.FC = (props) => { Access ports running on the agent with the{" "} port, agent name, workspace name and{" "} - your username URL schema, as shown below. + your username URL schema, as shown below. Port URLs are + only accessible by you. @@ -82,7 +104,13 @@ const EnabledView: React.FC = (props) => { {ports && ports.map((p, i) => { - const url = `${location.protocol}//${p.port}--${agentName}--${workspaceName}--${username}.${host}` + const url = portForwardURL( + host, + p.port, + agentName, + workspaceName, + username, + ) let label = `${p.port}` if (p.process_name) { label = `${p.process_name} - ${p.port}` diff --git a/site/src/components/Resources/AgentLatency.tsx b/site/src/components/Resources/AgentLatency.tsx new file mode 100644 index 0000000000..ab74f8271e --- /dev/null +++ b/site/src/components/Resources/AgentLatency.tsx @@ -0,0 +1,114 @@ +import { useRef, useState, FC } from "react" +import { makeStyles, Theme, useTheme } from "@material-ui/core/styles" +import { + HelpTooltipText, + HelpPopover, + HelpTooltipTitle, +} from "components/Tooltips/HelpTooltip" +import { Stack } from "components/Stack/Stack" +import { WorkspaceAgent, DERPRegion } from "api/typesGenerated" + +const getDisplayLatency = (theme: Theme, agent: WorkspaceAgent) => { + // Find the right latency to display + const latencyValues = Object.values(agent.latency ?? {}) + const latency = + latencyValues.find((derp) => derp.preferred) ?? + // Accessing an array index can return undefined as well + // for some reason TS does not handle that + (latencyValues[0] as DERPRegion | undefined) + + if (!latency) { + return undefined + } + + // Get the color + let color = theme.palette.success.light + if (latency.latency_ms >= 150 && latency.latency_ms < 300) { + color = theme.palette.warning.light + } else if (latency.latency_ms >= 300) { + color = theme.palette.error.light + } + + return { + ...latency, + color, + } +} + +export const AgentLatency: FC<{ agent: WorkspaceAgent }> = ({ agent }) => { + const theme: Theme = useTheme() + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "latency-popover" : undefined + const latency = getDisplayLatency(theme, agent) + const styles = useStyles() + + if (!latency || !agent.latency) { + return null + } + + return ( + <> + setIsOpen(true)} + className={styles.trigger} + style={{ color: latency.color }} + > + {Math.round(Math.round(latency.latency_ms))}ms + + setIsOpen(true)} + onClose={() => setIsOpen(false)} + > + Latency + + Latency from relay servers, used when connections cannot connect + peer-to-peer. Star indicates the preferred relay. + + + + + {Object.keys(agent.latency).map((regionName) => { + if (!agent.latency) { + throw new Error("No latency found on agent") + } + + const region = agent.latency[regionName] + + return ( + + {regionName} + {Math.round(region.latency_ms)}ms + + ) + })} + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + trigger: { + cursor: "pointer", + }, + regions: { + marginTop: theme.spacing(2), + }, + preferred: { + color: theme.palette.text.primary, + }, +})) diff --git a/site/src/components/Resources/AgentStatus.tsx b/site/src/components/Resources/AgentStatus.tsx new file mode 100644 index 0000000000..1a55f2255b --- /dev/null +++ b/site/src/components/Resources/AgentStatus.tsx @@ -0,0 +1,100 @@ +import Tooltip from "@material-ui/core/Tooltip" +import { makeStyles } from "@material-ui/core/styles" +import { combineClasses } from "util/combineClasses" +import { WorkspaceAgent } from "api/typesGenerated" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { useTranslation } from "react-i18next" + +const ConnectedStatus: React.FC = () => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + +
+ + ) +} + +const DisconnectedStatus: React.FC = () => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + +
+ + ) +} + +const ConnectingStatus: React.FC = () => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + +
+ + ) +} + +export const AgentStatus: React.FC<{ agent: WorkspaceAgent }> = ({ agent }) => { + return ( + + + + + + + + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + status: { + width: theme.spacing(1), + height: theme.spacing(1), + borderRadius: "100%", + }, + + connected: { + backgroundColor: theme.palette.success.light, + }, + + disconnected: { + backgroundColor: theme.palette.text.secondary, + }, + + "@keyframes pulse": { + "0%": { + opacity: 0.25, + }, + "50%": { + opacity: 1, + }, + "100%": { + opacity: 0.25, + }, + }, + + connecting: { + backgroundColor: theme.palette.info.light, + animation: "$pulse 1s ease-in-out forwards infinite", + }, +})) diff --git a/site/src/components/Resources/AgentVersion.tsx b/site/src/components/Resources/AgentVersion.tsx new file mode 100644 index 0000000000..aab5fa73e7 --- /dev/null +++ b/site/src/components/Resources/AgentVersion.tsx @@ -0,0 +1,61 @@ +import { useRef, useState, FC } from "react" +import { makeStyles } from "@material-ui/core/styles" +import { + HelpTooltipText, + HelpPopover, + HelpTooltipTitle, +} from "components/Tooltips/HelpTooltip" +import { WorkspaceAgent } from "api/typesGenerated" +import { getDisplayVersionStatus } from "util/workspace" + +export const AgentVersion: FC<{ + agent: WorkspaceAgent + serverVersion: string +}> = ({ agent, serverVersion }) => { + const styles = useStyles() + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "version-outdated-popover" : undefined + const { displayVersion, outdated } = getDisplayVersionStatus( + agent.version, + serverVersion, + ) + + if (!outdated) { + return {displayVersion} + } + + return ( + <> + setIsOpen(true)} + className={styles.trigger} + > + Agent Outdated + + setIsOpen(true)} + onClose={() => setIsOpen(false)} + > + Agent Outdated + + This agent is an older version than the Coder server. This can happen + after you update Coder with running workspaces. To fix this, you can + stop and start the workspace. + + + + ) +} + +const useStyles = makeStyles(() => ({ + trigger: { + cursor: "pointer", + }, +})) diff --git a/site/src/components/Resources/ResourceCard.stories.tsx b/site/src/components/Resources/ResourceCard.stories.tsx new file mode 100644 index 0000000000..11684a3ec4 --- /dev/null +++ b/site/src/components/Resources/ResourceCard.stories.tsx @@ -0,0 +1,81 @@ +import { Story } from "@storybook/react" +import { + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceResource, +} from "testHelpers/entities" +import { ResourceCard, ResourceCardProps } from "./ResourceCard" + +export default { + title: "components/ResourceCard", + component: ResourceCard, +} + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + resource: MockWorkspaceResource, + workspace: MockWorkspace, + applicationsHost: "https://dev.coder.com", + hideSSHButton: false, + showApps: true, + serverVersion: MockWorkspaceAgent.version, +} + +export const NotShowingApps = Template.bind({}) +NotShowingApps.args = { + ...Example.args, + showApps: false, +} + +export const HideSSHButton = Template.bind({}) +HideSSHButton.args = { + ...Example.args, + hideSSHButton: true, +} + +export const BunchOfMetadata = Template.bind({}) +BunchOfMetadata.args = { + ...Example.args, + resource: { + ...MockWorkspaceResource, + metadata: [ + { key: "type", value: "kubernetes_pod", sensitive: false }, + { + key: "CPU(limits, requests)", + value: "2 cores, 500m", + sensitive: false, + }, + { key: "container image pull policy", value: "Always", sensitive: false }, + { key: "Disk", value: "10GiB", sensitive: false }, + { + key: "image", + value: "docker.io/markmilligan/pycharm-community:latest", + sensitive: false, + }, + { key: "kubernetes namespace", value: "oss", sensitive: false }, + { + key: "memory(limits, requests)", + value: "4GB, 500mi", + sensitive: false, + }, + { + key: "security context - container", + value: "run_as_user 1000", + sensitive: false, + }, + { + key: "security context - pod", + value: "run_as_user 1000 fs_group 1000", + sensitive: false, + }, + { key: "volume", value: "/home/coder", sensitive: false }, + { + key: "secret", + value: "3XqfNW0b1bvsGsqud8O6OW6VabH3fwzI", + sensitive: true, + }, + ], + }, +} diff --git a/site/src/components/Resources/ResourceCard.tsx b/site/src/components/Resources/ResourceCard.tsx new file mode 100644 index 0000000000..6e4d766260 --- /dev/null +++ b/site/src/components/Resources/ResourceCard.tsx @@ -0,0 +1,288 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Skeleton } from "@material-ui/lab" +import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" +import { FC, useState } from "react" +import { Workspace, WorkspaceResource } from "../../api/typesGenerated" +import { AppLink } from "../AppLink/AppLink" +import { SSHButton } from "../SSHButton/SSHButton" +import { Stack } from "../Stack/Stack" +import { TerminalLink } from "../TerminalLink/TerminalLink" +import { ResourceAvatar } from "./ResourceAvatar" +import { SensitiveValue } from "./SensitiveValue" +import { AgentLatency } from "./AgentLatency" +import { AgentVersion } from "./AgentVersion" +import { + OpenDropdown, + CloseDropdown, +} from "components/DropdownArrows/DropdownArrows" +import IconButton from "@material-ui/core/IconButton" +import Tooltip from "@material-ui/core/Tooltip" +import { Maybe } from "components/Conditionals/Maybe" +import { CopyableValue } from "components/CopyableValue/CopyableValue" +import { AgentStatus } from "./AgentStatus" + +export interface ResourceCardProps { + resource: WorkspaceResource + workspace: Workspace + applicationsHost: string | undefined + showApps: boolean + hideSSHButton?: boolean + serverVersion: string +} + +export const ResourceCard: FC = ({ + resource, + workspace, + applicationsHost, + showApps, + hideSSHButton, + serverVersion, +}) => { + const [shouldDisplayAllMetadata, setShouldDisplayAllMetadata] = + useState(false) + const styles = useStyles() + const metadataToDisplay = + // Type is already displayed in the header + resource.metadata?.filter((data) => data.key !== "type") ?? [] + const visibleMetadata = shouldDisplayAllMetadata + ? metadataToDisplay + : metadataToDisplay.slice(0, 4) + + return ( +
+ + +
+ +
+
+
{resource.type}
+
{resource.name}
+
+
+ + +
+ {visibleMetadata.map((meta) => { + return ( +
+
{meta.key}
+
+ {meta.sensitive ? ( + + ) : ( + + {meta.value} + + )} +
+
+ ) + })} +
+ + 4}> + + { + setShouldDisplayAllMetadata((value) => !value) + }} + > + {shouldDisplayAllMetadata ? ( + + ) : ( + + )} + + + +
+
+ + {resource.agents && resource.agents.length > 0 && ( +
+ {resource.agents.map((agent) => { + return ( + + + +
+
{agent.name}
+ + + {agent.operating_system} + + + + + + + + +
+
+ + + {showApps && agent.status === "connected" && ( + <> + {applicationsHost !== undefined && ( + + )} + {!hideSSHButton && ( + + )} + + {agent.apps.map((app) => ( + + ))} + + )} + {showApps && agent.status === "connecting" && ( + <> + + + + )} + +
+ ) + })} +
+ )} +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + resourceCard: { + background: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + + "&:not(:first-child)": { + borderTop: 0, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + + "&:not(:last-child)": { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + }, + + resourceCardProfile: { + flexShrink: 0, + width: "fit-content", + }, + + resourceCardHeader: { + padding: theme.spacing(3, 4), + borderBottom: `1px solid ${theme.palette.divider}`, + + "&:last-child": { + borderBottom: 0, + }, + }, + + metadataHeader: { + display: "grid", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gap: theme.spacing(5), + rowGap: theme.spacing(3), + }, + + metadata: { + fontSize: 16, + }, + + metadataLabel: { + fontSize: 12, + color: theme.palette.text.secondary, + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + }, + + metadataValue: { + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + }, + + agentRow: { + padding: theme.spacing(3, 4), + backgroundColor: theme.palette.background.paperLight, + fontSize: 16, + + "&:not(:last-child)": { + borderBottom: `1px solid ${theme.palette.divider}`, + }, + }, + + agentName: { + fontWeight: 600, + }, + + agentOS: { + textTransform: "capitalize", + }, + + agentData: { + fontSize: 14, + color: theme.palette.text.secondary, + marginTop: theme.spacing(0.5), + }, +})) diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index f8833fdc11..2351f6ccba 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -1,46 +1,21 @@ import Button from "@material-ui/core/Button" -import { makeStyles, Theme } from "@material-ui/core/styles" -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableContainer from "@material-ui/core/TableContainer" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" -import { Skeleton } from "@material-ui/lab" -import useTheme from "@material-ui/styles/useTheme" +import { makeStyles } from "@material-ui/core/styles" import { CloseDropdown, OpenDropdown, } from "components/DropdownArrows/DropdownArrows" -import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" -import { TableCellDataPrimary } from "components/TableCellData/TableCellData" import { FC, useState } from "react" -import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace" import { BuildInfoResponse, Workspace, WorkspaceResource, } from "../../api/typesGenerated" -import { AppLink } from "../AppLink/AppLink" -import { SSHButton } from "../SSHButton/SSHButton" import { Stack } from "../Stack/Stack" -import { TableHeaderRow } from "../TableHeaders/TableHeaders" -import { TerminalLink } from "../TerminalLink/TerminalLink" -import { AgentHelpTooltip } from "../Tooltips/AgentHelpTooltip" -import { AgentOutdatedTooltip } from "../Tooltips/AgentOutdatedTooltip" -import { ResourcesHelpTooltip } from "../Tooltips/ResourcesHelpTooltip" -import { ResourceAgentLatency } from "./ResourceAgentLatency" -import { ResourceAvatarData } from "./ResourceAvatarData" import { AlertBanner } from "components/AlertBanner/AlertBanner" +import { ResourceCard } from "./ResourceCard" -const Language = { - resources: "Resources", - resourceLabel: "Resource", - agentsLabel: "Agents", - agentLabel: "Agent", - statusLabel: "status: ", - versionLabel: "version: ", - osLabel: "os: ", +const countAgents = (resource: WorkspaceResource) => { + return resource.agents ? resource.agents.length : 0 } interface ResourcesProps { @@ -58,177 +33,41 @@ export const Resources: FC> = ({ getResourcesError, workspace, canUpdateWorkspace, - buildInfo, hideSSHButton, applicationsHost, + buildInfo, }) => { - const styles = useStyles() - const theme: Theme = useTheme() const serverVersion = buildInfo?.version || "" + const styles = useStyles() const [shouldDisplayHideResources, setShouldDisplayHideResources] = useState(false) const displayResources = shouldDisplayHideResources ? resources - : resources.filter((resource) => !resource.hide) + : resources + .filter((resource) => !resource.hide) + // Display the resources with agents first + .sort((a, b) => countAgents(b) - countAgents(a)) const hasHideResources = resources.some((r) => r.hide) + if (getResourcesError) { + return + } + return ( - -
- {getResourcesError ? ( - - ) : ( - - - - - - - {Language.resourceLabel} - - - - - - {Language.agentLabel} - - - - {canUpdateWorkspace && } - - - - {displayResources.map((resource) => { - { - /* We need to initialize the agents to display the resource */ - } - const agents = resource.agents ?? [null] - const resourceName = ( - - ) - - return agents.map((agent, agentIndex) => { - { - /* If there is no agent, just display the resource name */ - } - if ( - !agent || - workspace.latest_build.transition === "stop" - ) { - return ( - - {resourceName} - - - ) - } - const { displayVersion, outdated } = - getDisplayVersionStatus(agent.version, serverVersion) - const agentStatus = getDisplayAgentStatus(theme, agent) - return ( - - {/* We only want to display the name in the first row because we are using rowSpan */} - {/* The rowspan should be the same than the number of agents */} - {agentIndex === 0 && ( - - {resourceName} - - )} - - - - {agent.name} - -
-
- {Language.statusLabel} - - {agentStatus.status} - -
-
- {Language.osLabel} - - {agent.operating_system} - -
-
- {Language.versionLabel} - - {displayVersion} - - -
-
- -
-
-
- -
- {canUpdateWorkspace && - agent.status === "connected" && ( - <> - {applicationsHost !== undefined && ( - - )} - {!hideSSHButton && ( - - )} - - {agent.apps.map((app) => ( - - ))} - - )} - {canUpdateWorkspace && - agent.status === "connecting" && ( - <> - - - - )} -
-
-
- ) - }) - })} -
-
-
- )} -
+ + {displayResources.map((resource) => { + return ( + + ) + })} {hasHideResources && (
@@ -254,77 +93,7 @@ export const Resources: FC> = ({ ) } -const useStyles = makeStyles((theme) => ({ - wrapper: { - borderRadius: theme.shape.borderRadius, - border: `1px solid ${theme.palette.divider}`, - }, - - tableContainer: { - border: 0, - }, - - resourceAvatar: { - color: "#FFF", - backgroundColor: "#3B73D8", - }, - - resourceNameCell: { - borderRight: `1px solid ${theme.palette.divider}`, - }, - - resourceType: { - fontSize: 14, - color: theme.palette.text.secondary, - marginTop: theme.spacing(0.5), - display: "block", - }, - - // Adds some left spacing - agentColumn: { - paddingLeft: `${theme.spacing(4)}px !important`, - }, - - operatingSystem: { - display: "block", - textTransform: "capitalize", - }, - - agentVersion: { - display: "block", - }, - - accessLinks: { - display: "flex", - gap: theme.spacing(0.5), - flexWrap: "wrap", - justifyContent: "flex-end", - }, - - status: { - whiteSpace: "nowrap", - }, - - data: { - color: theme.palette.text.secondary, - fontSize: 14, - marginTop: theme.spacing(0.75), - display: "grid", - gridAutoFlow: "row", - whiteSpace: "nowrap", - gap: theme.spacing(0.75), - height: "fit-content", - }, - - dataRow: { - display: "flex", - alignItems: "center", - - "& strong": { - marginRight: theme.spacing(1), - }, - }, - +const useStyles = makeStyles(() => ({ buttonWrapper: { display: "flex", alignItems: "center", diff --git a/site/src/components/Resources/SensitiveValue.tsx b/site/src/components/Resources/SensitiveValue.tsx new file mode 100644 index 0000000000..b8e7f42d76 --- /dev/null +++ b/site/src/components/Resources/SensitiveValue.tsx @@ -0,0 +1,69 @@ +import IconButton from "@material-ui/core/IconButton" +import { makeStyles } from "@material-ui/core/styles" +import Tooltip from "@material-ui/core/Tooltip" +import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined" +import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined" +import { CopyableValue } from "components/CopyableValue/CopyableValue" +import { useState } from "react" + +const Language = { + showLabel: "Show value", + hideLabel: "Hide value", +} + +export const SensitiveValue: React.FC<{ value: string }> = ({ value }) => { + const [shouldDisplay, setShouldDisplay] = useState(false) + const styles = useStyles() + const displayValue = shouldDisplay ? value : "••••••••" + const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel + const icon = shouldDisplay ? ( + + ) : ( + + ) + + return ( +
+ + {displayValue} + + + { + setShouldDisplay((value) => !value) + }} + size="small" + aria-label={buttonLabel} + > + {icon} + + +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + value: { + // 22px is the button width + width: "calc(100% - 22px)", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + }, + + sensitiveValue: { + display: "flex", + alignItems: "center", + gap: theme.spacing(0.5), + }, + + button: { + color: "inherit", + + "& .MuiSvgIcon-root": { + width: 16, + height: 16, + }, + }, +})) diff --git a/site/src/components/Stack/Stack.tsx b/site/src/components/Stack/Stack.tsx index d12f4e5821..1f181816c6 100644 --- a/site/src/components/Stack/Stack.tsx +++ b/site/src/components/Stack/Stack.tsx @@ -12,6 +12,7 @@ export type StackProps = { spacing?: number alignItems?: CSSProperties["alignItems"] justifyContent?: CSSProperties["justifyContent"] + wrap?: CSSProperties["flexWrap"] } & React.HTMLProps type StyleProps = Omit @@ -23,6 +24,7 @@ const useStyles = makeStyles((theme) => ({ gap: ({ spacing }: StyleProps) => spacing && theme.spacing(spacing), alignItems: ({ alignItems }: StyleProps) => alignItems, justifyContent: ({ justifyContent }: StyleProps) => justifyContent, + flexWrap: ({ wrap }: StyleProps) => wrap, [theme.breakpoints.down("sm")]: { width: "100%", @@ -37,6 +39,7 @@ export const Stack: FC = ({ spacing = 2, alignItems, justifyContent, + wrap, ...divProps }) => { const styles = useStyles({ @@ -44,6 +47,7 @@ export const Stack: FC = ({ direction, alignItems, justifyContent, + wrap, }) return ( diff --git a/site/src/components/TemplateStats/TemplateStats.tsx b/site/src/components/TemplateStats/TemplateStats.tsx index 6065c5a0ec..3bc9434795 100644 --- a/site/src/components/TemplateStats/TemplateStats.tsx +++ b/site/src/components/TemplateStats/TemplateStats.tsx @@ -1,12 +1,16 @@ import { makeStyles } from "@material-ui/core/styles" import { FC } from "react" import { createDayString } from "util/createDayString" -import { formatTemplateActiveDevelopers } from "util/templates" +import { + formatTemplateBuildTime, + formatTemplateActiveDevelopers, +} from "util/templates" import { Template, TemplateVersion } from "../../api/typesGenerated" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" const Language = { usedByLabel: "Used by", + buildTimeLabel: "Build time", activeVersionLabel: "Active version", lastUpdateLabel: "Last updated", developerPlural: "developers", @@ -38,6 +42,14 @@ export const TemplateStats: FC = ({
+
+ {Language.buildTimeLabel} + + + {formatTemplateBuildTime(template.build_time_stats.start_ms)}{" "} + +
+
{Language.activeVersionLabel} {activeVersion.name} @@ -75,7 +87,7 @@ const useStyles = makeStyles((theme) => ({ }, statItem: { - minWidth: "20%", + minWidth: "15%", padding: theme.spacing(2), paddingTop: theme.spacing(1.75), }, diff --git a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx index dc9a555357..9b992e6bca 100644 --- a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx @@ -1,5 +1,5 @@ import Link from "@material-ui/core/Link" -import Popover from "@material-ui/core/Popover" +import Popover, { PopoverProps } from "@material-ui/core/Popover" import { makeStyles } from "@material-ui/core/styles" import HelpIcon from "@material-ui/icons/HelpOutline" import OpenInNewIcon from "@material-ui/icons/OpenInNew" @@ -35,6 +35,35 @@ const useHelpTooltip = () => { return helpTooltipContext } +export const HelpPopover: React.FC< + PopoverProps & { onOpen: () => void; onClose: () => void } +> = ({ onOpen, onClose, children, ...props }) => { + const styles = useStyles({ size: "small" }) + + return ( + + {children} + + ) +} + export const HelpTooltip: React.FC< React.PropsWithChildren > = ({ children, open, size = "medium" }) => { @@ -60,38 +89,24 @@ export const HelpTooltip: React.FC< onMouseEnter={() => { setIsOpen(true) }} + onMouseLeave={() => { + setIsOpen(false) + }} aria-label={Language.ariaLabel} > - { - setIsOpen(true) - }, - onMouseLeave: () => { - setIsOpen(false) - }, - }} + onOpen={() => setIsOpen(true)} + onClose={() => setIsOpen(false)} > {children} - + ) } diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index f8430ee947..273bb3bdd1 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -49,6 +49,7 @@ Running.args = { canUpdateWorkspace: true, workspaceErrors: {}, buildInfo: Mocks.MockBuildInfo, + template: Mocks.MockTemplate, } export const WithoutUpdateAccess = Template.bind({}) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index a9849063b6..f2c22d3864 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -20,6 +20,10 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" import { AlertBanner } from "../AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" +import { + EstimateTransitionTime, + WorkspaceBuildProgress, +} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" export enum WorkspaceErrors { GET_RESOURCES_ERROR = "getResourcesError", @@ -34,10 +38,12 @@ export interface WorkspaceProps { onExtend: () => void } scheduleProps: { - onDeadlinePlus: () => void - onDeadlineMinus: () => void + onDeadlinePlus: (hours: number) => void + onDeadlineMinus: (hours: number) => void deadlinePlusEnabled: () => boolean deadlineMinusEnabled: () => boolean + maxDeadlineIncrease: number + maxDeadlineDecrease: number } handleStart: () => void handleStop: () => void @@ -53,6 +59,7 @@ export interface WorkspaceProps { workspaceErrors: Partial> buildInfo?: TypesGen.BuildInfoResponse applicationsHost?: string + template?: TypesGen.Template } /** @@ -75,6 +82,7 @@ export const Workspace: FC> = ({ hideSSHButton, buildInfo, applicationsHost, + template, }) => { const { t } = useTranslation("workspacePage") const styles = useStyles() @@ -110,6 +118,15 @@ export const Workspace: FC> = ({ /> ) + let buildTimeEstimate: number | undefined = undefined + let isTransitioning: boolean | undefined = undefined + if (template !== undefined) { + ;[buildTimeEstimate, isTransitioning] = EstimateTransitionTime( + template, + workspace, + ) + } + return ( > = ({ onDeadlinePlus={scheduleProps.onDeadlinePlus} deadlineMinusEnabled={scheduleProps.deadlineMinusEnabled} deadlinePlusEnabled={scheduleProps.deadlinePlusEnabled} + maxDeadlineDecrease={scheduleProps.maxDeadlineDecrease} + maxDeadlineIncrease={scheduleProps.maxDeadlineIncrease} canUpdateWorkspace={canUpdateWorkspace} /> > = ({ + {isTransitioning !== undefined && isTransitioning && ( + + )} + {typeof resources !== "undefined" && resources.length > 0 && ( + +const Template: Story = (args) => ( + +) + +export const Starting = Template.bind({}) +Starting.args = { + buildEstimate: 10000, + workspace: { + ...MockStartingWorkspace, + latest_build: { + ...MockWorkspaceBuild, + status: "starting", + job: { + ...MockProvisionerJob, + started_at: dayjs().add(-5, "second").format(), + status: "running", + }, + }, + }, +} + +export const StartingUnknown = Template.bind({}) +StartingUnknown.args = { + ...Starting.args, + buildEstimate: undefined, +} + +export const StartingPassedEstimate = Template.bind({}) +StartingPassedEstimate.args = { + ...Starting.args, + buildEstimate: 1000, +} diff --git a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx new file mode 100644 index 0000000000..f1d61ee26e --- /dev/null +++ b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx @@ -0,0 +1,136 @@ +import LinearProgress from "@material-ui/core/LinearProgress" +import makeStyles from "@material-ui/core/styles/makeStyles" +import { Template, Workspace } from "api/typesGenerated" +import dayjs, { Dayjs } from "dayjs" +import { FC, useEffect, useState } from "react" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" + +import duration from "dayjs/plugin/duration" + +dayjs.extend(duration) + +const estimateFinish = ( + startedAt: Dayjs, + buildEstimate: number, +): [number, string] => { + const realPercentage = dayjs().diff(startedAt) / buildEstimate + + const maxPercentage = 1 + if (realPercentage > maxPercentage) { + return [maxPercentage * 100, "Any moment now..."] + } + + return [ + realPercentage * 100, + `~${Math.ceil( + dayjs.duration((1 - realPercentage) * buildEstimate).asSeconds(), + )} seconds remaining...`, + ] +} + +export interface WorkspaceBuildProgressProps { + workspace: Workspace + buildEstimate?: number +} + +// EstimateTransitionTime gets the build estimate for the workspace, +// if it is in a transition state. +export const EstimateTransitionTime = ( + template: Template, + workspace: Workspace, +): [number | undefined, boolean] => { + switch (workspace.latest_build.status) { + case "starting": + return [template.build_time_stats.start_ms, true] + case "stopping": + return [template.build_time_stats.stop_ms, true] + case "deleting": + return [template.build_time_stats.delete_ms, true] + default: + // Not in a transition state + return [undefined, false] + } +} + +export const WorkspaceBuildProgress: FC = ({ + workspace, + buildEstimate, +}) => { + const styles = useStyles() + const job = workspace.latest_build.job + const [progressValue, setProgressValue] = useState(0) + + // By default workspace is updated every second, which can cause visual stutter + // when the build estimate is a few seconds. The timer ensures no observable + // stutter in all cases. + useEffect(() => { + const updateProgress = () => { + if (job.status !== "running" || buildEstimate === undefined) { + setProgressValue(undefined) + return + } + const est = estimateFinish(dayjs(job.started_at), buildEstimate)[0] + setProgressValue(est) + } + setTimeout(updateProgress, 5) + }, [progressValue, job, buildEstimate]) + + return ( +
+ +
+
{`Build ${job.status}`}
+
+ {(() => { + if (job.status !== "running") { + return "" + } else if (buildEstimate !== undefined) { + return estimateFinish(dayjs(job.started_at), buildEstimate)[1] + } else { + return "Unknown ETA" + } + })()} +
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + stack: { + paddingLeft: theme.spacing(0.2), + paddingRight: theme.spacing(0.2), + }, + noTransition: { + transition: "none", + }, + barHelpers: { + display: "flex", + justifyContent: "space-between", + }, + label: { + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 12, + textTransform: "uppercase", + display: "block", + fontWeight: 600, + color: theme.palette.text.secondary, + }, +})) diff --git a/site/src/components/WorkspaceScheduleButton/EditHours.tsx b/site/src/components/WorkspaceScheduleButton/EditHours.tsx new file mode 100644 index 0000000000..5bcd34a710 --- /dev/null +++ b/site/src/components/WorkspaceScheduleButton/EditHours.tsx @@ -0,0 +1,53 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import TextField from "@material-ui/core/TextField" +import { Stack } from "components/Stack/Stack" +import { useState } from "react" +import { useTranslation } from "react-i18next" + +interface EditHoursProps { + handleSubmit: (hours: number) => void + max: number +} + +export const EditHours = ({ + handleSubmit, + max, +}: EditHoursProps): JSX.Element => { + const { t } = useTranslation("workspacePage") + const [hours, setHours] = useState(1) + const styles = useStyles() + + return ( +
handleSubmit(hours)}> + + setHours(parseInt(e.target.value))} + type="number" + /> + + +
+ ) +} + +const useStyles = makeStyles(() => ({ + inputField: { + width: "70px", + "& .MuiOutlinedInput-root": { + height: "30px", + }, + }, + button: { + "&.MuiButton-root": { + minHeight: "30px", + height: "30px", + }, + }, +})) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx index 043c53da94..033c9698ce 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx @@ -16,6 +16,12 @@ export default { canUpdateWorkspace: { defaultValue: true, }, + deadlineMinusEnabled: { + defaultValue: (): boolean => false, + }, + deadlinePlusEnabled: { + defaultValue: (): boolean => false, + }, }, } @@ -64,6 +70,7 @@ WorkspaceOffShort.args = { export const WorkspaceOffLong = Template.bind({}) WorkspaceOffLong.args = { + deadlinePlusEnabled: () => true, workspace: { ...Mocks.MockWorkspace, diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx index 8e0d4e5358..5ee401f7be 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx @@ -2,7 +2,7 @@ import dayjs from "dayjs" import utc from "dayjs/plugin/utc" import * as TypesGen from "../../api/typesGenerated" import * as Mocks from "../../testHelpers/entities" -import { shouldDisplayPlusMinus } from "./WorkspaceScheduleButton" +import { canEditDeadline } from "./WorkspaceScheduleButton" dayjs.extend(utc) @@ -13,7 +13,7 @@ describe("WorkspaceScheduleButton", () => { const workspace: TypesGen.Workspace = Mocks.MockStoppedWorkspace // Then: shouldDisplayPlusMinus should be false - expect(shouldDisplayPlusMinus(workspace)).toBeFalsy() + expect(canEditDeadline(workspace)).toBeFalsy() }) it("should display if the workspace is running", () => { @@ -21,7 +21,7 @@ describe("WorkspaceScheduleButton", () => { const workspace: TypesGen.Workspace = Mocks.MockWorkspace // Then: shouldDisplayPlusMinus should be false - expect(shouldDisplayPlusMinus(workspace)).toBeTruthy() + expect(canEditDeadline(workspace)).toBeTruthy() }) }) }) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx index 66e3df5e30..b613786910 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx @@ -1,11 +1,13 @@ import Button from "@material-ui/core/Button" import IconButton from "@material-ui/core/IconButton" import Popover from "@material-ui/core/Popover" -import { makeStyles } from "@material-ui/core/styles" +import { makeStyles, Theme } from "@material-ui/core/styles" import Tooltip from "@material-ui/core/Tooltip" import AddIcon from "@material-ui/icons/Add" import RemoveIcon from "@material-ui/icons/Remove" import ScheduleIcon from "@material-ui/icons/Schedule" +import { Maybe } from "components/Conditionals/Maybe" +import { Stack } from "components/Stack/Stack" import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import duration from "dayjs/plugin/duration" @@ -17,6 +19,7 @@ import { useTranslation } from "react-i18next" import { Workspace } from "../../api/typesGenerated" import { isWorkspaceOn } from "../../util/workspace" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" +import { EditHours } from "./EditHours" import { WorkspaceScheduleLabel } from "./WorkspaceScheduleLabel" // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're @@ -27,12 +30,12 @@ dayjs.extend(duration) dayjs.extend(relativeTime) dayjs.extend(timezone) -export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => { +export const canEditDeadline = (workspace: Workspace): boolean => { return isWorkspaceOn(workspace) && Boolean(workspace.latest_build.deadline) } export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => { - if (shouldDisplayPlusMinus(workspace)) { + if (canEditDeadline(workspace)) { return true } if (isWorkspaceOn(workspace)) { @@ -43,13 +46,17 @@ export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => { export interface WorkspaceScheduleButtonProps { workspace: Workspace - onDeadlinePlus: () => void - onDeadlineMinus: () => void + onDeadlinePlus: (hours: number) => void + onDeadlineMinus: (hours: number) => void deadlineMinusEnabled: () => boolean deadlinePlusEnabled: () => boolean + maxDeadlineIncrease: number + maxDeadlineDecrease: number canUpdateWorkspace: boolean } +export type EditMode = "add" | "subtract" | "off" + export const WorkspaceScheduleButton: React.FC< WorkspaceScheduleButtonProps > = ({ @@ -58,49 +65,93 @@ export const WorkspaceScheduleButton: React.FC< onDeadlineMinus, deadlinePlusEnabled, deadlineMinusEnabled, + maxDeadlineDecrease, + maxDeadlineIncrease, canUpdateWorkspace, }) => { const { t } = useTranslation("workspacePage") const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) + const [editMode, setEditMode] = useState("off") const id = isOpen ? "schedule-popover" : undefined - const styles = useStyles() + const styles = useStyles({ editMode }) const onClose = () => { setIsOpen(false) } + const handleSubmitHours = (hours: number) => { + if (hours !== 0) { + if (editMode === "add") { + onDeadlinePlus(hours) + } + if (editMode === "subtract") { + onDeadlineMinus(hours) + } + } + setEditMode("off") + } + return ( - {shouldDisplayScheduleLabel(workspace) && ( - - - {canUpdateWorkspace && shouldDisplayPlusMinus(workspace) && ( - - - - - - - - - - - - - )} - - )} + + + + + + + { + setEditMode("subtract") + }} + > + + + + + { + setEditMode("add") + }} + > + + + + + + + + + + + + <>