Merge branch 'main' into notification-banners

This commit is contained in:
McKayla Washburn 2024-04-30 20:04:01 +00:00
commit 094238634f
78 changed files with 1411 additions and 235 deletions

View File

@ -142,7 +142,7 @@ jobs:
# Check for any typos
- name: Check for typos
uses: crate-ci/typos@v1.20.9
uses: crate-ci/typos@v1.20.10
with:
config: .github/workflows/typos.toml
@ -909,7 +909,8 @@ jobs:
uses: actions/checkout@v4
- name: "Dependency Review"
id: review
uses: actions/dependency-review-action@v4
# TODO: Replace this with the latest release once https://github.com/actions/dependency-review-action/pull/761 is merged.
uses: actions/dependency-review-action@49fbbe0acb033b7824f26d00b005d7d598d76301
with:
allow-licenses: Apache-2.0, BSD-2-Clause, BSD-3-Clause, CC0-1.0, ISC, MIT, MIT-0, MPL-2.0
allow-dependencies-licenses: "pkg:golang/github.com/pelletier/go-toml/v2"

View File

@ -17,6 +17,9 @@
},
{
"pattern": "tailscale.com"
},
{
"pattern": "wireguard.com"
}
],
"aliveStatusCodes": [200, 0]

View File

@ -128,6 +128,13 @@ jobs:
- name: Setup Node
uses: ./.github/actions/setup-node
# Necessary for signing Windows binaries.
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: "zulu"
java-version: "11.0"
- name: Install nsis and zstd
run: sudo apt-get install -y nsis zstd
@ -161,10 +168,32 @@ jobs:
AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }}
- name: Setup Windows EV Signing Certificate
run: |
set -euo pipefail
touch /tmp/ev_cert.pem
chmod 600 /tmp/ev_cert.pem
echo "$EV_SIGNING_CERT" > /tmp/ev_cert.pem
wget https://github.com/ebourg/jsign/releases/download/6.0/jsign-6.0.jar -O /tmp/jsign-6.0.jar
env:
EV_SIGNING_CERT: ${{ secrets.EV_SIGNING_CERT }}
- name: Test migrations from current ref to main
run: |
make test-migrations
# Setup GCloud for signing Windows binaries.
- name: Authenticate to Google Cloud
id: gcloud_auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
token_format: "access_token"
- name: Setup GCloud SDK
uses: "google-github-actions/setup-gcloud@v2"
- name: Build binaries
run: |
set -euo pipefail
@ -179,16 +208,26 @@ jobs:
build/coder_helm_"$version".tgz \
build/provisioner_helm_"$version".tgz
env:
CODER_SIGN_WINDOWS: "1"
CODER_SIGN_DARWIN: "1"
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
AC_APIKEY_ISSUER_ID: ${{ secrets.AC_APIKEY_ISSUER_ID }}
AC_APIKEY_ID: ${{ secrets.AC_APIKEY_ID }}
AC_APIKEY_FILE: /tmp/apple_apikey.p8
EV_KEY: ${{ secrets.EV_KEY }}
EV_KEYSTORE: ${{ secrets.EV_KEYSTORE }}
EV_TSA_URL: ${{ secrets.EV_TSA_URL }}
EV_CERTIFICATE_PATH: /tmp/ev_cert.pem
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
JSIGN_PATH: /tmp/jsign-6.0.jar
- name: Delete Apple Developer certificate and API key
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
- name: Delete Windows EV Signing Cert
run: rm /tmp/ev_cert.pem
- name: Determine base image tag
id: image-base-tag
run: |

View File

@ -200,7 +200,8 @@ endef
# calling this manually.
$(CODER_ALL_BINARIES): go.mod go.sum \
$(GO_SRC_FILES) \
$(shell find ./examples/templates)
$(shell find ./examples/templates) \
site/static/error.html
$(get-mode-os-arch-ext)
if [[ "$$os" != "windows" ]] && [[ "$$ext" != "" ]]; then

View File

