Merge branch 'main' into paginate-ws/presleyp

This commit is contained in:
Presley Pizzo
2022-10-19 03:45:03 +00:00
252 changed files with 9044 additions and 1827 deletions
+2
View File
@@ -5,6 +5,8 @@ IST = "IST"
MacOS = "macOS"
[default.extend-words]
# do as sudo replacement
doas = "doas"
[files]
extend-exclude = [
+4
View File
@@ -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",
+2 -2
View File
@@ -60,7 +60,7 @@ Once installed, you can start a production deployment<sup>1</sup> 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 <url> --access-url <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).
+14 -3
View File
@@ -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)
}
+2 -4
View File
@@ -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,
+4 -1
View File
@@ -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 {
+6 -8
View File
@@ -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()
+5
View File
@@ -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"))
}
+1 -2
View File
@@ -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()
+8 -1
View File
@@ -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",
+8 -6
View File
@@ -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
}
+6 -3
View File
@@ -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.
+45 -11
View File
@@ -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) {
+3
View File
@@ -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)
+4 -2
View File
@@ -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
}
+1 -3
View File
@@ -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
}
+4 -3
View File
@@ -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,
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
}
+2 -1
View File
@@ -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
+13 -3
View File
@@ -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()
+17
View File
@@ -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,
+52 -21
View File
@@ -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) {
+9 -1
View File
@@ -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.
+6 -2
View File
@@ -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{}
}
@@ -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 {
+44 -15
View File
@@ -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 <workspace.agent> 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()
}
+2 -2
View File
@@ -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(),
+1 -1
View File
@@ -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,
})
+196 -54
View File
@@ -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-----`
+191 -31
View File
@@ -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
}
+9
View File
@@ -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 {
+34 -7
View File
@@ -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);
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,2 @@
DROP TABLE replicas;
ALTER TABLE provisioner_daemons DROP COLUMN replica_id;
@@ -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;
@@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE groups DROP COLUMN avatar_url;
COMMIT;
@@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE groups ADD COLUMN avatar_url text NOT NULL DEFAULT '';
COMMIT;
+39 -1
View File
@@ -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 {
+2 -1
View File
@@ -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
}
+10 -2
View File
@@ -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)
+380 -52
View File
@@ -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
}
+16 -3
View File
@@ -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 *;
+4 -2
View File
@@ -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
+6 -4
View File
@@ -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
+1 -1
View File
@@ -59,7 +59,7 @@ INSERT INTO
initiator_id,
provisioner,
storage_method,
storage_source,
file_id,
"type",
"input"
)
+31
View File
@@ -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;
+6
View File
@@ -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';
+29
View File
@@ -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
;
+2 -1
View File
@@ -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
+1
View File
@@ -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);
+1
View File
@@ -73,6 +73,7 @@ func FindClosestNode() (Node, error) {
}
pinger.Count = 5
pinger.Timeout = 5 * time.Second
err = pinger.Run()
if err != nil {
return err
+24 -8
View File
@@ -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
+3 -2
View File
@@ -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)
+23 -11
View File
@@ -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,
+8
View File
@@ -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)
})
}
+80 -13
View File
@@ -17,21 +17,10 @@ var (
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
`^(?P<AppName>%[1]s)--(?P<AgentName>%[1]s)--(?P<WorkspaceName>%[1]s)--(?P<Username>%[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
}
+237 -58
View File
@@ -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)
}
})
}
}
+28 -22
View File
@@ -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)
}
+2 -2
View File
@@ -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) {
+18 -3
View File
@@ -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
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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) {
+55 -5
View File
@@ -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),
}
}
+167 -1
View File
@@ -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",
)
}
})
}
}
+10 -1
View File
@@ -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,
+2 -2
View File
@@ -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)
+5 -5
View File
@@ -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 {
+4
View File
@@ -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
+6 -1
View File
@@ -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(),
+15 -2
View File
@@ -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,
+7 -9
View File
@@ -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{'{', '}'},
})
+4 -4
View File
@@ -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,
})
+4
View File
@@ -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 {
+51 -9
View File
@@ -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,
+64 -47
View File
@@ -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)
+1
View File
@@ -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,
}
}
+2 -2
View File
@@ -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)
+38 -31
View File
@@ -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
}
}
}
+5 -3
View File
@@ -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()
+204 -76
View File
@@ -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(),
})
+354 -97
View File
@@ -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)
})
})
}
+19 -2
View File
@@ -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)
}
+7 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
+3 -1
View File
@@ -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()
+4 -2
View File
@@ -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
}
+38 -18
View File
@@ -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.
+9 -6
View File
@@ -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"`
+5 -3
View File
@@ -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
}
+3 -2
View File
@@ -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"`
+4 -1
View File
@@ -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) {
+1 -1
View File
@@ -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.
+9 -9
View File
@@ -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 {
+44
View File
@@ -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)
}
+14 -7
View File
@@ -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 {

Some files were not shown because too many files have changed in this diff Show More