Compare commits

...

39 Commits

Author SHA1 Message Date
Kyle Carberry 9cf3e102ba chore: Pin typos to fix CI (#4396) 2022-10-06 10:27:23 -05:00
Dean Sheather 3b15f13ae4 fix: fix apps being unavailable until rebuild (#4395) 2022-10-06 10:23:55 -05:00
Kyle Carberry 9b1ff43e9f fix: Don't run CI for releases (#4393)
This was unnecessary and causing weird issues like double deploys and runs.
2022-10-06 10:02:37 -05:00
Ben Potter ea42212a2a chore: add icons to quickstarts (#4379) 2022-10-06 10:56:46 -04:00
Kyle Carberry 0ebcb7de55 fix: Remove reliance of relative_path on subdomains (#4390)
This broke all relative path applications.
2022-10-06 09:30:10 -05:00
Kyle Carberry d275331c13 fix: Remove audit warning if unlicensed (#4387)
Fixes #4383.
2022-10-06 08:48:44 -05:00
Dean Sheather 29a2fe46e8 fix: fix builds on windows_arm64 (#4388) 2022-10-06 23:42:58 +10:00
Mathias Fredriksson 93b8121c9b fix: Change use of 1337 to 13337 in example templates (#4386) 2022-10-06 13:25:18 +00:00
Dean Sheather 1386465631 feat: add endpoint to get listening ports in agent (#4260) 2022-10-06 22:38:22 +10:00
Kyle Carberry bbe2baf3f6 fix: Ignore all hidden files and folders in archive (#4382)
This also adds a suite of tests to ensure this cannot happen again!
2022-10-06 00:36:45 +00:00
Kira Pilot 3ad5e11d22 feat: add warning if workspace page becomes stale (#4375)
* added a warning summary component

* added warning to workspace page

* consolidated warnings

* prettier

* updated design
2022-10-05 18:46:46 -04:00
Presley Pizzo 9a670b90df chore: refactor frontend to use workspace status directly (#4361)
* Add/update copy

* Update mocks

* Handle disabled button labels separately

* Use workspace status directly, use i18n

* Update stories and tests

* Fix optimistic update in xservice to use status, pending

* Rename started to running in story

* Fix deletion banner conditional

* Send label to disabled button

* Refactor workspace actions
2022-10-05 16:20:29 -04:00
Dean Sheather 2a66395fb7 feat: use app wildcards for apps if configured (#4263)
* feat: use app wildcards for apps if configured

* feat: relative_path -> subdomain

- rename relative_path -> subdomain when referring to apps
    - migrate workspace_apps.relative_path to workspace_apps.subdomain
- upgrade coder/coder terraform module to 0.5.0
2022-10-05 19:23:01 +00:00
Ammar Bandukwala 4f3958c831 docs: link all enterprise features (#4368) 2022-10-05 15:05:28 -04:00
Garrett Delfosse b65c555dfc fix: warn user if not entitled feature is enabled (#4377) 2022-10-05 17:45:05 +00:00
Garrett Delfosse 8d14076a23 fix: move quotas above inputs (#4376) 2022-10-05 13:44:15 -04:00
Muhammad Atif Ali 3759bb2a9a docs: fixed a typo (#4374) 2022-10-05 09:50:56 -05:00
Kyle Carberry 504cd462a7 fix: Check for a response body when dialing the Tailnet WebSocket (#4327)
There was a panic in this code that caused it to fail on error!
2022-10-04 19:46:59 -05:00
Kyle Carberry 8940ea179e fix: Always set DisconnectedAt if the agent isn't connected (#4328)
Fixes #4315.
2022-10-05 00:28:47 +00:00
Steven Masley 587017665a feat: Also log out of apps if they are hosted on the same domain (#4334)
* feat: Also log out of apps if they are hosted on the same domain

* Update comment
2022-10-04 19:01:16 -04:00
Kyle Carberry 06d7e368ab fix: Ignore hidden folders when archiving (#4370)
Fixes #4369.
2022-10-04 22:27:14 +00:00
Kyle Carberry f2952000d9 fix: Ensure WebSockets routinely transfer data (#4367)
Fixes #4351.
2022-10-04 17:10:58 -05:00
Ammar Bandukwala a6bb3b29d0 docs: add quotas (#4366) 2022-10-04 20:55:43 +00:00
Ammar Bandukwala db7030716d docs: add minor quickstart fixups (#4363)
- And fix Telemetry in manifest.json
2022-10-04 14:57:06 -05:00
Kyle Carberry 45c05a0896 Fix additional .md on port-forwarding docs 2022-10-04 19:52:30 +00:00
Garrett Delfosse ffbaa93722 feat: add experimental flag (#4364) 2022-10-04 19:45:00 +00:00
Geoffrey Huntley 18b282cabb docs(quickstart): styling fixes (#4356) 2022-10-05 03:16:21 +10:00
Joe Previte 78283cf236 fix: add keys to createCtas elements (#4362) 2022-10-04 16:50:15 +00:00
Dean Sheather d165d76338 feat: static error page in applications handlers (#4299) 2022-10-05 02:30:55 +10:00
Joe Previte ce953441fb refactor: clean up types in jest.setup.ts (#4285) 2022-10-04 09:04:23 -07:00
Steven Masley cd4ab97efa feat: Convert rego queries into SQL clauses (#4225)
* feat: Convert rego queries into SQL clauses

* Fix postgres quotes to single quotes

* Ensure all test cases can compile into SQL clauses

* Do not export extra types

* Add custom query with rbac filter

* First draft of a custom authorized db call

* Add comments + tests

* Support better regex style matching for variables

* Handle jsonb arrays

* Remove auth call on workspaces

* Fix PG endpoints test

* Match psql implementation

* Add some comments

* Remove unused argument

* Add query name for tracking

* Handle nested types

This solves it without proper types in our AST.
Might bite the bullet and implement some better types

* Add comment

* Renaming function call to GetAuthorizedWorkspaces
2022-10-04 11:35:33 -04:00
Dean Sheather 6325a9ea91 feat: support multiple certificates in coder server and helm (#4150) 2022-10-04 21:45:21 +10:00
Ammar Bandukwala a1056bfa2a docs: describe our telemetry (#2641) 2022-10-04 04:03:46 +00:00
Bruno Quaresma bf63cc929a fix: Fix audit search query (#4352) 2022-10-03 20:56:54 -03:00
Ali Diamond 1d88b9c65c Add AWS and Azure quickstarts (#4176)
* Creating Azure QS and adding images

* adding AWS images and QS, plus fix on azure

* adding ben changes

* adding ammar changes

* adding ammar and ben edits

* pushing final changes to AWS

* removed troubleshooting

* fixing access word

* ammar pls

Co-authored-by: Ali Diamond <user@ali.dev>
2022-10-03 17:15:52 -04:00
Garrett Delfosse 738a38d71f chore: remove resources calls (#4344) 2022-10-03 21:01:13 +00:00
Kyle Carberry 9bc0d06aa0 fix: Install Terraform once and only log >=500 (#4339)
Fixes #4302.
2022-10-03 15:19:02 -05:00
Eric Paulsen aa3812ff4e add: deployment annotations (#4342) 2022-10-03 13:31:34 -05:00
Bruno Quaresma 15d7b78527 fix: Handle invalid resource types and actions (#4341)
* fix: Handle invalid resource types and actions

* Return all values if invalid

* Use types
2022-10-03 15:29:01 -03:00
195 changed files with 3998 additions and 2010 deletions
+1 -3
View File
@@ -4,8 +4,6 @@ on:
push:
branches:
- main
tags:
- "*"
pull_request:
@@ -36,7 +34,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v2
- name: typos-action
uses: crate-ci/typos@master
uses: crate-ci/typos@v1.12.8
with:
config: .github/workflows/typos.toml
- name: Fix Helper
+28
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
@@ -206,6 +207,7 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) {
go a.sshServer.HandleConn(a.stats.wrapConn(conn))
}
}()
reconnectingPTYListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetReconnectingPTYPort))
if err != nil {
a.logger.Critical(ctx, "listen for reconnecting pty", slog.Error(err))
@@ -240,6 +242,7 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) {
go a.handleReconnectingPTY(ctx, msg, conn)
}
}()
speedtestListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetSpeedtestPort))
if err != nil {
a.logger.Critical(ctx, "listen for speedtest", slog.Error(err))
@@ -261,6 +264,31 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) {
}()
}
}()
statisticsListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetStatisticsPort))
if err != nil {
a.logger.Critical(ctx, "listen for statistics", slog.Error(err))
return
}
go func() {
defer statisticsListener.Close()
server := &http.Server{
Handler: a.statisticsHandler(),
ReadTimeout: 20 * time.Second,
ReadHeaderTimeout: 20 * time.Second,
WriteTimeout: 20 * time.Second,
ErrorLog: slog.Stdlib(ctx, a.logger.Named("statistics_http_server"), slog.LevelInfo),
}
go func() {
<-ctx.Done()
_ = server.Close()
}()
err = server.Serve(statisticsListener)
if err != nil && !xerrors.Is(err, http.ErrServerClosed) && !strings.Contains(err.Error(), "use of closed network connection") {
a.logger.Critical(ctx, "serve statistics HTTP server", slog.Error(err))
}
}()
}
// runCoordinator listens for nodes and updates the self-node as it changes.
+64
View File
@@ -0,0 +1,64 @@
//go:build linux || (windows && amd64)
package agent
import (
"time"
"github.com/cakturk/go-netstat/netstat"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
)
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) {
lp.mut.Lock()
defer lp.mut.Unlock()
if time.Since(lp.mtime) < time.Second {
// copy
ports := make([]codersdk.ListeningPort, len(lp.ports))
copy(ports, lp.ports)
return ports, nil
}
tabs, err := netstat.TCPSocks(func(s *netstat.SockTabEntry) bool {
return s.State == netstat.Listen
})
if err != nil {
return nil, xerrors.Errorf("scan listening ports: %w", err)
}
seen := make(map[uint16]struct{}, len(tabs))
ports := []codersdk.ListeningPort{}
for _, tab := range tabs {
if tab.LocalAddr == nil || tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) {
continue
}
// Don't include ports that we've already seen. This can happen on
// Windows, and maybe on Linux if you're using a shared listener socket.
if _, ok := seen[tab.LocalAddr.Port]; ok {
continue
}
seen[tab.LocalAddr.Port] = struct{}{}
procName := ""
if tab.Process != nil {
procName = tab.Process.Name
}
ports = append(ports, codersdk.ListeningPort{
ProcessName: procName,
Network: codersdk.ListeningPortNetworkTCP,
Port: tab.LocalAddr.Port,
})
}
lp.ports = ports
lp.mtime = time.Now()
// copy
ports = make([]codersdk.ListeningPort, len(lp.ports))
copy(ports, lp.ports)
return ports, nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux && !(windows && amd64)
package agent
import "github.com/coder/coder/codersdk"
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) {
// Can't scan for ports on non-linux or non-windows_amd64 systems at the
// moment. The UI will not show any "no ports found" message to the user, so
// the user won't suspect a thing.
return []codersdk.ListeningPort{}, nil
}
+49
View File
@@ -0,0 +1,49 @@
package agent
import (
"net/http"
"sync"
"time"
"github.com/go-chi/chi"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
func (*agent) statisticsHandler() http.Handler {
r := chi.NewRouter()
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
Message: "Hello from the agent!",
})
})
lp := &listeningPortsHandler{}
r.Get("/api/v0/listening-ports", lp.handler)
return r
}
type listeningPortsHandler struct {
mut sync.Mutex
ports []codersdk.ListeningPort
mtime time.Time
}
// handler returns a list of listening ports. This is tested by coderd's
// TestWorkspaceAgentListeningPorts test.
func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request) {
ports, err := lp.getListeningPorts()
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Could not scan for listening ports.",
Detail: err.Error(),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.ListeningPortsResponse{
Ports: ports,
})
}
+10 -7
View File
@@ -60,10 +60,11 @@ func TestWorkspaceAgent(t *testing.T) {
ctx := context.WithValue(ctx, "azure-client", metadataClient)
errC <- cmd.ExecuteContext(ctx)
}()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
resources := workspace.LatestBuild.Resources
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)
@@ -120,9 +121,10 @@ func TestWorkspaceAgent(t *testing.T) {
ctx := context.WithValue(ctx, "aws-client", metadataClient)
errC <- cmd.ExecuteContext(ctx)
}()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
resources := workspace.LatestBuild.Resources
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
assert.NotEmpty(t, resources[0].Agents[0].Version)
}
@@ -180,9 +182,10 @@ func TestWorkspaceAgent(t *testing.T) {
ctx := context.WithValue(ctx, "gcp-client", metadata)
errC <- cmd.ExecuteContext(ctx)
}()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
resources := workspace.LatestBuild.Resources
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
assert.NotEmpty(t, resources[0].Agents[0].Version)
}
+1 -1
View File
@@ -114,7 +114,7 @@ func TestConfigSSH(t *testing.T) {
defer func() {
_ = agentCloser.Close()
}()
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
agentConn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, resources[0].Agents[0].ID)
require.NoError(t, err)
defer agentConn.Close()
+1 -1
View File
@@ -81,7 +81,7 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str
errC <- cmd.ExecuteContext(ctx)
}()
t.Cleanup(func() { require.NoError(t, <-errC) })
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
return agentClient, agentToken, pubkey
}
+6 -8
View File
@@ -114,9 +114,9 @@ func TestPortForward(t *testing.T) {
// Setup agent once to be shared between test-cases (avoid expensive
// non-parallel setup).
var (
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
_, workspace = runAgent(t, client, user.UserID)
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
workspace = runAgent(t, client, user.UserID)
)
for _, c := range cases { //nolint:paralleltest // the `c := c` confuses the linter
@@ -283,7 +283,7 @@ func TestPortForward(t *testing.T) {
// runAgent creates a fake workspace and starts an agent locally for that
// workspace. The agent will be cleaned up on test completion.
// nolint:unused
func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]codersdk.WorkspaceResource, codersdk.Workspace) {
func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.Workspace {
ctx := context.Background()
user, err := client.User(ctx, userID.String())
require.NoError(t, err, "specified user does not exist")
@@ -336,11 +336,9 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]coders
errC <- cmd.ExecuteContext(agentCtx)
}()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
return resources, workspace
return workspace
}
// setupTestListener starts accepting connections and echoing a single packet.
+18
View File
@@ -48,10 +48,12 @@ const (
varNoFeatureWarning = "no-feature-warning"
varForceTty = "force-tty"
varVerbose = "verbose"
varExperimental = "experimental"
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
envExperimental = "CODER_EXPERIMENTAL"
)
var (
@@ -184,6 +186,7 @@ func Root(subcommands []*cobra.Command) *cobra.Command {
cmd.PersistentFlags().Bool(varNoOpen, false, "Block automatically opening URLs in the browser.")
_ = cmd.PersistentFlags().MarkHidden(varNoOpen)
cliflag.Bool(cmd.PersistentFlags(), varVerbose, "v", "CODER_VERBOSE", false, "Enable verbose output.")
cliflag.Bool(cmd.PersistentFlags(), varExperimental, "", envExperimental, false, "Enable experimental features. Experimental features are not ready for production.")
return cmd
}
@@ -598,3 +601,18 @@ func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
}
return h.transport.RoundTrip(req)
}
// ExperimentalEnabled returns if the experimental feature flag is enabled.
func ExperimentalEnabled(cmd *cobra.Command) bool {
return cliflag.IsSetBool(cmd, varExperimental)
}
// EnsureExperimental will ensure that the experimental feature flag is set if the given flag is set.
func EnsureExperimental(cmd *cobra.Command, name string) error {
_, set := cliflag.IsSet(cmd, name)
if set && !ExperimentalEnabled(cmd) {
return xerrors.Errorf("flag %s is set but requires flag --experimental or environment variable CODER_EXPERIMENTAL=true.", name)
}
return nil
}
+16
View File
@@ -13,6 +13,7 @@ import (
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/cli"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/codersdk"
)
@@ -153,4 +154,19 @@ func TestRoot(t *testing.T) {
// This won't succeed, because we're using the login cmd to assert requests.
_ = cmd.Execute()
})
t.Run("Experimental", func(t *testing.T) {
t.Parallel()
cmd, _ := clitest.New(t, "--experimental")
err := cmd.Execute()
require.NoError(t, err)
require.True(t, cli.ExperimentalEnabled(cmd))
cmd, _ = clitest.New(t, "help", "--verbose")
_ = cmd.Execute()
_, set := cliflag.IsSet(cmd, "verbose")
require.True(t, set)
require.ErrorContains(t, cli.EnsureExperimental(cmd, "verbose"), "--experimental")
})
}
+56 -36
View File
@@ -5,7 +5,6 @@ import (
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/pem"
"errors"
"fmt"
"io"
@@ -106,11 +105,11 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
telemetryEnable bool
telemetryTraceEnable bool
telemetryURL string
tlsCertFile string
tlsCertFiles []string
tlsClientCAFile string
tlsClientAuth string
tlsEnable bool
tlsKeyFile string
tlsKeyFiles []string
tlsMinVersion string
tunnel bool
traceEnable bool
@@ -221,7 +220,7 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
defer listener.Close()
if tlsEnable {
listener, err = configureTLS(listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile)
listener, err = configureServerTLS(listener, tlsMinVersion, tlsClientAuth, tlsCertFiles, tlsKeyFiles, tlsClientCAFile)
if err != nil {
return xerrors.Errorf("configure tls: %w", err)
}
@@ -369,6 +368,7 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
AutoImportTemplates: validatedAutoImportTemplates,
MetricsCacheRefreshInterval: metricsCacheRefreshInterval,
AgentStatsRefreshInterval: agentStatRefreshInterval,
Experimental: ExperimentalEnabled(cmd),
}
if oauth2GithubClientSecret != "" {
@@ -842,8 +842,8 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
_ = root.Flags().MarkHidden("telemetry-url")
cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false,
"Whether TLS will be enabled.")
cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "",
"Path to the certificate for TLS. It requires a PEM-encoded file. "+
cliflag.StringArrayVarP(root.Flags(), &tlsCertFiles, "tls-cert-file", "", "CODER_TLS_CERT_FILE", []string{},
"Path to each certificate for TLS. It requires a PEM-encoded file. "+
"To configure the listener to use a CA certificate, concatenate the primary certificate "+
"and the CA certificate together. The primary certificate should appear first in the combined file.")
cliflag.StringVarP(root.Flags(), &tlsClientCAFile, "tls-client-ca-file", "", "CODER_TLS_CLIENT_CA_FILE", "",
@@ -851,8 +851,8 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
cliflag.StringVarP(root.Flags(), &tlsClientAuth, "tls-client-auth", "", "CODER_TLS_CLIENT_AUTH", "request",
`Policy the server will follow for TLS Client Authentication. `+
`Accepted values are "none", "request", "require-any", "verify-if-given", or "require-and-verify"`)
cliflag.StringVarP(root.Flags(), &tlsKeyFile, "tls-key-file", "", "CODER_TLS_KEY_FILE", "",
"Path to the private key for the certificate. It requires a PEM-encoded file")
cliflag.StringArrayVarP(root.Flags(), &tlsKeyFiles, "tls-key-file", "", "CODER_TLS_KEY_FILE", []string{},
"Paths to the private keys for each of the certificates. It requires a PEM-encoded file")
cliflag.StringVarP(root.Flags(), &tlsMinVersion, "tls-min-version", "", "CODER_TLS_MIN_VERSION", "tls12",
`Minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`)
cliflag.BoolVarP(root.Flags(), &tunnel, "tunnel", "", "CODER_TUNNEL", false,
@@ -1040,7 +1040,32 @@ func printLogo(cmd *cobra.Command, spooky bool) {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s - Remote development on your infrastucture\n", cliui.Styles.Bold.Render("Coder "+buildinfo.Version()))
}
func configureTLS(listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile string) (net.Listener, error) {
func loadCertificates(tlsCertFiles, tlsKeyFiles []string) ([]tls.Certificate, error) {
if len(tlsCertFiles) != len(tlsKeyFiles) {
return nil, xerrors.New("--tls-cert-file and --tls-key-file must be used the same amount of times")
}
if len(tlsCertFiles) == 0 {
return nil, xerrors.New("--tls-cert-file is required when tls is enabled")
}
if len(tlsKeyFiles) == 0 {
return nil, xerrors.New("--tls-key-file is required when tls is enabled")
}
certs := make([]tls.Certificate, len(tlsCertFiles))
for i := range tlsCertFiles {
certFile, keyFile := tlsCertFiles[i], tlsKeyFiles[i]
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, xerrors.Errorf("load TLS key pair %d (%q, %q): %w", i, certFile, keyFile, err)
}
certs[i] = cert
}
return certs, nil
}
func configureServerTLS(listener net.Listener, tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles []string, tlsClientCAFile string) (net.Listener, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
@@ -1072,36 +1097,31 @@ func configureTLS(listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFi
return nil, xerrors.Errorf("unrecognized tls client auth: %q", tlsClientAuth)
}
if tlsCertFile == "" {
return nil, xerrors.New("tls-cert-file is required when tls is enabled")
}
if tlsKeyFile == "" {
return nil, xerrors.New("tls-key-file is required when tls is enabled")
certs, err := loadCertificates(tlsCertFiles, tlsKeyFiles)
if err != nil {
return nil, xerrors.Errorf("load certificates: %w", err)
}
tlsConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
// If there's only one certificate, return it.
if len(certs) == 1 {
return &certs[0], nil
}
certPEMBlock, err := os.ReadFile(tlsCertFile)
if err != nil {
return nil, xerrors.Errorf("read file %q: %w", tlsCertFile, err)
}
keyPEMBlock, err := os.ReadFile(tlsKeyFile)
if err != nil {
return nil, xerrors.Errorf("read file %q: %w", tlsKeyFile, err)
}
keyBlock, _ := pem.Decode(keyPEMBlock)
if keyBlock == nil {
return nil, xerrors.New("decoded pem is blank")
}
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
if err != nil {
return nil, xerrors.Errorf("create key pair: %w", err)
}
tlsConfig.GetCertificate = func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return &cert, nil
}
// Expensively check which certificate matches the client hello.
for _, cert := range certs {
cert := cert
if err := hi.SupportsCertificate(&cert); err == nil {
return &cert, nil
}
}
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(certPEMBlock)
tlsConfig.RootCAs = certPool
// Return the first certificate if we have one, or return nil so the
// server doesn't fail.
if len(certs) > 0 {
return &certs[0], nil
}
return nil, nil //nolint:nilnil
}
if tlsClientCAFile != "" {
caPool := x509.NewCertPool()
+145 -13
View File
@@ -21,6 +21,7 @@ import (
"runtime"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
@@ -240,20 +241,64 @@ func TestServer(t *testing.T) {
err := root.ExecuteContext(ctx)
require.Error(t, err)
})
t.Run("TLSNoCertFile", func(t *testing.T) {
t.Run("TLSInvalid", func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
root, _ := clitest.New(t,
"server",
"--in-memory",
"--address", ":0",
"--tls-enable",
"--cache-dir", t.TempDir(),
)
err := root.ExecuteContext(ctx)
require.Error(t, err)
cert1Path, key1Path := generateTLSCertificate(t)
cert2Path, key2Path := generateTLSCertificate(t)
cases := []struct {
name string
args []string
errContains string
}{
{
name: "NoCertAndKey",
args: []string{"--tls-enable"},
errContains: "--tls-cert-file is required when tls is enabled",
},
{
name: "NoCert",
args: []string{"--tls-enable", "--tls-key-file", key1Path},
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
},
{
name: "NoKey",
args: []string{"--tls-enable", "--tls-cert-file", cert1Path},
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
},
{
name: "MismatchedCount",
args: []string{"--tls-enable", "--tls-cert-file", cert1Path, "--tls-key-file", key1Path, "--tls-cert-file", cert2Path},
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
},
{
name: "MismatchedCertAndKey",
args: []string{"--tls-enable", "--tls-cert-file", cert1Path, "--tls-key-file", key2Path},
errContains: "load TLS key pair",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
args := []string{
"server",
"--in-memory",
"--address", ":0",
"--cache-dir", t.TempDir(),
}
args = append(args, c.args...)
root, _ := clitest.New(t, args...)
err := root.ExecuteContext(ctx)
require.Error(t, err)
require.ErrorContains(t, err, c.errContains)
})
}
})
t.Run("TLSValid", func(t *testing.T) {
t.Parallel()
@@ -293,6 +338,86 @@ func TestServer(t *testing.T) {
cancelFunc()
require.NoError(t, <-errC)
})
t.Run("TLSValidMultiple", func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
cert1Path, key1Path := generateTLSCertificate(t, "alpaca.com")
cert2Path, key2Path := generateTLSCertificate(t, "*.llama.com")
root, cfg := clitest.New(t,
"server",
"--in-memory",
"--address", ":0",
"--tls-enable",
"--tls-cert-file", cert1Path,
"--tls-key-file", key1Path,
"--tls-cert-file", cert2Path,
"--tls-key-file", key2Path,
"--cache-dir", t.TempDir(),
)
errC := make(chan error, 1)
go func() {
errC <- root.ExecuteContext(ctx)
}()
accessURL := waitAccessURL(t, cfg)
require.Equal(t, "https", accessURL.Scheme)
originalHost := accessURL.Host
var (
expectAddr string
dials int64
)
client := codersdk.New(accessURL)
client.HTTPClient = &http.Client{
Transport: &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
atomic.AddInt64(&dials, 1)
assert.Equal(t, expectAddr, addr)
host, _, err := net.SplitHostPort(addr)
require.NoError(t, err)
// Always connect to the accessURL ip:port regardless of
// hostname.
conn, err := tls.Dial(network, originalHost, &tls.Config{
MinVersion: tls.VersionTLS12,
//nolint:gosec
InsecureSkipVerify: true,
ServerName: host,
})
if err != nil {
return nil, err
}
// We can't call conn.VerifyHostname because it requires
// that the certificates are valid, so we call
// VerifyHostname on the first certificate instead.
require.Len(t, conn.ConnectionState().PeerCertificates, 1)
err = conn.ConnectionState().PeerCertificates[0].VerifyHostname(host)
assert.NoError(t, err, "invalid cert common name")
return conn, nil
},
},
}
// Use the first certificate and hostname.
client.URL.Host = "alpaca.com:443"
expectAddr = "alpaca.com:443"
_, err := client.HasFirstUser(ctx)
require.NoError(t, err)
require.EqualValues(t, 1, atomic.LoadInt64(&dials))
// Use the second certificate (wildcard) and hostname.
client.URL.Host = "hi.llama.com:443"
expectAddr = "hi.llama.com:443"
_, err = client.HasFirstUser(ctx)
require.NoError(t, err)
require.EqualValues(t, 2, atomic.LoadInt64(&dials))
cancelFunc()
require.NoError(t, <-errC)
})
// This cannot be ran in parallel because it uses a signal.
//nolint:paralleltest
t.Run("Shutdown", func(t *testing.T) {
@@ -480,16 +605,22 @@ func TestServer(t *testing.T) {
})
}
func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
func generateTLSCertificate(t testing.TB, commonName ...string) (certPath, keyPath string) {
dir := t.TempDir()
commonNameStr := "localhost"
if len(commonName) > 0 {
commonNameStr = commonName[0]
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Acme Co"},
CommonName: commonNameStr,
},
DNSNames: []string{commonNameStr},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour * 24 * 180),
@@ -498,6 +629,7 @@ func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
certFile, err := os.CreateTemp(dir, "")
+1 -5
View File
@@ -26,11 +26,7 @@ func show() *cobra.Command {
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
}
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
if err != nil {
return xerrors.Errorf("get workspace resources: %w", err)
}
return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
return cliui.WorkspaceResources(cmd.OutOrStdout(), workspace.LatestBuild.Resources, cliui.WorkspaceResourcesOptions{
WorkspaceName: workspace.Name,
ServerVersion: buildInfo.Version,
})
+1 -1
View File
@@ -29,7 +29,7 @@ func TestSpeedtest(t *testing.T) {
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer agentCloser.Close()
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
cmd, root := clitest.New(t, "speedtest", workspace.Name)
clitest.SetupConfig(t, client, root)
+1 -4
View File
@@ -261,10 +261,7 @@ func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *coder
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is being deleted", workspace.Name)
}
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
if err != nil {
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("fetch workspace resources: %w", err)
}
resources := workspace.LatestBuild.Resources
agents := make([]codersdk.WorkspaceAgent, 0)
for _, resource := range resources {
+4 -4
View File
@@ -36,7 +36,7 @@ func TestWorkspaceActivityBump(t *testing.T) {
)
firstDeadline := workspace.LatestBuild.Deadline.Time
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
return client, workspace, func(want bool) {
if !want {
@@ -73,7 +73,7 @@ func TestWorkspaceActivityBump(t *testing.T) {
client, workspace, assertBumped := setupActivityTest(t)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil), resources[0].Agents[0].ID)
require.NoError(t, err)
defer conn.Close()
@@ -90,9 +90,9 @@ func TestWorkspaceActivityBump(t *testing.T) {
client, workspace, assertBumped := setupActivityTest(t)
// Benign operations like retrieving resources must not
// Benign operations like retrieving workspace must not
// bump the deadline.
_, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
_, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
assertBumped(false)
+34 -3
View File
@@ -259,12 +259,43 @@ func auditSearchQuery(query string) (database.GetAuditLogsOffsetParams, []coders
// other parsing.
parser := httpapi.NewQueryParamParser()
filter := database.GetAuditLogsOffsetParams{
ResourceType: parser.String(searchParams, "", "resource_type"),
ResourceType: resourceTypeFromString(parser.String(searchParams, "", "resource_type")),
ResourceID: parser.UUID(searchParams, uuid.Nil, "resource_id"),
Action: parser.String(searchParams, "", "action"),
Action: actionFromString(parser.String(searchParams, "", "action")),
Username: parser.String(searchParams, "", "username"),
Email: parser.String(searchParams, "", "email"),
}
return filter, parser.Errors
}
func resourceTypeFromString(resourceTypeString string) string {
switch codersdk.ResourceType(resourceTypeString) {
case codersdk.ResourceTypeOrganization:
return resourceTypeString
case codersdk.ResourceTypeTemplate:
return resourceTypeString
case codersdk.ResourceTypeTemplateVersion:
return resourceTypeString
case codersdk.ResourceTypeUser:
return resourceTypeString
case codersdk.ResourceTypeWorkspace:
return resourceTypeString
case codersdk.ResourceTypeGitSSHKey:
return resourceTypeString
case codersdk.ResourceTypeAPIKey:
return resourceTypeString
}
return ""
}
func actionFromString(actionString string) string {
switch codersdk.AuditAction(actionString) {
case codersdk.AuditActionCreate:
return actionString
case codersdk.AuditActionWrite:
return actionString
case codersdk.AuditActionDelete:
return actionString
}
return ""
}
+16
View File
@@ -112,9 +112,25 @@ func TestAuditLogsFilter(t *testing.T) {
SearchQuery: "resource_id:" + userResourceID.String(),
ExpectedResult: 2,
},
{
Name: "FilterInvalidSingleValue",
SearchQuery: "invalid",
ExpectedResult: 3,
},
{
Name: "FilterWithInvalidResourceType",
SearchQuery: "resource_type:invalid",
ExpectedResult: 3,
},
{
Name: "FilterWithInvalidAction",
SearchQuery: "action:invalid",
ExpectedResult: 3,
},
}
for _, testCase := range testCases {
testCase := testCase
// Test filtering
t.Run(testCase.Name, func(t *testing.T) {
t.Parallel()
+23
View File
@@ -13,6 +13,9 @@ import (
"github.com/coder/coder/codersdk"
)
// AuthorizeFilter takes a list of objects and returns the filtered list of
// objects that the user is authorized to perform the given action on.
// This is faster than calling Authorize() on each object.
func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action rbac.Action, objects []O) ([]O, error) {
roles := httpmw.UserAuthorization(r)
objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, objects)
@@ -85,6 +88,26 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object r
return true
}
// AuthorizeSQLFilter returns an authorization filter that can used in a
// SQL 'WHERE' clause. If the filter is used, the resulting rows returned
// from postgres are already authorized, and the caller does not need to
// call 'Authorize()' on the returned objects.
// Note the authorization is only for the given action and object type.
func (h *HTTPAuthorizer) AuthorizeSQLFilter(r *http.Request, action rbac.Action, objectType string) (rbac.AuthorizeFilter, error) {
roles := httpmw.UserAuthorization(r)
prepared, err := h.Authorizer.PrepareByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, objectType)
if err != nil {
return nil, xerrors.Errorf("prepare filter: %w", err)
}
filter, err := prepared.Compile()
if err != nil {
return nil, xerrors.Errorf("compile filter: %w", err)
}
return filter, nil
}
// checkAuthorization returns if the current API key can use the given
// permissions, factoring in the current user's roles and the API key scopes.
func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) {
+2 -8
View File
@@ -81,6 +81,7 @@ type Options struct {
MetricsCacheRefreshInterval time.Duration
AgentStatsRefreshInterval time.Duration
Experimental bool
}
// New constructs a Coder API handler.
@@ -437,6 +438,7 @@ func New(options *Options) *API {
)
r.Get("/", api.workspaceAgent)
r.Get("/pty", api.workspaceAgentPTY)
r.Get("/listening-ports", api.workspaceAgentListeningPorts)
r.Get("/connection", api.workspaceAgentConnection)
r.Get("/coordinate", api.workspaceAgentClientCoordinate)
// TODO: This can be removed in October. It allows for a friendly
@@ -449,14 +451,6 @@ func New(options *Options) *API {
})
})
})
r.Route("/workspaceresources/{workspaceresource}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractWorkspaceResourceParam(options.Database),
httpmw.ExtractWorkspaceParam(options.Database),
)
r.Get("/", api.workspaceResource)
})
r.Route("/workspaces", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
+52 -25
View File
@@ -100,10 +100,6 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
AssertAction: rbac.ActionUpdate,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/workspaceresources/{workspaceresource}": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"PATCH:/api/v2/workspacebuilds/{workspacebuild}/cancel": {
AssertAction: rbac.ActionUpdate,
AssertObject: workspaceRBACObj,
@@ -128,11 +124,6 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
AssertAction: rbac.ActionCreate,
AssertObject: workspaceExecObj,
},
"GET:/api/v2/workspaces/": {
StatusCode: http.StatusOK,
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/organizations/{organization}/templates": {
StatusCode: http.StatusOK,
AssertAction: rbac.ActionRead,
@@ -250,6 +241,9 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true},
"POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
// Endpoints that use the SQLQuery filter.
"GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true},
}
// Routes like proxy routes support all HTTP methods. A helper func to expand
@@ -351,7 +345,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a
AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
file, err := client.Upload(ctx, codersdk.ContentTypeTar, make([]byte, 1024))
require.NoError(t, err, "upload file")
workspaceResources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
workspace, err = client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "workspace resources")
templateVersionDryRun, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{
ParameterValues: []codersdk.CreateParameterRequest{},
@@ -372,16 +366,16 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a
"{workspace}": workspace.ID.String(),
"{workspacebuild}": workspace.LatestBuild.ID.String(),
"{workspacename}": workspace.Name,
"{workspaceagent}": workspaceResources[0].Agents[0].ID.String(),
"{workspaceagent}": workspace.LatestBuild.Resources[0].Agents[0].ID.String(),
"{buildnumber}": strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10),
"{template}": template.ID.String(),
"{hash}": file.Hash,
"{workspaceresource}": workspaceResources[0].ID.String(),
"{workspaceapp}": workspaceResources[0].Agents[0].Apps[0].Name,
"{workspaceresource}": workspace.LatestBuild.Resources[0].ID.String(),
"{workspaceapp}": workspace.LatestBuild.Resources[0].Agents[0].Apps[0].Name,
"{templateversion}": version.ID.String(),
"{jobID}": templateVersionDryRun.ID.String(),
"{templatename}": template.Name,
"{workspace_and_agent}": workspace.Name + "." + workspaceResources[0].Agents[0].Name,
"{workspace_and_agent}": workspace.Name + "." + workspace.LatestBuild.Resources[0].Agents[0].Name,
// Only checking template scoped params here
"parameters/{scope}/{id}": fmt.Sprintf("parameters/%s/%s",
string(templateParam.Scope), templateParam.ScopeID.String()),
@@ -397,7 +391,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a
Admin: admin,
Template: template,
Version: version,
WorkspaceResource: workspaceResources[0],
WorkspaceResource: workspace.LatestBuild.Resources[0],
File: file,
TemplateVersionDryRun: templateVersionDryRun,
TemplateParam: templateParam,
@@ -517,6 +511,12 @@ type RecordingAuthorizer struct {
var _ rbac.Authorizer = (*RecordingAuthorizer)(nil)
// ByRoleNameSQL does not record the call. This matches the postgres behavior
// of not calling Authorize()
func (r *RecordingAuthorizer) ByRoleNameSQL(_ context.Context, _ string, _ []string, _ rbac.Scope, _ rbac.Action, _ rbac.Object) error {
return r.AlwaysReturn
}
func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, scope rbac.Scope, action rbac.Action, object rbac.Object) error {
r.Called = &authCall{
SubjectID: subjectID,
@@ -530,11 +530,12 @@ func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, ro
func (r *RecordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, scope rbac.Scope, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) {
return &fakePreparedAuthorizer{
Original: r,
SubjectID: subjectID,
Roles: roles,
Scope: scope,
Action: action,
Original: r,
SubjectID: subjectID,
Roles: roles,
Scope: scope,
Action: action,
HardCodedSQLString: "true",
}, nil
}
@@ -543,13 +544,39 @@ func (r *RecordingAuthorizer) reset() {
}
type fakePreparedAuthorizer struct {
Original *RecordingAuthorizer
SubjectID string
Roles []string
Scope rbac.Scope
Action rbac.Action
Original *RecordingAuthorizer
SubjectID string
Roles []string
Scope rbac.Scope
Action rbac.Action
HardCodedSQLString string
HardCodedRegoString string
}
func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error {
return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Scope, f.Action, object)
}
// Compile returns a compiled version of the authorizer that will work for
// in memory databases. This fake version will not work against a SQL database.
func (f *fakePreparedAuthorizer) Compile() (rbac.AuthorizeFilter, error) {
return f, nil
}
func (f *fakePreparedAuthorizer) Eval(object rbac.Object) bool {
return f.Original.ByRoleNameSQL(context.Background(), f.SubjectID, f.Roles, f.Scope, f.Action, object) == nil
}
func (f fakePreparedAuthorizer) RegoString() string {
if f.HardCodedRegoString != "" {
return f.HardCodedRegoString
}
panic("not implemented")
}
func (f fakePreparedAuthorizer) SQLString(_ rbac.SQLConfig) string {
if f.HardCodedSQLString != "" {
return f.HardCodedSQLString
}
panic("not implemented")
}
+10 -4
View File
@@ -486,18 +486,22 @@ func AwaitWorkspaceBuildJob(t *testing.T, client *codersdk.Client, build uuid.UU
}
// AwaitWorkspaceAgents waits for all resources with agents to be connected.
func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID) []codersdk.WorkspaceResource {
func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID) []codersdk.WorkspaceResource {
t.Helper()
t.Logf("waiting for workspace agents (build %s)", build)
t.Logf("waiting for workspace agents (workspace %s)", workspaceID)
var resources []codersdk.WorkspaceResource
require.Eventually(t, func() bool {
var err error
resources, err = client.WorkspaceResourcesByBuild(context.Background(), build)
workspace, err := client.Workspace(context.Background(), workspaceID)
if !assert.NoError(t, err) {
return false
}
for _, resource := range resources {
if workspace.LatestBuild.Job.CompletedAt.IsZero() {
return false
}
for _, resource := range workspace.LatestBuild.Resources {
for _, agent := range resource.Agents {
if agent.Status != codersdk.WorkspaceAgentConnected {
t.Logf("agent %s not connected yet", agent.Name)
@@ -505,6 +509,8 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID
}
}
}
resources = workspace.LatestBuild.Resources
return true
}, testutil.WaitLong, testutil.IntervalFast)
return resources
+1 -1
View File
@@ -23,7 +23,7 @@ func TestNew(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
_, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false)
_, _ = coderdtest.NewAWSInstanceIdentity(t, "an-instance")
}
+62
View File
@@ -0,0 +1,62 @@
package database
import (
"context"
"fmt"
"github.com/lib/pq"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/rbac"
)
type customQuerier interface {
GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error)
}
// GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access.
// This code is copied from `GetWorkspaces` and adds the authorized filter WHERE
// clause.
func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error) {
// The name comment is for metric tracking
query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s AND %s", getWorkspaces, authorizedFilter.SQLString(rbac.DefaultConfig()))
rows, err := q.db.QueryContext(ctx, query,
arg.Deleted,
arg.OwnerID,
arg.OwnerUsername,
arg.TemplateName,
pq.Array(arg.TemplateIds),
arg.Name,
)
if err != nil {
return nil, xerrors.Errorf("get authorized workspaces: %w", err)
}
defer rows.Close()
var items []Workspace
for rows.Next() {
var i Workspace
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
&i.OrganizationID,
&i.TemplateID,
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
); 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
}
+13 -2
View File
@@ -520,7 +520,13 @@ func (q *fakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U
}, nil
}
func (q *fakeQuerier) GetWorkspaces(_ context.Context, arg database.GetWorkspacesParams) ([]database.Workspace, error) {
func (q *fakeQuerier) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesParams) ([]database.Workspace, error) {
// A nil auth filter means no auth filter.
workspaces, err := q.GetAuthorizedWorkspaces(ctx, arg, nil)
return workspaces, err
}
func (q *fakeQuerier) GetAuthorizedWorkspaces(_ context.Context, arg database.GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -560,6 +566,11 @@ func (q *fakeQuerier) GetWorkspaces(_ context.Context, arg database.GetWorkspace
continue
}
}
// If the filter exists, ensure the object is authorized.
if authorizedFilter != nil && !authorizedFilter.Eval(workspace.RBACObject()) {
continue
}
workspaces = append(workspaces, workspace)
}
@@ -2058,7 +2069,7 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW
Icon: arg.Icon,
Command: arg.Command,
Url: arg.Url,
RelativePath: arg.RelativePath,
Subdomain: arg.Subdomain,
HealthcheckUrl: arg.HealthcheckUrl,
HealthcheckInterval: arg.HealthcheckInterval,
HealthcheckThreshold: arg.HealthcheckThreshold,
+2
View File
@@ -20,6 +20,8 @@ import (
// It extends the generated interface to add transaction support.
type Store interface {
querier
// customQuerier contains custom queries that are not generated.
customQuerier
InTx(func(Store) error) error
}
+2 -2
View File
@@ -352,11 +352,11 @@ CREATE TABLE workspace_apps (
icon character varying(256) NOT NULL,
command character varying(65534),
url character varying(65534),
relative_path boolean DEFAULT false NOT NULL,
healthcheck_url text DEFAULT ''::text NOT NULL,
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
health workspace_app_health DEFAULT 'disabled'::public.workspace_app_health NOT NULL,
subdomain boolean DEFAULT false NOT NULL
);
CREATE TABLE workspace_builds (
@@ -0,0 +1,8 @@
-- Add column relative_path of type bool to workspace_apps
ALTER TABLE "workspace_apps" ADD COLUMN "relative_path" bool NOT NULL DEFAULT false;
-- Set column relative_path to the opposite of subdomain
UPDATE "workspace_apps" SET "relative_path" = NOT "subdomain";
-- Drop column subdomain
ALTER TABLE "workspace_apps" DROP COLUMN "subdomain";
@@ -0,0 +1,8 @@
-- Add column subdomain of type bool to workspace_apps
ALTER TABLE "workspace_apps" ADD COLUMN "subdomain" bool NOT NULL DEFAULT false;
-- Set column subdomain to the opposite of relative_path
UPDATE "workspace_apps" SET "subdomain" = NOT "relative_path";
-- Drop column relative_path
ALTER TABLE "workspace_apps" DROP COLUMN "relative_path";
@@ -0,0 +1 @@
-- nothing
@@ -0,0 +1,12 @@
-- There was a mistake in the last migration which set "subdomain" to be the
-- opposite of the deprecated value "relative_path", however the "relative_path"
-- value may not have been correct as it was not consumed anywhere prior to this
-- point.
--
-- Force all workspace apps to use path based routing until rebuild. This should
-- not impact any existing workspaces as the only supported routing method has
-- been path based routing prior to this point.
--
-- On rebuild the value from the Terraform template will be used instead
-- (defaulting to false if unspecified).
UPDATE "workspace_apps" SET "subdomain" = false;
+1 -1
View File
@@ -605,11 +605,11 @@ type WorkspaceApp struct {
Icon string `db:"icon" json:"icon"`
Command sql.NullString `db:"command" json:"command"`
Url sql.NullString `db:"url" json:"url"`
RelativePath bool `db:"relative_path" json:"relative_path"`
HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"`
HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"`
HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"`
Health WorkspaceAppHealth `db:"health" json:"health"`
Subdomain bool `db:"subdomain" json:"subdomain"`
}
type WorkspaceBuild struct {
+13 -13
View File
@@ -3895,7 +3895,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, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health 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 FROM workspace_apps WHERE agent_id = $1 AND name = $2
`
type GetWorkspaceAppByAgentIDAndNameParams struct {
@@ -3914,17 +3914,17 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndName(ctx context.Context, arg Ge
&i.Icon,
&i.Command,
&i.Url,
&i.RelativePath,
&i.HealthcheckUrl,
&i.HealthcheckInterval,
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
)
return i, err
}
const getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many
SELECT id, created_at, agent_id, name, icon, command, url, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health 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 FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC
`
func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) {
@@ -3944,11 +3944,11 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid
&i.Icon,
&i.Command,
&i.Url,
&i.RelativePath,
&i.HealthcheckUrl,
&i.HealthcheckInterval,
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
); err != nil {
return nil, err
}
@@ -3964,7 +3964,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, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health 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 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) {
@@ -3984,11 +3984,11 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.
&i.Icon,
&i.Command,
&i.Url,
&i.RelativePath,
&i.HealthcheckUrl,
&i.HealthcheckInterval,
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
); err != nil {
return nil, err
}
@@ -4004,7 +4004,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, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health 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 FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC
`
func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) {
@@ -4024,11 +4024,11 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt
&i.Icon,
&i.Command,
&i.Url,
&i.RelativePath,
&i.HealthcheckUrl,
&i.HealthcheckInterval,
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
); err != nil {
return nil, err
}
@@ -4053,14 +4053,14 @@ INSERT INTO
icon,
command,
url,
relative_path,
subdomain,
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, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health
($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
`
type InsertWorkspaceAppParams struct {
@@ -4071,7 +4071,7 @@ type InsertWorkspaceAppParams struct {
Icon string `db:"icon" json:"icon"`
Command sql.NullString `db:"command" json:"command"`
Url sql.NullString `db:"url" json:"url"`
RelativePath bool `db:"relative_path" json:"relative_path"`
Subdomain bool `db:"subdomain" json:"subdomain"`
HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"`
HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"`
HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"`
@@ -4087,7 +4087,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
arg.Icon,
arg.Command,
arg.Url,
arg.RelativePath,
arg.Subdomain,
arg.HealthcheckUrl,
arg.HealthcheckInterval,
arg.HealthcheckThreshold,
@@ -4102,11 +4102,11 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
&i.Icon,
&i.Command,
&i.Url,
&i.RelativePath,
&i.HealthcheckUrl,
&i.HealthcheckInterval,
&i.HealthcheckThreshold,
&i.Health,
&i.Subdomain,
)
return i, err
}
+1 -1
View File
@@ -20,7 +20,7 @@ INSERT INTO
icon,
command,
url,
relative_path,
subdomain,
healthcheck_url,
healthcheck_interval,
healthcheck_threshold,
+27
View File
@@ -0,0 +1,27 @@
package httpapi
import (
"context"
"time"
"nhooyr.io/websocket"
)
// Heartbeat loops to ping a WebSocket to keep it alive.
// Default idle connection timeouts are typically 60 seconds.
// See: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#connection-idle-timeout
func Heartbeat(ctx context.Context, conn *websocket.Conn) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
err := conn.Ping(ctx)
if err != nil {
return
}
}
}
+3 -3
View File
@@ -25,7 +25,7 @@ func Logger(log slog.Logger) func(next http.Handler) http.Handler {
next.ServeHTTP(sw, r)
// Don't log successful health check requests.
if r.URL.Path == "/api/v2" && sw.Status == 200 {
if r.URL.Path == "/api/v2" && sw.Status == http.StatusOK {
return
}
@@ -37,7 +37,7 @@ func Logger(log slog.Logger) func(next http.Handler) http.Handler {
// For status codes 400 and higher we
// want to log the response body.
if sw.Status >= 400 {
if sw.Status >= http.StatusInternalServerError {
httplog = httplog.With(
slog.F("response_body", string(sw.ResponseBody())),
)
@@ -47,7 +47,7 @@ func Logger(log slog.Logger) func(next http.Handler) http.Handler {
// includes proxy errors etc. It also causes slogtest to fail
// instantly without an error message by default.
logLevelFn := httplog.Debug
if sw.Status >= 400 {
if sw.Status >= http.StatusInternalServerError {
logLevelFn = httplog.Warn
}
+1 -1
View File
@@ -828,7 +828,7 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
String: app.Url,
Valid: app.Url != "",
},
RelativePath: app.RelativePath,
Subdomain: app.Subdomain,
HealthcheckUrl: app.Healthcheck.Url,
HealthcheckInterval: app.Healthcheck.Interval,
HealthcheckThreshold: app.Healthcheck.Threshold,
+1
View File
@@ -151,6 +151,7 @@ func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job
})
return
}
go httpapi.Heartbeat(ctx, conn)
ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageText)
defer wsNetConn.Close() // Also closes conn.
+1
View File
@@ -21,6 +21,7 @@ type Authorizer interface {
type PreparedAuthorized interface {
Authorize(ctx context.Context, object Object) error
Compile() (AuthorizeFilter, error)
}
// Filter takes in a list of objects, and will filter the list removing all
+5
View File
@@ -781,6 +781,11 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes
partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, subject.Scope, a, c.resource.Type)
require.NoError(t, err, "make prepared authorizer")
// Ensure the partial can compile to a SQL clause.
// This does not guarantee that the clause is valid SQL.
_, err = Compile(partialAuthz.partialQueries)
require.NoError(t, err, "compile prepared authorizer")
// Also check the rego policy can form a valid partial query result.
// This ensures we can convert the queries into SQL WHERE clauses in the future.
// If this function returns 'Support' sections, then we cannot convert the query into SQL.
+8
View File
@@ -28,6 +28,14 @@ type PartialAuthorizer struct {
var _ PreparedAuthorized = (*PartialAuthorizer)(nil)
func (pa *PartialAuthorizer) Compile() (AuthorizeFilter, error) {
filter, err := Compile(pa.partialQueries)
if err != nil {
return nil, xerrors.Errorf("compile: %w", err)
}
return filter, nil
}
func (pa *PartialAuthorizer) Authorize(ctx context.Context, object Object) error {
if pa.alwaysTrue {
return nil
+616
View File
@@ -0,0 +1,616 @@
package rbac
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego"
"golang.org/x/xerrors"
)
type TermType string
const (
VarTypeJsonbTextArray TermType = "jsonb-text-array"
VarTypeText TermType = "text"
)
type SQLColumn struct {
// RegoMatch matches the original variable string.
// If it is a match, then this variable config will apply.
RegoMatch *regexp.Regexp
// ColumnSelect is the name of the postgres column to select.
// Can use capture groups from RegoMatch with $1, $2, etc.
ColumnSelect string
// Type indicates the postgres type of the column. Some expressions will
// need to know this in order to determine what SQL to produce.
// An example is if the variable is a jsonb array, the "contains" SQL
// query is `variable ? 'value'` instead of `'value' = ANY(variable)`.
// This type is only needed to be provided
Type TermType
}
type SQLConfig struct {
// Variables is a map of rego variable names to SQL columns.
// Example:
// "input\.object\.org_owner": SQLColumn{
// ColumnSelect: "organization_id",
// Type: VarTypeUUID
// }
// "input\.object\.owner": SQLColumn{
// ColumnSelect: "owner_id",
// Type: VarTypeUUID
// }
// "input\.object\.group_acl\.(.*)": SQLColumn{
// ColumnSelect: "group_acl->$1",
// Type: VarTypeJsonbTextArray
// }
Variables []SQLColumn
}
func DefaultConfig() SQLConfig {
return SQLConfig{
Variables: []SQLColumn{
{
RegoMatch: regexp.MustCompile(`^input\.object\.acl_group_list\.?(.*)$`),
ColumnSelect: "group_acl->$1",
Type: VarTypeJsonbTextArray,
},
{
RegoMatch: regexp.MustCompile(`^input\.object\.acl_user_list\.?(.*)$`),
ColumnSelect: "user_acl->$1",
Type: VarTypeJsonbTextArray,
},
{
RegoMatch: regexp.MustCompile(`^input\.object\.org_owner$`),
ColumnSelect: "organization_id :: text",
Type: VarTypeText,
},
{
RegoMatch: regexp.MustCompile(`^input\.object\.owner$`),
ColumnSelect: "owner_id :: text",
Type: VarTypeText,
},
},
}
}
type AuthorizeFilter interface {
// RegoString is used in debugging to see the original rego expression.
RegoString() string
// SQLString returns the SQL expression that can be used in a WHERE clause.
SQLString(cfg SQLConfig) string
// Eval is required for the fake in memory database to work. The in memory
// database can use this function to filter the results.
Eval(object Object) bool
}
// Compile will convert a rego query AST into our custom types. The output is
// an AST that can be used to generate SQL.
func Compile(partialQueries *rego.PartialQueries) (Expression, error) {
if len(partialQueries.Support) > 0 {
return nil, xerrors.Errorf("cannot convert support rules, expect 0 found %d", len(partialQueries.Support))
}
// 0 queries means the result is "undefined". This is the same as "false".
if len(partialQueries.Queries) == 0 {
return &termBoolean{
base: base{Rego: "false"},
Value: false,
}, nil
}
// Abort early if any of the "OR"'d expressions are the empty string.
// This is the same as "true".
for _, query := range partialQueries.Queries {
if query.String() == "" {
return &termBoolean{
base: base{Rego: "true"},
Value: true,
}, nil
}
}
result := make([]Expression, 0, len(partialQueries.Queries))
var builder strings.Builder
for i := range partialQueries.Queries {
query, err := processQuery(partialQueries.Queries[i])
if err != nil {
return nil, err
}
result = append(result, query)
if i != 0 {
builder.WriteString("\n")
}
builder.WriteString(partialQueries.Queries[i].String())
}
return expOr{
base: base{
Rego: builder.String(),
},
Expressions: result,
}, nil
}
// processQuery processes an entire set of expressions and joins them with
// "AND".
func processQuery(query ast.Body) (Expression, error) {
expressions := make([]Expression, 0, len(query))
for _, astExpr := range query {
expr, err := processExpression(astExpr)
if err != nil {
return nil, err
}
expressions = append(expressions, expr)
}
return expAnd{
base: base{
Rego: query.String(),
},
Expressions: expressions,
}, nil
}
func processExpression(expr *ast.Expr) (Expression, error) {
if !expr.IsCall() {
// This could be a single term that is a valid expression.
if term, ok := expr.Terms.(*ast.Term); ok {
value, err := processTerm(term)
if err != nil {
return nil, xerrors.Errorf("single term expression: %w", err)
}
if boolExp, ok := value.(Expression); ok {
return boolExp, nil
}
// Default to error.
}
return nil, xerrors.Errorf("invalid expression: single non-boolean terms not supported")
}
op := expr.Operator().String()
base := base{Rego: op}
switch op {
case "neq", "eq", "equal":
terms, err := processTerms(2, expr.Operands())
if err != nil {
return nil, xerrors.Errorf("invalid '%s' expression: %w", op, err)
}
return &opEqual{
base: base,
Terms: [2]Term{terms[0], terms[1]},
Not: op == "neq",
}, nil
case "internal.member_2":
terms, err := processTerms(2, expr.Operands())
if err != nil {
return nil, xerrors.Errorf("invalid '%s' expression: %w", op, err)
}
return &opInternalMember2{
base: base,
Needle: terms[0],
Haystack: terms[1],
}, nil
default:
return nil, xerrors.Errorf("invalid expression: operator %s not supported", op)
}
}
func processTerms(expected int, terms []*ast.Term) ([]Term, error) {
if len(terms) != expected {
return nil, xerrors.Errorf("too many arguments, expect %d found %d", expected, len(terms))
}
result := make([]Term, 0, len(terms))
for _, term := range terms {
processed, err := processTerm(term)
if err != nil {
return nil, xerrors.Errorf("invalid term: %w", err)
}
result = append(result, processed)
}
return result, nil
}
func processTerm(term *ast.Term) (Term, error) {
base := base{Rego: term.String()}
switch v := term.Value.(type) {
case ast.Boolean:
return &termBoolean{
base: base,
Value: bool(v),
}, nil
case ast.Ref:
obj := &termObject{
base: base,
Variables: []termVariable{},
}
var idx int
// A ref is a set of terms. If the first term is a var, then the
// following terms are the path to the value.
var builder strings.Builder
for _, term := range v {
if idx == 0 {
if _, ok := v[0].Value.(ast.Var); !ok {
return nil, xerrors.Errorf("invalid term (%s): ref must start with a var, started with %T", v[0].String(), v[0])
}
}
if _, ok := term.Value.(ast.Ref); ok {
// New obj
obj.Variables = append(obj.Variables, termVariable{
base: base,
Name: builder.String(),
})
builder.Reset()
idx = 0
}
if builder.Len() != 0 {
builder.WriteString(".")
}
builder.WriteString(trimQuotes(term.String()))
idx++
}
obj.Variables = append(obj.Variables, termVariable{
base: base,
Name: builder.String(),
})
return obj, nil
case ast.Var:
return &termVariable{
Name: trimQuotes(v.String()),
base: base,
}, nil
case ast.String:
return &termString{
Value: trimQuotes(v.String()),
base: base,
}, nil
case ast.Set:
slice := v.Slice()
set := make([]Term, 0, len(slice))
for _, elem := range slice {
processed, err := processTerm(elem)
if err != nil {
return nil, xerrors.Errorf("invalid set term %s: %w", elem.String(), err)
}
set = append(set, processed)
}
return &termSet{
Value: set,
base: base,
}, nil
default:
return nil, xerrors.Errorf("invalid term: %T not supported, %q", v, term.String())
}
}
type base struct {
// Rego is the original rego string
Rego string
}
func (b base) RegoString() string {
return b.Rego
}
// Expression comprises a set of terms, operators, and functions. All
// expressions return a boolean value.
//
// Eg: neq(input.object.org_owner, "") AND input.object.org_owner == "foo"
type Expression interface {
AuthorizeFilter
}
type expAnd struct {
base
Expressions []Expression
}
func (t expAnd) SQLString(cfg SQLConfig) string {
if len(t.Expressions) == 1 {
return t.Expressions[0].SQLString(cfg)
}
exprs := make([]string, 0, len(t.Expressions))
for _, expr := range t.Expressions {
exprs = append(exprs, expr.SQLString(cfg))
}
return "(" + strings.Join(exprs, " AND ") + ")"
}
func (t expAnd) Eval(object Object) bool {
for _, expr := range t.Expressions {
if !expr.Eval(object) {
return false
}
}
return true
}
type expOr struct {
base
Expressions []Expression
}
func (t expOr) SQLString(cfg SQLConfig) string {
if len(t.Expressions) == 1 {
return t.Expressions[0].SQLString(cfg)
}
exprs := make([]string, 0, len(t.Expressions))
for _, expr := range t.Expressions {
exprs = append(exprs, expr.SQLString(cfg))
}
return "(" + strings.Join(exprs, " OR ") + ")"
}
func (t expOr) Eval(object Object) bool {
for _, expr := range t.Expressions {
if expr.Eval(object) {
return true
}
}
return false
}
// Operator joins terms together to form an expression.
// Operators are also expressions.
//
// Eg: "=", "neq", "internal.member_2", etc.
type Operator interface {
Expression
}
type opEqual struct {
base
Terms [2]Term
// For NotEqual
Not bool
}
func (t opEqual) SQLString(cfg SQLConfig) string {
op := "="
if t.Not {
op = "!="
}
return fmt.Sprintf("%s %s %s", t.Terms[0].SQLString(cfg), op, t.Terms[1].SQLString(cfg))
}
func (t opEqual) Eval(object Object) bool {
a, b := t.Terms[0].EvalTerm(object), t.Terms[1].EvalTerm(object)
if t.Not {
return a != b
}
return a == b
}
// opInternalMember2 is checking if the first term is a member of the second term.
// The second term is a set or list.
type opInternalMember2 struct {
base
Needle Term
Haystack Term
}
func (t opInternalMember2) Eval(object Object) bool {
a, b := t.Needle.EvalTerm(object), t.Haystack.EvalTerm(object)
bset, ok := b.([]interface{})
if !ok {
return false
}
for _, elem := range bset {
if a == elem {
return true
}
}
return false
}
func (t opInternalMember2) SQLString(cfg SQLConfig) string {
if haystack, ok := t.Haystack.(*termObject); ok {
// This is a special case where the haystack is a jsonb array.
// There is a more general way to solve this, but that requires a lot
// more code to cover a lot more cases that we do not care about.
// To handle this more generally we should implement "Array" as a type.
// Then have the `contains` function on the Array type. This would defer
// knowing the element type to the Array and cover more cases without
// having to add more "if" branches here.
// But until we need more cases, our basic type system is ok, and
// this is the only case we need to handle.
if haystack.SQLType(cfg) == VarTypeJsonbTextArray {
return fmt.Sprintf("%s ? %s", haystack.SQLString(cfg), t.Needle.SQLString(cfg))
}
}
return fmt.Sprintf("%s = ANY(%s)", t.Needle.SQLString(cfg), t.Haystack.SQLString(cfg))
}
// Term is a single value in an expression. Terms can be variables or constants.
//
// Eg: "f9d6fb75-b59b-4363-ab6b-ae9d26b679d7", "input.object.org_owner",
// "{"f9d6fb75-b59b-4363-ab6b-ae9d26b679d7"}"
type Term interface {
RegoString() string
SQLString(cfg SQLConfig) string
// Eval will evaluate the term
// Terms can eval to any type. The operator/expression will type check.
EvalTerm(object Object) interface{}
}
type termString struct {
base
Value string
}
func (t termString) EvalTerm(_ Object) interface{} {
return t.Value
}
func (t termString) SQLString(_ SQLConfig) string {
return "'" + t.Value + "'"
}
func (termString) SQLType(_ SQLConfig) TermType {
return VarTypeText
}
// termObject is a variable that can be dereferenced. We count some rego objects
// as single variables, eg: input.object.org_owner. In reality, it is a nested
// object.
// In rego, we can dereference the object with the "." operator, which we can
// handle with regex.
// Or we can dereference the object with the "[]", which we can handle with this
// term type.
type termObject struct {
base
Variables []termVariable
}
func (t termObject) EvalTerm(obj Object) interface{} {
if len(t.Variables) == 0 {
return t.Variables[0].EvalTerm(obj)
}
panic("no nested structures are supported yet")
}
func (t termObject) SQLType(cfg SQLConfig) TermType {
// Without a full type system, let's just assume the type of the first var
// is the resulting type. This is correct for our use case.
// Solving this more generally requires a full type system, which is
// excessive for our mostly static policy.
return t.Variables[0].SQLType(cfg)
}
func (t termObject) SQLString(cfg SQLConfig) string {
if len(t.Variables) == 1 {
return t.Variables[0].SQLString(cfg)
}
// Combine the last 2 variables into 1 variable.
end := t.Variables[len(t.Variables)-1]
before := t.Variables[len(t.Variables)-2]
// Recursively solve the SQLString by removing the last nested reference.
// This continues until we have a single variable.
return termObject{
base: t.base,
Variables: append(
t.Variables[:len(t.Variables)-2],
termVariable{
base: base{
Rego: before.base.Rego + "[" + end.base.Rego + "]",
},
// Convert the end to SQL string. We evaluate each term
// one at a time.
Name: before.Name + "." + end.SQLString(cfg),
},
),
}.SQLString(cfg)
}
type termVariable struct {
base
Name string
}
func (t termVariable) EvalTerm(obj Object) interface{} {
switch t.Name {
case "input.object.org_owner":
return obj.OrgID
case "input.object.owner":
return obj.Owner
case "input.object.type":
return obj.Type
default:
return fmt.Sprintf("'Unknown variable %s'", t.Name)
}
}
func (t termVariable) SQLType(cfg SQLConfig) TermType {
if col := t.ColumnConfig(cfg); col != nil {
return col.Type
}
return VarTypeText
}
func (t termVariable) SQLString(cfg SQLConfig) string {
if col := t.ColumnConfig(cfg); col != nil {
matches := col.RegoMatch.FindStringSubmatch(t.Name)
if len(matches) > 0 {
// This config matches this variable.
replace := make([]string, 0, len(matches)*2)
for i, m := range matches {
replace = append(replace, fmt.Sprintf("$%d", i))
replace = append(replace, m)
}
replacer := strings.NewReplacer(replace...)
return replacer.Replace(col.ColumnSelect)
}
}
return t.Name
}
// ColumnConfig returns the correct SQLColumn settings for the
// term. If there is no configured column, it will return nil.
func (t termVariable) ColumnConfig(cfg SQLConfig) *SQLColumn {
for _, col := range cfg.Variables {
matches := col.RegoMatch.MatchString(t.Name)
if matches {
return &col
}
}
return nil
}
// termSet is a set of unique terms.
type termSet struct {
base
Value []Term
}
func (t termSet) EvalTerm(obj Object) interface{} {
set := make([]interface{}, 0, len(t.Value))
for _, term := range t.Value {
set = append(set, term.EvalTerm(obj))
}
return set
}
func (t termSet) SQLString(cfg SQLConfig) string {
elems := make([]string, 0, len(t.Value))
for _, v := range t.Value {
elems = append(elems, v.SQLString(cfg))
}
return fmt.Sprintf("ARRAY [%s]", strings.Join(elems, ","))
}
type termBoolean struct {
base
Value bool
}
func (t termBoolean) Eval(_ Object) bool {
return t.Value
}
func (t termBoolean) EvalTerm(_ Object) interface{} {
return t.Value
}
func (t termBoolean) SQLString(_ SQLConfig) string {
return strconv.FormatBool(t.Value)
}
func trimQuotes(s string) string {
return strings.Trim(s, "\"")
}
+92
View File
@@ -0,0 +1,92 @@
package rbac
import (
"testing"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego"
"github.com/stretchr/testify/require"
)
func TestCompileQuery(t *testing.T) {
t.Parallel()
opts := ast.ParserOptions{
AllFutureKeywords: true,
}
t.Run("EmptyQuery", func(t *testing.T) {
t.Parallel()
expression, err := Compile(&rego.PartialQueries{
Queries: []ast.Body{
must(ast.ParseBody("")),
},
Support: []*ast.Module{},
})
require.NoError(t, err, "compile empty")
require.Equal(t, "true", expression.RegoString(), "empty query is rego 'true'")
require.Equal(t, "true", expression.SQLString(SQLConfig{}), "empty query is sql 'true'")
})
t.Run("TrueQuery", func(t *testing.T) {
t.Parallel()
expression, err := Compile(&rego.PartialQueries{
Queries: []ast.Body{
must(ast.ParseBody("true")),
},
Support: []*ast.Module{},
})
require.NoError(t, err, "compile")
require.Equal(t, "true", expression.RegoString(), "true query is rego 'true'")
require.Equal(t, "true", expression.SQLString(SQLConfig{}), "true query is sql 'true'")
})
t.Run("ACLIn", func(t *testing.T) {
t.Parallel()
expression, err := Compile(&rego.PartialQueries{
Queries: []ast.Body{
ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list.allUsers`, opts),
},
Support: []*ast.Module{},
})
require.NoError(t, err, "compile")
require.Equal(t, `internal.member_2("*", input.object.acl_group_list.allUsers)`, expression.RegoString(), "convert to internal_member")
require.Equal(t, `group_acl->allUsers ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in")
})
t.Run("Complex", func(t *testing.T) {
t.Parallel()
expression, err := Compile(&rego.PartialQueries{
Queries: []ast.Body{
ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts),
ast.MustParseBodyWithOpts(`input.object.org_owner in {"a", "b", "c"}`, opts),
ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts),
ast.MustParseBodyWithOpts(`"read" in input.object.acl_group_list.allUsers`, opts),
ast.MustParseBodyWithOpts(`"read" in input.object.acl_user_list.me`, opts),
},
Support: []*ast.Module{},
})
require.NoError(t, err, "compile")
require.Equal(t, `(organization_id :: text != '' OR `+
`organization_id :: text = ANY(ARRAY ['a','b','c']) OR `+
`organization_id :: text != '' OR `+
`group_acl->allUsers ? 'read' OR `+
`user_acl->me ? 'read')`,
expression.SQLString(DefaultConfig()), "complex")
})
t.Run("SetDereference", func(t *testing.T) {
t.Parallel()
expression, err := Compile(&rego.PartialQueries{
Queries: []ast.Body{
ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list[input.object.org_owner]`, opts),
},
Support: []*ast.Module{},
})
require.NoError(t, err, "compile")
require.Equal(t, `group_acl->organization_id :: text ? '*'`,
expression.SQLString(DefaultConfig()), "set dereference")
})
}
+10 -10
View File
@@ -528,11 +528,11 @@ func ConvertWorkspaceAgent(agent database.WorkspaceAgent) WorkspaceAgent {
// ConvertWorkspaceApp anonymizes a workspace app.
func ConvertWorkspaceApp(app database.WorkspaceApp) WorkspaceApp {
return WorkspaceApp{
ID: app.ID,
CreatedAt: app.CreatedAt,
AgentID: app.AgentID,
Icon: app.Icon,
RelativePath: app.RelativePath,
ID: app.ID,
CreatedAt: app.CreatedAt,
AgentID: app.AgentID,
Icon: app.Icon,
Subdomain: app.Subdomain,
}
}
@@ -692,11 +692,11 @@ type WorkspaceAgent struct {
}
type WorkspaceApp struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
AgentID uuid.UUID `json:"agent_id"`
Icon string `json:"icon"`
RelativePath bool `json:"relative_path"`
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
AgentID uuid.UUID `json:"agent_id"`
Icon string `json:"icon"`
Subdomain bool `json:"subdomain"`
}
type WorkspaceBuild struct {
+1 -1
View File
@@ -610,7 +610,7 @@ func TestTemplateDAUs(t *testing.T) {
defer func() {
_ = agentCloser.Close()
}()
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
+37 -11
View File
@@ -1018,6 +1018,43 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
}
http.SetCookie(rw, cookie)
// Delete the session token from database.
apiKey := httpmw.APIKey(r)
err := api.Database.DeleteAPIKeyByID(ctx, apiKey.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting API key.",
Detail: err.Error(),
})
return
}
// Deployments should not host app tokens on the same domain as the
// primary deployment. But in the case they are, we should also delete this
// token.
if appCookie, _ := r.Cookie(httpmw.DevURLSessionTokenCookie); appCookie != nil {
appCookieRemove := &http.Cookie{
// MaxAge < 0 means to delete the cookie now.
MaxAge: -1,
Name: httpmw.DevURLSessionTokenCookie,
Path: "/",
Domain: "." + api.AccessURL.Hostname(),
}
http.SetCookie(rw, appCookieRemove)
id, _, err := httpmw.SplitAPIToken(appCookie.Value)
if err == nil {
err = api.Database.DeleteAPIKeyByID(ctx, id)
if err != nil {
// Don't block logout, just log any errors.
api.Logger.Warn(r.Context(), "failed to delete devurl token on logout",
slog.Error(err),
slog.F("id", id),
)
}
}
}
// This code should be removed after Jan 1 2023.
// This code logs out of the old session cookie before we renamed it
// if it is a valid coder token. Otherwise, this old cookie hangs around
@@ -1036,17 +1073,6 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
}
}
// Delete the session token from database.
apiKey := httpmw.APIKey(r)
err = api.Database.DeleteAPIKeyByID(ctx, apiKey.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting API key.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: "Logged out!",
})
+61 -4
View File
@@ -195,6 +195,7 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
})
return
}
go httpapi.Heartbeat(ctx, conn)
_, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageBinary)
defer wsNetConn.Close() // Also closes conn.
@@ -218,6 +219,52 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(ptNetConn, wsNetConn)
}
func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspace := httpmw.WorkspaceParam(r)
workspaceAgent := httpmw.WorkspaceAgentParam(r)
if !api.Authorize(r, rbac.ActionRead, workspace) {
httpapi.ResourceNotFound(rw)
return
}
apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading workspace agent.",
Detail: err.Error(),
})
return
}
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
httpapi.Write(ctx, rw, http.StatusPreconditionRequired, codersdk.Response{
Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected),
})
return
}
agentConn, release, err := api.workspaceAgentCache.Acquire(r, workspaceAgent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error dialing workspace agent.",
Detail: err.Error(),
})
return
}
defer release()
portsResponse, err := agentConn.ListeningPorts(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching listening ports.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, portsResponse)
}
func (api *API) dialWorkspaceAgentTailnet(r *http.Request, agentID uuid.UUID) (*codersdk.AgentConn, error) {
clientConn, serverConn := net.Pipe()
go func() {
@@ -356,6 +403,8 @@ func (api *API) workspaceAgentCoordinate(rw http.ResponseWriter, r *http.Request
})
return
}
go httpapi.Heartbeat(ctx, conn)
ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageBinary)
defer wsNetConn.Close()
@@ -477,6 +526,8 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
})
return
}
go httpapi.Heartbeat(ctx, conn)
defer conn.Close(websocket.StatusNormalClosure, "")
err = api.TailnetCoordinator.ServeClient(websocket.NetConn(ctx, conn, websocket.MessageBinary), uuid.New(), workspaceAgent.ID)
if err != nil {
@@ -489,10 +540,11 @@ 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,
ID: dbApp.ID,
Name: dbApp.Name,
Command: dbApp.Command.String,
Icon: dbApp.Icon,
Subdomain: dbApp.Subdomain,
Healthcheck: codersdk.Healthcheck{
URL: dbApp.HealthcheckUrl,
Interval: dbApp.HealthcheckInterval,
@@ -574,6 +626,8 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator *tailnet.Coordi
case database.Now().Sub(dbAgent.LastConnectedAt.Time) > agentInactiveDisconnectTimeout:
// The connection died without updating the last connected.
workspaceAgent.Status = codersdk.WorkspaceAgentDisconnected
// Client code needs an accurate disconnected at if the agent has been inactive.
workspaceAgent.DisconnectedAt = &dbAgent.LastConnectedAt.Time
case dbAgent.LastConnectedAt.Valid:
// The agent should be assumed connected if it's under inactivity timeouts
// and last connected at has been properly set.
@@ -582,6 +636,7 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator *tailnet.Coordi
return workspaceAgent, nil
}
func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -628,6 +683,8 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques
})
return
}
go httpapi.Heartbeat(ctx, conn)
defer conn.Close(websocket.StatusGoingAway, "")
var lastReport codersdk.AgentStatsReportResponse
+135 -7
View File
@@ -4,7 +4,9 @@ import (
"bufio"
"context"
"encoding/json"
"net"
"runtime"
"strconv"
"strings"
"testing"
"time"
@@ -61,10 +63,10 @@ func TestWorkspaceAgent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Equal(t, tmpDir, resources[0].Agents[0].Directory)
_, err = client.WorkspaceAgent(ctx, resources[0].Agents[0].ID)
require.Equal(t, tmpDir, workspace.LatestBuild.Resources[0].Agents[0].Directory)
_, err = client.WorkspaceAgent(ctx, workspace.LatestBuild.Resources[0].Agents[0].ID)
require.NoError(t, err)
})
}
@@ -119,7 +121,7 @@ func TestWorkspaceAgentListen(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID)
require.NoError(t, err)
defer func() {
@@ -246,7 +248,7 @@ func TestWorkspaceAgentTailnet(t *testing.T) {
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
})
defer agentCloser.Close()
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
@@ -313,8 +315,7 @@ func TestWorkspaceAgentPTY(t *testing.T) {
defer func() {
_ = agentCloser.Close()
}()
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
@@ -364,6 +365,133 @@ func TestWorkspaceAgentPTY(t *testing.T) {
expectLine(matchEchoOutput)
}
func TestWorkspaceAgentListeningPorts(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
coderdPort, err := strconv.Atoi(client.URL.Port())
require.NoError(t, err)
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(),
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
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").Leveled(slog.LevelDebug),
})
t.Cleanup(func() {
_ = agentCloser.Close()
})
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
t.Run("LinuxAndWindows", func(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
t.Skip("only runs on linux and windows")
return
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Create a TCP listener on a random port that we expect to see in the
// response.
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
defer l.Close()
tcpAddr, _ := l.Addr().(*net.TCPAddr)
// List ports and ensure that the port we expect to see is there.
res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID)
require.NoError(t, err)
var (
expected = map[uint16]bool{
// expect the listener we made
uint16(tcpAddr.Port): false,
// expect the coderdtest server
uint16(coderdPort): false,
}
)
for _, port := range res.Ports {
if port.Network == codersdk.ListeningPortNetworkTCP {
if val, ok := expected[port.Port]; ok {
if val {
t.Fatalf("expected to find TCP port %d only once in response", port.Port)
}
}
expected[port.Port] = true
}
}
for port, found := range expected {
if !found {
t.Fatalf("expected to find TCP port %d in response", port)
}
}
// Close the listener and check that the port is no longer in the response.
require.NoError(t, l.Close())
time.Sleep(2 * time.Second) // avoid cache
res, err = client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID)
require.NoError(t, err)
for _, port := range res.Ports {
if port.Network == codersdk.ListeningPortNetworkTCP && port.Port == uint16(tcpAddr.Port) {
t.Fatalf("expected to not find TCP port %d in response", tcpAddr.Port)
}
}
})
t.Run("Darwin", func(t *testing.T) {
t.Parallel()
if runtime.GOOS != "darwin" {
t.Skip("only runs on darwin")
return
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Create a TCP listener on a random port.
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
defer l.Close()
// List ports and ensure that the list is empty because we're on darwin.
res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID)
require.NoError(t, err)
require.Len(t, res.Ports, 0)
})
}
func TestWorkspaceAgentAppHealth(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
+114 -64
View File
@@ -4,12 +4,14 @@ import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"time"
@@ -66,10 +68,9 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
Workspace: workspace,
Agent: agent,
// We do not support port proxying for paths.
AppName: chi.URLParam(r, "workspaceapp"),
Port: 0,
Path: chiPath,
DashboardOnError: true,
AppName: chi.URLParam(r, "workspaceapp"),
Port: 0,
Path: chiPath,
}, rw, r)
}
@@ -162,12 +163,11 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
}
api.proxyWorkspaceApplication(proxyApplication{
Workspace: workspace,
Agent: agent,
AppName: app.AppName,
Port: app.Port,
Path: r.URL.Path,
DashboardOnError: false,
Workspace: workspace,
Agent: agent,
AppName: app.AppName,
Port: app.Port,
Path: r.URL.Path,
}, rw, r)
})).ServeHTTP(rw, r.WithContext(ctx))
})
@@ -175,20 +175,19 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
}
func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
ctx := r.Context()
// Check if the hostname matches the access URL. If it does, the
// user was definitely trying to connect to the dashboard/API.
// Check if the hostname matches the access URL. If it does, the user was
// definitely trying to connect to the dashboard/API.
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), 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.
// 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.
// If there are no periods in the hostname, then it can't be a valid
// application URL.
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
@@ -197,27 +196,34 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
// 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 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
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Could not parse subdomain application URL.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Invalid application URL",
Description: fmt.Sprintf("Could not parse subdomain application URL %q: %s", subdomain, err.Error()),
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
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.
// 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 {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "The server does not accept application requests on this hostname.",
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,
DashboardURL: api.AccessURL.String(),
})
return httpapi.ApplicationURL{}, false
}
@@ -230,12 +236,10 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
// 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 {
ctx := r.Context()
_, ok := httpmw.APIKeyOptional(r)
if ok {
if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) {
// TODO: This should be a static error page.
httpapi.ResourceNotFound(rw)
renderApplicationNotFound(rw, r, api.AccessURL)
return false
}
@@ -249,9 +253,14 @@ func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.R
// Exchange the encoded API key for a real one.
_, apiKey, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Could not decrypt API key. Please remove the query parameter and try again.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
// Retry is disabled because the user needs to remove the query
// parameter before they try again.
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return false
}
@@ -302,6 +311,10 @@ func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.R
// workspaceApplicationAuth is an endpoint on the main router that handles
// redirects from the subdomain handler.
//
// This endpoint is under /api so we don't return the friendly error page here.
// Any errors on this endpoint should be errors that are unlikely to happen
// in production unless the user messes with the URL.
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if api.AppHostname == "" {
@@ -413,11 +426,6 @@ type proxyApplication struct {
Port uint16
// Path must either be empty or have a leading slash.
Path string
// DashboardOnError determines whether or not the dashboard should be
// rendered on error. This should be set for proxy path URLs but not
// hostname based URLs.
DashboardOnError bool
}
func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) {
@@ -439,17 +447,28 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
AgentID: proxyApp.Agent.ID,
Name: proxyApp.AppName,
})
if xerrors.Is(err, sql.ErrNoRows) {
renderApplicationNotFound(rw, r, api.AccessURL)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace application.",
Detail: err.Error(),
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 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Application %s does not have a url.", app.Name),
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),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
@@ -458,13 +477,37 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
appURL, err := url.Parse(internalURL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("App URL %q is invalid.", internalURL),
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: fmt.Sprintf("Application has an invalid URL %q: %s", internalURL, err.Error()),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
// Verify that the port is allowed. See the docs above
// `codersdk.MinimumListeningPort` for more details.
port := appURL.Port()
if port != "" {
portInt, err := strconv.Atoi(port)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("App URL %q has an invalid port %q.", internalURL, port),
Detail: err.Error(),
})
return
}
if portInt < codersdk.MinimumListeningPort {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Application port %d is not permitted. Coder reserves ports less than %d for internal use.", portInt, codersdk.MinimumListeningPort),
})
return
}
}
// Ensure path and query parameter correctness.
if proxyApp.Path == "" {
// Web applications typically request paths relative to the
@@ -489,28 +532,23 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
proxy := httputil.NewSingleHostReverseProxy(appURL)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
if proxyApp.DashboardOnError {
// To pass friendly errors to the frontend, special meta tags are
// overridden in the index.html with the content passed here.
r = r.WithContext(site.WithAPIResponse(ctx, site.APIResponse{
StatusCode: http.StatusBadGateway,
Message: err.Error(),
}))
api.siteHandler.ServeHTTP(w, r)
return
}
httpapi.Write(ctx, w, http.StatusBadGateway, codersdk.Response{
Message: "Failed to proxy request to application.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadGateway,
Title: "Bad Gateway",
Description: "Failed to proxy request to application: " + err.Error(),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
}
conn, release, err := api.workspaceAgentCache.Acquire(r, proxyApp.Agent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to dial workspace agent.",
Detail: err.Error(),
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadGateway,
Title: "Bad Gateway",
Description: "Could not connect to workspace agent: " + err.Error(),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
@@ -648,3 +686,15 @@ func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey strin
return key, payload.APIKey, nil
}
// renderApplicationNotFound should always be used when the app is not found or
// the current user doesn't have permission to access it.
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.",
RetryEnabled: false,
DashboardURL: accessURL.String(),
})
}
+26 -10
View File
@@ -148,7 +148,7 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork
t.Cleanup(func() {
_ = agentCloser.Close()
})
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
// Configure the HTTP client to not follow redirects and to route all
// requests regardless of hostname to the coderd test server.
@@ -258,8 +258,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/fake/", nil)
require.NoError(t, err)
defer resp.Body.Close()
// this is 200 OK because it returns a dashboard page
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
})
}
@@ -529,10 +528,9 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
// Should have an error response.
require.Equal(t, http.StatusNotFound, resp.StatusCode)
var resBody codersdk.Response
err = json.NewDecoder(resp.Body).Decode(&resBody)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, resBody.Message, "does not accept application requests on this hostname")
require.Contains(t, string(body), "does not accept application requests on this hostname")
})
t.Run("InvalidSubdomain", func(t *testing.T) {
@@ -547,12 +545,11 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
require.NoError(t, err)
defer resp.Body.Close()
// Should have an error response.
// Should have a HTML error response.
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
var resBody codersdk.Response
err = json.NewDecoder(resp.Body).Decode(&resBody)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, resBody.Message, "Could not parse subdomain application URL")
require.Contains(t, string(body), "Could not parse subdomain application URL")
})
}
@@ -695,4 +692,23 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
defer resp.Body.Close()
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
})
t.Run("ProxyPortMinimumError", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
port := uint16(codersdk.MinimumListeningPort - 1)
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, port, "/", proxyTestAppQuery), nil)
require.NoError(t, err)
defer resp.Body.Close()
// Should have an error response.
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
var resBody codersdk.Response
err = json.NewDecoder(resp.Body).Decode(&resBody)
require.NoError(t, err)
require.Contains(t, resBody.Message, "Coder reserves ports less than")
})
}
+6 -6
View File
@@ -393,13 +393,13 @@ func TestWorkspaceBuildResources(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.NotNil(t, resources)
require.Len(t, resources, 2)
require.Equal(t, "some", resources[1].Name)
require.Equal(t, "example", resources[1].Type)
require.Len(t, resources[1].Agents, 1)
require.NotNil(t, workspace.LatestBuild.Resources)
require.Len(t, workspace.LatestBuild.Resources, 2)
require.Equal(t, "some", workspace.LatestBuild.Resources[0].Name)
require.Equal(t, "example", workspace.LatestBuild.Resources[1].Type)
require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1)
})
}
-98
View File
@@ -1,98 +0,0 @@
package coderd
import (
"database/sql"
"errors"
"net/http"
"sort"
"github.com/google/uuid"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func (api *API) workspaceResource(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspaceResource := httpmw.WorkspaceResourceParam(r)
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(r, rbac.ActionRead, workspace) {
httpapi.ResourceNotFound(rw)
return
}
job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
if !job.CompletedAt.Valid {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Job hasn't completed!",
})
return
}
agents, err := api.Database.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{workspaceResource.ID})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job agents.",
Detail: err.Error(),
})
return
}
agentIDs := make([]uuid.UUID, 0)
for _, agent := range agents {
agentIDs = append(agentIDs, agent.ID)
}
apps, err := api.Database.GetWorkspaceAppsByAgentIDs(ctx, agentIDs)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agent applications.",
Detail: err.Error(),
})
return
}
apiAgents := make([]codersdk.WorkspaceAgent, 0)
for _, agent := range agents {
dbApps := make([]database.WorkspaceApp, 0)
for _, app := range apps {
if app.AgentID == agent.ID {
dbApps = append(dbApps, app)
}
}
convertedAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, agent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading workspace agent.",
Detail: err.Error(),
})
return
}
apiAgents = append(apiAgents, convertedAgent)
}
sort.Slice(apiAgents, func(i, j int) bool {
return apiAgents[i].Name < apiAgents[j].Name
})
metadata, err := api.Database.GetWorkspaceResourceMetadataByResourceID(ctx, workspaceResource.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resource metadata.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertWorkspaceResource(workspaceResource, apiAgents, metadata))
}
-212
View File
@@ -1,212 +0,0 @@
package coderd_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/testutil"
)
func TestWorkspaceResource(t *testing.T) {
t.Parallel()
t.Run("Get", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "beta",
Type: "example",
Icon: "/icon/server.svg",
Agents: []*proto.Agent{{
Id: "something",
Name: "b",
Auth: &proto.Agent_Token{},
}, {
Id: "another",
Name: "a",
Auth: &proto.Agent_Token{},
}},
}, {
Name: "alpha",
Type: "example",
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
// Ensure it's sorted alphabetically!
require.Equal(t, "alpha", resources[0].Name)
require.Equal(t, "beta", resources[1].Name)
resource, err := client.WorkspaceResource(ctx, resources[1].ID)
require.NoError(t, err)
require.Len(t, resource.Agents, 2)
// Ensure agents are sorted alphabetically!
require.Equal(t, "a", resource.Agents[0].Name)
require.Equal(t, "b", resource.Agents[1].Name)
// Ensure Icon is present
require.Equal(t, "/icon/server.svg", resources[1].Icon)
})
t.Run("Apps", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
apps := []*proto.App{
{
Name: "code-server",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
},
{
Name: "code-server-2",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
Healthcheck: &proto.Healthcheck{
Url: "http://localhost:3000",
Interval: 5,
Threshold: 6,
},
},
}
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "some",
Type: "example",
Agents: []*proto.Agent{{
Id: "something",
Auth: &proto.Agent_Token{},
Apps: apps,
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
resource, err := client.WorkspaceResource(ctx, resources[0].ID)
require.NoError(t, err)
require.Len(t, resource.Agents, 1)
agent := resource.Agents[0]
require.Len(t, agent.Apps, 2)
got := agent.Apps[0]
app := apps[0]
require.EqualValues(t, app.Command, got.Command)
require.EqualValues(t, app.Icon, got.Icon)
require.EqualValues(t, app.Name, got.Name)
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, got.Health)
require.EqualValues(t, "", got.Healthcheck.URL)
require.EqualValues(t, 0, got.Healthcheck.Interval)
require.EqualValues(t, 0, got.Healthcheck.Threshold)
got = agent.Apps[1]
app = apps[1]
require.EqualValues(t, app.Command, got.Command)
require.EqualValues(t, app.Icon, got.Icon)
require.EqualValues(t, app.Name, got.Name)
require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, got.Health)
require.EqualValues(t, app.Healthcheck.Url, got.Healthcheck.URL)
require.EqualValues(t, app.Healthcheck.Interval, got.Healthcheck.Interval)
require.EqualValues(t, app.Healthcheck.Threshold, got.Healthcheck.Threshold)
})
t.Run("Metadata", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "some",
Type: "example",
Agents: []*proto.Agent{{
Id: "something",
Auth: &proto.Agent_Token{},
}},
Metadata: []*proto.Resource_Metadata{{
Key: "foo",
Value: "bar",
}, {
Key: "null",
IsNull: true,
}, {
Key: "empty",
}, {
Key: "secret",
Value: "squirrel",
Sensitive: true,
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
resource, err := client.WorkspaceResource(ctx, resources[0].ID)
require.NoError(t, err)
metadata := resource.Metadata
require.Equal(t, []codersdk.WorkspaceResourceMetadata{{
Key: "empty",
}, {
Key: "foo",
Value: "bar",
}, {
Key: "secret",
Value: "squirrel",
Sensitive: true,
}, {
Key: "type",
Value: "example",
}}, metadata)
})
}
+3 -4
View File
@@ -113,17 +113,16 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
filter.OwnerUsername = ""
}
workspaces, err := api.Database.GetWorkspaces(ctx, filter)
sqlFilter, err := api.HTTPAuth.AuthorizeSQLFilter(r, rbac.ActionRead, rbac.ResourceWorkspace.Type)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspaces.",
Message: "Internal error preparing sql filter.",
Detail: err.Error(),
})
return
}
// Only return workspaces the user can read
workspaces, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, workspaces)
workspaces, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, sqlFilter)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspaces.",
+186
View File
@@ -1265,3 +1265,189 @@ func mustLocation(t *testing.T, location string) *time.Location {
return loc
}
func TestWorkspaceResource(t *testing.T) {
t.Parallel()
t.Run("Get", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "beta",
Type: "example",
Icon: "/icon/server.svg",
Agents: []*proto.Agent{{
Id: "something",
Name: "b",
Auth: &proto.Agent_Token{},
}, {
Id: "another",
Name: "a",
Auth: &proto.Agent_Token{},
}},
}, {
Name: "alpha",
Type: "example",
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Len(t, workspace.LatestBuild.Resources[0].Agents, 2)
// Ensure Icon is present
require.Equal(t, "/icon/server.svg", workspace.LatestBuild.Resources[0].Icon)
})
t.Run("Apps", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
apps := []*proto.App{
{
Name: "code-server",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
},
{
Name: "code-server-2",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
Healthcheck: &proto.Healthcheck{
Url: "http://localhost:3000",
Interval: 5,
Threshold: 6,
},
},
}
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "some",
Type: "example",
Agents: []*proto.Agent{{
Id: "something",
Auth: &proto.Agent_Token{},
Apps: apps,
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1)
agent := workspace.LatestBuild.Resources[0].Agents[0]
require.Len(t, agent.Apps, 2)
got := agent.Apps[0]
app := apps[0]
require.EqualValues(t, app.Command, got.Command)
require.EqualValues(t, app.Icon, got.Icon)
require.EqualValues(t, app.Name, got.Name)
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, got.Health)
require.EqualValues(t, "", got.Healthcheck.URL)
require.EqualValues(t, 0, got.Healthcheck.Interval)
require.EqualValues(t, 0, got.Healthcheck.Threshold)
got = agent.Apps[1]
app = apps[1]
require.EqualValues(t, app.Command, got.Command)
require.EqualValues(t, app.Icon, got.Icon)
require.EqualValues(t, app.Name, got.Name)
require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, got.Health)
require.EqualValues(t, app.Healthcheck.Url, got.Healthcheck.URL)
require.EqualValues(t, app.Healthcheck.Interval, got.Healthcheck.Interval)
require.EqualValues(t, app.Healthcheck.Threshold, got.Healthcheck.Threshold)
})
t.Run("Metadata", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "some",
Type: "example",
Agents: []*proto.Agent{{
Id: "something",
Auth: &proto.Agent_Token{},
}},
Metadata: []*proto.Resource_Metadata{{
Key: "foo",
Value: "bar",
}, {
Key: "null",
IsNull: true,
}, {
Key: "empty",
}, {
Key: "secret",
Value: "squirrel",
Sensitive: true,
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
metadata := workspace.LatestBuild.Resources[0].Metadata
require.Equal(t, []codersdk.WorkspaceResourceMetadata{{
Key: "empty",
}, {
Key: "foo",
Value: "bar",
}, {
Key: "secret",
Value: "squirrel",
Sensitive: true,
}, {
Key: "type",
Value: "example",
}}, metadata)
})
}
+94
View File
@@ -4,7 +4,10 @@ import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"strconv"
"time"
@@ -26,6 +29,20 @@ var (
TailnetSSHPort = 1
TailnetReconnectingPTYPort = 2
TailnetSpeedtestPort = 3
// TailnetStatisticsPort serves a HTTP server with endpoints for gathering
// agent statistics.
TailnetStatisticsPort = 4
// MinimumListeningPort is the minimum port that the listening-ports
// endpoint will return to the client, and the minimum port that is accepted
// by the proxy applications endpoint. Coder consumes ports 1-4 at the
// moment, and we reserve some extra ports for future use. Port 9 and up are
// available for the user.
//
// This is not enforced in the CLI intentionally as we don't really care
// *that* much. The user could bypass this in the CLI by using SSH instead
// anyways.
MinimumListeningPort = 9
)
// ReconnectingPTYRequest is sent from the client to the server
@@ -153,3 +170,80 @@ func (c *AgentConn) DialContext(ctx context.Context, network string, addr string
}
return c.Conn.DialContextTCP(ctx, ipp)
}
func (c *AgentConn) statisticsClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
// Disable keep alives as we're usually only making a single
// request, and this triggers goleak in tests
DisableKeepAlives: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if network != "tcp" {
return nil, xerrors.Errorf("network must be tcp")
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, xerrors.Errorf("split host port %q: %w", addr, err)
}
// Verify that host is TailnetIP and port is
// TailnetStatisticsPort.
if host != TailnetIP.String() || port != strconv.Itoa(TailnetStatisticsPort) {
return nil, xerrors.Errorf("request %q does not appear to be for statistics server", addr)
}
conn, err := c.DialContextTCP(context.Background(), netip.AddrPortFrom(TailnetIP, uint16(TailnetStatisticsPort)))
if err != nil {
return nil, xerrors.Errorf("dial statistics: %w", err)
}
return conn, nil
},
},
}
}
func (c *AgentConn) doStatisticsRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
host := net.JoinHostPort(TailnetIP.String(), strconv.Itoa(TailnetStatisticsPort))
url := fmt.Sprintf("http://%s%s", host, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, xerrors.Errorf("new statistics server request to %q: %w", url, err)
}
return c.statisticsClient().Do(req)
}
type ListeningPortsResponse struct {
// If there are no ports in the list, nothing should be displayed in the UI.
// There must not be a "no ports available" message or anything similar, as
// there will always be no ports displayed on platforms where our port
// detection logic is unsupported.
Ports []ListeningPort `json:"ports"`
}
type ListeningPortNetwork string
const (
ListeningPortNetworkTCP ListeningPortNetwork = "tcp"
)
type ListeningPort struct {
ProcessName string `json:"process_name"` // may be empty
Network ListeningPortNetwork `json:"network"` // only "tcp" at the moment
Port uint16 `json:"port"`
}
func (c *AgentConn) ListeningPorts(ctx context.Context) (ListeningPortsResponse, error) {
res, err := c.doStatisticsRequest(ctx, http.MethodGet, "/api/v0/listening-ports", nil)
if err != nil {
return ListeningPortsResponse{}, xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ListeningPortsResponse{}, readBodyAsError(res)
}
var resp ListeningPortsResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// These cookies are Coder-specific. If a new one is added or changed, the name
// shouldn't be likely to conflict with any user-application set cookies.
// Be sure to strip additional cookies in httpapi.StripCoder Cookies!
// Be sure to strip additional cookies in httpapi.StripCoderCookies!
const (
// SessionTokenKey represents the name of the cookie or query parameter the API key is stored in.
SessionTokenKey = "coder_session_token"
+4 -3
View File
@@ -38,9 +38,10 @@ type Feature struct {
}
type Entitlements struct {
Features map[string]Feature `json:"features"`
Warnings []string `json:"warnings"`
HasLicense bool `json:"has_license"`
Features map[string]Feature `json:"features"`
Warnings []string `json:"warnings"`
HasLicense bool `json:"has_license"`
Experimental bool `json:"experimental"`
}
func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {
+71 -1
View File
@@ -26,6 +26,61 @@ import (
"github.com/coder/retry"
)
type WorkspaceAgentStatus string
const (
WorkspaceAgentConnecting WorkspaceAgentStatus = "connecting"
WorkspaceAgentConnected WorkspaceAgentStatus = "connected"
WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected"
)
type WorkspaceAgent struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FirstConnectedAt *time.Time `json:"first_connected_at,omitempty"`
LastConnectedAt *time.Time `json:"last_connected_at,omitempty"`
DisconnectedAt *time.Time `json:"disconnected_at,omitempty"`
Status WorkspaceAgentStatus `json:"status"`
Name string `json:"name"`
ResourceID uuid.UUID `json:"resource_id"`
InstanceID string `json:"instance_id,omitempty"`
Architecture string `json:"architecture"`
EnvironmentVariables map[string]string `json:"environment_variables"`
OperatingSystem string `json:"operating_system"`
StartupScript string `json:"startup_script,omitempty"`
Directory string `json:"directory,omitempty"`
Version string `json:"version"`
Apps []WorkspaceApp `json:"apps"`
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
DERPLatency map[string]DERPRegion `json:"latency,omitempty"`
}
type WorkspaceAgentResourceMetadata struct {
MemoryTotal uint64 `json:"memory_total"`
DiskTotal uint64 `json:"disk_total"`
CPUCores uint64 `json:"cpu_cores"`
CPUModel string `json:"cpu_model"`
CPUMhz float64 `json:"cpu_mhz"`
}
type DERPRegion struct {
Preferred bool `json:"preferred"`
LatencyMilliseconds float64 `json:"latency_ms"`
}
type WorkspaceAgentInstanceMetadata struct {
JailOrchestrator string `json:"jail_orchestrator"`
OperatingSystem string `json:"operating_system"`
Platform string `json:"platform"`
PlatformFamily string `json:"platform_family"`
KernelVersion string `json:"kernel_version"`
KernelArchitecture string `json:"kernel_architecture"`
Cloud string `json:"cloud"`
Jail string `json:"jail"`
VNC bool `json:"vnc"`
}
// @typescript-ignore GoogleInstanceIdentityToken
type GoogleInstanceIdentityToken struct {
JSONWebToken string `json:"json_web_token" validate:"required"`
@@ -332,7 +387,7 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
CompressionMode: websocket.CompressionDisabled,
})
if isFirst {
if err != nil && res.StatusCode == http.StatusConflict {
if res != nil && res.StatusCode == http.StatusConflict {
first <- readBodyAsError(res)
return
}
@@ -465,6 +520,21 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
}
// WorkspaceAgentListeningPorts returns a list of ports that are currently being
// listened on inside the workspace agent's network namespace.
func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid.UUID) (ListeningPortsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/listening-ports", agentID), nil)
if err != nil {
return ListeningPortsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ListeningPortsResponse{}, readBodyAsError(res)
}
var listeningPorts ListeningPortsResponse
return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts)
}
// Stats records the Agent's network connection statistics for use in
// user-facing metrics and debugging.
// Each member value must be written and read with atomic.
+5
View File
@@ -21,6 +21,11 @@ type WorkspaceApp struct {
// Icon is a relative path or external URL that specifies
// an icon to be displayed in the dashboard.
Icon string `json:"icon,omitempty"`
// Subdomain denotes whether the app should be accessed via a path on the
// `coder server` or via a hostname-based dev URL. If this is set to true
// and there is no app wildcard configured on the server, the app will not
// be accessible in the UI.
Subdomain bool `json:"subdomain"`
// Healthcheck specifies the configuration for checking app health.
Healthcheck Healthcheck `json:"healthcheck"`
Health WorkspaceAppHealth `json:"health"`
+19 -14
View File
@@ -70,6 +70,25 @@ type WorkspaceBuild struct {
Status WorkspaceStatus `json:"status"`
}
type WorkspaceResource struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
JobID uuid.UUID `json:"job_id"`
Transition WorkspaceTransition `json:"workspace_transition"`
Type string `json:"type"`
Name string `json:"name"`
Hide bool `json:"hide"`
Icon string `json:"icon"`
Agents []WorkspaceAgent `json:"agents,omitempty"`
Metadata []WorkspaceResourceMetadata `json:"metadata,omitempty"`
}
type WorkspaceResourceMetadata struct {
Key string `json:"key"`
Value string `json:"value"`
Sensitive bool `json:"sensitive"`
}
// WorkspaceBuild returns a single workspace build for a workspace.
// If history is "", the latest version is returned.
func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) {
@@ -98,20 +117,6 @@ func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error {
return nil
}
// WorkspaceResourcesByBuild returns resources for a workspace build.
func (c *Client) WorkspaceResourcesByBuild(ctx context.Context, build uuid.UUID) ([]WorkspaceResource, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/resources", build), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var resources []WorkspaceResource
return resources, json.NewDecoder(res.Body).Decode(&resources)
}
// WorkspaceBuildLogsBefore returns logs that occurred before a specific time.
func (c *Client) WorkspaceBuildLogsBefore(ctx context.Context, build uuid.UUID, before time.Time) ([]ProvisionerJobLog, error) {
return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), before)
-98
View File
@@ -1,98 +0,0 @@
package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
type WorkspaceAgentStatus string
const (
WorkspaceAgentConnecting WorkspaceAgentStatus = "connecting"
WorkspaceAgentConnected WorkspaceAgentStatus = "connected"
WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected"
)
type WorkspaceResource struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
JobID uuid.UUID `json:"job_id"`
Transition WorkspaceTransition `json:"workspace_transition"`
Type string `json:"type"`
Name string `json:"name"`
Hide bool `json:"hide"`
Icon string `json:"icon"`
Agents []WorkspaceAgent `json:"agents,omitempty"`
Metadata []WorkspaceResourceMetadata `json:"metadata,omitempty"`
}
type WorkspaceResourceMetadata struct {
Key string `json:"key"`
Value string `json:"value"`
Sensitive bool `json:"sensitive"`
}
type DERPRegion struct {
Preferred bool `json:"preferred"`
LatencyMilliseconds float64 `json:"latency_ms"`
}
type WorkspaceAgent struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FirstConnectedAt *time.Time `json:"first_connected_at,omitempty"`
LastConnectedAt *time.Time `json:"last_connected_at,omitempty"`
DisconnectedAt *time.Time `json:"disconnected_at,omitempty"`
Status WorkspaceAgentStatus `json:"status"`
Name string `json:"name"`
ResourceID uuid.UUID `json:"resource_id"`
InstanceID string `json:"instance_id,omitempty"`
Architecture string `json:"architecture"`
EnvironmentVariables map[string]string `json:"environment_variables"`
OperatingSystem string `json:"operating_system"`
StartupScript string `json:"startup_script,omitempty"`
Directory string `json:"directory,omitempty"`
Version string `json:"version"`
Apps []WorkspaceApp `json:"apps"`
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
DERPLatency map[string]DERPRegion `json:"latency,omitempty"`
}
type WorkspaceAgentResourceMetadata struct {
MemoryTotal uint64 `json:"memory_total"`
DiskTotal uint64 `json:"disk_total"`
CPUCores uint64 `json:"cpu_cores"`
CPUModel string `json:"cpu_model"`
CPUMhz float64 `json:"cpu_mhz"`
}
type WorkspaceAgentInstanceMetadata struct {
JailOrchestrator string `json:"jail_orchestrator"`
OperatingSystem string `json:"operating_system"`
Platform string `json:"platform"`
PlatformFamily string `json:"platform_family"`
KernelVersion string `json:"kernel_version"`
KernelArchitecture string `json:"kernel_architecture"`
Cloud string `json:"cloud"`
Jail string `json:"jail"`
VNC bool `json:"vnc"`
}
func (c *Client) WorkspaceResource(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceresources/%s", id), nil)
if err != nil {
return WorkspaceResource{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceResource{}, readBodyAsError(res)
}
var resource WorkspaceResource
return resource, json.NewDecoder(res.Body).Decode(&resource)
}
+4
View File
@@ -77,6 +77,10 @@ Once complete, run `sudo service coder restart` to reboot Coder.
## SCIM
<blockquote class="info">
SCIM is only available in the Enterprise Edition.
</blockquote>
Coder supports user provisioning and deprovisioning via SCIM 2.0 with header
authentication. Upon deactivation, users are [suspended](userd.md#suspend-a-user)
and are not deleted. [Configure](./configure.md) your SCIM application with an
+9 -5
View File
@@ -3,13 +3,17 @@
Coder is free to use and includes some features that are only accessible with a paid license.
Contact sales@coder.com to obtain a license.
Our Enterprise-only features include:
These features are available in the enterprise edition:
- [Audit Logging](./audit-logs.md)
- [Browser Only Connections](../networking.md#browser-only-connections)
- [Quotas](./quotas.md)
- [SCIM](./auth.md#scim)
And we're releasing these imminently:
- Audit Logging
- Browser Only Connections
- Template RBAC
- Quotas
- High Availability
- Template RBAC
## Adding your license key
+25
View File
@@ -0,0 +1,25 @@
# Quotas
<blockquote class="info">
Workspace Quotas are only available in the Enterprise Edition.
</blockquote>
Coder Enterprise admins may define deployment-level quotas to protect against
Denial-of-Service, control costs, and ensure equitable access to cloud resources.
The quota is enabled by either the `CODER_USER_WORKSPACE_QUOTA`
environment variable or the `--user-workspace-quota` flag. For example,
you may limit each user in a deployment to 5 workspaces like so:
```bash
coder server --user-workspace-quota=5
```
Then, when users create workspaces they would see:
<img src="../images/admin/quotas.png"/>
## Up next
- [Enterprise](./enterprise.md)
- [Configuring](./configure.md)
+25
View File
@@ -0,0 +1,25 @@
# Telemetry
Coder collects telemetry data from all free installations. Our users have the right to know what we collect, why we collect it, and how we use the data.
## What we collect
First of all, we do not collect any information that could threaten the security of your installation. For example, we do not collect parameters, environment variables, or passwords.
You can find a full list of the data we collect in the source code [here](https://github.com/coder/coder/blob/main/coderd/telemetry/telemetry.go).
Telemetry can be configured with the `CODER_TELEMETRY=x` environment variable.
For example, telemetry can be disabled with `CODER_TELEMETRY=false`.
`CODER_TELEMETRY=true` is our default level. It includes user email and IP addresses. This information is used in aggregate to understand where our users are and general demographic information. We may reach out to the deployment admin, but will never use these emails for outbound marketing.
`CODER_TELEMETRY=false` disables telemetry altogether.
## How we use telemetry
We use telemetry to build product better and faster. Without telemetry, we don't know which features are most useful, we don't know where users are dropping off in our funnel, and we don't know if our roadmap is aligned with the demographics that really use Coder.
Typical SaaS companies collect far more than what we do with little transparency and configurability. It's hard to imagine our favorite products today existing without their backers having good intelligence.
We've decided the only way we can make our product open-source _and_ build at a fast pace is by collecting usage data as well.
Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

+1 -1
View File
@@ -1 +1 @@
<svg height="1995" viewBox="0 0 161.67 129" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m88.33 0-47.66 41.33-40.67 73h36.67zm6.34 9.67-20.34 57.33 39 49-75.66 13h124z" fill="#0072c6"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill="#035bda" d="M46 40L29.317 10.852 22.808 23.96 34.267 37.24 13 39.655zM13.092 18.182L2 36.896 11.442 35.947 28.033 5.678z"/></svg>

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 203 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z"/></svg>

After

Width:  |  Height:  |  Size: 484 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>

After

Width:  |  Height:  |  Size: 866 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><path d="M19.8,18.4L14,10.67V6.5l1.35-1.69C15.61,4.48,15.38,4,14.96,4H9.04C8.62,4,8.39,4.48,8.65,4.81L10,6.5v4.17L4.2,18.4 C3.71,19.06,4.18,20,5,20h14C19.82,20,20.29,19.06,19.8,18.4z"/></g></svg>

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

+25 -1
View File
@@ -68,6 +68,18 @@
"description": "Setup Coder with Docker",
"icon_path": "./images/icons/docker.svg",
"path": "./quickstart/docker.md"
},
{
"title": "AWS",
"description": "Setup Coder with AWS",
"icon_path": "./images/aws.svg",
"path": "./quickstart/aws.md"
},
{
"title": "Azure",
"description": "Setup Coder with Azure",
"icon_path": "./images/azure.svg",
"path": "./quickstart/azure.md"
}
]
},
@@ -176,7 +188,7 @@
},
{
"title": "Configuration",
"description": "Learn how to configure Coder",
"description": "Learn how to configure Coder.",
"path": "./admin/configure.md",
"icon_path": "./images/icons/toggle_on.svg"
},
@@ -192,11 +204,23 @@
"icon_path": "./images/icons/radar.svg",
"path": "./admin/audit-logs.md"
},
{
"title": "Quotas",
"description": "Learn how to use Workspace Quotas in Coder.",
"icon_path": "./images/icons/dollar.svg",
"path": "./admin/quotas.md"
},
{
"title": "Enterprise",
"description": "Learn how to enable Enterprise features.",
"icon_path": "./images/icons/group.svg",
"path": "./admin/enterprise.md"
},
{
"title": "Telemetry",
"description": "Learn what usage telemetry Coder collects.",
"icon_path": "./images/icons/science.svg",
"path": "./admin/telemetry.md"
}
]
},
+10
View File
@@ -88,6 +88,16 @@ The dashboard (and web apps opened through the dashboard) are served from the
coder server, so they can only be geo-distributed with High Availability mode in
our Enterprise Edition. [Reach out to sales](mailto:sales@coder.com) to learn more.
## Browser-only connections
<blockquote class="info">
Browser-only connections are available in the Enterprise Edition.
</blockquote>
Some Coder deployments must only permit access through the browser to comply
with security policies. In these cases, pass the `--browser-only` flag to
`coder server` or set `CODER_BROWSER_ONLY=true`.
## Troubleshooting
The `coder speedtest <workspace>` command measures user <-> workspace throughput.
+156
View File
@@ -0,0 +1,156 @@
# Amazon Web Services
This quickstart shows you how to set up the Coder server on AWS which will
provision AWS-hosted, Linux workspaces.
## Requirements
This quickstart assumes you are assigned the `AdministratorAccess` policy on AWS.
## Setting Up Security Groups for EC2
To set up a security group for an EC2 instance, navigate to the AWS EC2 Dashboard. In the side panel click `Security Groups`.
In the upper right hand corner, click `Create Security Group`. In the creator screen, name the security group something relevant to the EC2 instance you will create.
<img src="../images/quickstart/aws/aws1.png">
For ease of use, we are going to set this up using the simplest rules.
<img src="../images/quickstart/aws/aws2.png">
Create a new `Inbound Rule` that allows for SSH from your computers IP address.
Youve now created a security group that will be used by your EC2 instance.
## Setting Up Your EC2 instance
On the EC2 dashboard, click `Instances`. This will take you to all the EC2 instances you have created. Click `Launch New Instance`. Name the EC2 instance following the naming convention of your choice.
<img src="../images/quickstart/aws/aws3.png">
For this tutorial, we are going to launch this as the base Ubuntu server.
For the `Create key pair`, we are using ED25519 and `.pem` as we will SSH into the instance later in the tutorial.
<img src="../images/quickstart/aws/aws4.png">
Next, under `Network Settings`, change your Firewall security group to Select existing security group and from the resulting dropdown, select the security group you created in the previous section.
You dont need to change anything else - click `Launch Instance`.
<img src="../images/quickstart/aws/aws5.png">
Itll take a few minutes for it to show up in your existing instances, so take a break as it starts up.
## SSHing into the EC2 instance
If youve launched a new EC2 instance following the previous steps of this tutorial, find the username for the EC2 instance [here](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/connection-prereqs.html). The version launched in the previous steps was a version of the Amazon Linux so the username is `ubuntu`.
You will also need the IP address of the server. Click on the server in the `Instances` dashboard, and copy the IPv4 address
<img src="../images/quickstart/aws/aws6.png">
Now that weve gathered all the information you will need to SSH into your EC2 instance, on a terminal on your local system, navigate to the `.pem` file downloaded when you created the EC2 instance. Run the following command:
```sh
chmod 400 [mykey].pem
```
This adds the required permissions for SSH-ing into an EC2 instance.
Run the following command in terminal, where `mykey` is the security key file, `username` is the username found above for the relevant EC2 operating system image, and the `ip-address` is the IPv4 address for the server:
```sh
ssh -i [mykey].pem username@ip-address
```
Congrats youve SSHd into the server.
## Install Coder
For this instance, we will run Coder as a system service, however you can run Coder a multitude of different ways. You can learn more about those [here](https://coder.com/docs/coder-oss/latest/install).
In the EC2 instance, run the following command to install Coder
```sh
curl -fsSL https://coder.com/install.sh | sh
```
## Run Coder
First, edit the `coder.env` file to enable `CODER_TUNNEL` by setting the value to true with the following command:
```sh
sudo vim /etc/coder.d/coder.env
```
<img src="../images/quickstart/aws/aws7.png">
Exit vim and run the following command to start Coder as a system level service:
```sh
sudo systemctl enable --now coder
```
The following command will get you information about the Coder launch service
```sh
journalctl -u coder.service -b
```
This will return a series of Coder logs, however, embedded in the launch is the URL for accessing Coder.
<img src="../images/quickstart/aws/aws8.png">
In this instance, Coder can be accessed at the url `https://fccad1b6c901511b30cf2cf4fbd0973e.pit-1.try.coder.app`.
Copy the URL and run the following command to create the first user, either on your local machine or in the AWS EC2 instance terminal.
```sh
coder login <url***.try.coder.app>
```
Fill out the prompts. Be sure to save use email and password as these are your admin username and password.
You can now access Coder on your local machine with the relevant `***.try.coder.app` URL and logging in with the username and password.
## Creating and Uploading Your First Template
Run `coder template init` to create your first template. Youll be given a list of possible templates. This tutorial will show you how to set up your Coder instance to create Linux based machines on AWS.
<img src="../images/quickstart/aws/aws9.png">
Press `enter` to select `Develop in Linux` on AWS template. This will return the following:
<img src="../images/quickstart/aws/aws10.png">
Now, we must install the AWS CLI and authorize the template. Follow [these instructions to install the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) and [add your credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html).
Coder runs as a system service under a system user `coder`. The `coder` user will require access to the AWS credentials to initialize the template and provision workspaces.
Run the following command to create a folder for the AWS credentials to live in:
```sh
sudo mkdir /home/coder/.aws
```
Run the following commands to copy the AWS credentials and give the `coder` user access to them:
```sh
sudo cp ~/.aws/credentials /home/coder/.aws/credentials
sudo chown coder:coder /home/coder/.aws/credentials
```
Navigate to the `./aws-linux` folder where you created your template and run the following command to put the template on your Coder instance.
```sh
coder templates create
```
Congrats! You can now navigate to your Coder dashboard and use this Linux on AWS template to create a new workspace!
## Next Steps
- [Port-forward](../networking/port-forwarding.md)
- [Learn more about template configuration](../templates.md)
- [Configure more IDEs](../ides/web-ides.md)
+117
View File
@@ -0,0 +1,117 @@
# Microsoft Azure
This quickstart shows you how to set up the Coder server on Azure which will
provision Azure-hosted Linux workspaces.
## Requirements
This quickstart assumes you have full administrator privileges on Azure.
## Create An Azure VM
From the Azure Portal, navigate to the Virtual Machines Dashboard. Click Create, and select creating a new Azure Virtual machine .
<img src="../images/quickstart/azure/azure1.jpg">
This will bring you to the `Create a virtual machine` page. Select the subscription group of your choice, or create one if necessary.
Next, name the VM something relevant to this project using the naming convention of your choice. Change the region to something more appropriate for your current location. For this tutorial, we will use the base selection of the Ubuntu Gen2 Image and keep the rest of the base settings for this image the same.
<img src="../images/quickstart/azure/azure2.png">
<img src="../images/quickstart/azure/azure3.png">
Up next, under `Inbound port rules` modify the Select `inbound ports` to also take in `HTTPS` and `HTTP`.
<img src="../images/quickstart/azure/azure4.png">
The set up for the image is complete at this stage. Click `Review and Create` - review the information and click `Create`. A popup will appear asking you to download the key pair for the server. Click `Download private key and create resource` and place it into a folder of your choice on your local system.
<img src="../images/quickstart/azure/azure5.png">
Click `Return to create a virtual machine`. Your VM will start up!
<img src="../images/quickstart/azure/azure6.png">
Click `Go to resource` in the virtual machine and copy the public IP address. You will need it to SSH into the virtual machine via your local machine.
Follow [these instructions](https://learn.microsoft.com/en-us/azure/virtual-machines/linux-vm-connect?tabs=Linux) to SSH into the virtual machine. Once on the VM, you can run and install Coder using your method of choice. For the fastest install, we recommend running Coder as a system service.
## Install Coder
For this instance, we will run Coder as a system service, however you can run Coder a multitude of different ways. You can learn more about those [here](https://coder.com/docs/coder-oss/latest/install).
In the Azure VM instance, run the following command to install Coder
```sh
curl -fsSL <https://coder.com/install.sh> | sh
```
## Run Coder
First, edit the `coder.env` file to enable `CODER_TUNNEL` by setting the value to true with the following command:
```sh
sudo vim /etc/coder.d/coder.env
```
<img src="../images/quickstart/azure/azure7.png">
Exit vim and run the following command to start Coder as a system level service:
```sh
sudo systemctl enable --now coder
```
The following command will get you information about the Coder launch service
```sh
journalctl -u coder.service -b
```
This will return a series of logs related to running Coder as a system service. Embedded in the logs is the Coder Access URL.
Copy the URL and run the following command to create the first user, either on your local machine or in the instance terminal.
```sh
coder login <url***.try.coder.app>
```
Fill out the prompts. Be sure to save use email and password as these are your admin username and password.
You can now access Coder on your local machine with the relevant `***.try.coder.app` URL and logging in with the username and password.
## Creating and Uploading Your First Template
First, run `coder template init` to create your first template. Youll be given a list of possible templates to use. This tutorial will show you how to set up your Coder instance to create a Linux based machine on Azure.
<img src="../images/quickstart/azure/azure9.png">
Press `enter` to select `Develop in Linux on Azure` template. This will return the following:
<img src="../images/quickstart/azure/azure10.png">
To get started using the Azure template, install the Azure CLI by following the instructions [here](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux?pivots=apt). Run `az login` and follow the instructions to configure the Azure command line.
Coder is running as a system service, which creates the system user `coder` for handling processes. The Coder user will require access to the Azure credentials to initialize the template.
Run the following commands to copy the Azure credentials and give the `coder` user access to them:
```sh
sudo cp -r ~/.azure /home/coder/.azure
sudo chown -R coder:coder /home/coder/.azure/
```
Navigate to the `./azure-linux` folder where you created your template and run the following command to put the template on your Coder instance.
```sh
coder templates create
```
Congrats! You can now navigate to your Coder dashboard and use this Linux on Azure template to create a new workspace!
## Next Steps
- [Port-forward](../networking/port-forwarding.md)
- [Learn more about template configuration](../templates.md)
- [Configure more IDEs](../ides/web-ides.md)
+1 -1
View File
@@ -61,6 +61,6 @@ Coder with Docker has the following advantages:
## Next Steps
- [Port-forward](../networking/port-forwarding.md.md)
- [Port-forward](../networking/port-forwarding.md)
- [Learn more about template configuration](../templates.md)
- [Configure more IDEs](../ides/web-ides.md)
+2 -2
View File
@@ -177,8 +177,8 @@ runs an additional
[terraform apply](https://www.terraform.io/cli/commands/apply), informing the
Coder provider that the workspace has a new transition state.
This template sample has one persistent resource (docker image) and one ephemeral resource
(docker volume).
This template sample has one persistent resource (docker volume) and one ephemeral resource
(docker image).
```sh
data "coder_workspace" "me" {
+2 -2
View File
@@ -2,7 +2,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = "0.4.15"
version = "0.5.0"
}
docker = {
source = "kreuzwerker/docker"
@@ -44,7 +44,7 @@ resource "coder_app" "code-server" {
icon = "/icon/code.svg"
healthcheck {
url = "http://localhost:1337/healthz"
url = "http://localhost:13337/healthz"
interval = 3
threshold = 10
}

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