@ -10,7 +10,7 @@ import (
"github.com/coder/serpent"
)
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter, defaultOverrides map[string]string) (string, error) {
label := templateVersionParameter.Name
if templateVersionParameter.DisplayName != "" {
label = templateVersionParameter.DisplayName
@ -26,6 +26,11 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n")
}
defaultValue := templateVersionParameter.DefaultValue
if v, ok := defaultOverrides[templateVersionParameter.Name]; ok {
defaultValue = v
}
var err error
var value string
if templateVersionParameter.Type == "list(string)" {
@ -58,7 +63,7 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
var richParameterOption *codersdk.TemplateVersionParameterOption
richParameterOption, err = RichSelect(inv, RichSelectOptions{
Options: templateVersionParameter.Options,
Default: templateVersionParameter.DefaultValue,
Default: defaultValue,
HideSearch: true,
})
if err == nil {
@ -69,7 +74,7 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
} else {
text := "Enter a value"
if !templateVersionParameter.Required {
text += fmt.Sprintf(" (default: %q)", templateVersionParameter.DefaultValue)
text += fmt.Sprintf(" (default: %q)", defaultValue)
}
text += ":"
@ -87,7 +92,7 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
// If they didn't specify anything, use the default value if set.
if len(templateVersionParameter.Options) == 0 && value == "" {
value = templateVersionParameter.DefaultValue
value = defaultValue
}
return value, nil

View File

@ -165,6 +165,11 @@ func (r *RootCmd) create() *serpent.Command {
return xerrors.Errorf("can't parse given parameter values: %w", err)
}
cliBuildParameterDefaults, err := asWorkspaceBuildParameters(parameterFlags.richParameterDefaults)
if err != nil {
return xerrors.Errorf("can't parse given parameter defaults: %w", err)
}
var sourceWorkspaceParameters []codersdk.WorkspaceBuildParameter
if copyParametersFrom != "" {
sourceWorkspaceParameters, err = client.WorkspaceBuildParameters(inv.Context(), sourceWorkspace.LatestBuild.ID)
@ -178,8 +183,9 @@ func (r *RootCmd) create() *serpent.Command {
TemplateVersionID: templateVersionID,
NewWorkspaceName: workspaceName,
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliBuildParameters,
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliBuildParameters,
RichParameterDefaults: cliBuildParameterDefaults,
SourceWorkspaceParameters: sourceWorkspaceParameters,
})
@ -262,6 +268,7 @@ func (r *RootCmd) create() *serpent.Command {
cliui.SkipPromptOption(),
)
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
cmd.Options = append(cmd.Options, parameterFlags.cliParameterDefaults()...)
return cmd
}
@ -276,9 +283,10 @@ type prepWorkspaceBuildArgs struct {
PromptBuildOptions bool
BuildOptions []codersdk.WorkspaceBuildParameter
PromptRichParameters bool
RichParameters []codersdk.WorkspaceBuildParameter
RichParameterFile string
PromptRichParameters bool
RichParameters []codersdk.WorkspaceBuildParameter
RichParameterFile string
RichParameterDefaults []codersdk.WorkspaceBuildParameter
}
// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
@ -311,7 +319,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
WithBuildOptions(args.BuildOptions).
WithPromptRichParameters(args.PromptRichParameters).
WithRichParameters(args.RichParameters).
WithRichParametersFile(parameterFile)
WithRichParametersFile(parameterFile).
WithRichParametersDefaults(args.RichParameterDefaults)
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
if err != nil {
return nil, err

View File

@ -315,6 +315,68 @@ func TestCreateWithRichParameters(t *testing.T) {
<-doneChan
})
t.Run("ParametersDefaults", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
"--parameter-default", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
"--parameter-default", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
"--parameter-default", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
matches := []string{
firstParameterDescription, firstParameterValue,
secondParameterDescription, secondParameterValue,
immutableParameterDescription, immutableParameterValue,
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
defaultValue := matches[i+1]
pty.ExpectMatch(match)
pty.ExpectMatch(`Enter a value (default: "` + defaultValue + `")`)
pty.WriteLine("")
}
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
<-doneChan
// Verify that the expected default values were used.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: "my-workspace",
})
require.NoError(t, err, "can't list available workspaces")
require.Len(t, workspaces.Workspaces, 1)
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParameters, 3)
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
})
t.Run("RichParametersFile", func(t *testing.T) {
t.Parallel()

View File

@ -14,7 +14,6 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/google/uuid"
@ -245,14 +244,8 @@ func (o *scaleTestOutput) write(res harness.Results, stdout io.Writer) error {
// Sync the file to disk if it's a file.
if s, ok := w.(interface{ Sync() error }); ok {
err := s.Sync()
// On Linux, EINVAL is returned when calling fsync on /dev/stdout. We
// can safely ignore this error.
// On macOS, ENOTTY is returned when calling sync on /dev/stdout. We
// can safely ignore this error.
if err != nil && !xerrors.Is(err, syscall.EINVAL) && !xerrors.Is(err, syscall.ENOTTY) {
return xerrors.Errorf("flush output file: %w", err)
}
// Best effort. If we get an error from syncing, just ignore it.
_ = s.Sync()
}
if c != nil {

View File

@ -18,14 +18,16 @@ type workspaceParameterFlags struct {
promptBuildOptions bool
buildOptions []string
richParameterFile string
richParameters []string
richParameterFile string
richParameters []string
richParameterDefaults []string
promptRichParameters bool
}
func (wpf *workspaceParameterFlags) allOptions() []serpent.Option {
options := append(wpf.cliBuildOptions(), wpf.cliParameters()...)
options = append(options, wpf.cliParameterDefaults()...)
return append(options, wpf.alwaysPrompt())
}
@ -62,6 +64,17 @@ func (wpf *workspaceParameterFlags) cliParameters() []serpent.Option {
}
}
func (wpf *workspaceParameterFlags) cliParameterDefaults() []serpent.Option {
return serpent.OptionSet{
serpent.Option{
Flag: "parameter-default",
Env: "CODER_RICH_PARAMETER_DEFAULT",
Description: `Rich parameter default values in the format "name=value".`,
Value: serpent.StringArrayOf(&wpf.richParameterDefaults),
},
}
}
func (wpf *workspaceParameterFlags) alwaysPrompt() serpent.Option {
return serpent.Option{
Flag: "always-prompt",

View File

@ -26,9 +26,10 @@ type ParameterResolver struct {
lastBuildParameters []codersdk.WorkspaceBuildParameter
sourceWorkspaceParameters []codersdk.WorkspaceBuildParameter
richParameters []codersdk.WorkspaceBuildParameter
richParametersFile map[string]string
buildOptions []codersdk.WorkspaceBuildParameter
richParameters []codersdk.WorkspaceBuildParameter
richParametersDefaults map[string]string
richParametersFile map[string]string
buildOptions []codersdk.WorkspaceBuildParameter
promptRichParameters bool
promptBuildOptions bool
@ -59,6 +60,16 @@ func (pr *ParameterResolver) WithRichParametersFile(fileMap map[string]string) *
return pr
}
func (pr *ParameterResolver) WithRichParametersDefaults(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
if pr.richParametersDefaults == nil {
pr.richParametersDefaults = make(map[string]string)
}
for _, p := range params {
pr.richParametersDefaults[p.Name] = p.Value
}
return pr
}
func (pr *ParameterResolver) WithPromptRichParameters(promptRichParameters bool) *ParameterResolver {
pr.promptRichParameters = promptRichParameters
return pr
@ -227,7 +238,7 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
(action == WorkspaceUpdate && tvp.Mutable && tvp.Required) ||
(action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) ||
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) {
parameterValue, err := cliui.RichParameter(inv, tvp)
parameterValue, err := cliui.RichParameter(inv, tvp, pr.richParametersDefaults)
if err != nil {
return nil, err
}

View File

@ -99,7 +99,12 @@ func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client
cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
if err != nil {
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse build options: %w", err)
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse rich parameters: %w", err)
}
cliRichParameterDefaults, err := asWorkspaceBuildParameters(parameterFlags.richParameterDefaults)
if err != nil {
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse rich parameter defaults: %w", err)
}
buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
@ -108,11 +113,12 @@ func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client
NewWorkspaceName: workspace.Name,
LastBuildParameters: lastBuildParameters,
PromptBuildOptions: parameterFlags.promptBuildOptions,
BuildOptions: buildOptions,
PromptRichParameters: parameterFlags.promptRichParameters,
RichParameters: cliRichParameters,
RichParameterFile: parameterFlags.richParameterFile,
PromptBuildOptions: parameterFlags.promptBuildOptions,
BuildOptions: buildOptions,
PromptRichParameters: parameterFlags.promptRichParameters,
RichParameters: cliRichParameters,
RichParameterFile: parameterFlags.richParameterFile,
RichParameterDefaults: cliRichParameterDefaults,
})
if err != nil {
return codersdk.CreateWorkspaceBuildRequest{}, err

View File

@ -20,6 +20,9 @@ OPTIONS:
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".
--parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT
Rich parameter default values in the format "name=value".
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
Specify a file path with values for rich parameters defined in the
template.

View File

@ -19,6 +19,9 @@ OPTIONS:
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".
--parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT
Rich parameter default values in the format "name=value".
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
Specify a file path with values for rich parameters defined in the
template.

View File

@ -60,6 +60,10 @@ OPTIONS:
--support-links struct[[]codersdk.LinkConfig], $CODER_SUPPORT_LINKS
Support links to display in the top right drop down menu.
--terms-of-service-url string, $CODER_TERMS_OF_SERVICE_URL
A URL to an external Terms of Service that must be accepted by users
when logging in.
--update-check bool, $CODER_UPDATE_CHECK (default: false)
Periodically check for new releases of Coder and inform the owner. The
check is performed once per day.

View File

@ -19,6 +19,9 @@ OPTIONS:
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".
--parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT
Rich parameter default values in the format "name=value".
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
Specify a file path with values for rich parameters defined in the
template.

View File

@ -21,6 +21,9 @@ OPTIONS:
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".
--parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT
Rich parameter default values in the format "name=value".
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
Specify a file path with values for rich parameters defined in the
template.

View File

@ -414,6 +414,10 @@ inMemoryDatabase: false
# Type of auth to use when connecting to postgres.
# (default: password, type: enum[password\|awsiamrds])
pgAuth: password
# A URL to an external Terms of Service that must be accepted by users when
# logging in.
# (default: <unset>, type: string)
termsOfServiceURL: ""
# The algorithm to use for generating ssh keys. Accepted values are "ed25519",
# "ecdsa", or "rsa4096".
# (default: ed25519, type: string)

10
coderd/apidoc/docs.go generated
View File

@ -8457,6 +8457,9 @@ const docTemplate = `{
},
"password": {
"$ref": "#/definitions/codersdk.AuthMethod"
},
"terms_of_service_url": {
"type": "string"
}
}
},
@ -8563,6 +8566,10 @@ const docTemplate = `{
"description": "DashboardURL is the URL to hit the deployment's dashboard.\nFor external workspace proxies, this is the coderd they are connected\nto.",
"type": "string"
},
"deployment_id": {
"description": "DeploymentID is the unique identifier for this deployment.",
"type": "string"
},
"external_url": {
"description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.",
"type": "string"
@ -9433,6 +9440,9 @@ const docTemplate = `{
"telemetry": {
"$ref": "#/definitions/codersdk.TelemetryConfig"
},
"terms_of_service_url": {
"type": "string"
},
"tls": {
"$ref": "#/definitions/codersdk.TLSConfig"
},

View File

@ -7526,6 +7526,9 @@
},
"password": {
"$ref": "#/definitions/codersdk.AuthMethod"
},
"terms_of_service_url": {
"type": "string"
}
}
},
@ -7621,6 +7624,10 @@
"description": "DashboardURL is the URL to hit the deployment's dashboard.\nFor external workspace proxies, this is the coderd they are connected\nto.",
"type": "string"
},
"deployment_id": {
"description": "DeploymentID is the unique identifier for this deployment.",
"type": "string"
},
"external_url": {
"description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.",
"type": "string"
@ -8438,6 +8445,9 @@
"telemetry": {
"$ref": "#/definitions/codersdk.TelemetryConfig"
},
"terms_of_service_url": {
"type": "string"
},
"tls": {
"$ref": "#/definitions/codersdk.TLSConfig"
},

View File

@ -64,7 +64,7 @@ func TestValidate(t *testing.T) {
func TestExpiresSoon(t *testing.T) {
t.Parallel()
const threshold = 2
const threshold = 1
for _, c := range azureidentity.Certificates {
block, rest := pem.Decode([]byte(c))

View File

@ -436,6 +436,15 @@ func New(options *Options) *API {
api.AppearanceFetcher.Store(&appearance.DefaultFetcher)
api.PortSharer.Store(&portsharing.DefaultPortSharer)
buildInfo := codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
AgentAPIVersion: AgentAPIVersionREST,
DashboardURL: api.AccessURL.String(),
WorkspaceProxy: false,
UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(),
DeploymentID: api.DeploymentID,
}
api.SiteHandler = site.New(&site.Options{
BinFS: binFS,
BinHashes: binHashes,
@ -444,6 +453,7 @@ func New(options *Options) *API {
OAuth2Configs: oauthConfigs,
DocsURL: options.DeploymentValues.DocsURL.String(),
AppearanceFetcher: &api.AppearanceFetcher,
BuildInfo: buildInfo,
})
api.SiteHandler.Experiments.Store(&experiments)
@ -735,7 +745,7 @@ func New(options *Options) *API {
// All CSP errors will be logged
r.Post("/csp/reports", api.logReportCSPViolations)
r.Get("/buildinfo", buildInfo(api.AccessURL, api.DeploymentValues.CLIUpgradeMessage.String()))
r.Get("/buildinfo", buildInfoHandler(buildInfo))
// /regions is overridden in the enterprise version
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)

View File

@ -2,9 +2,7 @@ package coderd
import (
"net/http"
"net/url"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
@ -68,16 +66,10 @@ func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) {
// @Tags General
// @Success 200 {object} codersdk.BuildInfoResponse
// @Router /buildinfo [get]
func buildInfo(accessURL *url.URL, upgradeMessage string) http.HandlerFunc {
func buildInfoHandler(resp codersdk.BuildInfoResponse) http.HandlerFunc {
// This is in a handler so that we can generate API docs info.
return func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
AgentAPIVersion: AgentAPIVersionREST,
DashboardURL: accessURL.String(),
WorkspaceProxy: false,
UpgradeMessage: upgradeMessage,
})
httpapi.Write(r.Context(), rw, http.StatusOK, resp)
}
}

View File

@ -563,6 +563,9 @@ func applyDefaultsToConfig(config *codersdk.ExternalAuthConfig) {
// Dynamic defaults
switch codersdk.EnhancedExternalAuthProvider(config.Type) {
case codersdk.EnhancedExternalAuthProviderGitLab:
copyDefaultSettings(config, gitlabDefaults(config))
return
case codersdk.EnhancedExternalAuthProviderBitBucketServer:
copyDefaultSettings(config, bitbucketServerDefaults(config))
return
@ -667,6 +670,44 @@ func bitbucketServerDefaults(config *codersdk.ExternalAuthConfig) codersdk.Exter
return defaults
}
// gitlabDefaults returns a static config if using the gitlab cloud offering.
// The values are dynamic if using a self-hosted gitlab.
// When the decision is not obvious, just defer to the cloud defaults.
// Any user specific fields will override this if provided.
func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
cloud := codersdk.ExternalAuthConfig{
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"},
}
if config.AuthURL == "" || config.AuthURL == cloud.AuthURL {
return cloud
}
au, err := url.Parse(config.AuthURL)
if err != nil || au.Host == "gitlab.com" {
// If the AuthURL is not a valid URL or is using the cloud,
// use the cloud static defaults.
return cloud
}
// At this point, assume it is self-hosted and use the AuthURL
return codersdk.ExternalAuthConfig{
DisplayName: cloud.DisplayName,
Scopes: cloud.Scopes,
DisplayIcon: cloud.DisplayIcon,
AuthURL: au.ResolveReference(&url.URL{Path: "/oauth/authorize"}).String(),
TokenURL: au.ResolveReference(&url.URL{Path: "/oauth/token"}).String(),
ValidateURL: au.ResolveReference(&url.URL{Path: "/oauth/token/info"}).String(),
Regex: fmt.Sprintf(`^(https?://)?%s(/.*)?$`, strings.ReplaceAll(au.Host, ".", `\.`)),
}
}
func jfrogArtifactoryDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
defaults := codersdk.ExternalAuthConfig{
DisplayName: "JFrog Artifactory",
@ -789,15 +830,6 @@ var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.External
Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
Scopes: []string{"account", "repository:write"},
},
codersdk.EnhancedExternalAuthProviderGitLab: {
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"},
},
codersdk.EnhancedExternalAuthProviderGitHub: {
AuthURL: xgithub.Endpoint.AuthURL,
TokenURL: xgithub.Endpoint.TokenURL,

View File

@ -8,6 +8,112 @@ import (
"github.com/coder/coder/v2/codersdk"
)
func TestGitlabDefaults(t *testing.T) {
t.Parallel()
// The default cloud setup. Copying this here as hard coded
// values.
cloud := codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ID: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"},
}
tests := []struct {
name string
input codersdk.ExternalAuthConfig
expected codersdk.ExternalAuthConfig
mutateExpected func(*codersdk.ExternalAuthConfig)
}{
// Cloud
{
name: "OnlyType",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
},
expected: cloud,
},
{
// If someone was to manually configure the gitlab cli.
name: "CloudByConfig",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize",
},
expected: cloud,
},
{
// Changing some of the defaults of the cloud option
name: "CloudWithChanges",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
// Adding an extra query param intentionally to break simple
// string comparisons.
AuthURL: "https://gitlab.com/oauth/authorize?foo=bar",
DisplayName: "custom",
Regex: ".*",
},
expected: cloud,
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://gitlab.com/oauth/authorize?foo=bar"
config.DisplayName = "custom"
config.Regex = ".*"
},
},
// Self-hosted
{
// Dynamically figures out the Validate, Token, and Regex fields.
name: "SelfHostedOnlyAuthURL",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.company.org/oauth/authorize?foo=bar",
},
expected: cloud,
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://gitlab.company.org/oauth/authorize?foo=bar"
config.ValidateURL = "https://gitlab.company.org/oauth/token/info"
config.TokenURL = "https://gitlab.company.org/oauth/token"
config.Regex = `^(https?://)?gitlab\.company\.org(/.*)?$`
},
},
{
// Strange values
name: "RandomValues",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://auth.com/auth",
ValidateURL: "https://validate.com/validate",
TokenURL: "https://token.com/token",
Regex: "random",
},
expected: cloud,
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://auth.com/auth"
config.ValidateURL = "https://validate.com/validate"
config.TokenURL = "https://token.com/token"
config.Regex = `random`
},
},
}
for _, c := range tests {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
applyDefaultsToConfig(&c.input)
if c.mutateExpected != nil {
c.mutateExpected(&c.expected)
}
require.Equal(t, c.input, c.expected)
})
}
}
func Test_bitbucketServerConfigDefaults(t *testing.T) {
t.Parallel()

View File

@ -4,11 +4,14 @@ import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
@ -23,6 +26,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/site"
"github.com/coder/coder/v2/tailnet"
@ -341,7 +345,7 @@ type ServerTailnet struct {
totalConns *prometheus.CounterVec
}
func (s *ServerTailnet) ReverseProxy(targetURL, dashboardURL *url.URL, agentID uuid.UUID) *httputil.ReverseProxy {
func (s *ServerTailnet) ReverseProxy(targetURL, dashboardURL *url.URL, agentID uuid.UUID, app appurl.ApplicationURL, wildcardHostname string) *httputil.ReverseProxy {
// Rewrite the targetURL's Host to point to the agent's IP. This is
// necessary because due to TCP connection caching, each agent needs to be
// addressed invidivually. Otherwise, all connections get dialed as
@ -351,13 +355,46 @@ func (s *ServerTailnet) ReverseProxy(targetURL, dashboardURL *url.URL, agentID u
tgt.Host = net.JoinHostPort(tailnet.IPFromUUID(agentID).String(), port)
proxy := httputil.NewSingleHostReverseProxy(&tgt)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, theErr error) {
var (
desc = "Failed to proxy request to application: " + theErr.Error()
additionalInfo = ""
additionalButtonLink = ""
additionalButtonText = ""
)
var tlsError tls.RecordHeaderError
if (errors.As(theErr, &tlsError) && tlsError.Msg == "first record does not look like a TLS handshake") ||
errors.Is(theErr, http.ErrSchemeMismatch) {
// If the error is due to an HTTP/HTTPS mismatch, we can provide a
// more helpful error message with redirect buttons.
switchURL := url.URL{
Scheme: dashboardURL.Scheme,
}
_, protocol, isPort := app.PortInfo()
if isPort {
targetProtocol := "https"
if protocol == "https" {
targetProtocol = "http"
}
app = app.ChangePortProtocol(targetProtocol)
switchURL.Host = fmt.Sprintf("%s%s", app.String(), strings.TrimPrefix(wildcardHostname, "*"))
additionalButtonLink = switchURL.String()
additionalButtonText = fmt.Sprintf("Switch to %s", strings.ToUpper(targetProtocol))
additionalInfo += fmt.Sprintf("This error seems to be due to an app protocol mismatch, try switching to %s.", strings.ToUpper(targetProtocol))
}
}
site.RenderStaticErrorPage(w, r, site.ErrorPageData{
Status: http.StatusBadGateway,
Title: "Bad Gateway",
Description: "Failed to proxy request to application: " + err.Error(),
RetryEnabled: true,
DashboardURL: dashboardURL.String(),
Status: http.StatusBadGateway,
Title: "Bad Gateway",
Description: desc,
RetryEnabled: true,
DashboardURL: dashboardURL.String(),
AdditionalInfo: additionalInfo,
AdditionalButtonLink: additionalButtonLink,
AdditionalButtonText: additionalButtonText,
})
}
proxy.Director = s.director(agentID, proxy.Director)

View File

@ -26,6 +26,7 @@ import (
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/tailnet"
@ -81,7 +82,7 @@ func TestServerTailnet_ReverseProxy_ProxyEnv(t *testing.T) {
u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", workspacesdk.AgentHTTPAPIServerPort))
require.NoError(t, err)
rp := serverTailnet.ReverseProxy(u, u, a.id)
rp := serverTailnet.ReverseProxy(u, u, a.id, appurl.ApplicationURL{}, "")
rw := httptest.NewRecorder()
req := httptest.NewRequest(
@ -112,7 +113,7 @@ func TestServerTailnet_ReverseProxy(t *testing.T) {
u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", workspacesdk.AgentHTTPAPIServerPort))
require.NoError(t, err)
rp := serverTailnet.ReverseProxy(u, u, a.id)
rp := serverTailnet.ReverseProxy(u, u, a.id, appurl.ApplicationURL{}, "")
rw := httptest.NewRecorder()
req := httptest.NewRequest(
@ -143,7 +144,7 @@ func TestServerTailnet_ReverseProxy(t *testing.T) {
u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", workspacesdk.AgentHTTPAPIServerPort))
require.NoError(t, err)
rp := serverTailnet.ReverseProxy(u, u, a.id)
rp := serverTailnet.ReverseProxy(u, u, a.id, appurl.ApplicationURL{}, "")
rw := httptest.NewRecorder()
req := httptest.NewRequest(
@ -177,7 +178,7 @@ func TestServerTailnet_ReverseProxy(t *testing.T) {
u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", workspacesdk.AgentHTTPAPIServerPort))
require.NoError(t, err)
rp := serverTailnet.ReverseProxy(u, u, a.id)
rp := serverTailnet.ReverseProxy(u, u, a.id, appurl.ApplicationURL{}, "")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
require.NoError(t, err)
@ -222,7 +223,7 @@ func TestServerTailnet_ReverseProxy(t *testing.T) {
u, err := url.Parse("http://127.0.0.1" + port)
require.NoError(t, err)
rp := serverTailnet.ReverseProxy(u, u, a.id)
rp := serverTailnet.ReverseProxy(u, u, a.id, appurl.ApplicationURL{}, "")
for i := 0; i < 5; i++ {
rw := httptest.NewRecorder()
@ -279,7 +280,7 @@ func TestServerTailnet_ReverseProxy(t *testing.T) {
require.NoError(t, err)
for i, ag := range agents {
rp := serverTailnet.ReverseProxy(u, u, ag.id)
rp := serverTailnet.ReverseProxy(u, u, ag.id, appurl.ApplicationURL{}, "")
rw := httptest.NewRecorder()
req := httptest.NewRequest(
@ -317,7 +318,7 @@ func TestServerTailnet_ReverseProxy(t *testing.T) {
uri, err := url.Parse(s.URL)
require.NoError(t, err)
rp := serverTailnet.ReverseProxy(uri, uri, a.id)
rp := serverTailnet.ReverseProxy(uri, uri, a.id, appurl.ApplicationURL{}, "")
rw := httptest.NewRecorder()
req := httptest.NewRequest(
@ -347,7 +348,7 @@ func TestServerTailnet_ReverseProxy(t *testing.T) {
u, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", workspacesdk.AgentHTTPAPIServerPort))
require.NoError(t, err)
rp := serverTailnet.ReverseProxy(u, u, a.id)
rp := serverTailnet.ReverseProxy(u, u, a.id, appurl.ApplicationURL{}, "")
rw := httptest.NewRecorder()
req := httptest.NewRequest(

View File

@ -472,6 +472,7 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
}
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AuthMethods{
TermsOfServiceURL: api.DeploymentValues.TermsOfServiceURL.Value(),
Password: codersdk.AuthMethod{
Enabled: !api.DeploymentValues.DisablePasswordAuth.Value(),
},

View File

@ -5,6 +5,7 @@ import (
"net"
"net/url"
"regexp"
"strconv"
"strings"
"golang.org/x/xerrors"
@ -83,6 +84,55 @@ func (a ApplicationURL) Path() string {
return fmt.Sprintf("/@%s/%s.%s/apps/%s", a.Username, a.WorkspaceName, a.AgentName, a.AppSlugOrPort)
}
// PortInfo returns the port, protocol, and whether the AppSlugOrPort is a port or not.
func (a ApplicationURL) PortInfo() (uint, string, bool) {
var (
port uint64
protocol string
isPort bool
err error
)
if strings.HasSuffix(a.AppSlugOrPort, "s") {
trimmed := strings.TrimSuffix(a.AppSlugOrPort, "s")
port, err = strconv.ParseUint(trimmed, 10, 16)
if err == nil {
protocol = "https"
isPort = true
}
} else {
port, err = strconv.ParseUint(a.AppSlugOrPort, 10, 16)
if err == nil {
protocol = "http"
isPort = true
}
}
return uint(port), protocol, isPort
}
func (a *ApplicationURL) ChangePortProtocol(target string) ApplicationURL {
newAppURL := *a
port, protocol, isPort := a.PortInfo()
if !isPort {
return newAppURL
}
if target == protocol {
return newAppURL
}
if target == "https" {
newAppURL.AppSlugOrPort = fmt.Sprintf("%ds", port)
}
if target == "http" {
newAppURL.AppSlugOrPort = fmt.Sprintf("%d", port)
}
return newAppURL
}
// ParseSubdomainAppURL parses an ApplicationURL from the given subdomain. If
// the subdomain is not a valid application URL hostname, returns a non-nil
// error. If the hostname is not a subdomain of the given base hostname, returns

View File

@ -124,6 +124,16 @@ func TestParseSubdomainAppURL(t *testing.T) {
Username: "user",
},
},
{
Name: "Port--Agent--Workspace--User",
Subdomain: "8080s--agent--workspace--user",
Expected: appurl.ApplicationURL{
AppSlugOrPort: "8080s",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
},
{
Name: "HyphenatedNames",
Subdomain: "app-slug--agent-name--workspace-name--user-name",

View File

@ -66,7 +66,7 @@ var nonCanonicalHeaders = map[string]string{
type AgentProvider interface {
// ReverseProxy returns an httputil.ReverseProxy for proxying HTTP requests
// to the specified agent.
ReverseProxy(targetURL, dashboardURL *url.URL, agentID uuid.UUID) *httputil.ReverseProxy
ReverseProxy(targetURL, dashboardURL *url.URL, agentID uuid.UUID, app appurl.ApplicationURL, wildcardHost string) *httputil.ReverseProxy
// AgentConn returns a new connection to the specified agent.
AgentConn(ctx context.Context, agentID uuid.UUID) (_ *workspacesdk.AgentConn, release func(), _ error)
@ -314,7 +314,7 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
return
}
s.proxyWorkspaceApp(rw, r, *token, chiPath)
s.proxyWorkspaceApp(rw, r, *token, chiPath, appurl.ApplicationURL{})
}
// HandleSubdomain handles subdomain-based application proxy requests (aka.
@ -417,7 +417,7 @@ func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler)
if !ok {
return
}
s.proxyWorkspaceApp(rw, r, *token, r.URL.Path)
s.proxyWorkspaceApp(rw, r, *token, r.URL.Path, app)
})).ServeHTTP(rw, r.WithContext(ctx))
})
}
@ -476,7 +476,7 @@ func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next htt
return app, true
}
func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appToken SignedToken, path string) {
func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appToken SignedToken, path string, app appurl.ApplicationURL) {
ctx := r.Context()
// Filter IP headers from untrusted origins.
@ -545,8 +545,12 @@ func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appT
r.URL.Path = path
appURL.RawQuery = ""
_, protocol, isPort := app.PortInfo()
if isPort {
appURL.Scheme = protocol
}
proxy := s.AgentProvider.ReverseProxy(appURL, s.DashboardURL, appToken.AgentID)
proxy := s.AgentProvider.ReverseProxy(appURL, s.DashboardURL, appToken.AgentID, app, s.Hostname)
proxy.ModifyResponse = func(r *http.Response) error {
r.Header.Del(httpmw.AccessControlAllowOriginHeader)

View File

@ -200,6 +200,7 @@ type DeploymentValues struct {
AllowWorkspaceRenames serpent.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"`
Healthcheck HealthcheckConfig `json:"healthcheck,omitempty" typescript:",notnull"`
CLIUpgradeMessage serpent.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"`
TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"`
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"`
@ -1683,6 +1684,14 @@ when required by your organization's security policy.`,
YAML: "secureAuthCookie",
Annotations: serpent.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Terms of Service URL",
Description: "A URL to an external Terms of Service that must be accepted by users when logging in.",
Flag: "terms-of-service-url",
Env: "CODER_TERMS_OF_SERVICE_URL",
YAML: "termsOfServiceURL",
Value: &c.TermsOfServiceURL,
},
{
Name: "Strict-Transport-Security",
Description: "Controls if the 'Strict-Transport-Security' header is set on all static file responses. " +
@ -2149,6 +2158,9 @@ type BuildInfoResponse struct {
// UpgradeMessage is the message displayed to users when an outdated client
// is detected.
UpgradeMessage string `json:"upgrade_message"`
// DeploymentID is the unique identifier for this deployment.
DeploymentID string `json:"deployment_id"`
}
type WorkspaceProxyBuildInfo struct {

View File

@ -209,9 +209,10 @@ type CreateOrganizationRequest struct {
// AuthMethods contains authentication method information like whether they are enabled or not or custom text, etc.
type AuthMethods struct {
Password AuthMethod `json:"password"`
Github AuthMethod `json:"github"`
OIDC OIDCAuthMethod `json:"oidc"`
TermsOfServiceURL string `json:"terms_of_service_url,omitempty"`
Password AuthMethod `json:"password"`
Github AuthMethod `json:"github"`
OIDC OIDCAuthMethod `json:"oidc"`
}
type AuthMethod struct {

View File

@ -89,7 +89,7 @@ GitHub Enterprise requires the following environment variables:
```env
CODER_EXTERNAL_AUTH_0_ID="primary-github"
CODER_EXTERNAL_AUTH_0_TYPE=github-enterprise
CODER_EXTERNAL_AUTH_0_TYPE=github
CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx
CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx
CODER_EXTERNAL_AUTH_0_VALIDATE_URL="https://github.example.com/api/v3/user"
@ -102,8 +102,8 @@ CODER_EXTERNAL_AUTH_0_TOKEN_URL="https://github.example.com/login/oauth/access_t
Bitbucket Server requires the following environment variables:
```env
CODER_EXTERNAL_AUTH_0_TYPE="bitbucket-server"
CODER_EXTERNAL_AUTH_0_ID=bitbucket
CODER_EXTERNAL_AUTH_0_ID="primary-bitbucket-server"
CODER_EXTERNAL_AUTH_0_TYPE=bitbucket-server
CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxx
CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxx
CODER_EXTERNAL_AUTH_0_AUTH_URL=https://bitbucket.domain.com/rest/oauth2/latest/authorize

View File

@ -21,6 +21,7 @@ Learn more about [Coders architecture](../about/architecture.md) and our
| Kubernetes (GKE) | 2 cores | 4 GB | 1 | db-custom-1-3840 | 500 | 20 | 500 simulated | `v0.27.2` | Jul 27, 2023 |
| Kubernetes (GKE) | 2 cores | 8 GB | 2 | db-custom-2-7680 | 1000 | 20 | 1000 simulated | `v2.2.1` | Oct 9, 2023 |
| Kubernetes (GKE) | 4 cores | 16 GB | 2 | db-custom-8-30720 | 2000 | 50 | 2000 simulated | `v2.8.4` | Feb 28, 2024 |
| Kubernetes (GKE) | 2 cores | 4 GB | 2 | db-custom-2-7680 | 1000 | 50 | 1000 simulated | `v2.10.2` | Apr 26, 2024 |
> Note: a simulated connection reads and writes random data at 40KB/s per
> connection.

2
docs/api/general.md generated
View File

@ -55,6 +55,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \
{
"agent_api_version": "string",
"dashboard_url": "string",
"deployment_id": "string",
"external_url": "string",
"upgrade_message": "string",
"version": "string",
@ -377,6 +378,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"user": {}
}
},
"terms_of_service_url": "string",
"tls": {
"address": {
"host": "string",

19
docs/api/schemas.md generated
View File

@ -1048,17 +1048,19 @@
},
"password": {
"enabled": true
}
},
"terms_of_service_url": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------- | -------------------------------------------------- | -------- | ------------ | ----------- |
| `github` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | |
| `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| Name | Type | Required | Restrictions | Description |
| ---------------------- | -------------------------------------------------- | -------- | ------------ | ----------- |
| `github` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | |
| `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| `terms_of_service_url` | string | false | | |
## codersdk.AuthorizationCheck
@ -1202,6 +1204,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
{
"agent_api_version": "string",
"dashboard_url": "string",
"deployment_id": "string",
"external_url": "string",
"upgrade_message": "string",
"version": "string",
@ -1215,6 +1218,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| ------------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent_api_version` | string | false | | Agent api version is the current version of the Agent API (back versions MAY still be supported). |
| `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. |
| `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. |
| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. |
| `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. |
| `version` | string | false | | Version returns the semantic version of the build. |
@ -2128,6 +2132,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"user": {}
}
},
"terms_of_service_url": "string",
"tls": {
"address": {
"host": "string",
@ -2500,6 +2505,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"user": {}
}
},
"terms_of_service_url": "string",
"tls": {
"address": {
"host": "string",
@ -2588,6 +2594,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | |
| `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | |
| `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | |
| `terms_of_service_url` | string | false | | |
| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | |
| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | |
| `update_check` | boolean | false | | |

3
docs/api/users.md generated
View File

@ -157,7 +157,8 @@ curl -X GET http://coder-server:8080/api/v2/users/authmethods \
},
"password": {
"enabled": true
}
},
"terms_of_service_url": "string"
}
```

9
docs/cli/create.md generated
View File

@ -91,3 +91,12 @@ Rich parameter value in the format "name=value".
| Environment | <code>$CODER_RICH_PARAMETER_FILE</code> |
Specify a file path with values for rich parameters defined in the template.
### --parameter-default
| | |
| ----------- | ------------------------------------------ |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_RICH_PARAMETER_DEFAULT</code> |
Rich parameter default values in the format "name=value".

9
docs/cli/restart.md generated
View File

@ -55,6 +55,15 @@ Rich parameter value in the format "name=value".
Specify a file path with values for rich parameters defined in the template.
### --parameter-default
| | |
| ----------- | ------------------------------------------ |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_RICH_PARAMETER_DEFAULT</code> |
Rich parameter default values in the format "name=value".
### --always-prompt
| | |

10
docs/cli/server.md generated
View File

@ -928,6 +928,16 @@ Type of auth to use when connecting to postgres.
Controls if the 'Secure' property is set on browser session cookies.
### --terms-of-service-url
| | |
| ----------- | ---------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_TERMS_OF_SERVICE_URL</code> |
| YAML | <code>termsOfServiceURL</code> |
A URL to an external Terms of Service that must be accepted by users when logging in.
### --strict-transport-security
| | |

9
docs/cli/start.md generated
View File

@ -55,6 +55,15 @@ Rich parameter value in the format "name=value".
Specify a file path with values for rich parameters defined in the template.
### --parameter-default
| | |
| ----------- | ------------------------------------------ |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_RICH_PARAMETER_DEFAULT</code> |
Rich parameter default values in the format "name=value".
### --always-prompt
| | |

9
docs/cli/update.md generated
View File

@ -53,6 +53,15 @@ Rich parameter value in the format "name=value".
Specify a file path with values for rich parameters defined in the template.
### --parameter-default
| | |
| ----------- | ------------------------------------------ |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_RICH_PARAMETER_DEFAULT</code> |
Rich parameter default values in the format "name=value".
### --always-prompt
| | |

View File

@ -24,6 +24,11 @@ alternate installation methods (e.g. standalone binaries, system packages).
## Windows
> **Important:** If you plan to use the built-in PostgreSQL database, you will
> need to ensure that the
> [Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version)
> is installed.
Use [GitHub releases](https://github.com/coder/coder/releases) to download the
Windows installer (`.msi`) or standalone binary (`.exe`).

View File

@ -157,7 +157,6 @@ resource "coder_agent" "dev" {
os = "linux"
dir = local.repo_dir
env = {
GITHUB_TOKEN : data.coder_external_auth.github.access_token,
OIDC_TOKEN : data.coder_workspace.me.owner_oidc_access_token,
}
startup_script_behavior = "blocking"

View File

@ -61,6 +61,10 @@ OPTIONS:
--support-links struct[[]codersdk.LinkConfig], $CODER_SUPPORT_LINKS
Support links to display in the top right drop down menu.
--terms-of-service-url string, $CODER_TERMS_OF_SERVICE_URL
A URL to an external Terms of Service that must be accepted by users
when logging in.
--update-check bool, $CODER_UPDATE_CHECK (default: false)
Periodically check for new releases of Coder and inform the owner. The
check is performed once per day.

View File

@ -147,13 +147,14 @@ func NewWithAPI(t *testing.T, options *Options) (
}
type LicenseOptions struct {
AccountType string
AccountID string
Trial bool
AllFeatures bool
GraceAt time.Time
ExpiresAt time.Time
Features license.Features
AccountType string
AccountID string
DeploymentIDs []string
Trial bool
AllFeatures bool
GraceAt time.Time
ExpiresAt time.Time
Features license.Features
}
// AddFullLicense generates a license with all features enabled.
@ -190,6 +191,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
LicenseExpires: jwt.NewNumericDate(options.GraceAt),
AccountType: options.AccountType,
AccountID: options.AccountID,
DeploymentIDs: options.DeploymentIDs,
Trial: options.Trial,
Version: license.CurrentVersion,
AllFeatures: options.AllFeatures,

View File

@ -257,14 +257,16 @@ type Claims struct {
// the end of the grace period (identical to LicenseExpires if there is no grace period).
// The reason we use the standard claim for the end of the grace period is that we want JWT
// processing libraries to consider the token "valid" until then.
LicenseExpires *jwt.NumericDate `json:"license_expires,omitempty"`
AccountType string `json:"account_type,omitempty"`
AccountID string `json:"account_id,omitempty"`
Trial bool `json:"trial"`
AllFeatures bool `json:"all_features"`
Version uint64 `json:"version"`
Features Features `json:"features"`
RequireTelemetry bool `json:"require_telemetry,omitempty"`
LicenseExpires *jwt.NumericDate `json:"license_expires,omitempty"`
AccountType string `json:"account_type,omitempty"`
AccountID string `json:"account_id,omitempty"`
// DeploymentIDs enforces the license can only be used on a set of deployments.
DeploymentIDs []string `json:"deployment_ids,omitempty"`
Trial bool `json:"trial"`
AllFeatures bool `json:"all_features"`
Version uint64 `json:"version"`
Features Features `json:"features"`
RequireTelemetry bool `json:"require_telemetry,omitempty"`
}
// ParseRaw consumes a license and returns the claims.

View File

@ -10,6 +10,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
@ -120,6 +121,15 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
// old licenses with a uuid.
id = uuid.New()
}
if len(claims.DeploymentIDs) > 0 && !slices.Contains(claims.DeploymentIDs, api.AGPL.DeploymentID) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "License cannot be used on this deployment!",
Detail: fmt.Sprintf("The provided license is locked to the following deployments: %q. "+
"Your deployment identifier is %q. Please contact sales.", claims.DeploymentIDs, api.AGPL.DeploymentID),
})
return
}
dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{
UploadedAt: dbtime.Now(),
JWT: addLicense.License,

View File

@ -5,6 +5,7 @@ import (
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
@ -36,6 +37,22 @@ func TestPostLicense(t *testing.T) {
assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog])
})
t.Run("InvalidDeploymentID", func(t *testing.T) {
t.Parallel()
// The generated deployment will start out with a different deployment ID.
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
license := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
DeploymentIDs: []string{uuid.NewString()},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: license,
})
errResp := &codersdk.Error{}
require.ErrorAs(t, err, &errResp)
require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
require.Contains(t, errResp.Message, "License cannot be used on this deployment!")
})
t.Run("Unauthorized", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})

15
go.mod
View File

@ -42,7 +42,7 @@ replace github.com/dlclark/regexp2 => github.com/dlclark/regexp2 v1.7.0
// There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here:
// https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20240401202854-d329bbdb530d
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20240430122706-f586aa40c0c1
// Fixes a race-condition in coder/wgtunnel.
// Upstream PR: https://github.com/WireGuard/wireguard-go/pull/85
@ -144,7 +144,7 @@ require (
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
github.com/imulab/go-scim/pkg/v2 v2.2.0
github.com/jedib0t/go-pretty/v6 v6.5.0
github.com/jmoiron/sqlx v1.3.5
github.com/jmoiron/sqlx v1.4.0
github.com/justinas/nosurf v1.1.1
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
@ -153,7 +153,7 @@ require (
github.com/mattn/go-isatty v0.0.20
github.com/mitchellh/go-wordwrap v1.0.1
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c
github.com/moby/moby v26.0.1+incompatible
github.com/moby/moby v26.1.0+incompatible
github.com/muesli/termenv v0.15.2
github.com/open-policy-agent/opa v0.58.0
github.com/ory/dockertest/v3 v3.10.0
@ -200,7 +200,7 @@ require (
golang.org/x/tools v0.20.0
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1
google.golang.org/api v0.175.0
google.golang.org/api v0.176.1
google.golang.org/grpc v1.63.2
google.golang.org/protobuf v1.33.0
gopkg.in/DataDog/dd-trace-go.v1 v1.61.0
@ -222,8 +222,8 @@ require (
)
require (
cloud.google.com/go/auth v0.2.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.1 // indirect
cloud.google.com/go/auth v0.3.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
github.com/DataDog/go-libddwaf/v2 v2.3.1 // indirect
github.com/alecthomas/chroma/v2 v2.13.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 // indirect
@ -237,7 +237,7 @@ require (
require (
cloud.google.com/go/logging v1.9.0 // indirect
cloud.google.com/go/longrunning v0.5.5 // indirect
filippo.io/edwards25519 v1.0.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/DataDog/appsec-internal-go v1.4.1 // indirect
github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0 // indirect
@ -307,7 +307,6 @@ require (
github.com/go-openapi/swag v0.22.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/go-test/deep v1.0.8 // indirect
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect
github.com/gobwas/glob v0.2.3 // indirect

39
go.sum
View File

@ -1,18 +1,18 @@
cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 h1:KHblWIE/KHOwQ6lEbMZt6YpcGve2FEZ1sDtrW1Am5UI=
cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/auth v0.2.2 h1:gmxNJs4YZYcw6YvKRtVBaF2fyUE6UrWPyzU8jHvYfmI=
cloud.google.com/go/auth v0.2.2/go.mod h1:2bDNJWtWziDT3Pu1URxHHbkHE/BbOCuyUiKIGcNvafo=
cloud.google.com/go/auth/oauth2adapt v0.2.1 h1:VSPmMmUlT8CkIZ2PzD9AlLN+R3+D1clXMWHHa6vG/Ag=
cloud.google.com/go/auth/oauth2adapt v0.2.1/go.mod h1:tOdK/k+D2e4GEwfBRA48dKNQiDsqIXxLh7VU319eV0g=
cloud.google.com/go/auth v0.3.0 h1:PRyzEpGfx/Z9e8+lHsbkoUVXD0gnu4MNmm7Gp8TQNIs=
cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/logging v1.9.0 h1:iEIOXFO9EmSiTjDmfpbRjOxECO7R8C7b8IXUGOj7xZw=
cloud.google.com/go/logging v1.9.0/go.mod h1:1Io0vnZv4onoUnsVUQY3HZ3Igb1nBchky0A0y7BBBhE=
cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg=
cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s=
filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek=
filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/AlecAivazis/survey/v2 v2.3.5 h1:A8cYupsAZkjaUmhtTYv3sSqc7LO5mp1XDfqe5E/9wRQ=
@ -217,8 +217,8 @@ github.com/coder/serpent v0.7.0 h1:zGpD2GlF3lKIVkMjNGKbkip88qzd5r/TRcc30X/SrT0=
github.com/coder/serpent v0.7.0/go.mod h1:REkJ5ZFHQUWFTPLExhXYZ1CaHFjxvGNRlLXLdsI08YA=
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw=
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ=
github.com/coder/tailscale v1.1.1-0.20240401202854-d329bbdb530d h1:IMvBC1GrCIiZFxpOYRQacZtdjnmsdWNAMilPz+kvdG4=
github.com/coder/tailscale v1.1.1-0.20240401202854-d329bbdb530d/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4=
github.com/coder/tailscale v1.1.1-0.20240430122706-f586aa40c0c1 h1:cu5YyztCk8FAOvP1sR3b/2D96EfvBAzKUu0B/Cqhg8U=
github.com/coder/tailscale v1.1.1-0.20240430122706-f586aa40c0c1/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4=
github.com/coder/terraform-provider-coder v0.21.0 h1:aoDmFJULYZpS66EIAZuNY4IxElaDkdRaWMWp9ScD2R8=
github.com/coder/terraform-provider-coder v0.21.0/go.mod h1:hqxd15PJeftFBOnGBBPN6WfNQutZtnahwwPeV8U6TyA=
github.com/coder/wgtunnel v0.1.13-0.20231127054351-578bfff9b92a h1:KhR9LUVllMZ+e9lhubZ1HNrtJDgH5YLoTvpKwmrGag4=
@ -380,9 +380,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
@ -582,8 +581,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk=
@ -638,7 +637,6 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@ -670,9 +668,8 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
@ -703,8 +700,8 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/moby v26.0.1+incompatible h1:vCKs/AM0lLYnMxFwpf8ycsOekPPPcGn0s0Iczqv3/ec=
github.com/moby/moby v26.0.1+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
github.com/moby/moby v26.1.0+incompatible h1:mjepCwMH0KpCgPvrXjqqyCeTCHgzO7p9TwZ2nQMI2qU=
github.com/moby/moby v26.1.0+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -1158,8 +1155,8 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvY
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/api v0.175.0 h1:9bMDh10V9cBuU8N45Wlc3cKkItfqMRV0Fi8UscLEtbY=
google.golang.org/api v0.175.0/go.mod h1:Rra+ltKu14pps/4xTycZfobMgLpbosoaaL7c+SEMrO8=
google.golang.org/api v0.176.1 h1:DJSXnV6An+NhJ1J+GWtoF2nHEuqB1VNoTfnIbjNvwD4=
google.golang.org/api v0.176.1/go.mod h1:j2MaSDYcvYV1lkZ1+SMW4IeF90SrEyFA+tluDYWRrFg=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=

View File

@ -35,6 +35,7 @@ os="${GOOS:-linux}"
arch="${GOARCH:-amd64}"
slim="${CODER_SLIM_BUILD:-0}"
sign_darwin="${CODER_SIGN_DARWIN:-0}"
sign_windows="${CODER_SIGN_WINDOWS:-0}"
output_path=""
agpl="${CODER_BUILD_AGPL:-0}"
boringcrypto=${CODER_BUILD_BORINGCRYPTO:-0}
@ -106,6 +107,11 @@ if [[ "$sign_darwin" == 1 ]]; then
requiredenvs AC_CERTIFICATE_FILE AC_CERTIFICATE_PASSWORD_FILE
fi
if [[ "$sign_windows" == 1 ]]; then
dependencies java
requiredenvs JSIGN_PATH EV_KEYSTORE EV_KEY EV_CERTIFICATE_PATH EV_TSA_URL GCLOUD_ACCESS_TOKEN
fi
ldflags=(
-X "'github.com/coder/coder/v2/buildinfo.tag=$version'"
)
@ -176,4 +182,8 @@ if [[ "$sign_darwin" == 1 ]] && [[ "$os" == "darwin" ]]; then
execrelative ./sign_darwin.sh "$output_path" 1>&2
fi
if [[ "$sign_windows" == 1 ]] && [[ "$os" == "windows" ]]; then
execrelative ./sign_windows.sh "$output_path" 1>&2
fi
echo "$output_path"

View File

@ -4,6 +4,9 @@
# [#pr-deployments](https://codercom.slack.com/archives/C05DNE982E8) Slack channel
set -euo pipefail
# shellcheck source=scripts/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cdroot
# default settings
dryRun=false
@ -64,6 +67,9 @@ if $confirm; then
fi
fi
# Authenticate gh CLI
gh_auth
# get branch name and pr number
branchName=$(gh pr view --json headRefName | jq -r .headRefName)
prNumber=$(gh pr view --json number | jq -r .number)

View File

@ -130,6 +130,22 @@ requiredenvs() {
fi
}
gh_auth() {
local fail=0
if [[ "${CODER:-}" == "true" ]]; then
if ! output=$(coder external-auth access-token github 2>&1); then
log "ERROR: Could not authenticate with GitHub."
log "$output"
fail=1
else
GITHUB_TOKEN=$(coder external-auth access-token github)
export GITHUB_TOKEN
fi
else
log "Please authenticate gh CLI by running 'gh auth login'"
fi
}
# maybedryrun prints the given program and flags, and then, if the first
# argument is 0, executes it. The reason the first argument should be 0 is that
# it is expected that you have a dry_run variable in your script that is set to

View File

@ -53,6 +53,10 @@ script_check=1
mainline=1
channel=mainline
# These values will be used for any PRs created.
pr_review_assignee=${CODER_RELEASE_PR_REVIEW_ASSIGNEE:-@me}
pr_review_reviewer=${CODER_RELEASE_PR_REVIEW_REVIEWER:-bpmct,stirby}
args="$(getopt -o h -l dry-run,help,ref:,mainline,stable,major,minor,patch,force,ignore-script-out-of-date -- "$@")"
eval set -- "$args"
while true; do
@ -109,6 +113,9 @@ done
# Check dependencies.
dependencies gh jq sort
# Authenticate gh CLI
gh_auth
if [[ -z $increment ]]; then
# Default to patch versions.
increment="patch"
@ -294,7 +301,7 @@ log "Release tags for ${new_version} created successfully and pushed to ${remote
log
# Write to a tmp file for ease of debugging.
release_json_file=$(mktemp -t coder-release.json)
release_json_file=$(mktemp -t coder-release.json.XXXXXX)
log "Writing release JSON to ${release_json_file}"
jq -n \
--argjson dry_run "${dry_run}" \
@ -310,6 +317,49 @@ maybedryrun "${dry_run}" cat "${release_json_file}" |
log
log "Release workflow started successfully!"
log
log "Would you like for me to create a pull request for you to automatically bump the version numbers in the docs?"
while [[ ! ${create_pr:-} =~ ^[YyNn]$ ]]; do
read -p "Create PR? (y/n) " -n 1 -r create_pr
log
done
if [[ ${create_pr} =~ ^[Yy]$ ]]; then
pr_branch=autoversion/${new_version}
title="docs: bump ${channel} version to ${new_version}"
body="This PR was automatically created by the [release script](https://github.com/coder/coder/blob/main/scripts/release.sh).
Please review the changes and merge if they look good and the release is complete.
You can follow the release progress [here](https://github.com/coder/coder/actions/workflows/release.yaml) and view the published release [here](https://github.com/coder/coder/releases/tag/${new_version}) (once complete)."
log
log "Creating branch \"${pr_branch}\" and updating versions..."
create_pr_stash=0
if ! git diff --quiet --exit-code -- docs; then
maybedryrun "${dry_run}" git stash push --message "scripts/release.sh: autostash (autoversion)" -- docs
create_pr_stash=1
fi
maybedryrun "${dry_run}" git checkout -b "${pr_branch}" "${remote}/${branch}"
execrelative go run ./release autoversion --channel "${channel}" "${new_version}" --dry-run
maybedryrun "${dry_run}" git add docs
maybedryrun "${dry_run}" git commit -m "${title}"
# Return to previous branch.
maybedryrun "${dry_run}" git checkout -
if ((create_pr_stash)); then
maybedryrun "${dry_run}" git stash pop
fi
log "Creating pull request..."
maybedryrun "${dry_run}" gh pr create \
--assignee "${pr_review_assignee}" \
--reviewer "${pr_review_reviewer}" \
--base "${branch}" \
--head "${pr_branch}" \
--title "${title}" \
--body "${body}"
fi
if ((dry_run)); then
# We can't watch the release.yaml workflow if we're in dry-run mode.
exit 0

View File

@ -31,6 +31,9 @@ range="${from_ref}..${to_ref}"
# Check dependencies.
dependencies gh
# Authenticate gh CLI
gh_auth
COMMIT_METADATA_BREAKING=0
declare -a COMMIT_METADATA_COMMITS
declare -A COMMIT_METADATA_TITLE COMMIT_METADATA_HUMAN_TITLE COMMIT_METADATA_CATEGORY COMMIT_METADATA_AUTHORS
@ -145,7 +148,6 @@ main() {
done
} | sort -t- -n | head -n 1
)
# Get the labels for all PRs merged since the last release, this is
# inexact based on date, so a few PRs part of the previous release may
# be included.

View File

@ -57,6 +57,9 @@ done
# Check dependencies.
dependencies gh sort
# Authticate gh CLI
gh_auth
if [[ -z ${old_version} ]]; then
error "No old version specified"
fi

View File

@ -381,12 +381,18 @@ func (r *releaseCommand) autoversionFile(ctx context.Context, file, channel, ver
}
}
if matchRe != nil {
// Apply matchRe and find the group named "version", then replace it with the new version.
// Utilize the index where the match was found to replace the correct part. The only
// match group is the version.
// Apply matchRe and find the group named "version", then replace it
// with the new version.
if match := matchRe.FindStringSubmatchIndex(line); match != nil {
logger.Info(ctx, "updating version number", "line_number", i+1, "match", match)
lines[i] = line[:match[2]] + version + line[match[3]:]
vg := matchRe.SubexpIndex("version")
if vg == -1 {
logger.Error(ctx, "version group not found in match", "num_subexp", matchRe.NumSubexp(), "subexp_names", matchRe.SubexpNames(), "match", match)
return xerrors.Errorf("bug: version group not found in match")
}
start := match[vg*2]
end := match[vg*2+1]
logger.Info(ctx, "updating version number", "line_number", i+1, "match_start", start, "match_end", end, "old_version", line[start:end])
lines[i] = line[:start] + version + line[end:]
matchRe = nil
break
}

View File

@ -71,6 +71,9 @@ done
# Check dependencies
dependencies gh
# Authenticate gh CLI
gh_auth
# Remove the "v" prefix.
version="${version#v}"
if [[ "$version" == "" ]]; then

View File

@ -191,7 +191,7 @@ fi
# Ensure the ref is in the release branch.
branch_contains_ref=$(git branch --contains "${ref}" --list "${release_branch}" --format='%(refname)')
if [[ -z $branch_contains_ref ]]; then
if ((!dry_run)) && [[ -z $branch_contains_ref ]]; then
error "Provided ref (${ref_name}) is not in the required release branch (${release_branch})."
fi

View File

@ -0,0 +1,14 @@
# Some documentation
1. Run the following command to install the chart in your cluster.
For the **mainline** Coder release:
<!-- autoversion(mainline): "--version [version] # trailing comment!" -->
```shell
helm install coder coder-v2/coder \
--namespace coder \
--values values.yaml \
--version 2.10.0 # trailing comment!
```

View File

@ -0,0 +1,14 @@
# Some documentation
1. Run the following command to install the chart in your cluster.
For the **mainline** Coder release:
<!-- autoversion(mainline): "--version [version] # trailing comment!" -->
```shell
helm install coder coder-v2/coder \
--namespace coder \
--values values.yaml \
--version 2.11.1 # trailing comment!
```

35
scripts/sign_windows.sh Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# This script signs the provided windows binary with an Extended Validation
# code signing certificate.
#
# Usage: ./sign_windows.sh path/to/binary
#
# On success, the input file will be signed using the EV cert.
#
# Depends on the jsign utility (and thus Java). Requires the following environment variables
# to be set:
# - $JSIGN_PATH: The path to the jsign jar.
# - $EV_KEYSTORE: The name of the keyring containing the private key
# - $EV_KEY: The name of the key.
# - $EV_CERTIFICATE_PATH: The path to the certificate.
# - $EV_TSA_URL: The url of the timestamp server to use.
set -euo pipefail
# shellcheck source=scripts/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
# Check dependencies
dependencies java
requiredenvs JSIGN_PATH EV_KEYSTORE EV_KEY EV_CERTIFICATE_PATH EV_TSA_URL GCLOUD_ACCESS_TOKEN
java -jar "$JSIGN_PATH" \
--storetype GOOGLECLOUD \
--storepass "$GCLOUD_ACCESS_TOKEN" \
--keystore "$EV_KEYSTORE" \
--alias "$EV_KEY" \
--certfile "$EV_CERTIFICATE_PATH" \
--tsmode RFC3161 \
--tsaurl "$EV_TSA_URL" \
"$@" \
1>&2

View File

@ -34,7 +34,6 @@ import (
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/appearance"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
@ -78,6 +77,7 @@ type Options struct {
SiteFS fs.FS
OAuth2Configs *httpmw.OAuth2Configs
DocsURL string
BuildInfo codersdk.BuildInfoResponse
AppearanceFetcher *atomic.Pointer[appearance.Fetcher]
}
@ -149,12 +149,7 @@ func New(opts *Options) *Handler {
// static files.
OnlyFiles(opts.SiteFS))),
)
buildInfo := codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
}
buildInfoResponse, err := json.Marshal(buildInfo)
buildInfoResponse, err := json.Marshal(opts.BuildInfo)
if err != nil {
panic("failed to marshal build info: " + err.Error())
}
@ -786,12 +781,15 @@ func extractBin(dest string, r io.Reader) (numExtracted int, err error) {
type ErrorPageData struct {
Status int
// HideStatus will remove the status code from the page.
HideStatus bool
Title string
Description string
RetryEnabled bool
DashboardURL string
Warnings []string
HideStatus bool
Title string
Description string
RetryEnabled bool
DashboardURL string
Warnings []string
AdditionalInfo string
AdditionalButtonLink string
AdditionalButtonText string
RenderDescriptionMarkdown bool
}

View File

@ -125,6 +125,7 @@ export interface AuthMethod {
// From codersdk/users.go
export interface AuthMethods {
readonly terms_of_service_url?: string;
readonly password: AuthMethod;
readonly github: AuthMethod;
readonly oidc: OIDCAuthMethod;
@ -172,6 +173,7 @@ export interface BuildInfoResponse {
readonly workspace_proxy: boolean;
readonly agent_api_version: string;
readonly upgrade_message: string;
readonly deployment_id: string;
}
// From codersdk/insights.go
@ -453,6 +455,7 @@ export interface DeploymentValues {
readonly allow_workspace_renames?: boolean;
readonly healthcheck?: HealthcheckConfig;
readonly cli_upgrade_message?: string;
readonly terms_of_service_url?: string;
readonly config?: string;
readonly write_config?: boolean;
readonly address?: string;

View File

@ -0,0 +1,31 @@
import FormHelperText, {
type FormHelperTextProps,
} from "@mui/material/FormHelperText";
import type { ComponentProps, FC } from "react";
import { Stack } from "components/Stack/Stack";
/**
* Use these components as the label in FormControlLabel when implementing radio
* buttons, checkboxes, or switches to ensure proper styling.
*/
export const StackLabel: FC<ComponentProps<typeof Stack>> = (props) => {
return (
<Stack
spacing={0.5}
css={{ paddingLeft: 12, fontWeight: 500 }}
{...props}
/>
);
};
export const StackLabelHelperText: FC<FormHelperTextProps> = (props) => {
return (
<FormHelperText
css={{
marginTop: 0,
}}
{...props}
/>
);
};

View File

@ -12,9 +12,11 @@ import LaunchIcon from "@mui/icons-material/LaunchOutlined";
import DocsIcon from "@mui/icons-material/MenuBook";
import Divider from "@mui/material/Divider";
import MenuItem from "@mui/material/MenuItem";
import Tooltip from "@mui/material/Tooltip";
import type { FC } from "react";
import { Link } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated";
import { CopyButton } from "components/CopyButton/CopyButton";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { usePopover } from "components/Popover/Popover";
import { Stack } from "components/Stack/Stack";
@ -161,15 +163,51 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
<Divider css={{ marginBottom: "0 !important" }} />
<Stack css={styles.info} spacing={0}>
<a
title="Browse Source Code"
css={[styles.footerText, styles.buildInfo]}
href={buildInfo?.external_url}
target="_blank"
rel="noreferrer"
>
{buildInfo?.version} <LaunchIcon />
</a>
<Tooltip title="Coder Version">
<a
title="Browse Source Code"
css={[styles.footerText, styles.buildInfo]}
href={buildInfo?.external_url}
target="_blank"
rel="noreferrer"
>
{buildInfo?.version} <LaunchIcon />
</a>
</Tooltip>
{Boolean(buildInfo?.deployment_id) && (
<div
css={css`
font-size: 12px;
display: flex;
align-items: center;
`}
>
<Tooltip title="Deployment Identifier">
<div
css={css`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`}
>
{buildInfo?.deployment_id}
</div>
</Tooltip>
<CopyButton
text={buildInfo!.deployment_id}
buttonStyles={css`
width: 16px;
height: 16px;
svg {
width: 16px;
height: 16px;
}
`}
/>
</div>
)}
<div css={styles.footerText}>{Language.copyrightText}</div>
</Stack>

View File

@ -3,6 +3,8 @@ import {
MockAuthMethodsAll,
MockAuthMethodsExternal,
MockAuthMethodsPasswordOnly,
MockAuthMethodsPasswordTermsOfService,
MockBuildInfo,
mockApiError,
} from "testHelpers/entities";
import { LoginPageView } from "./LoginPageView";
@ -10,6 +12,9 @@ import { LoginPageView } from "./LoginPageView";
const meta: Meta<typeof LoginPageView> = {
title: "pages/LoginPage",
component: LoginPageView,
args: {
buildInfo: MockBuildInfo,
},
};
export default meta;
@ -33,6 +38,12 @@ export const WithAllAuthMethods: Story = {
},
};
export const WithTermsOfService: Story = {
args: {
authMethods: MockAuthMethodsPasswordTermsOfService,
},
};
export const AuthError: Story = {
args: {
error: mockApiError({
@ -53,6 +64,7 @@ export const ExternalAuthError: Story = {
export const LoadingAuthMethods: Story = {
args: {
isLoading: true,
authMethods: undefined,
},
};

View File

@ -1,5 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { FC } from "react";
import Button from "@mui/material/Button";
import { type FC, useState } from "react";
import { useLocation } from "react-router-dom";
import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
import { CoderIcon } from "components/Icons/CoderIcon";
@ -7,6 +8,7 @@ import { Loader } from "components/Loader/Loader";
import { getApplicationName, getLogoURL } from "utils/appearance";
import { retrieveRedirect } from "utils/redirect";
import { SignInForm } from "./SignInForm";
import { TermsOfServiceLink } from "./TermsOfServiceLink";
export interface LoginPageViewProps {
authMethods: AuthMethods | undefined;
@ -49,12 +51,21 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
<CoderIcon fill="white" opacity={1} css={styles.icon} />
);
const [tosAccepted, setTosAccepted] = useState(false);
const tosAcceptanceRequired =
authMethods?.terms_of_service_url && !tosAccepted;
return (
<div css={styles.root}>
<div css={styles.container}>
{applicationLogo}
{isLoading ? (
<Loader />
) : tosAcceptanceRequired ? (
<>
<TermsOfServiceLink url={authMethods.terms_of_service_url} />
<Button onClick={() => setTosAccepted(true)}>I agree</Button>
</>
) : (
<SignInForm
authMethods={authMethods}
@ -70,6 +81,12 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
Copyright &copy; {new Date().getFullYear()} Coder Technologies, Inc.
</div>
<div>{buildInfo?.version}</div>
{tosAccepted && (
<TermsOfServiceLink
url={authMethods?.terms_of_service_url}
css={{ fontSize: 12 }}
/>
)}
</footer>
</div>
</div>

View File

@ -110,7 +110,7 @@ export const SignInForm: FC<SignInFormProps> = ({
{passwordEnabled && oAuthEnabled && (
<div css={styles.divider}>
<div css={styles.dividerLine} />
<div css={styles.dividerLabel}>Or</div>
<div css={styles.dividerLabel}>or</div>
<div css={styles.dividerLine} />
</div>
)}

View File

@ -0,0 +1,28 @@
import LaunchIcon from "@mui/icons-material/LaunchOutlined";
import Link from "@mui/material/Link";
import type { FC } from "react";
interface TermsOfServiceLinkProps {
className?: string;
url?: string;
}
export const TermsOfServiceLink: FC<TermsOfServiceLinkProps> = ({
className,
url,
}) => {
return (
<div css={{ paddingTop: 12, fontSize: 16 }} className={className}>
By continuing, you agree to the{" "}
<Link
css={{ fontWeight: 500, textWrap: "nowrap" }}
href={url}
target="_blank"
rel="noreferrer"
>
Terms of Service&nbsp;
<LaunchIcon css={{ fontSize: 12 }} />
</Link>
</div>
);
};

View File

@ -21,12 +21,7 @@ export const TemplateScheduleAutostart: FC<TemplateScheduleAutostartProps> = ({
onChange,
}) => {
return (
<Stack
direction="column"
width="100%"
alignItems="center"
css={{ marginBottom: "20px" }}
>
<Stack width="100%" alignItems="start" spacing={1}>
<Stack
direction="row"
spacing={0}
@ -49,6 +44,7 @@ export const TemplateScheduleAutostart: FC<TemplateScheduleAutostartProps> = ({
}[]
).map((day) => (
<Button
fullWidth
key={day.key}
css={{ borderRadius: 0 }}
// TODO: Adding a background color would also help

View File

@ -1,4 +1,3 @@
import { useTheme } from "@emotion/react";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
@ -14,6 +13,10 @@ import {
FormFields,
} from "components/Form/Form";
import { Stack } from "components/Stack/Stack";
import {
StackLabel,
StackLabelHelperText,
} from "components/StackLabel/StackLabel";
import { getFormHelpers } from "utils/formUtils";
import {
calculateAutostopRequirementDaysValue,
@ -51,7 +54,8 @@ const DORMANT_AUTODELETION_DEFAULT = 30;
* The default form field space is 4 but since this form is quite heavy I think
* increase the space can make it feels lighter.
*/
const FORM_FIELDS_SPACING = 6;
const FORM_FIELDS_SPACING = 8;
const DORMANT_FIELDSET_SPACING = 4;
export interface TemplateScheduleForm {
template: Template;
@ -151,7 +155,6 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
form,
error,
);
const theme = useTheme();
const now = new Date();
const weekFromNow = new Date(now);
@ -404,34 +407,30 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
/>
</Stack>
<Stack direction="row" alignItems="center">
<Checkbox
id="allow-user-autostop"
size="small"
disabled={isSubmitting || !allowAdvancedScheduling}
onChange={async () => {
await form.setFieldValue(
"allow_user_autostop",
!form.values.allow_user_autostop,
);
}}
name="allow_user_autostop"
checked={form.values.allow_user_autostop}
/>
<Stack spacing={0.5}>
<strong>Enforce these settings across all workspaces</strong>
<span
css={{
fontSize: 12,
color: theme.palette.text.secondary,
<FormControlLabel
control={
<Checkbox
id="allow-user-autostop"
size="small"
disabled={isSubmitting || !allowAdvancedScheduling}
onChange={async (_, checked) => {
await form.setFieldValue("allow_user_autostop", checked);
}}
>
Workspaces by default allow users to set custom autostop timers.
Use this to apply the template settings to all workspaces under
this template.
</span>
</Stack>
</Stack>
name="allow_user_autostop"
checked={form.values.allow_user_autostop}
/>
}
label={
<StackLabel>
Allow users to customize autostop duration for workspaces.
<StackLabelHelperText>
By default, workspaces will inherit the Autostop timer from
this template. Enabling this option allows users to set custom
Autostop timers on their workspaces or turn off the timer.
</StackLabelHelperText>
</StackLabel>
}
/>
</FormFields>
</FormSection>
@ -439,27 +438,30 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
title="Autostart"
description="Allow users to set custom autostart and autostop scheduling options for workspaces created from this template."
>
<Stack direction="column">
<Stack direction="row" alignItems="center">
<Checkbox
id="allow_user_autostart"
size="small"
disabled={isSubmitting || !allowAdvancedScheduling}
onChange={async () => {
await form.setFieldValue(
"allow_user_autostart",
!form.values.allow_user_autostart,
);
}}
name="allow_user_autostart"
checked={form.values.allow_user_autostart}
/>
<Stack spacing={0.5}>
<strong>
<Stack>
<FormControlLabel
control={
<Checkbox
id="allow_user_autostart"
size="small"
disabled={isSubmitting || !allowAdvancedScheduling}
onChange={async () => {
await form.setFieldValue(
"allow_user_autostart",
!form.values.allow_user_autostart,
);
}}
name="allow_user_autostart"
checked={form.values.allow_user_autostart}
/>
}
label={
<StackLabel>
Allow users to automatically start workspaces on a schedule.
</strong>
</Stack>
</Stack>
</StackLabel>
}
/>
{allowAdvancedScheduling && (
<TemplateScheduleAutostart
enabled={Boolean(form.values.allow_user_autostart)}
@ -482,19 +484,20 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
<>
<FormSection
title="Dormancy"
description="Coder's Dormancy Threshold determines when workspaces become dormant due to inactivity, requiring manual activation for access."
description="When enabled, Coder will mark workspaces as dormant after a period of time with no connections. Dormant workspaces can be auto-deleted (see below) or manually reviewed by the workspace owner or admins."
>
<FormFields spacing={FORM_FIELDS_SPACING}>
<Stack>
<Stack spacing={DORMANT_FIELDSET_SPACING}>
<FormControlLabel
control={
<Switch
size="small"
name="dormancyThreshold"
checked={form.values.inactivity_cleanup_enabled}
onChange={handleToggleInactivityCleanup}
/>
}
label="Enable Dormancy Threshold"
label={<StackLabel>Enable Dormancy Threshold</StackLabel>}
/>
<TextField
{...getFieldHelpers("time_til_dormant_ms", {
@ -514,16 +517,33 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
/>
</Stack>
<Stack>
<Stack spacing={DORMANT_FIELDSET_SPACING}>
<FormControlLabel
control={
<Switch
size="small"
name="dormancyAutoDeletion"
checked={form.values.dormant_autodeletion_cleanup_enabled}
onChange={handleToggleDormantAutoDeletion}
/>
}
label="Enable Dormancy Auto-Deletion"
label={
<StackLabel>
Enable Dormancy Auto-Deletion
<StackLabelHelperText>
When enabled, Coder will permanently delete dormant
workspaces after a period of time.{" "}
<span
css={(theme) => ({
fontWeight: 500,
color: theme.palette.text.primary,
})}
>
Once a workspace is deleted it cannot be recovered.
</span>
</StackLabelHelperText>
</StackLabel>
}
/>
<TextField
{...getFieldHelpers("time_til_dormant_autodelete_ms", {
@ -544,16 +564,25 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
/>
</Stack>
<Stack>
<Stack spacing={DORMANT_FIELDSET_SPACING}>
<FormControlLabel
control={
<Switch
size="small"
name="failureCleanupEnabled"
checked={form.values.failure_cleanup_enabled}
onChange={handleToggleFailureCleanup}
/>
}
label="Enable Failure Cleanup"
label={
<StackLabel>
Enable Failure Cleanup
<StackLabelHelperText>
When enabled, Coder will attempt to stop workspaces that
are in a failed state after a specified number of days.
</StackLabelHelperText>
</StackLabel>
}
/>
<TextField
{...getFieldHelpers("failure_ttl_ms", {

View File

@ -136,10 +136,7 @@ export const SingleSignOnSection: FC<SingleSignOnSectionProps> = ({
}) => {
const theme = useTheme();
const authList = Object.values(
authMethods,
) as (typeof authMethods)[keyof typeof authMethods][];
const noSsoEnabled = !authList.some((method) => method.enabled);
const noSsoEnabled = !authMethods.github.enabled && !authMethods.oidc.enabled;
return (
<>

View File

@ -201,6 +201,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = {
dashboard_url: "https:///mock-url",
workspace_proxy: false,
upgrade_message: "My custom upgrade message",
deployment_id: "510d407f-e521-4180-b559-eab4a6d802b8",
};
export const MockSupportLinks: TypesGen.LinkConfig[] = [
@ -1373,6 +1374,13 @@ export const MockAuthMethodsPasswordOnly: TypesGen.AuthMethods = {
oidc: { enabled: false, signInText: "", iconUrl: "" },
};
export const MockAuthMethodsPasswordTermsOfService: TypesGen.AuthMethods = {
terms_of_service_url: "https://www.youtube.com/watch?v=C2f37Vb2NAE",
password: { enabled: true },
github: { enabled: false },
oidc: { enabled: false, signInText: "", iconUrl: "" },
};
export const MockAuthMethodsExternal: TypesGen.AuthMethods = {
password: { enabled: false },
github: { enabled: true },

View File

@ -33,7 +33,7 @@ running). */}}
.container {
--side-padding: 24px;
width: 100%;
max-width: calc(320px + var(--side-padding) * 2);
max-width: calc(500px + var(--side-padding) * 2);
padding: 0 var(--side-padding);
text-align: center;
}
@ -170,6 +170,9 @@ running). */}}
{{- if .Error.RenderDescriptionMarkdown }} {{ .ErrorDescriptionHTML }} {{
else }}
<p>{{ .Error.Description }}</p>
{{ end }} {{- if .Error.AdditionalInfo }}
<br />
<p>{{ .Error.AdditionalInfo }}</p>
{{ end }} {{- if .Error.Warnings }}
<div class="warning">
<div class="warning-title">
@ -195,7 +198,11 @@ running). */}}
</div>
{{ end }}
<div class="button-group">
{{- if .Error.RetryEnabled }}
{{- if and .Error.AdditionalButtonText .Error.AdditionalButtonLink }}
<a href="{{ .Error.AdditionalButtonLink }}"
>{{ .Error.AdditionalButtonText }}</a
>
{{ end }} {{- if .Error.RetryEnabled }}
<button onclick="window.location.reload()">Retry</button>
{{ end }}
<a href="{{ .Error.DashboardURL }}">Back to site</a>

View File

@ -0,0 +1,128 @@
package integration
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"tailscale.com/tailcfg"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil"
)
func NetworkSetupDefault(*testing.T) {}
func DERPMapTailscale(ctx context.Context, t *testing.T) *tailcfg.DERPMap {
ctx, cancel := context.WithTimeout(ctx, testutil.WaitShort)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://controlplane.tailscale.com/derpmap/default", nil)
require.NoError(t, err)
res, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
dm := &tailcfg.DERPMap{}
dec := json.NewDecoder(res.Body)
err = dec.Decode(dm)
require.NoError(t, err)
return dm
}
func CoordinatorInMemory(t *testing.T, logger slog.Logger, dm *tailcfg.DERPMap) (coord tailnet.Coordinator, url string) {
coord = tailnet.NewCoordinator(logger)
var coordPtr atomic.Pointer[tailnet.Coordinator]
coordPtr.Store(&coord)
t.Cleanup(func() { _ = coord.Close() })
csvc, err := tailnet.NewClientService(logger, &coordPtr, 10*time.Minute, func() *tailcfg.DERPMap {
return dm
})
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/")
id, err := uuid.Parse(idStr)
if err != nil {
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
Message: "Bad agent id.",
Detail: err.Error(),
})
return
}
conn, err := websocket.Accept(w, r, nil)
if err != nil {
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
Message: "Failed to accept websocket.",
Detail: err.Error(),
})
return
}
ctx, wsNetConn := codersdk.WebsocketNetConn(r.Context(), conn, websocket.MessageBinary)
defer wsNetConn.Close()
err = csvc.ServeConnV2(ctx, wsNetConn, tailnet.StreamID{
Name: "client-" + id.String(),
ID: id,
Auth: tailnet.SingleTailnetCoordinateeAuth{},
})
if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) {
_ = conn.Close(websocket.StatusInternalError, err.Error())
return
}
}))
t.Cleanup(srv.Close)
return coord, srv.URL
}
func TailnetSetupDRPC(ctx context.Context, t *testing.T, logger slog.Logger,
id, agentID uuid.UUID,
coordinateURL string,
dm *tailcfg.DERPMap,
) *tailnet.Conn {
ip := tailnet.IPFromUUID(id)
conn, err := tailnet.NewConn(&tailnet.Options{
Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)},
DERPMap: dm,
Logger: logger,
})
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
//nolint:bodyclose
ws, _, err := websocket.Dial(ctx, coordinateURL+"/"+id.String(), nil)
require.NoError(t, err)
client, err := tailnet.NewDRPCClient(
websocket.NetConn(ctx, ws, websocket.MessageBinary),
logger,
)
require.NoError(t, err)
coord, err := client.Coordinate(ctx)
require.NoError(t, err)
coordination := tailnet.NewRemoteCoordination(logger, coord, conn, agentID)
t.Cleanup(func() { _ = coordination.Close() })
return conn
}

View File

@ -0,0 +1,194 @@
package integration
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
"strconv"
"syscall"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil"
)
var (
isChild = flag.Bool("child", false, "Run tests as a child")
childTestID = flag.Int("child-test-id", 0, "Which test is being run")
childCoordinateURL = flag.String("child-coordinate-url", "", "The coordinate url to connect back to")
childAgentID = flag.String("child-agent-id", "", "The agent id of the child")
)
func TestMain(m *testing.M) {
if run := os.Getenv("CODER_TAILNET_TESTS"); run == "" {
_, _ = fmt.Println("skipping tests...")
return
}
if os.Getuid() != 0 {
_, _ = fmt.Println("networking integration tests must run as root")
return
}
flag.Parse()
os.Exit(m.Run())
}
var tests = []Test{{
Name: "Normal",
DERPMap: DERPMapTailscale,
Coordinator: CoordinatorInMemory,
Parent: Parent{
NetworkSetup: NetworkSetupDefault,
TailnetSetup: TailnetSetupDRPC,
Run: func(ctx context.Context, t *testing.T, opts ParentOpts) {
reach := opts.Conn.AwaitReachable(ctx, tailnet.IPFromUUID(opts.AgentID))
assert.True(t, reach)
},
},
Child: Child{
NetworkSetup: NetworkSetupDefault,
TailnetSetup: TailnetSetupDRPC,
Run: func(ctx context.Context, t *testing.T, opts ChildOpts) {
// wait until the parent kills us
<-make(chan struct{})
},
},
}}
//nolint:paralleltest
func TestIntegration(t *testing.T) {
if *isChild {
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
t.Cleanup(cancel)
agentID, err := uuid.Parse(*childAgentID)
require.NoError(t, err)
test := tests[*childTestID]
test.Child.NetworkSetup(t)
dm := test.DERPMap(ctx, t)
conn := test.Child.TailnetSetup(ctx, t, logger, agentID, uuid.Nil, *childCoordinateURL, dm)
test.Child.Run(ctx, t, ChildOpts{
Logger: logger,
Conn: conn,
AgentID: agentID,
})
return
}
for id, test := range tests {
t.Run(test.Name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
t.Cleanup(cancel)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
parentID, childID := uuid.New(), uuid.New()
dm := test.DERPMap(ctx, t)
_, coordURL := test.Coordinator(t, logger, dm)
child, waitChild := execChild(ctx, id, coordURL, childID)
test.Parent.NetworkSetup(t)
conn := test.Parent.TailnetSetup(ctx, t, logger, parentID, childID, coordURL, dm)
test.Parent.Run(ctx, t, ParentOpts{
Logger: logger,
Conn: conn,
ClientID: parentID,
AgentID: childID,
})
child.Process.Signal(syscall.SIGINT)
<-waitChild
})
}
}
type Test struct {
// Name is the name of the test.
Name string
// DERPMap returns the DERP map to use for both the parent and child. It is
// called once at the beginning of the test.
DERPMap func(ctx context.Context, t *testing.T) *tailcfg.DERPMap
// Coordinator returns a running tailnet coordinator, and the url to reach
// it on.
Coordinator func(t *testing.T, logger slog.Logger, dm *tailcfg.DERPMap) (coord tailnet.Coordinator, url string)
Parent Parent
Child Child
}
// Parent is the struct containing all of the parent specific configurations.
// Functions are invoked in order of struct definition.
type Parent struct {
// NetworkSetup is run before all test code. It can be used to setup
// networking scenarios.
NetworkSetup func(t *testing.T)
// TailnetSetup creates a tailnet network.
TailnetSetup func(
ctx context.Context, t *testing.T, logger slog.Logger,
id, agentID uuid.UUID, coordURL string, dm *tailcfg.DERPMap,
) *tailnet.Conn
Run func(ctx context.Context, t *testing.T, opts ParentOpts)
}
// Child is the struct containing all of the child specific configurations.
// Functions are invoked in order of struct definition.
type Child struct {
// NetworkSetup is run before all test code. It can be used to setup
// networking scenarios.
NetworkSetup func(t *testing.T)
// TailnetSetup creates a tailnet network.
TailnetSetup func(
ctx context.Context, t *testing.T, logger slog.Logger,
id, agentID uuid.UUID, coordURL string, dm *tailcfg.DERPMap,
) *tailnet.Conn
// Run runs the actual test. Parents and children run in separate processes,
// so it's important to ensure no communication happens over memory between
// run functions of parents and children.
Run func(ctx context.Context, t *testing.T, opts ChildOpts)
}
type ParentOpts struct {
Logger slog.Logger
Conn *tailnet.Conn
ClientID uuid.UUID
AgentID uuid.UUID
}
type ChildOpts struct {
Logger slog.Logger
Conn *tailnet.Conn
AgentID uuid.UUID
}
func execChild(ctx context.Context, testID int, coordURL string, agentID uuid.UUID) (*exec.Cmd, <-chan error) {
ch := make(chan error)
binary := os.Args[0]
args := os.Args[1:]
args = append(args,
"--child=true",
"--child-test-id="+strconv.Itoa(testID),
"--child-coordinate-url="+coordURL,
"--child-agent-id="+agentID.String(),
)
cmd := exec.CommandContext(ctx, binary, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
go func() {
ch <- cmd.Run()
}()
return cmd, ch
}