Compare commits

...

2 Commits

Author SHA1 Message Date
Stephen Kirby
003dc5cc03 chore: patch known bugs in stable (#14925)
- [x] https://github.com/coder/coder/pull/14601
- [x] https://github.com/coder/coder/pull/14602
- [x] https://github.com/coder/coder/pull/14633

---------

Co-authored-by: Justin Fowler <justinfowler1996@gmail.com>
Co-authored-by: Ethan <39577870+ethanndickson@users.noreply.github.com>
Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
2024-10-01 17:20:26 -05:00
Stephen Kirby
190cd1c713 chore: apply fixes for the 2.15 release (#14540)
* Minor fixups, added troubleshooting (#14519)

(cherry picked from commit 66c8060605)

* 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)

---------

Co-authored-by: Danny Kopping <danny@coder.com>
Co-authored-by: Spike Curtis <spike@coder.com>
2024-09-03 11:31:04 -05:00
11 changed files with 226 additions and 43 deletions

View File

@@ -7,7 +7,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
)
@@ -109,9 +108,9 @@ func TestCreateUserRequestJSON(t *testing.T) {
t.Parallel()
req := codersdk.CreateUserRequestWithOrgs{
Email: coderdtest.RandomName(t),
Username: coderdtest.RandomName(t),
Name: coderdtest.RandomName(t),
Email: "alice@coder.com",
Username: "alice",
Name: "Alice User",
Password: "",
UserLoginType: codersdk.LoginTypePassword,
OrganizationIDs: []uuid.UUID{uuid.New(), uuid.New()},
@@ -123,9 +122,9 @@ func TestCreateUserRequestJSON(t *testing.T) {
t.Parallel()
req := codersdk.CreateUserRequestWithOrgs{
Email: coderdtest.RandomName(t),
Username: coderdtest.RandomName(t),
Name: coderdtest.RandomName(t),
Email: "alice@coder.com",
Username: "alice",
Name: "Alice User",
Password: "",
UserLoginType: codersdk.LoginTypePassword,
OrganizationIDs: []uuid.UUID{uuid.New()},
@@ -137,9 +136,9 @@ func TestCreateUserRequestJSON(t *testing.T) {
t.Parallel()
req := codersdk.CreateUserRequestWithOrgs{
Email: coderdtest.RandomName(t),
Username: coderdtest.RandomName(t),
Name: coderdtest.RandomName(t),
Email: "alice@coder.com",
Username: "alice",
Name: "Alice User",
Password: "",
UserLoginType: codersdk.LoginTypePassword,
OrganizationIDs: []uuid.UUID{},

View File

@@ -77,9 +77,9 @@ can only be delivered to one method, and this method is configured globally with
[`CODER_NOTIFICATIONS_METHOD`](https://coder.com/docs/reference/cli/server#--notifications-method)
(default: `smtp`).
Enterprise customers can configured which method to use for each of the
supported [Events](#events); see the [Preferences](#preferences) section below
for more details.
Enterprise customers can configure which method to use for each of the supported
[Events](#events); see the [Preferences](#preferences) section below for more
details.
## SMTP (Email)
@@ -93,7 +93,7 @@ existing one.
| :------: | --------------------------------- | ------------------------------------- | ----------- | ----------------------------------------- | ------------- |
| ✔️ | `--notifications-email-from` | `CODER_NOTIFICATIONS_EMAIL_FROM` | `string` | The sender's address to use. | |
| ✔️ | `--notifications-email-smarthost` | `CODER_NOTIFICATIONS_EMAIL_SMARTHOST` | `host:port` | The SMTP relay to send messages through. | localhost:587 |
| - | `--notifications-email-hello` | `CODER_NOTIFICATIONS_EMAIL_HELLO` | `string` | The hostname identifying the SMTP server. | localhost |
| | `--notifications-email-hello` | `CODER_NOTIFICATIONS_EMAIL_HELLO` | `string` | The hostname identifying the SMTP server. | localhost |
**Authentication Settings:**
@@ -252,6 +252,18 @@ To pause sending notifications, execute
To resume sending notifications, execute
[`coder notifications resume`](https://coder.com/docs/reference/cli/notifications_resume).
## Troubleshooting
If notifications are not being delivered, use the following methods to
troubleshoot:
1. Ensure notifications are being added to the `notification_messages` table
2. Review any error messages in the `status_reason` column, should an error have
occurred
3. Review the logs (search for the term `notifications`) for diagnostic
information<br> _If you do not see any relevant logs, set
`CODER_VERBOSE=true` or `--verbose` to output debug logs_
## Internals
The notification system is built to operate concurrently in a single- or
@@ -288,5 +300,4 @@ messages._
- after `CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS` is exceeded, it transitions to
`permanent_failure`
Diagnostic messages will be saved in the `notification_messages` table and will
be logged, in the case of failure.
See [Troubleshooting](#troubleshooting) above for more details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 71 KiB

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

@@ -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

@@ -826,6 +826,25 @@ func TestLicenseEntitlements(t *testing.T) {
assert.True(t, entitlements.Features[codersdk.FeatureCustomRoles].Enabled, "custom-roles 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)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureCustomRoles].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

@@ -154,7 +154,8 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
submitDisabled={disabled}
submitLabel="Submit and restart"
submitDisabled={disabled || !form.dirty}
/>
</HorizontalForm>
</>

View File

@@ -61,7 +61,9 @@ test("Submit the workspace settings page successfully", async () => {
);
await user.clear(parameter2);
await user.type(parameter2, "1");
await user.click(within(form).getByRole("button", { name: "Submit" }));
await user.click(
within(form).getByRole("button", { name: "Submit and restart" }),
);
// Assert that the API calls were made with the correct data
await waitFor(() => {
expect(postWorkspaceBuildSpy).toHaveBeenCalledWith(MockWorkspace.id, {
@@ -73,3 +75,58 @@ test("Submit the workspace settings page successfully", async () => {
});
});
});
test("Submit button is only enabled when changes are made", async () => {
// Mock the API calls that loads data
jest
.spyOn(API, "getWorkspaceByOwnerAndName")
.mockResolvedValueOnce(MockWorkspace);
jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
// Immutable parameters
MockTemplateVersionParameter4,
]);
jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([
MockWorkspaceBuildParameter1,
MockWorkspaceBuildParameter2,
// Immutable value
MockWorkspaceBuildParameter4,
]);
// Setup event and rendering
const user = userEvent.setup();
renderWithWorkspaceSettingsLayout(<WorkspaceParametersPage />, {
route: "/@test-user/test-workspace/settings",
path: "/:username/:workspace/settings",
// Need this because after submit the user is redirected
extraRoutes: [{ path: "/:username/:workspace", element: <div /> }],
});
await waitForLoaderToBeRemoved();
const submitButton: HTMLButtonElement = screen.getByRole("button", {
name: "Submit and restart",
});
const form = screen.getByTestId("form");
const parameter1 = within(form).getByLabelText(
MockWorkspaceBuildParameter1.name,
{ exact: false },
);
// There are no changes, the button should be disabled.
expect(submitButton.disabled).toBeTruthy();
// Make changes to the form
await user.clear(parameter1);
await user.type(parameter1, "new-value");
// There are now changes, the button should be enabled.
expect(submitButton.disabled).toBeFalsy();
// Change form value back to default
await user.clear(parameter1);
await user.type(parameter1, MockWorkspaceBuildParameter1.value);
// There are now no changes, the button should be disabled.
expect(submitButton.disabled).toBeTruthy();
});

View File

@@ -9,7 +9,7 @@ function defaultDocsUrl(): string {
}
// Strip the postfix version info that's not part of the link.
const i = version?.indexOf("-") ?? -1;
const i = version?.match(/[+-]/)?.index ?? -1;
if (i >= 0) {
version = version.slice(0, i);
}