Compare commits

...

5 Commits

Author SHA1 Message Date
Jon Ayers
cb70300b37 fix(site): sanitize login redirect (#15221)
Co-authored-by: Colin Adler <colin1adler@gmail.com>
2024-10-24 20:49:29 +01:00
Stephen Kirby
5c6d7f4434 fix: patch mainline improvements back to 2.14.3 (#14566)
* fix: increase group name limit to 36 from 32 (#14443)

(cherry picked from commit 499769187b)

* fix: allow posting licenses that will be valid in future (#14491)

(cherry picked from commit 5bd5801286)

* fix: stop reporting future licenses as errors (#14492)

(cherry picked from commit 4eac2acede)

* fix(provisionerd/runner): do not log entire resources (#14538)

fix(coderd/workspaceagentsrpc): do not log entire agent
fix(provisionerd/runner): do not log entire resources

(cherry picked from commit 5366f2576f)

* fix(site): fix agent logs streaming for third party apps (#14541)

(cherry picked from commit 242b1ea4ca)

* Fix TestLicenseEntitlements/CurrentAndFuture test

---------

Co-authored-by: Asher <ash@coder.com>
Co-authored-by: Spike Curtis <spike@coder.com>
Co-authored-by: Cian Johnston <cian@coder.com>
Co-authored-by: Bruno Quaresma <bruno@coder.com>
2024-09-05 16:01:45 -05:00
Stephen Kirby
d1cd784866 chore: patch 2.14.2 (#14371)
* chore: sign the windows installer (#14353)

(cherry picked from commit 6f9b3c1592)

* chore: handle merge conflicts

* make fmt patch

---------

Co-authored-by: Kyle Carberry <kyle@coder.com>
Co-authored-by: Jaayden Halko <jaayden.halko@gmail.com>
2024-08-20 15:46:19 -05:00
Stephen Kirby
db94b30b1c fix: patch known issues in 2.14.1 (#14207)
* chore: revert status code change for delete users endpoint (#14168)

Revert from https://github.com/coder/coder/pull/13870

(cherry picked from commit b80d99550a)

* chore: delete user codersdk to support status code regression (#14173)

* chore: delete user codersdk to support status code regression
* Update codersdk/users.go

Co-authored-by: Kayla Washburn-Love <mckayla@hey.com>

---------

Co-authored-by: Kayla Washburn-Love <mckayla@hey.com>
(cherry picked from commit b55a7a8b78)

* fix: add template editor to /templates/:templateName route group (#14206)

(cherry picked from commit d0feb70811)

---------

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
Co-authored-by: Kayla Washburn-Love <mckayla@hey.com>
2024-08-07 11:56:07 -05:00
Stephen Kirby
a0411a39f9 chore: bump github.com/docker/docker (#14185)
* chore: bump github.com/docker/docker

* fixup! chore: bump github.com/docker/docker

---------

Co-authored-by: Colin Adler <colin1adler@gmail.com>
2024-08-06 10:40:34 -05:00
28 changed files with 405 additions and 127 deletions

4
coderd/apidoc/docs.go generated
View File

@@ -4845,8 +4845,8 @@ const docTemplate = `{
}
],
"responses": {
"204": {
"description": "No Content"
"200": {
"description": "OK"
}
}
}

View File

@@ -4273,8 +4273,8 @@
}
],
"responses": {
"204": {
"description": "No Content"
"200": {
"description": "OK"
}
}
}

View File

@@ -46,7 +46,7 @@ func init() {
valid := NameValid(str)
return valid == nil
}
for _, tag := range []string{"username", "organization_name", "template_name", "group_name", "workspace_name", "oauth2_app_name"} {
for _, tag := range []string{"username", "organization_name", "template_name", "workspace_name", "oauth2_app_name"} {
err := Validate.RegisterValidation(tag, nameValidator)
if err != nil {
panic(err)
@@ -96,6 +96,20 @@ func init() {
if err != nil {
panic(err)
}
groupNameValidator := func(fl validator.FieldLevel) bool {
f := fl.Field().Interface()
str, ok := f.(string)
if !ok {
return false
}
valid := GroupNameValid(str)
return valid == nil
}
err = Validate.RegisterValidation("group_name", groupNameValidator)
if err != nil {
panic(err)
}
}
// Is404Error returns true if the given error should return a 404 status code.

View File

@@ -96,6 +96,23 @@ func UserRealNameValid(str string) error {
return nil
}
// GroupNameValid returns whether the input string is a valid group name.
func GroupNameValid(str string) error {
// 36 is to support using UUIDs as the group name.
if len(str) > 36 {
return xerrors.New("must be <= 36 characters")
}
// Avoid conflicts with routes like /groups/new and /groups/create.
if str == "new" || str == "create" {
return xerrors.Errorf("cannot use %q as a name", str)
}
matched := UsernameValidRegex.MatchString(str)
if !matched {
return xerrors.New("must be alphanumeric with hyphens")
}
return nil
}
// NormalizeUserRealName normalizes a user name such that it will pass
// validation by UserRealNameValid. This is done to avoid blocking
// little Bobby Whitespace from using Coder.

View File

@@ -513,7 +513,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
// @Security CoderSessionToken
// @Tags Users
// @Param user path string true "User ID, name, or me"
// @Success 204
// @Success 200
// @Router /users/{user} [delete]
func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -588,7 +588,9 @@ func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) {
}
}
rw.WriteHeader(http.StatusNoContent)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: "User has been deleted!",
})
}
// Returns the parameterized user requested. All validation

View File

@@ -116,7 +116,19 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
}
defer mux.Close()
logger.Debug(ctx, "accepting agent RPC connection", slog.F("agent", workspaceAgent))
logger.Debug(ctx, "accepting agent RPC connection",
slog.F("agent_id", workspaceAgent.ID),
slog.F("agent_created_at", workspaceAgent.CreatedAt),
slog.F("agent_updated_at", workspaceAgent.UpdatedAt),
slog.F("agent_name", workspaceAgent.Name),
slog.F("agent_first_connected_at", workspaceAgent.FirstConnectedAt.Time),
slog.F("agent_last_connected_at", workspaceAgent.LastConnectedAt.Time),
slog.F("agent_disconnected_at", workspaceAgent.DisconnectedAt.Time),
slog.F("agent_version", workspaceAgent.Version),
slog.F("agent_last_connected_replica_id", workspaceAgent.LastConnectedReplicaID),
slog.F("agent_connection_timeout_seconds", workspaceAgent.ConnectionTimeoutSeconds),
slog.F("agent_api_version", workspaceAgent.APIVersion),
slog.F("agent_resource_id", workspaceAgent.ResourceID))
closeCtx, closeCtxCancel := context.WithCancel(ctx)
defer closeCtxCancel()

View File

@@ -309,7 +309,9 @@ func (c *Client) DeleteUser(ctx context.Context, id uuid.UUID) error {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
// Check for a 200 or a 204 response. 2.14.0 accidentally included a 204 response,
// which was a breaking change, and reverted in 2.14.1.
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil

6
docs/api/users.md generated
View File

@@ -426,9 +426,9 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \
### Responses
| Status | Meaning | Description | Schema |
| ------ | --------------------------------------------------------------- | ----------- | ------ |
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------ |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

View File

@@ -174,6 +174,10 @@ type LicenseOptions struct {
// ExpiresAt is the time at which the license will hard expire.
// ExpiresAt should always be greater then GraceAt.
ExpiresAt time.Time
// NotBefore is the time at which the license becomes valid. If set to the
// zero value, the `nbf` claim on the license is set to 1 minute in the
// past.
NotBefore time.Time
Features license.Features
}
@@ -195,6 +199,13 @@ func (opts *LicenseOptions) Valid(now time.Time) *LicenseOptions {
return opts
}
func (opts *LicenseOptions) FutureTerm(now time.Time) *LicenseOptions {
opts.NotBefore = now.Add(time.Hour * 24)
opts.ExpiresAt = now.Add(time.Hour * 24 * 60)
opts.GraceAt = now.Add(time.Hour * 24 * 53)
return opts
}
func (opts *LicenseOptions) UserLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureUserLimit, limit)
}
@@ -233,13 +244,16 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
if options.GraceAt.IsZero() {
options.GraceAt = time.Now().Add(time.Hour)
}
if options.NotBefore.IsZero() {
options.NotBefore = time.Now().Add(-time.Minute)
}
c := &license.Claims{
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(),
Issuer: "test@testing.test",
ExpiresAt: jwt.NewNumericDate(options.ExpiresAt),
NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
NotBefore: jwt.NewNumericDate(options.NotBefore),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
LicenseExpires: jwt.NewNumericDate(options.GraceAt),

View File

@@ -156,7 +156,7 @@ func TestPatchGroup(t *testing.T) {
const displayName = "foobar"
ctx := testutil.Context(t, testutil.WaitLong)
group, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "hi",
Name: "ff7dcee2-e7c4-4bc4-a9e4-84870770e4c5", // GUID should fit.
AvatarURL: "https://example.com",
QuotaAllowance: 10,
DisplayName: "",
@@ -165,14 +165,14 @@ func TestPatchGroup(t *testing.T) {
require.Equal(t, 10, group.QuotaAllowance)
group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
Name: "bye",
Name: "ddd502d2-2984-4724-b5bf-1109a4d7462d", // GUID should fit.
AvatarURL: ptr.Ref("https://google.com"),
QuotaAllowance: ptr.Ref(20),
DisplayName: ptr.Ref(displayName),
})
require.NoError(t, err)
require.Equal(t, displayName, group.DisplayName)
require.Equal(t, "bye", group.Name)
require.Equal(t, "ddd502d2-2984-4724-b5bf-1109a4d7462d", group.Name)
require.Equal(t, "https://google.com", group.AvatarURL)
require.Equal(t, 20, group.QuotaAllowance)
})

View File

@@ -100,6 +100,13 @@ func LicensesEntitlements(
// 'Entitlements' group as a whole.
for _, license := range licenses {
claims, err := ParseClaims(license.JWT, keys)
var vErr *jwt.ValidationError
if xerrors.As(err, &vErr) && vErr.Is(jwt.ErrTokenNotValidYet) {
// The license isn't valid yet. We don't consider any entitlements contained in it, but
// it's also not an error. Just skip it silently. This can happen if an administrator
// uploads a license for a new term that hasn't started yet.
continue
}
if err != nil {
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Invalid license (%s) parsing claims: %s", license.UUID.String(), err.Error()))
@@ -287,6 +294,8 @@ var (
ErrInvalidVersion = xerrors.New("license must be version 3")
ErrMissingKeyID = xerrors.Errorf("JOSE header must contain %s", HeaderKeyID)
ErrMissingLicenseExpires = xerrors.New("license missing license_expires")
ErrMissingExp = xerrors.New("exp claim missing or not parsable")
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
)
type Features map[codersdk.FeatureName]int64
@@ -336,7 +345,7 @@ func ParseRaw(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error
return nil, xerrors.New("unable to parse Claims")
}
// ParseClaims validates a database.License record, and if valid, returns the claims. If
// ParseClaims validates a raw JWT, and if valid, returns the claims. If
// unparsable or invalid, it returns an error
func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
tok, err := jwt.ParseWithClaims(
@@ -348,18 +357,53 @@ func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, err
if err != nil {
return nil, err
}
if claims, ok := tok.Claims.(*Claims); ok && tok.Valid {
return validateClaims(tok)
}
func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims, ok := tok.Claims.(*Claims); ok {
if claims.Version != uint64(CurrentVersion) {
return nil, ErrInvalidVersion
}
if claims.LicenseExpires == nil {
return nil, ErrMissingLicenseExpires
}
if claims.ExpiresAt == nil {
return nil, ErrMissingExp
}
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
}
// ParseClaimsIgnoreNbf validates a raw JWT, but ignores `nbf` claim. If otherwise valid, it returns
// the claims. If unparsable or invalid, it returns an error. Ignoring the `nbf` (not before) is
// useful to determine if a JWT _will_ become valid at any point now or in the future.
func ParseClaimsIgnoreNbf(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
tok, err := jwt.ParseWithClaims(
rawJWT,
&Claims{},
keyFunc(keys),
jwt.WithValidMethods(ValidMethods),
)
var vErr *jwt.ValidationError
if xerrors.As(err, &vErr) {
// zero out the NotValidYet error to check if there were other problems
vErr.Errors = vErr.Errors & (^jwt.ValidationErrorNotValidYet)
if vErr.Errors != 0 {
// There are other errors besides not being valid yet. We _could_ go
// through all the jwt.ValidationError bits and try to work out the
// correct error, but if we get here something very strange is
// going on so let's just return a generic error that says to get in
// touch with our support team.
return nil, ErrMultipleIssues
}
} else if err != nil {
return nil, err
}
return validateClaims(tok)
}
func keyFunc(keys map[string]ed25519.PublicKey) func(*jwt.Token) (interface{}, error) {
return func(j *jwt.Token) (interface{}, error) {
keyID, ok := j.Header[HeaderKeyID].(string)

View File

@@ -824,6 +824,23 @@ func TestLicenseEntitlements(t *testing.T) {
assert.True(t, entitlements.Features[codersdk.FeatureMultipleOrganizations].Enabled, "multi-org enabled for premium")
},
},
{
Name: "CurrentAndFuture",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(100),
premiumLicense().UserLimit(200).FutureTerm(time.Now()),
},
Enablements: defaultEnablements,
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assertEnterpriseFeatures(t, entitlements)
assertNoErrors(t, entitlements)
assertNoWarnings(t, entitlements)
userFeature := entitlements.Features[codersdk.FeatureUserLimit]
assert.Equalf(t, int64(100), *userFeature.Limit, "user limit")
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureMultipleOrganizations].Entitlement)
},
},
}
for _, tc := range testCases {

View File

@@ -86,25 +86,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
return
}
rawClaims, err := license.ParseRaw(addLicense.License, api.LicenseKeys)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid license",
Detail: err.Error(),
})
return
}
exp, ok := rawClaims["exp"].(float64)
if !ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid license",
Detail: "exp claim missing or not parsable",
})
return
}
expTime := time.Unix(int64(exp), 0)
claims, err := license.ParseClaims(addLicense.License, api.LicenseKeys)
claims, err := license.ParseClaimsIgnoreNbf(addLicense.License, api.LicenseKeys)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid license",
@@ -134,7 +116,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{
UploadedAt: dbtime.Now(),
JWT: addLicense.License,
Exp: expTime,
Exp: claims.ExpiresAt.Time,
UUID: id,
})
if err != nil {
@@ -160,7 +142,15 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
// don't fail the HTTP request, since we did write it successfully to the database
}
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, rawClaims))
c, err := decodeClaims(dl)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to decode database response",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, c))
}
// postRefreshEntitlements forces an `updateEntitlements` call and publishes

View File

@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
@@ -82,6 +83,53 @@ func TestPostLicense(t *testing.T) {
t.Error("expected to get error status 400")
}
})
// Test a license that isn't yet valid, but will be in the future. We should allow this so that
// operators can upload a license ahead of time.
t.Run("NotYet", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
respLic := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
AccountType: license.AccountTypeSalesforce,
AccountID: "testing",
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
NotBefore: time.Now().Add(time.Hour),
GraceAt: time.Now().Add(2 * time.Hour),
ExpiresAt: time.Now().Add(3 * time.Hour),
})
assert.GreaterOrEqual(t, respLic.ID, int32(0))
// just a couple spot checks for sanity
assert.Equal(t, "testing", respLic.Claims["account_id"])
features, err := respLic.FeaturesClaims()
require.NoError(t, err)
assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog])
})
// Test we still reject a license that isn't valid yet, but has other issues (e.g. expired
// before it starts).
t.Run("NotEver", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
AccountType: license.AccountTypeSalesforce,
AccountID: "testing",
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
NotBefore: time.Now().Add(time.Hour),
GraceAt: time.Now().Add(2 * time.Hour),
ExpiresAt: time.Now().Add(-time.Hour),
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
errResp := &codersdk.Error{}
require.ErrorAs(t, err, &errResp)
require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
require.Contains(t, errResp.Detail, license.ErrMultipleIssues.Error())
})
}
func TestGetLicense(t *testing.T) {

View File

@@ -117,7 +117,7 @@
name = "coder-${osArch}";
# Updated with ./scripts/update-flake.sh`.
# This should be updated whenever go.mod changes!
vendorHash = "sha256-SkIcowUjVHuwCAJ9b1SwWD7V91UN7rHKMuLUSRquUl4=";
vendorHash = "sha256-VRn69VifEhmriaH461JRSbPs0eWtvZx7g2X3KtnhBN0=";
proxyVendor = true;
src = ./.;
nativeBuildInputs = with pkgs; [ getopt openssl zstd ];

9
go.mod
View File

@@ -207,11 +207,15 @@ require (
require (
cloud.google.com/go/auth v0.7.3 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/DataDog/go-libddwaf/v2 v2.4.2 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/mitchellh/hashstructure v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/pion/transport/v2 v2.0.0 // indirect
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
@@ -270,8 +274,8 @@ require (
github.com/containerd/continuity v0.4.2 // indirect
github.com/coreos/go-iptables v0.6.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/docker/cli v23.0.5+incompatible // indirect
github.com/docker/docker v24.0.9+incompatible // indirect
github.com/docker/cli v27.1.1+incompatible // indirect
github.com/docker/docker v27.1.1+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -324,7 +328,6 @@ require (
github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0 // indirect
github.com/hdevalence/ed25519consensus v0.1.0 // indirect
github.com/illarion/gonotify v1.0.1 // indirect
github.com/imdario/mergo v0.3.15 // indirect
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect

20
go.sum
View File

@@ -11,6 +11,8 @@ cloud.google.com/go/logging v1.11.0 h1:v3ktVzXMV7CwHq1MBF65wcqLMA7i+z3YxbUsoK7mO
cloud.google.com/go/logging v1.11.0/go.mod h1:5LDiJC/RxTt+fHc1LAt20R9TKiUTReDg6RuuFOZ67+A=
cloud.google.com/go/longrunning v0.5.11 h1:Havn1kGjz3whCfoD8dxMLP73Ph5w+ODyZB9RUsDxtGk=
cloud.google.com/go/longrunning v0.5.11/go.mod h1:rDn7//lmlfWV1Dx6IB4RatCPenTwwmqXuiP0/RgoEO4=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
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=
@@ -256,14 +258,14 @@ github.com/dhui/dktest v0.4.0 h1:z05UmuXZHO/bgj/ds2bGMBu8FI4WA+Ag/m3ghL+om7M=
github.com/dhui/dktest v0.4.0/go.mod h1:v/Dbz1LgCBOi2Uki2nUqLBGa83hWBGFMu5MrgMDCc78=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v23.0.5+incompatible h1:ufWmAOuD3Vmr7JP2G5K3cyuNC4YZWiAsuDEvFVVDafE=
github.com/docker/cli v23.0.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE=
github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -393,6 +395,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA=
github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -576,8 +580,6 @@ github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJ
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio=
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE=
github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY=
@@ -708,6 +710,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/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby v27.1.1+incompatible h1:WdCIKJ4WIxhrKti5c+Z7sj2SLADbsuB/reEBpQ4rtOQ=
github.com/moby/moby v27.1.1+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=

View File

@@ -723,7 +723,7 @@ func (r *Runner) runTemplateImportProvisionWithRichParameters(
r.logger.Info(context.Background(), "parse dry-run provision successful",
slog.F("resource_count", len(c.Resources)),
slog.F("resources", c.Resources),
slog.F("resources", resourceNames(c.Resources)),
)
return &templateImportProvision{
@@ -853,7 +853,7 @@ func (r *Runner) buildWorkspace(ctx context.Context, stage string, req *sdkproto
func (r *Runner) commitQuota(ctx context.Context, resources []*sdkproto.Resource) *proto.FailedJob {
cost := sumDailyCost(resources)
r.logger.Debug(ctx, "committing quota",
slog.F("resources", resources),
slog.F("resources", resourceNames(resources)),
slog.F("cost", cost),
)
if cost == 0 {
@@ -964,7 +964,7 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p
r.logger.Info(context.Background(), "plan request successful",
slog.F("resource_count", len(planComplete.Resources)),
slog.F("resources", planComplete.Resources),
slog.F("resources", resourceNames(planComplete.Resources)),
)
r.flushQueuedLogs(ctx)
if commitQuota {
@@ -1015,7 +1015,7 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p
r.logger.Info(context.Background(), "apply successful",
slog.F("resource_count", len(applyComplete.Resources)),
slog.F("resources", applyComplete.Resources),
slog.F("resources", resourceNames(applyComplete.Resources)),
slog.F("state_len", len(applyComplete.State)),
)
r.flushQueuedLogs(ctx)
@@ -1031,6 +1031,19 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p
}, nil
}
func resourceNames(rs []*sdkproto.Resource) []string {
var sb strings.Builder
names := make([]string, 0, len(rs))
for _, r := range rs {
_, _ = sb.WriteString(r.Type)
_, _ = sb.WriteString(".")
_, _ = sb.WriteString(r.Name)
names = append(names, sb.String())
sb.Reset()
}
return names
}
func (r *Runner) failedWorkspaceBuildf(format string, args ...interface{}) *proto.FailedJob {
failedJob := r.failedJobf(format, args...)
failedJob.Type = &proto.FailedJob_WorkspaceBuild_{}

View File

@@ -19,6 +19,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
agpl="${CODER_BUILD_AGPL:-0}"
output_path=""
version=""
sign_windows="${CODER_SIGN_WINDOWS:-0}"
args="$(getopt -o "" -l agpl,output:,version: -- "$@")"
eval set -- "$args"
@@ -51,6 +52,11 @@ if [[ "$output_path" == "" ]]; then
error "--output is a required parameter"
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
if [[ "$#" != 1 ]]; then
error "Exactly one argument must be provided to this script, $# were supplied"
fi
@@ -125,3 +131,7 @@ popd
cp "$temp_dir/installer.exe" "$output_path"
rm -rf "$temp_dir"
if [[ "$sign_windows" == 1 ]]; then
execrelative ./sign_windows.sh "$output_path" 1>&2
fi

View File

@@ -28,7 +28,7 @@ describe("useAgentLogs", () => {
expect(wsSpy).not.toHaveBeenCalled();
});
it("should return existing logs without network calls", async () => {
it("should return existing logs without network calls if state is off", async () => {
const queryClient = createTestQueryClient();
queryClient.setQueryData(
agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id),
@@ -39,7 +39,7 @@ describe("useAgentLogs", () => {
const { result } = renderUseAgentLogs(queryClient, {
workspaceId: MockWorkspace.id,
agentId: MockWorkspaceAgent.id,
agentLifeCycleState: "ready",
agentLifeCycleState: "off",
});
await waitFor(() => {
expect(result.current).toHaveLength(5);
@@ -48,12 +48,12 @@ describe("useAgentLogs", () => {
expect(wsSpy).not.toHaveBeenCalled();
});
it("should fetch logs when empty and should not connect to WebSocket when not starting", async () => {
it("should fetch logs when empty", async () => {
const queryClient = createTestQueryClient();
const fetchSpy = jest
.spyOn(API, "getWorkspaceAgentLogs")
.mockResolvedValueOnce(generateLogs(5));
const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
const { result } = renderUseAgentLogs(queryClient, {
workspaceId: MockWorkspace.id,
agentId: MockWorkspaceAgent.id,
@@ -63,10 +63,9 @@ describe("useAgentLogs", () => {
expect(result.current).toHaveLength(5);
});
expect(fetchSpy).toHaveBeenCalledWith(MockWorkspaceAgent.id);
expect(wsSpy).not.toHaveBeenCalled();
});
it("should fetch logs and connect to websocket when agent is starting", async () => {
it("should fetch logs and connect to websocket", async () => {
const queryClient = createTestQueryClient();
const logs = generateLogs(5);
const fetchSpy = jest

View File

@@ -17,16 +17,13 @@ export type UseAgentLogsOptions = Readonly<{
/**
* Defines a custom hook that gives you all workspace agent logs for a given
* workspace.
*
* Depending on the status of the workspace, all logs may or may not be
* available.
* workspace.Depending on the status of the workspace, all logs may or may not
* be available.
*/
export function useAgentLogs(
options: UseAgentLogsOptions,
): readonly WorkspaceAgentLog[] | undefined {
const { workspaceId, agentId, agentLifeCycleState, enabled = true } = options;
const queryClient = useQueryClient();
const queryOptions = agentLogs(workspaceId, agentId);
const { data: logs, isFetched } = useQuery({ ...queryOptions, enabled });
@@ -55,7 +52,17 @@ export function useAgentLogs(
});
useEffect(() => {
if (agentLifeCycleState !== "starting" || !isFetched) {
// Stream data only for new logs. Old logs should be loaded beforehand
// using a regular fetch to avoid overloading the websocket with all
// logs at once.
if (!isFetched) {
return;
}
// If the agent is off, we don't need to stream logs. This is the only state
// where the Coder API can't receive logs for the agent from third-party
// apps like envbuilder.
if (agentLifeCycleState === "off") {
return;
}

View File

@@ -28,6 +28,15 @@ export const LoginPage: FC = () => {
const navigate = useNavigate();
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
let redirectError: Error | null = null;
let redirectUrl: URL | null = null;
try {
redirectUrl = new URL(redirectTo);
} catch {
// Do nothing
}
const isApiRouteRedirect = redirectTo.startsWith("/api/v2");
useEffect(() => {
if (!buildInfoQuery.data || isSignedIn) {
@@ -42,41 +51,24 @@ export const LoginPage: FC = () => {
}, [isSignedIn, buildInfoQuery.data, user?.id]);
if (isSignedIn) {
if (buildInfoQuery.data) {
// This uses `navigator.sendBeacon`, so window.href
// will not stop the request from being sent!
sendDeploymentEvent(buildInfoQuery.data, {
type: "deployment_login",
user_id: user?.id,
});
// The reason we need `window.location.href` for api redirects is that
// we need the page to reload and make a request to the backend. If we
// use `<Navigate>`, react would handle the redirect itself and never
// request the page from the backend.
if (isApiRouteRedirect) {
const sanitizedUrl = new URL(redirectTo, window.location.origin);
window.location.href = sanitizedUrl.pathname + sanitizedUrl.search;
// Setting the href should immediately request a new page. Show an
// error state if it doesn't.
redirectError = new Error("unable to redirect");
} else {
return (
<Navigate
to={redirectUrl ? redirectUrl.pathname : redirectTo}
replace
/>
);
}
// If the redirect is going to a workspace application, and we
// are missing authentication, then we need to change the href location
// to trigger a HTTP request. This allows the BE to generate the auth
// cookie required. Similarly for the OAuth2 exchange as the authorization
// page is served by the backend.
// If no redirect is present, then ignore this branched logic.
if (redirectTo !== "" && redirectTo !== "/") {
try {
// This catches any absolute redirects. Relative redirects
// will fail the try/catch. Subdomain apps are absolute redirects.
const redirectURL = new URL(redirectTo);
if (redirectURL.host !== window.location.host) {
window.location.href = redirectTo;
return null;
}
} catch {
// Do nothing
}
// Path based apps and OAuth2.
if (redirectTo.includes("/apps/") || redirectTo.includes("/oauth2/")) {
window.location.href = redirectTo;
return null;
}
}
return <Navigate to={redirectTo} replace />;
}
if (isConfiguringTheFirstUser) {
@@ -90,7 +82,7 @@ export const LoginPage: FC = () => {
</Helmet>
<LoginPageView
authMethods={authMethodsQuery.data}
error={signInError}
error={signInError ?? redirectError}
isLoading={isLoading || authMethodsQuery.isLoading}
buildInfo={buildInfoQuery.data}
isSigningIn={isSigningIn}
@@ -98,6 +90,7 @@ export const LoginPage: FC = () => {
await signIn(email, password);
navigate("/");
}}
redirectTo={redirectTo}
/>
</>
);

View File

@@ -6,7 +6,6 @@ import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
import { CoderIcon } from "components/Icons/CoderIcon";
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";
@@ -17,6 +16,7 @@ export interface LoginPageViewProps {
buildInfo?: BuildInfoResponse;
isSigningIn: boolean;
onSignIn: (credentials: { email: string; password: string }) => void;
redirectTo: string;
}
export const LoginPageView: FC<LoginPageViewProps> = ({
@@ -26,9 +26,9 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
buildInfo,
isSigningIn,
onSignIn,
redirectTo,
}) => {
const location = useLocation();
const redirectTo = retrieveRedirect(location.search);
// This allows messages to be displayed at the top of the sign in form.
// Helpful for any redirects that want to inform the user of something.
const message = new URLSearchParams(location.search).get("message");

View File

@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react";
import { screen, userEvent } from "@storybook/test";
import { CreateTemplateButton } from "./CreateTemplateButton";
const meta: Meta<typeof CreateTemplateButton> = {
title: "pages/TemplatesPage/CreateTemplateButton",
component: CreateTemplateButton,
};
export default meta;
type Story = StoryObj<typeof CreateTemplateButton>;
export const Close: Story = {};
export const Open: Story = {
play: async ({ step }) => {
const user = userEvent.setup();
await step("click on trigger", async () => {
await user.click(screen.getByRole("button"));
});
},
};

View File

@@ -0,0 +1,56 @@
import AddIcon from "@mui/icons-material/AddOutlined";
import Inventory2 from "@mui/icons-material/Inventory2";
import NoteAddOutlined from "@mui/icons-material/NoteAddOutlined";
import UploadOutlined from "@mui/icons-material/UploadOutlined";
import Button from "@mui/material/Button";
import type { FC } from "react";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
type CreateTemplateButtonProps = {
onNavigate: (path: string) => void;
};
export const CreateTemplateButton: FC<CreateTemplateButtonProps> = ({
onNavigate,
}) => {
return (
<MoreMenu>
<MoreMenuTrigger>
<Button startIcon={<AddIcon />} variant="contained">
Create Template
</Button>
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
onClick={() => {
onNavigate("/templates/new?exampleId=scratch");
}}
>
<NoteAddOutlined />
From scratch
</MoreMenuItem>
<MoreMenuItem
onClick={() => {
onNavigate("/templates/new");
}}
>
<UploadOutlined />
Upload template
</MoreMenuItem>
<MoreMenuItem
onClick={() => {
onNavigate("/starter-templates");
}}
>
<Inventory2 />
Choose a starter template
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
);
};

View File

@@ -17,7 +17,7 @@ test("create template from scratch", async () => {
element: <TemplatesPage />,
},
{
path: "/starter-templates",
path: "/templates/new",
element: <div data-testid="new-template-page" />,
},
],
@@ -34,6 +34,9 @@ test("create template from scratch", async () => {
name: "Create Template",
});
await user.click(createTemplateButton);
const fromScratchMenuItem = await screen.findByText("From scratch");
await user.click(fromScratchMenuItem);
await screen.findByTestId("new-template-page");
expect(router.state.location.pathname).toBe("/starter-templates");
expect(router.state.location.pathname).toBe("/templates/new");
expect(router.state.location.search).toBe("?exampleId=scratch");
});

View File

@@ -38,6 +38,7 @@ import {
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { useClickableTableRow } from "hooks/useClickableTableRow";
import { useDashboard } from "modules/dashboard/useDashboard";
import { linkToTemplate, useLinks } from "modules/navigation";
import { createDayString } from "utils/createDayString";
import { docs } from "utils/docs";
@@ -45,6 +46,7 @@ import {
formatTemplateBuildTime,
formatTemplateActiveDevelopers,
} from "utils/templates";
import { CreateTemplateButton } from "./CreateTemplateButton";
import { EmptyTemplates } from "./EmptyTemplates";
export const Language = {
@@ -167,38 +169,40 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
examples,
canCreateTemplates,
}) => {
const { experiments } = useDashboard();
const isLoading = !templates;
const isEmpty = templates && templates.length === 0;
const navigate = useNavigate();
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
const createTemplateAction = () => {
return multiOrgExperimentEnabled ? (
<Button
startIcon={<AddIcon />}
variant="contained"
onClick={() => {
navigate("/starter-templates");
}}
>
Create Template
</Button>
) : (
<CreateTemplateButton onNavigate={navigate} />
);
};
return (
<Margins>
<PageHeader
actions={
canCreateTemplates && (
<Button
startIcon={<AddIcon />}
variant="contained"
onClick={() => {
navigate("/starter-templates");
}}
>
Create Template
</Button>
)
}
>
<PageHeader actions={canCreateTemplates && createTemplateAction()}>
<PageHeaderTitle>
<Stack spacing={1} direction="row" alignItems="center">
Templates
<TemplateHelpTooltip />
</Stack>
</PageHeaderTitle>
{templates && templates.length > 0 && (
<PageHeaderSubtitle>
Select a template to create a workspace.
</PageHeaderSubtitle>
)}
<PageHeaderSubtitle>
Select a template to create a workspace.
</PageHeaderSubtitle>
</PageHeader>
{error ? (
@@ -212,7 +216,7 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
<TableCell width="15%">{Language.usedByLabel}</TableCell>
<TableCell width="10%">{Language.buildTimeLabel}</TableCell>
<TableCell width="15%">{Language.lastUpdatedLabel}</TableCell>
<TableCell width="1%"></TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
<TableBody>

View File

@@ -472,6 +472,10 @@ export const router = createBrowserRouter(
{/* Pages that don't have the dashboard layout */}
<Route path="/:username/:workspace" element={<WorkspacePage />} />
<Route
path="/templates/:template/versions/:version/edit"
element={<TemplateVersionEditorPage />}
/>
<Route
path="/templates/:organization/:template/versions/:version/edit"
element={<TemplateVersionEditorPage />}