mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Merge branch 'main' into paginate-ws/presleyp
This commit is contained in:
@@ -5,6 +5,8 @@ IST = "IST"
|
||||
MacOS = "macOS"
|
||||
|
||||
[default.extend-words]
|
||||
# do as sudo replacement
|
||||
doas = "doas"
|
||||
|
||||
[files]
|
||||
extend-exclude = [
|
||||
|
||||
Vendored
+4
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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-----`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+34
-7
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 *;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,7 +59,7 @@ INSERT INTO
|
||||
initiator_id,
|
||||
provisioner,
|
||||
storage_method,
|
||||
storage_source,
|
||||
file_id,
|
||||
"type",
|
||||
"input"
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{'{', '}'},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user