mirror of https://github.com/coder/coder.git
Merge branch 'main' into notification-banners
This commit is contained in:
commit
094238634f
|
@ -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"
|
||||
|
|
|
@ -17,6 +17,9 @@
|
|||
},
|
||||
{
|
||||
"pattern": "tailscale.com"
|
||||
},
|
||||
{
|
||||
"pattern": "wireguard.com"
|
||||
}
|
||||
],
|
||||
"aliveStatusCodes": [200, 0]
|
||||
|
|
|
@ -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: |
|
||||
|
|
3
Makefile
3
Makefile
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
18
cli/start.go
18
cli/start.go
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,6 +21,7 @@ Learn more about [Coder’s 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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 | | |
|
||||
|
|
|
@ -157,7 +157,8 @@ curl -X GET http://coder-server:8080/api/v2/users/authmethods \
|
|||
},
|
||||
"password": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"terms_of_service_url": "string"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
@ -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".
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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`).
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
15
go.mod
|
@ -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
39
go.sum
|
@ -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=
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -71,6 +71,9 @@ done
|
|||
# Check dependencies
|
||||
dependencies gh
|
||||
|
||||
# Authenticate gh CLI
|
||||
gh_auth
|
||||
|
||||
# Remove the "v" prefix.
|
||||
version="${version#v}"
|
||||
if [[ "$version" == "" ]]; then
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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!
|
||||
```
|
|
@ -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!
|
||||
```
|
|
@ -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
|
24
site/site.go
24
site/site.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 © {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>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
<LaunchIcon css={{ fontSize: 12 }} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue