Merge branch 'main' of github.com:/coder/coder into dk/prebuilds

Signed-off-by: Danny Kopping <dannykopping@gmail.com>
This commit is contained in:
Danny Kopping
2025-03-04 10:06:58 +00:00
210 changed files with 5932 additions and 1846 deletions

View File

@ -5,13 +5,13 @@ import (
"encoding/json"
"fmt"
"net/url"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"tailscale.com/tailcfg"

View File

@ -34,11 +34,12 @@ func TestInsertCustomRoles(t *testing.T) {
}
}
canAssignRole := rbac.Role{
canCreateCustomRole := rbac.Role{
Identifier: rbac.RoleIdentifier{Name: "can-assign"},
DisplayName: "",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceAssignRole.Type: {policy.ActionRead, policy.ActionCreate},
rbac.ResourceAssignRole.Type: {policy.ActionRead},
rbac.ResourceAssignOrgRole.Type: {policy.ActionRead, policy.ActionCreate},
}),
}
@ -61,17 +62,15 @@ func TestInsertCustomRoles(t *testing.T) {
return all
}
orgID := uuid.NullUUID{
UUID: uuid.New(),
Valid: true,
}
orgID := uuid.New()
testCases := []struct {
name string
subject rbac.ExpandableRoles
// Perms to create on new custom role
organizationID uuid.NullUUID
organizationID uuid.UUID
site []codersdk.Permission
org []codersdk.Permission
user []codersdk.Permission
@ -79,19 +78,21 @@ func TestInsertCustomRoles(t *testing.T) {
}{
{
// No roles, so no assign role
name: "no-roles",
subject: rbac.RoleIdentifiers{},
errorContains: "forbidden",
name: "no-roles",
organizationID: orgID,
subject: rbac.RoleIdentifiers{},
errorContains: "forbidden",
},
{
// This works because the new role has 0 perms
name: "empty",
subject: merge(canAssignRole),
name: "empty",
organizationID: orgID,
subject: merge(canCreateCustomRole),
},
{
name: "mixed-scopes",
subject: merge(canAssignRole, rbac.RoleOwner()),
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
@ -101,27 +102,30 @@ func TestInsertCustomRoles(t *testing.T) {
errorContains: "organization roles specify site or user permissions",
},
{
name: "invalid-action",
subject: merge(canAssignRole, rbac.RoleOwner()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
name: "invalid-action",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
// Action does not go with resource
codersdk.ResourceWorkspace: {codersdk.ActionViewInsights},
}),
errorContains: "invalid action",
},
{
name: "invalid-resource",
subject: merge(canAssignRole, rbac.RoleOwner()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
name: "invalid-resource",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
"foobar": {codersdk.ActionViewInsights},
}),
errorContains: "invalid resource",
},
{
// Not allowing these at this time.
name: "negative-permission",
subject: merge(canAssignRole, rbac.RoleOwner()),
site: []codersdk.Permission{
name: "negative-permission",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
org: []codersdk.Permission{
{
Negate: true,
ResourceType: codersdk.ResourceWorkspace,
@ -131,89 +135,69 @@ func TestInsertCustomRoles(t *testing.T) {
errorContains: "no negative permissions",
},
{
name: "wildcard", // not allowed
subject: merge(canAssignRole, rbac.RoleOwner()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
name: "wildcard", // not allowed
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {"*"},
}),
errorContains: "no wildcard symbols",
},
// escalation checks
{
name: "read-workspace-escalation",
subject: merge(canAssignRole),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
name: "read-workspace-escalation",
organizationID: orgID,
subject: merge(canCreateCustomRole),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
errorContains: "not allowed to grant this permission",
},
{
name: "read-workspace-outside-org",
organizationID: uuid.NullUUID{
UUID: uuid.New(),
Valid: true,
},
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
name: "read-workspace-outside-org",
organizationID: uuid.New(),
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
errorContains: "forbidden",
errorContains: "not allowed to grant this permission",
},
{
name: "user-escalation",
// These roles do not grant user perms
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
errorContains: "not allowed to grant this permission",
errorContains: "organization roles specify site or user permissions",
},
{
name: "template-admin-escalation",
subject: merge(canAssignRole, rbac.RoleTemplateAdmin()),
name: "site-escalation",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead}, // ok!
codersdk.ResourceDeploymentConfig: {codersdk.ActionUpdate}, // not ok!
}),
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead}, // ok!
}),
errorContains: "deployment_config",
errorContains: "organization roles specify site or user permissions",
},
// ok!
{
name: "read-workspace-template-admin",
subject: merge(canAssignRole, rbac.RoleTemplateAdmin()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
name: "read-workspace-template-admin",
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
},
{
name: "read-workspace-in-org",
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
organizationID: orgID,
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
},
{
name: "user-perms",
// This is weird, but is ok
subject: merge(canAssignRole, rbac.RoleMember()),
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
},
{
name: "site+user-perms",
subject: merge(canAssignRole, rbac.RoleMember(), rbac.RoleTemplateAdmin()),
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
},
}
for _, tc := range testCases {
@ -234,7 +218,7 @@ func TestInsertCustomRoles(t *testing.T) {
_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: "test-role",
DisplayName: "",
OrganizationID: tc.organizationID,
OrganizationID: uuid.NullUUID{UUID: tc.organizationID, Valid: true},
SitePermissions: db2sdk.List(tc.site, convertSDKPerm),
OrgPermissions: db2sdk.List(tc.org, convertSDKPerm),
UserPermissions: db2sdk.List(tc.user, convertSDKPerm),
@ -249,11 +233,11 @@ func TestInsertCustomRoles(t *testing.T) {
LookupRoles: []database.NameOrganizationPair{
{
Name: "test-role",
OrganizationID: tc.organizationID.UUID,
OrganizationID: tc.organizationID,
},
},
ExcludeOrgRoles: false,
OrganizationID: uuid.UUID{},
OrganizationID: uuid.Nil,
})
require.NoError(t, err)
require.Len(t, roles, 1)

View File

@ -5,13 +5,13 @@ import (
"database/sql"
"encoding/json"
"errors"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/open-policy-agent/opa/topdown"
@ -281,6 +281,7 @@ var (
DisplayName: "Notifier",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceInboxNotification.Type: {policy.ActionCreate},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
@ -747,7 +748,7 @@ func (*querier) convertToDeploymentRoles(names []string) []rbac.RoleIdentifier {
}
// canAssignRoles handles assigning built in and custom roles.
func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, removed []rbac.RoleIdentifier) error {
func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, removed []rbac.RoleIdentifier) error {
actor, ok := ActorFromContext(ctx)
if !ok {
return NoActorError
@ -755,12 +756,14 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
roleAssign := rbac.ResourceAssignRole
shouldBeOrgRoles := false
if orgID != nil {
roleAssign = rbac.ResourceAssignOrgRole.InOrg(*orgID)
if orgID != uuid.Nil {
roleAssign = rbac.ResourceAssignOrgRole.InOrg(orgID)
shouldBeOrgRoles = true
}
grantedRoles := append(added, removed...)
grantedRoles := make([]rbac.RoleIdentifier, 0, len(added)+len(removed))
grantedRoles = append(grantedRoles, added...)
grantedRoles = append(grantedRoles, removed...)
customRoles := make([]rbac.RoleIdentifier, 0)
// Validate that the roles being assigned are valid.
for _, r := range grantedRoles {
@ -774,11 +777,11 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
}
if shouldBeOrgRoles {
if orgID == nil {
if orgID == uuid.Nil {
return xerrors.Errorf("should never happen, orgID is nil, but trying to assign an organization role")
}
if r.OrganizationID != *orgID {
if r.OrganizationID != orgID {
return xerrors.Errorf("attempted to assign role from a different org, role %q to %q", r, orgID.String())
}
}
@ -824,7 +827,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
}
if len(removed) > 0 {
if err := q.authorizeContext(ctx, policy.ActionDelete, roleAssign); err != nil {
if err := q.authorizeContext(ctx, policy.ActionUnassign, roleAssign); err != nil {
return err
}
}
@ -1133,11 +1136,23 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error {
return q.db.CleanTailnetTunnels(ctx)
}
func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceInboxNotification.WithOwner(userID.String())); err != nil {
return 0, err
}
return q.db.CountUnreadInboxNotificationsByUserID(ctx, userID)
}
// TODO: Handle org scoped lookups
func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAssignRole); err != nil {
roleObject := rbac.ResourceAssignRole
if arg.OrganizationID != uuid.Nil {
roleObject = rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID)
}
if err := q.authorizeContext(ctx, policy.ActionRead, roleObject); err != nil {
return nil, err
}
return q.db.CustomRoles(ctx, arg)
}
@ -1194,14 +1209,11 @@ func (q *querier) DeleteCryptoKey(ctx context.Context, arg database.DeleteCrypto
}
func (q *querier) DeleteCustomRole(ctx context.Context, arg database.DeleteCustomRoleParams) error {
if arg.OrganizationID.UUID != uuid.Nil {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
return err
}
} else {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignRole); err != nil {
return err
}
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
return NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
}
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
return err
}
return q.db.DeleteCustomRole(ctx, arg)
@ -1435,6 +1447,17 @@ func (q *querier) FetchMemoryResourceMonitorsByAgentID(ctx context.Context, agen
return q.db.FetchMemoryResourceMonitorsByAgentID(ctx, agentID)
}
func (q *querier) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
// Ideally, we would return a list of monitors that the user has access to. However, that check would need to
// be implemented similarly to GetWorkspaces, which is more complex than what we're doing here. Since this query
// was introduced for telemetry, we perform a simpler check.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil {
return nil, err
}
return q.db.FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt)
}
func (q *querier) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationMessage); err != nil {
return database.FetchNewMessageMetadataRow{}, err
@ -1456,6 +1479,17 @@ func (q *querier) FetchVolumesResourceMonitorsByAgentID(ctx context.Context, age
return q.db.FetchVolumesResourceMonitorsByAgentID(ctx, agentID)
}
func (q *querier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
// Ideally, we would return a list of monitors that the user has access to. However, that check would need to
// be implemented similarly to GetWorkspaces, which is more complex than what we're doing here. Since this query
// was introduced for telemetry, we perform a simpler check.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil {
return nil, err
}
return q.db.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt)
}
func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id)
}
@ -1695,6 +1729,10 @@ func (q *querier) GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]dat
return q.db.GetFileTemplates(ctx, fileID)
}
func (q *querier) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetFilteredInboxNotificationsByUserID)(ctx, arg)
}
func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetGitSSHKey)(ctx, userID)
}
@ -1754,6 +1792,14 @@ func (q *querier) GetHungProvisionerJobs(ctx context.Context, hungSince time.Tim
return q.db.GetHungProvisionerJobs(ctx, hungSince)
}
func (q *querier) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
return fetchWithAction(q.log, q.auth, policy.ActionRead, q.db.GetInboxNotificationByID)(ctx, id)
}
func (q *querier) GetInboxNotificationsByUserID(ctx context.Context, userID database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetInboxNotificationsByUserID)(ctx, userID)
}
func (q *querier) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) {
if _, err := fetch(q.log, q.auth, q.db.GetWorkspaceByID)(ctx, arg.WorkspaceID); err != nil {
return database.JfrogXrayScan{}, err
@ -3046,14 +3092,11 @@ func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCrypto
func (q *querier) InsertCustomRole(ctx context.Context, arg database.InsertCustomRoleParams) (database.CustomRole, error) {
// Org and site role upsert share the same query. So switch the assertion based on the org uuid.
if arg.OrganizationID.UUID != uuid.Nil {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
return database.CustomRole{}, err
}
} else {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignRole); err != nil {
return database.CustomRole{}, err
}
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
return database.CustomRole{}, NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
}
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
return database.CustomRole{}, err
}
if err := q.customRoleCheck(ctx, database.CustomRole{
@ -3116,6 +3159,10 @@ func (q *querier) InsertGroupMember(ctx context.Context, arg database.InsertGrou
return update(q.log, q.auth, fetch, q.db.InsertGroupMember)(ctx, arg)
}
func (q *querier) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) {
return insert(q.log, q.auth, rbac.ResourceInboxNotification.WithOwner(arg.UserID.String()), q.db.InsertInboxNotification)(ctx, arg)
}
func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceLicense); err != nil {
return database.License{}, err
@ -3183,7 +3230,7 @@ func (q *querier) InsertOrganizationMember(ctx context.Context, arg database.Ins
// All roles are added roles. Org member is always implied.
addedRoles := append(orgRoles, rbac.ScopedRoleOrgMember(arg.OrganizationID))
err = q.canAssignRoles(ctx, &arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{})
err = q.canAssignRoles(ctx, arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{})
if err != nil {
return database.OrganizationMember{}, err
}
@ -3314,7 +3361,7 @@ func (q *querier) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg dat
func (q *querier) InsertUser(ctx context.Context, arg database.InsertUserParams) (database.User, error) {
// Always check if the assigned roles can actually be assigned by this actor.
impliedRoles := append([]rbac.RoleIdentifier{rbac.RoleMember()}, q.convertToDeploymentRoles(arg.RBACRoles)...)
err := q.canAssignRoles(ctx, nil, impliedRoles, []rbac.RoleIdentifier{})
err := q.canAssignRoles(ctx, uuid.Nil, impliedRoles, []rbac.RoleIdentifier{})
if err != nil {
return database.User{}, err
}
@ -3652,14 +3699,11 @@ func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.Upd
}
func (q *querier) UpdateCustomRole(ctx context.Context, arg database.UpdateCustomRoleParams) (database.CustomRole, error) {
if arg.OrganizationID.UUID != uuid.Nil {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
return database.CustomRole{}, err
}
} else {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignRole); err != nil {
return database.CustomRole{}, err
}
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
return database.CustomRole{}, NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
return database.CustomRole{}, err
}
if err := q.customRoleCheck(ctx, database.CustomRole{
@ -3713,6 +3757,14 @@ func (q *querier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfte
return q.db.UpdateInactiveUsersToDormant(ctx, lastSeenAfter)
}
func (q *querier) UpdateInboxNotificationReadStatus(ctx context.Context, args database.UpdateInboxNotificationReadStatusParams) error {
fetchFunc := func(ctx context.Context, args database.UpdateInboxNotificationReadStatusParams) (database.InboxNotification, error) {
return q.db.GetInboxNotificationByID(ctx, args.ID)
}
return update(q.log, q.auth, fetchFunc, q.db.UpdateInboxNotificationReadStatus)(ctx, args)
}
func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
// Authorized fetch will check that the actor has read access to the org member since the org member is returned.
member, err := database.ExpectOne(q.OrganizationMembers(ctx, database.OrganizationMembersParams{
@ -3739,7 +3791,7 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb
impliedTypes := append(scopedGranted, rbac.ScopedRoleOrgMember(arg.OrgID))
added, removed := rbac.ChangeRoleSet(originalRoles, impliedTypes)
err = q.canAssignRoles(ctx, &arg.OrgID, added, removed)
err = q.canAssignRoles(ctx, arg.OrgID, added, removed)
if err != nil {
return database.OrganizationMember{}, err
}
@ -4146,7 +4198,7 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo
impliedTypes := append(q.convertToDeploymentRoles(arg.GrantedRoles), rbac.RoleMember())
// If the changeset is nothing, less rbac checks need to be done.
added, removed := rbac.ChangeRoleSet(q.convertToDeploymentRoles(user.RBACRoles), impliedTypes)
err = q.canAssignRoles(ctx, nil, added, removed)
err = q.canAssignRoles(ctx, uuid.Nil, added, removed)
if err != nil {
return database.User{}, err
}

View File

@ -1011,7 +1011,7 @@ func (s *MethodTestSuite) TestOrganization() {
Asserts(
mem, policy.ActionRead,
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, // org-mem
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionDelete, // org-admin
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionUnassign, // org-admin
).Returns(out)
}))
}
@ -1619,7 +1619,7 @@ func (s *MethodTestSuite) TestUser() {
}).Asserts(
u, policy.ActionRead,
rbac.ResourceAssignRole, policy.ActionAssign,
rbac.ResourceAssignRole, policy.ActionDelete,
rbac.ResourceAssignRole, policy.ActionUnassign,
).Returns(o)
}))
s.Run("AllUserIDs", s.Subtest(func(db database.Store, check *expects) {
@ -1653,30 +1653,28 @@ func (s *MethodTestSuite) TestUser() {
check.Args(database.DeleteCustomRoleParams{
Name: customRole.Name,
}).Asserts(
rbac.ResourceAssignRole, policy.ActionDelete)
// fails immediately, missing organization id
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
}))
s.Run("Blank/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{})
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{
OrganizationID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
})
// Blank is no perms in the role
check.Args(database.UpdateCustomRoleParams{
Name: customRole.Name,
DisplayName: "Test Name",
OrganizationID: customRole.OrganizationID,
SitePermissions: nil,
OrgPermissions: nil,
UserPermissions: nil,
}).Asserts(rbac.ResourceAssignRole, policy.ActionUpdate).ErrorsWithPG(sql.ErrNoRows)
}).Asserts(rbac.ResourceAssignOrgRole.InOrg(customRole.OrganizationID.UUID), policy.ActionUpdate)
}))
s.Run("SitePermissions/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{
OrganizationID: uuid.NullUUID{
UUID: uuid.Nil,
Valid: false,
},
})
check.Args(database.UpdateCustomRoleParams{
Name: customRole.Name,
OrganizationID: customRole.OrganizationID,
Name: "",
OrganizationID: uuid.NullUUID{UUID: uuid.Nil, Valid: false},
DisplayName: "Test Name",
SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights},
@ -1686,17 +1684,8 @@ func (s *MethodTestSuite) TestUser() {
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}), convertSDKPerm),
}).Asserts(
// First check
rbac.ResourceAssignRole, policy.ActionUpdate,
// Escalation checks
rbac.ResourceTemplate, policy.ActionCreate,
rbac.ResourceTemplate, policy.ActionRead,
rbac.ResourceTemplate, policy.ActionUpdate,
rbac.ResourceTemplate, policy.ActionDelete,
rbac.ResourceTemplate, policy.ActionViewInsights,
rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead,
).ErrorsWithPG(sql.ErrNoRows)
// fails immediately, missing organization id
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
}))
s.Run("OrgPermissions/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
orgID := uuid.New()
@ -1726,13 +1715,15 @@ func (s *MethodTestSuite) TestUser() {
}))
s.Run("Blank/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
// Blank is no perms in the role
orgID := uuid.New()
check.Args(database.InsertCustomRoleParams{
Name: "test",
DisplayName: "Test Name",
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
SitePermissions: nil,
OrgPermissions: nil,
UserPermissions: nil,
}).Asserts(rbac.ResourceAssignRole, policy.ActionCreate)
}).Asserts(rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionCreate)
}))
s.Run("SitePermissions/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.InsertCustomRoleParams{
@ -1746,17 +1737,8 @@ func (s *MethodTestSuite) TestUser() {
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}), convertSDKPerm),
}).Asserts(
// First check
rbac.ResourceAssignRole, policy.ActionCreate,
// Escalation checks
rbac.ResourceTemplate, policy.ActionCreate,
rbac.ResourceTemplate, policy.ActionRead,
rbac.ResourceTemplate, policy.ActionUpdate,
rbac.ResourceTemplate, policy.ActionDelete,
rbac.ResourceTemplate, policy.ActionViewInsights,
rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead,
)
// fails immediately, missing organization id
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
}))
s.Run("OrgPermissions/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
orgID := uuid.New()
@ -4484,6 +4466,141 @@ func (s *MethodTestSuite) TestNotifications() {
Disableds: []bool{true, false},
}).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionUpdate)
}))
s.Run("GetInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
notifID := uuid.New()
notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
ID: notifID,
UserID: u.ID,
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
Title: "test title",
Content: "test content notification",
Icon: "https://coder.com/favicon.ico",
Actions: json.RawMessage("{}"),
})
check.Args(database.GetInboxNotificationsByUserIDParams{
UserID: u.ID,
ReadStatus: database.InboxNotificationReadStatusAll,
}).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif})
}))
s.Run("GetFilteredInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
notifID := uuid.New()
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
ID: notifID,
UserID: u.ID,
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
Targets: targets,
Title: "test title",
Content: "test content notification",
Icon: "https://coder.com/favicon.ico",
Actions: json.RawMessage("{}"),
})
check.Args(database.GetFilteredInboxNotificationsByUserIDParams{
UserID: u.ID,
Templates: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated},
Targets: []uuid.UUID{u.ID},
ReadStatus: database.InboxNotificationReadStatusAll,
}).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif})
}))
s.Run("GetInboxNotificationByID", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
notifID := uuid.New()
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
ID: notifID,
UserID: u.ID,
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
Targets: targets,
Title: "test title",
Content: "test content notification",
Icon: "https://coder.com/favicon.ico",
Actions: json.RawMessage("{}"),
})
check.Args(notifID).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns(notif)
}))
s.Run("CountUnreadInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
notifID := uuid.New()
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
_ = dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
ID: notifID,
UserID: u.ID,
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
Targets: targets,
Title: "test title",
Content: "test content notification",
Icon: "https://coder.com/favicon.ico",
Actions: json.RawMessage("{}"),
})
check.Args(u.ID).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionRead).Returns(int64(1))
}))
s.Run("InsertInboxNotification", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
notifID := uuid.New()
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
check.Args(database.InsertInboxNotificationParams{
ID: notifID,
UserID: u.ID,
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
Targets: targets,
Title: "test title",
Content: "test content notification",
Icon: "https://coder.com/favicon.ico",
Actions: json.RawMessage("{}"),
}).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionCreate)
}))
s.Run("UpdateInboxNotificationReadStatus", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
notifID := uuid.New()
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
readAt := dbtestutil.NowInDefaultTimezone()
notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
ID: notifID,
UserID: u.ID,
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
Targets: targets,
Title: "test title",
Content: "test content notification",
Icon: "https://coder.com/favicon.ico",
Actions: json.RawMessage("{}"),
})
notif.ReadAt = sql.NullTime{Time: readAt, Valid: true}
check.Args(database.UpdateInboxNotificationReadStatusParams{
ID: notifID,
ReadAt: sql.NullTime{Time: readAt, Valid: true},
}).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionUpdate)
}))
}
func (s *MethodTestSuite) TestOAuth2ProviderApps() {
@ -4802,6 +4919,14 @@ func (s *MethodTestSuite) TestResourcesMonitor() {
}).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionUpdate)
}))
s.Run("FetchMemoryResourceMonitorsUpdatedAfter", s.Subtest(func(db database.Store, check *expects) {
check.Args(dbtime.Now()).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionRead)
}))
s.Run("FetchVolumesResourceMonitorsUpdatedAfter", s.Subtest(func(db database.Store, check *expects) {
check.Args(dbtime.Now()).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionRead)
}))
s.Run("FetchMemoryResourceMonitorsByAgentID", s.Subtest(func(db database.Store, check *expects) {
agt, w := createAgent(s.T(), db)

View File

@ -450,6 +450,22 @@ func OrganizationMember(t testing.TB, db database.Store, orig database.Organizat
return mem
}
func NotificationInbox(t testing.TB, db database.Store, orig database.InsertInboxNotificationParams) database.InboxNotification {
notification, err := db.InsertInboxNotification(genCtx, database.InsertInboxNotificationParams{
ID: takeFirst(orig.ID, uuid.New()),
UserID: takeFirst(orig.UserID, uuid.New()),
TemplateID: takeFirst(orig.TemplateID, uuid.New()),
Targets: takeFirstSlice(orig.Targets, []uuid.UUID{}),
Title: takeFirst(orig.Title, testutil.GetRandomName(t)),
Content: takeFirst(orig.Content, testutil.GetRandomName(t)),
Icon: takeFirst(orig.Icon, ""),
Actions: orig.Actions,
CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
})
require.NoError(t, err, "insert notification")
return notification
}
func Group(t testing.TB, db database.Store, orig database.Group) database.Group {
t.Helper()

View File

@ -10,6 +10,7 @@ import (
"math"
"reflect"
"regexp"
"slices"
"sort"
"strings"
"sync"
@ -19,7 +20,6 @@ import (
"github.com/lib/pq"
"golang.org/x/exp/constraints"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/notifications/types"
@ -67,6 +67,7 @@ func New() database.Store {
gitSSHKey: make([]database.GitSSHKey, 0),
notificationMessages: make([]database.NotificationMessage, 0),
notificationPreferences: make([]database.NotificationPreference, 0),
InboxNotification: make([]database.InboxNotification, 0),
parameterSchemas: make([]database.ParameterSchema, 0),
provisionerDaemons: make([]database.ProvisionerDaemon, 0),
provisionerKeys: make([]database.ProvisionerKey, 0),
@ -206,6 +207,7 @@ type data struct {
notificationMessages []database.NotificationMessage
notificationPreferences []database.NotificationPreference
notificationReportGeneratorLogs []database.NotificationReportGeneratorLog
InboxNotification []database.InboxNotification
oauth2ProviderApps []database.OAuth2ProviderApp
oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret
oauth2ProviderAppCodes []database.OAuth2ProviderAppCode
@ -269,7 +271,7 @@ type data struct {
presetParameters []database.TemplateVersionPresetParameter
}
func tryPercentile(fs []float64, p float64) float64 {
func tryPercentileCont(fs []float64, p float64) float64 {
if len(fs) == 0 {
return -1
}
@ -282,6 +284,14 @@ func tryPercentile(fs []float64, p float64) float64 {
return fs[lower] + (fs[upper]-fs[lower])*(pos-float64(lower))
}
func tryPercentileDisc(fs []float64, p float64) float64 {
if len(fs) == 0 {
return -1
}
sort.Float64s(fs)
return fs[max(int(math.Ceil(float64(len(fs))*p/100-1)), 0)]
}
func validateDatabaseTypeWithValid(v reflect.Value) (handled bool, err error) {
if v.Kind() == reflect.Struct {
return false, nil
@ -1139,7 +1149,119 @@ func getOwnerFromTags(tags map[string]string) string {
return ""
}
func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLocked(_ context.Context, ids []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) {
// provisionerTagsetContains checks if daemonTags contain all key-value pairs from jobTags
func provisionerTagsetContains(daemonTags, jobTags map[string]string) bool {
for jobKey, jobValue := range jobTags {
if daemonValue, exists := daemonTags[jobKey]; !exists || daemonValue != jobValue {
return false
}
}
return true
}
// GetProvisionerJobsByIDsWithQueuePosition mimics the SQL logic in pure Go
func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLockedTagBasedQueue(_ context.Context, jobIDs []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) {
// Step 1: Filter provisionerJobs based on jobIDs
filteredJobs := make(map[uuid.UUID]database.ProvisionerJob)
for _, job := range q.provisionerJobs {
for _, id := range jobIDs {
if job.ID == id {
filteredJobs[job.ID] = job
}
}
}
// Step 2: Identify pending jobs
pendingJobs := make(map[uuid.UUID]database.ProvisionerJob)
for _, job := range q.provisionerJobs {
if job.JobStatus == "pending" {
pendingJobs[job.ID] = job
}
}
// Step 3: Identify pending jobs that have a matching provisioner
matchedJobs := make(map[uuid.UUID]struct{})
for _, job := range pendingJobs {
for _, daemon := range q.provisionerDaemons {
if provisionerTagsetContains(daemon.Tags, job.Tags) {
matchedJobs[job.ID] = struct{}{}
break
}
}
}
// Step 4: Rank pending jobs per provisioner
jobRanks := make(map[uuid.UUID][]database.ProvisionerJob)
for _, job := range pendingJobs {
for _, daemon := range q.provisionerDaemons {
if provisionerTagsetContains(daemon.Tags, job.Tags) {
jobRanks[daemon.ID] = append(jobRanks[daemon.ID], job)
}
}
}
// Sort jobs per provisioner by CreatedAt
for daemonID := range jobRanks {
sort.Slice(jobRanks[daemonID], func(i, j int) bool {
return jobRanks[daemonID][i].CreatedAt.Before(jobRanks[daemonID][j].CreatedAt)
})
}
// Step 5: Compute queue position & max queue size across all provisioners
jobQueueStats := make(map[uuid.UUID]database.GetProvisionerJobsByIDsWithQueuePositionRow)
for _, jobs := range jobRanks {
queueSize := int64(len(jobs)) // Queue size per provisioner
for i, job := range jobs {
queuePosition := int64(i + 1)
// If the job already exists, update only if this queuePosition is better
if existing, exists := jobQueueStats[job.ID]; exists {
jobQueueStats[job.ID] = database.GetProvisionerJobsByIDsWithQueuePositionRow{
ID: job.ID,
CreatedAt: job.CreatedAt,
ProvisionerJob: job,
QueuePosition: min(existing.QueuePosition, queuePosition),
QueueSize: max(existing.QueueSize, queueSize), // Take the maximum queue size across provisioners
}
} else {
jobQueueStats[job.ID] = database.GetProvisionerJobsByIDsWithQueuePositionRow{
ID: job.ID,
CreatedAt: job.CreatedAt,
ProvisionerJob: job,
QueuePosition: queuePosition,
QueueSize: queueSize,
}
}
}
}
// Step 6: Compute the final results with minimal checks
var results []database.GetProvisionerJobsByIDsWithQueuePositionRow
for _, job := range filteredJobs {
// If the job has a computed rank, use it
if rank, found := jobQueueStats[job.ID]; found {
results = append(results, rank)
} else {
// Otherwise, return (0,0) for non-pending jobs and unranked pending jobs
results = append(results, database.GetProvisionerJobsByIDsWithQueuePositionRow{
ID: job.ID,
CreatedAt: job.CreatedAt,
ProvisionerJob: job,
QueuePosition: 0,
QueueSize: 0,
})
}
}
// Step 7: Sort results by CreatedAt
sort.Slice(results, func(i, j int) bool {
return results[i].CreatedAt.Before(results[j].CreatedAt)
})
return results, nil
}
func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLockedGlobalQueue(_ context.Context, ids []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) {
// WITH pending_jobs AS (
// SELECT
// id, created_at
@ -1602,6 +1724,26 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error {
return ErrUnimplemented
}
func (q *FakeQuerier) CountUnreadInboxNotificationsByUserID(_ context.Context, userID uuid.UUID) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
var count int64
for _, notification := range q.InboxNotification {
if notification.UserID != userID {
continue
}
if notification.ReadAt.Valid {
continue
}
count++
}
return count, nil
}
func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -2365,6 +2507,19 @@ func (q *FakeQuerier) FetchMemoryResourceMonitorsByAgentID(_ context.Context, ag
return database.WorkspaceAgentMemoryResourceMonitor{}, sql.ErrNoRows
}
func (q *FakeQuerier) FetchMemoryResourceMonitorsUpdatedAfter(_ context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
monitors := []database.WorkspaceAgentMemoryResourceMonitor{}
for _, monitor := range q.workspaceAgentMemoryResourceMonitors {
if monitor.UpdatedAt.After(updatedAt) {
monitors = append(monitors, monitor)
}
}
return monitors, nil
}
func (q *FakeQuerier) FetchNewMessageMetadata(_ context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
err := validateDatabaseType(arg)
if err != nil {
@ -2409,6 +2564,19 @@ func (q *FakeQuerier) FetchVolumesResourceMonitorsByAgentID(_ context.Context, a
return monitors, nil
}
func (q *FakeQuerier) FetchVolumesResourceMonitorsUpdatedAfter(_ context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
monitors := []database.WorkspaceAgentVolumeResourceMonitor{}
for _, monitor := range q.workspaceAgentVolumeResourceMonitors {
if monitor.UpdatedAt.After(updatedAt) {
monitors = append(monitors, monitor)
}
}
return monitors, nil
}
func (q *FakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -2794,8 +2962,8 @@ func (q *FakeQuerier) GetDeploymentWorkspaceAgentStats(_ context.Context, create
latencies = append(latencies, agentStat.ConnectionMedianLatencyMS)
}
stat.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50)
stat.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95)
stat.WorkspaceConnectionLatency50 = tryPercentileCont(latencies, 50)
stat.WorkspaceConnectionLatency95 = tryPercentileCont(latencies, 95)
return stat, nil
}
@ -2843,8 +3011,8 @@ func (q *FakeQuerier) GetDeploymentWorkspaceAgentUsageStats(_ context.Context, c
stat.WorkspaceTxBytes += agentStat.TxBytes
latencies = append(latencies, agentStat.ConnectionMedianLatencyMS)
}
stat.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50)
stat.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95)
stat.WorkspaceConnectionLatency50 = tryPercentileCont(latencies, 50)
stat.WorkspaceConnectionLatency95 = tryPercentileCont(latencies, 95)
for _, agentStat := range sessions {
stat.SessionCountVSCode += agentStat.SessionCountVSCode
@ -3126,6 +3294,45 @@ func (q *FakeQuerier) GetFileTemplates(_ context.Context, id uuid.UUID) ([]datab
return rows, nil
}
func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
notifications := make([]database.InboxNotification, 0)
for _, notification := range q.InboxNotification {
if notification.UserID == arg.UserID {
for _, template := range arg.Templates {
templateFound := false
if notification.TemplateID == template {
templateFound = true
}
if !templateFound {
continue
}
}
for _, target := range arg.Targets {
isFound := false
for _, insertedTarget := range notification.Targets {
if insertedTarget == target {
isFound = true
break
}
}
if !isFound {
continue
}
notifications = append(notifications, notification)
}
}
}
return notifications, nil
}
func (q *FakeQuerier) GetGitSSHKey(_ context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -3324,6 +3531,33 @@ func (q *FakeQuerier) GetHungProvisionerJobs(_ context.Context, hungSince time.T
return hungJobs, nil
}
func (q *FakeQuerier) GetInboxNotificationByID(_ context.Context, id uuid.UUID) (database.InboxNotification, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, notification := range q.InboxNotification {
if notification.ID == id {
return notification, nil
}
}
return database.InboxNotification{}, sql.ErrNoRows
}
func (q *FakeQuerier) GetInboxNotificationsByUserID(_ context.Context, params database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
notifications := make([]database.InboxNotification, 0)
for _, notification := range q.InboxNotification {
if notification.UserID == params.UserID {
notifications = append(notifications, notification)
}
}
return notifications, nil
}
func (q *FakeQuerier) GetJFrogXrayScanByWorkspaceAndAgentID(_ context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) {
err := validateDatabaseType(arg)
if err != nil {
@ -4085,7 +4319,7 @@ func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.
}
slices.SortFunc(rows, func(a, b database.GetProvisionerDaemonsWithStatusByOrganizationRow) int {
return a.ProvisionerDaemon.CreatedAt.Compare(b.ProvisionerDaemon.CreatedAt)
return b.ProvisionerDaemon.CreatedAt.Compare(a.ProvisionerDaemon.CreatedAt)
})
if arg.Limit.Valid && arg.Limit.Int32 > 0 && len(rows) > int(arg.Limit.Int32) {
@ -4153,7 +4387,7 @@ func (q *FakeQuerier) GetProvisionerJobsByIDsWithQueuePosition(ctx context.Conte
if ids == nil {
ids = []uuid.UUID{}
}
return q.getProvisionerJobsByIDsWithQueuePositionLocked(ctx, ids)
return q.getProvisionerJobsByIDsWithQueuePositionLockedTagBasedQueue(ctx, ids)
}
func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx context.Context, arg database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) ([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, error) {
@ -4222,7 +4456,7 @@ func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePosition
LIMIT
sqlc.narg('limit')::int;
*/
rowsWithQueuePosition, err := q.getProvisionerJobsByIDsWithQueuePositionLocked(ctx, nil)
rowsWithQueuePosition, err := q.getProvisionerJobsByIDsWithQueuePositionLockedGlobalQueue(ctx, nil)
if err != nil {
return nil, err
}
@ -5003,9 +5237,9 @@ func (q *FakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg datab
}
var row database.GetTemplateAverageBuildTimeRow
row.Delete50, row.Delete95 = tryPercentile(deleteTimes, 50), tryPercentile(deleteTimes, 95)
row.Stop50, row.Stop95 = tryPercentile(stopTimes, 50), tryPercentile(stopTimes, 95)
row.Start50, row.Start95 = tryPercentile(startTimes, 50), tryPercentile(startTimes, 95)
row.Delete50, row.Delete95 = tryPercentileDisc(deleteTimes, 50), tryPercentileDisc(deleteTimes, 95)
row.Stop50, row.Stop95 = tryPercentileDisc(stopTimes, 50), tryPercentileDisc(stopTimes, 95)
row.Start50, row.Start95 = tryPercentileDisc(startTimes, 50), tryPercentileDisc(startTimes, 95)
return row, nil
}
@ -6044,8 +6278,8 @@ func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.Get
Username: user.Username,
AvatarURL: user.AvatarURL,
TemplateIDs: seenTemplatesByUserID[userID],
WorkspaceConnectionLatency50: tryPercentile(latencies, 50),
WorkspaceConnectionLatency95: tryPercentile(latencies, 95),
WorkspaceConnectionLatency50: tryPercentileCont(latencies, 50),
WorkspaceConnectionLatency95: tryPercentileCont(latencies, 95),
}
rows = append(rows, row)
}
@ -6689,8 +6923,8 @@ func (q *FakeQuerier) GetWorkspaceAgentStats(_ context.Context, createdAfter tim
if !ok {
continue
}
stat.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50)
stat.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95)
stat.WorkspaceConnectionLatency50 = tryPercentileCont(latencies, 50)
stat.WorkspaceConnectionLatency95 = tryPercentileCont(latencies, 95)
statByAgent[stat.AgentID] = stat
}
@ -6827,8 +7061,8 @@ func (q *FakeQuerier) GetWorkspaceAgentUsageStats(_ context.Context, createdAt t
for key, latencies := range latestAgentLatencies {
val, ok := latestAgentStats[key]
if ok {
val.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50)
val.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95)
val.WorkspaceConnectionLatency50 = tryPercentileCont(latencies, 50)
val.WorkspaceConnectionLatency95 = tryPercentileCont(latencies, 95)
}
latestAgentStats[key] = val
}
@ -6938,7 +7172,7 @@ func (q *FakeQuerier) GetWorkspaceAgentUsageStatsAndLabels(_ context.Context, cr
}
// WHERE usage = true AND created_at > now() - '1 minute'::interval
// GROUP BY user_id, agent_id, workspace_id
if agentStat.Usage && agentStat.CreatedAt.After(time.Now().Add(-time.Minute)) {
if agentStat.Usage && agentStat.CreatedAt.After(dbtime.Now().Add(-time.Minute)) {
val, ok := latestAgentStats[key]
if !ok {
latestAgentStats[key] = agentStat
@ -7977,6 +8211,30 @@ func (q *FakeQuerier) InsertGroupMember(_ context.Context, arg database.InsertGr
return nil
}
func (q *FakeQuerier) InsertInboxNotification(_ context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) {
if err := validateDatabaseType(arg); err != nil {
return database.InboxNotification{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
notification := database.InboxNotification{
ID: arg.ID,
UserID: arg.UserID,
TemplateID: arg.TemplateID,
Targets: arg.Targets,
Title: arg.Title,
Content: arg.Content,
Icon: arg.Icon,
Actions: arg.Actions,
CreatedAt: time.Now(),
}
q.InboxNotification = append(q.InboxNotification, notification)
return notification, nil
}
func (q *FakeQuerier) InsertLicense(
_ context.Context, arg database.InsertLicenseParams,
) (database.License, error) {
@ -9700,6 +9958,24 @@ func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, params dat
return updated, nil
}
func (q *FakeQuerier) UpdateInboxNotificationReadStatus(_ context.Context, arg database.UpdateInboxNotificationReadStatusParams) error {
err := validateDatabaseType(arg)
if err != nil {
return err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for i := range q.InboxNotification {
if q.InboxNotification[i].ID == arg.ID {
q.InboxNotification[i].ReadAt = arg.ReadAt
}
}
return nil
}
func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
if err := validateDatabaseType(arg); err != nil {
return database.OrganizationMember{}, err

View File

@ -2,11 +2,11 @@ package dbmetrics
import (
"context"
"slices"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/exp/slices"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"

View File

@ -5,11 +5,11 @@ package dbmetrics
import (
"context"
"slices"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/exp/slices"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
@ -185,6 +185,13 @@ func (m queryMetricsStore) CleanTailnetTunnels(ctx context.Context) error {
return r0
}
func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountUnreadInboxNotificationsByUserID(ctx, userID)
m.queryLatencies.WithLabelValues("CountUnreadInboxNotificationsByUserID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
start := time.Now()
r0, r1 := m.s.CustomRoles(ctx, arg)
@ -451,6 +458,13 @@ func (m queryMetricsStore) FetchMemoryResourceMonitorsByAgentID(ctx context.Cont
return r0, r1
}
func (m queryMetricsStore) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
start := time.Now()
r0, r1 := m.s.FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt)
m.queryLatencies.WithLabelValues("FetchMemoryResourceMonitorsUpdatedAfter").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
start := time.Now()
r0, r1 := m.s.FetchNewMessageMetadata(ctx, arg)
@ -465,6 +479,13 @@ func (m queryMetricsStore) FetchVolumesResourceMonitorsByAgentID(ctx context.Con
return r0, r1
}
func (m queryMetricsStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
start := time.Now()
r0, r1 := m.s.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt)
m.queryLatencies.WithLabelValues("FetchVolumesResourceMonitorsUpdatedAfter").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
start := time.Now()
apiKey, err := m.s.GetAPIKeyByID(ctx, id)
@ -717,6 +738,13 @@ func (m queryMetricsStore) GetFileTemplates(ctx context.Context, fileID uuid.UUI
return rows, err
}
func (m queryMetricsStore) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
start := time.Now()
r0, r1 := m.s.GetFilteredInboxNotificationsByUserID(ctx, arg)
m.queryLatencies.WithLabelValues("GetFilteredInboxNotificationsByUserID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
start := time.Now()
key, err := m.s.GetGitSSHKey(ctx, userID)
@ -780,6 +808,20 @@ func (m queryMetricsStore) GetHungProvisionerJobs(ctx context.Context, hungSince
return jobs, err
}
func (m queryMetricsStore) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
start := time.Now()
r0, r1 := m.s.GetInboxNotificationByID(ctx, id)
m.queryLatencies.WithLabelValues("GetInboxNotificationByID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetInboxNotificationsByUserID(ctx context.Context, userID database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
start := time.Now()
r0, r1 := m.s.GetInboxNotificationsByUserID(ctx, userID)
m.queryLatencies.WithLabelValues("GetInboxNotificationsByUserID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) {
start := time.Now()
r0, r1 := m.s.GetJFrogXrayScanByWorkspaceAndAgentID(ctx, arg)
@ -1914,6 +1956,13 @@ func (m queryMetricsStore) InsertGroupMember(ctx context.Context, arg database.I
return err
}
func (m queryMetricsStore) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) {
start := time.Now()
r0, r1 := m.s.InsertInboxNotification(ctx, arg)
m.queryLatencies.WithLabelValues("InsertInboxNotification").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) {
start := time.Now()
license, err := m.s.InsertLicense(ctx, arg)
@ -2376,6 +2425,13 @@ func (m queryMetricsStore) UpdateInactiveUsersToDormant(ctx context.Context, las
return r0, r1
}
func (m queryMetricsStore) UpdateInboxNotificationReadStatus(ctx context.Context, arg database.UpdateInboxNotificationReadStatusParams) error {
start := time.Now()
r0 := m.s.UpdateInboxNotificationReadStatus(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateInboxNotificationReadStatus").Observe(time.Since(start).Seconds())
return r0
}
func (m queryMetricsStore) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
start := time.Now()
member, err := m.s.UpdateMemberRoles(ctx, arg)

View File

@ -247,6 +247,21 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), ctx)
}
// CountUnreadInboxNotificationsByUserID mocks base method.
func (m *MockStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountUnreadInboxNotificationsByUserID", ctx, userID)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountUnreadInboxNotificationsByUserID indicates an expected call of CountUnreadInboxNotificationsByUserID.
func (mr *MockStoreMockRecorder) CountUnreadInboxNotificationsByUserID(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountUnreadInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).CountUnreadInboxNotificationsByUserID), ctx, userID)
}
// CustomRoles mocks base method.
func (m *MockStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
m.ctrl.T.Helper()
@ -787,6 +802,21 @@ func (mr *MockStoreMockRecorder) FetchMemoryResourceMonitorsByAgentID(ctx, agent
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchMemoryResourceMonitorsByAgentID", reflect.TypeOf((*MockStore)(nil).FetchMemoryResourceMonitorsByAgentID), ctx, agentID)
}
// FetchMemoryResourceMonitorsUpdatedAfter mocks base method.
func (m *MockStore) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FetchMemoryResourceMonitorsUpdatedAfter", ctx, updatedAt)
ret0, _ := ret[0].([]database.WorkspaceAgentMemoryResourceMonitor)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FetchMemoryResourceMonitorsUpdatedAfter indicates an expected call of FetchMemoryResourceMonitorsUpdatedAfter.
func (mr *MockStoreMockRecorder) FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchMemoryResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchMemoryResourceMonitorsUpdatedAfter), ctx, updatedAt)
}
// FetchNewMessageMetadata mocks base method.
func (m *MockStore) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
m.ctrl.T.Helper()
@ -817,6 +847,21 @@ func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsByAgentID(ctx, agen
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsByAgentID", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsByAgentID), ctx, agentID)
}
// FetchVolumesResourceMonitorsUpdatedAfter mocks base method.
func (m *MockStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FetchVolumesResourceMonitorsUpdatedAfter", ctx, updatedAt)
ret0, _ := ret[0].([]database.WorkspaceAgentVolumeResourceMonitor)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FetchVolumesResourceMonitorsUpdatedAfter indicates an expected call of FetchVolumesResourceMonitorsUpdatedAfter.
func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsUpdatedAfter), ctx, updatedAt)
}
// GetAPIKeyByID mocks base method.
func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
m.ctrl.T.Helper()
@ -1432,6 +1477,21 @@ func (mr *MockStoreMockRecorder) GetFileTemplates(ctx, fileID any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileTemplates", reflect.TypeOf((*MockStore)(nil).GetFileTemplates), ctx, fileID)
}
// GetFilteredInboxNotificationsByUserID mocks base method.
func (m *MockStore) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetFilteredInboxNotificationsByUserID", ctx, arg)
ret0, _ := ret[0].([]database.InboxNotification)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetFilteredInboxNotificationsByUserID indicates an expected call of GetFilteredInboxNotificationsByUserID.
func (mr *MockStoreMockRecorder) GetFilteredInboxNotificationsByUserID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFilteredInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).GetFilteredInboxNotificationsByUserID), ctx, arg)
}
// GetGitSSHKey mocks base method.
func (m *MockStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
m.ctrl.T.Helper()
@ -1567,6 +1627,36 @@ func (mr *MockStoreMockRecorder) GetHungProvisionerJobs(ctx, updatedAt any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHungProvisionerJobs", reflect.TypeOf((*MockStore)(nil).GetHungProvisionerJobs), ctx, updatedAt)
}
// GetInboxNotificationByID mocks base method.
func (m *MockStore) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetInboxNotificationByID", ctx, id)
ret0, _ := ret[0].(database.InboxNotification)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetInboxNotificationByID indicates an expected call of GetInboxNotificationByID.
func (mr *MockStoreMockRecorder) GetInboxNotificationByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInboxNotificationByID", reflect.TypeOf((*MockStore)(nil).GetInboxNotificationByID), ctx, id)
}
// GetInboxNotificationsByUserID mocks base method.
func (m *MockStore) GetInboxNotificationsByUserID(ctx context.Context, arg database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetInboxNotificationsByUserID", ctx, arg)
ret0, _ := ret[0].([]database.InboxNotification)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetInboxNotificationsByUserID indicates an expected call of GetInboxNotificationsByUserID.
func (mr *MockStoreMockRecorder) GetInboxNotificationsByUserID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).GetInboxNotificationsByUserID), ctx, arg)
}
// GetJFrogXrayScanByWorkspaceAndAgentID mocks base method.
func (m *MockStore) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) {
m.ctrl.T.Helper()
@ -4037,6 +4127,21 @@ func (mr *MockStoreMockRecorder) InsertGroupMember(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertGroupMember", reflect.TypeOf((*MockStore)(nil).InsertGroupMember), ctx, arg)
}
// InsertInboxNotification mocks base method.
func (m *MockStore) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertInboxNotification", ctx, arg)
ret0, _ := ret[0].(database.InboxNotification)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertInboxNotification indicates an expected call of InsertInboxNotification.
func (mr *MockStoreMockRecorder) InsertInboxNotification(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertInboxNotification", reflect.TypeOf((*MockStore)(nil).InsertInboxNotification), ctx, arg)
}
// InsertLicense mocks base method.
func (m *MockStore) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) {
m.ctrl.T.Helper()
@ -5041,6 +5146,20 @@ func (mr *MockStoreMockRecorder) UpdateInactiveUsersToDormant(ctx, arg any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateInactiveUsersToDormant", reflect.TypeOf((*MockStore)(nil).UpdateInactiveUsersToDormant), ctx, arg)
}
// UpdateInboxNotificationReadStatus mocks base method.
func (m *MockStore) UpdateInboxNotificationReadStatus(ctx context.Context, arg database.UpdateInboxNotificationReadStatusParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateInboxNotificationReadStatus", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateInboxNotificationReadStatus indicates an expected call of UpdateInboxNotificationReadStatus.
func (mr *MockStoreMockRecorder) UpdateInboxNotificationReadStatus(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateInboxNotificationReadStatus", reflect.TypeOf((*MockStore)(nil).UpdateInboxNotificationReadStatus), ctx, arg)
}
// UpdateMemberRoles mocks base method.
func (m *MockStore) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
m.ctrl.T.Helper()

View File

@ -7,6 +7,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"slices"
"testing"
"time"
@ -14,7 +15,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"golang.org/x/exp/slices"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"

View File

@ -66,6 +66,12 @@ CREATE TYPE group_source AS ENUM (
'oidc'
);
CREATE TYPE inbox_notification_read_status AS ENUM (
'all',
'unread',
'read'
);
CREATE TYPE log_level AS ENUM (
'trace',
'debug',
@ -902,6 +908,19 @@ CREATE VIEW group_members_expanded AS
COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).';
CREATE TABLE inbox_notifications (
id uuid NOT NULL,
user_id uuid NOT NULL,
template_id uuid NOT NULL,
targets uuid[],
title text NOT NULL,
content text NOT NULL,
icon text NOT NULL,
actions jsonb NOT NULL,
read_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE jfrog_xray_scans (
agent_id uuid NOT NULL,
workspace_id uuid NOT NULL,
@ -2132,6 +2151,9 @@ ALTER TABLE ONLY groups
ALTER TABLE ONLY groups
ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
ALTER TABLE ONLY inbox_notifications
ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id);
ALTER TABLE ONLY jfrog_xray_scans
ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id);
@ -2368,6 +2390,10 @@ CREATE INDEX idx_custom_roles_id ON custom_roles USING btree (id);
CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name));
CREATE INDEX idx_inbox_notifications_user_id_read_at ON inbox_notifications USING btree (user_id, read_at);
CREATE INDEX idx_inbox_notifications_user_id_template_id_targets ON inbox_notifications USING btree (user_id, template_id, targets);
CREATE INDEX idx_notification_messages_status ON notification_messages USING btree (status);
CREATE INDEX idx_organization_member_organization_id_uuid ON organization_members USING btree (organization_id);
@ -2380,6 +2406,8 @@ CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_da
COMMENT ON INDEX idx_provisioner_daemons_org_name_owner_key IS 'Allow unique provisioner daemon names by organization and user';
CREATE INDEX idx_provisioner_jobs_status ON provisioner_jobs USING btree (job_status);
CREATE INDEX idx_tailnet_agents_coordinator ON tailnet_agents USING btree (coordinator_id);
CREATE INDEX idx_tailnet_clients_coordinator ON tailnet_clients USING btree (coordinator_id);
@ -2650,6 +2678,12 @@ ALTER TABLE ONLY group_members
ALTER TABLE ONLY groups
ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY inbox_notifications
ADD CONSTRAINT inbox_notifications_template_id_fkey FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;
ALTER TABLE ONLY inbox_notifications
ADD CONSTRAINT inbox_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY jfrog_xray_scans
ADD CONSTRAINT jfrog_xray_scans_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;

View File

@ -14,6 +14,8 @@ const (
ForeignKeyGroupMembersGroupID ForeignKeyConstraint = "group_members_group_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
ForeignKeyGroupMembersUserID ForeignKeyConstraint = "group_members_user_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyGroupsOrganizationID ForeignKeyConstraint = "groups_organization_id_fkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyInboxNotificationsTemplateID ForeignKeyConstraint = "inbox_notifications_template_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_template_id_fkey FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;
ForeignKeyInboxNotificationsUserID ForeignKeyConstraint = "inbox_notifications_user_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyJfrogXrayScansAgentID ForeignKeyConstraint = "jfrog_xray_scans_agent_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyJfrogXrayScansWorkspaceID ForeignKeyConstraint = "jfrog_xray_scans_workspace_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
ForeignKeyNotificationMessagesNotificationTemplateID ForeignKeyConstraint = "notification_messages_notification_template_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;

View File

@ -5,11 +5,11 @@ import (
"go/ast"
"go/parser"
"go/token"
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
)
// TestCustomQueriesSynced makes sure the manual custom queries in modelqueries.go

View File

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS inbox_notifications;
DROP TYPE IF EXISTS inbox_notification_read_status;

View File

@ -0,0 +1,17 @@
CREATE TYPE inbox_notification_read_status AS ENUM ('all', 'unread', 'read');
CREATE TABLE inbox_notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
template_id UUID NOT NULL REFERENCES notification_templates(id) ON DELETE CASCADE,
targets UUID[],
title TEXT NOT NULL,
content TEXT NOT NULL,
icon TEXT NOT NULL,
actions JSONB NOT NULL,
read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_inbox_notifications_user_id_read_at ON inbox_notifications(user_id, read_at);
CREATE INDEX idx_inbox_notifications_user_id_template_id_targets ON inbox_notifications(user_id, template_id, targets);

View File

@ -0,0 +1 @@
DROP INDEX idx_provisioner_jobs_status;

View File

@ -0,0 +1 @@
CREATE INDEX idx_provisioner_jobs_status ON provisioner_jobs USING btree (job_status);

View File

@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"sync"
"testing"
@ -17,7 +18,6 @@ import (
"github.com/lib/pq"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
"github.com/coder/coder/v2/coderd/database/dbtestutil"

View File

@ -0,0 +1,25 @@
INSERT INTO
inbox_notifications (
id,
user_id,
template_id,
targets,
title,
content,
icon,
actions,
read_at,
created_at
)
VALUES (
'68b396aa-7f53-4bf1-b8d8-4cbf5fa244e5', -- uuid
'5755e622-fadd-44ca-98da-5df070491844', -- uuid
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', -- uuid
ARRAY[]::UUID[], -- uuid[]
'Test Notification',
'This is a test notification',
'https://test.coder.com/favicon.ico',
'{}',
'2025-01-01 00:00:00',
'2025-01-01 00:00:00'
);

View File

@ -168,6 +168,12 @@ func (TemplateVersion) RBACObject(template Template) rbac.Object {
return template.RBACObject()
}
func (i InboxNotification) RBACObject() rbac.Object {
return rbac.ResourceInboxNotification.
WithID(i.ID).
WithOwner(i.UserID.String())
}
// RBACObjectNoTemplate is for orphaned template versions.
func (v TemplateVersion) RBACObjectNoTemplate() rbac.Object {
return rbac.ResourceTemplate.InOrg(v.OrganizationID)

View File

@ -543,6 +543,67 @@ func AllGroupSourceValues() []GroupSource {
}
}
type InboxNotificationReadStatus string
const (
InboxNotificationReadStatusAll InboxNotificationReadStatus = "all"
InboxNotificationReadStatusUnread InboxNotificationReadStatus = "unread"
InboxNotificationReadStatusRead InboxNotificationReadStatus = "read"
)
func (e *InboxNotificationReadStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = InboxNotificationReadStatus(s)
case string:
*e = InboxNotificationReadStatus(s)
default:
return fmt.Errorf("unsupported scan type for InboxNotificationReadStatus: %T", src)
}
return nil
}
type NullInboxNotificationReadStatus struct {
InboxNotificationReadStatus InboxNotificationReadStatus `json:"inbox_notification_read_status"`
Valid bool `json:"valid"` // Valid is true if InboxNotificationReadStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullInboxNotificationReadStatus) Scan(value interface{}) error {
if value == nil {
ns.InboxNotificationReadStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.InboxNotificationReadStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullInboxNotificationReadStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.InboxNotificationReadStatus), nil
}
func (e InboxNotificationReadStatus) Valid() bool {
switch e {
case InboxNotificationReadStatusAll,
InboxNotificationReadStatusUnread,
InboxNotificationReadStatusRead:
return true
}
return false
}
func AllInboxNotificationReadStatusValues() []InboxNotificationReadStatus {
return []InboxNotificationReadStatus{
InboxNotificationReadStatusAll,
InboxNotificationReadStatusUnread,
InboxNotificationReadStatusRead,
}
}
type LogLevel string
const (
@ -2557,6 +2618,19 @@ type GroupMemberTable struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
}
type InboxNotification struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
Targets []uuid.UUID `db:"targets" json:"targets"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
Icon string `db:"icon" json:"icon"`
Actions json.RawMessage `db:"actions" json:"actions"`
ReadAt sql.NullTime `db:"read_at" json:"read_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
type JfrogXrayScan struct {
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`

View File

@ -65,6 +65,7 @@ type sqlcQuerier interface {
CleanTailnetCoordinators(ctx context.Context) error
CleanTailnetLostPeers(ctx context.Context) error
CleanTailnetTunnels(ctx context.Context) error
CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error)
DeleteAPIKeyByID(ctx context.Context, id string) error
DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error
@ -114,9 +115,11 @@ type sqlcQuerier interface {
EnqueueNotificationMessage(ctx context.Context, arg EnqueueNotificationMessageParams) error
FavoriteWorkspace(ctx context.Context, id uuid.UUID) error
FetchMemoryResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) (WorkspaceAgentMemoryResourceMonitor, error)
FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentMemoryResourceMonitor, error)
// This is used to build up the notification_message's JSON payload.
FetchNewMessageMetadata(ctx context.Context, arg FetchNewMessageMetadataParams) (FetchNewMessageMetadataRow, error)
FetchVolumesResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceAgentVolumeResourceMonitor, error)
FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error)
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
// there is no unique constraint on empty token names
GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error)
@ -160,6 +163,14 @@ type sqlcQuerier interface {
GetFileByID(ctx context.Context, id uuid.UUID) (File, error)
// Get all templates that use a file.
GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]GetFileTemplatesRow, error)
// Fetches inbox notifications for a user filtered by templates and targets
// param user_id: The user ID
// param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array
// param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array
// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error)
GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error)
GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error)
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
@ -172,6 +183,13 @@ type sqlcQuerier interface {
GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error)
GetHealthSettings(ctx context.Context) (string, error)
GetHungProvisionerJobs(ctx context.Context, updatedAt time.Time) ([]ProvisionerJob, error)
GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (InboxNotification, error)
// Fetches inbox notifications for a user filtered by templates and targets
// param user_id: The user ID
// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error)
GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error)
GetLastUpdateCheck(ctx context.Context) (string, error)
GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error)
@ -402,6 +420,7 @@ type sqlcQuerier interface {
InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error)
InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error)
InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error
InsertInboxNotification(ctx context.Context, arg InsertInboxNotificationParams) (InboxNotification, error)
InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error)
InsertMemoryResourceMonitor(ctx context.Context, arg InsertMemoryResourceMonitorParams) (WorkspaceAgentMemoryResourceMonitor, error)
// Inserts any group by name that does not exist. All new groups are given
@ -486,6 +505,7 @@ type sqlcQuerier interface {
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)
UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error)
UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error)
UpdateInboxNotificationReadStatus(ctx context.Context, arg UpdateInboxNotificationReadStatusParams) error
UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error)
UpdateMemoryResourceMonitor(ctx context.Context, arg UpdateMemoryResourceMonitorParams) error
UpdateNotificationTemplateMethodByID(ctx context.Context, arg UpdateNotificationTemplateMethodByIDParams) (NotificationTemplate, error)

View File

@ -1257,6 +1257,15 @@ func TestQueuePosition(t *testing.T) {
time.Sleep(time.Millisecond)
}
// Create default provisioner daemon:
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "default_provisioner",
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
// Ensure the `tags` field is NOT NULL for the default provisioner;
// otherwise, it won't be able to pick up any jobs.
Tags: database.StringMap{},
})
queued, err := db.GetProvisionerJobsByIDsWithQueuePosition(ctx, jobIDs)
require.NoError(t, err)
require.Len(t, queued, jobCount)
@ -2159,6 +2168,307 @@ func TestExpectOne(t *testing.T) {
func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
t.Parallel()
now := dbtime.Now()
ctx := testutil.Context(t, testutil.WaitShort)
testCases := []struct {
name string
jobTags []database.StringMap
daemonTags []database.StringMap
queueSizes []int64
queuePositions []int64
// GetProvisionerJobsByIDsWithQueuePosition takes jobIDs as a parameter.
// If skipJobIDs is empty, all jobs are passed to the function; otherwise, the specified jobs are skipped.
// NOTE: Skipping job IDs means they will be excluded from the result,
// but this should not affect the queue position or queue size of other jobs.
skipJobIDs map[int]struct{}
}{
// Baseline test case
{
name: "test-case-1",
jobTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "c": "3"},
},
daemonTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
},
queueSizes: []int64{2, 2, 0},
queuePositions: []int64{1, 1, 0},
},
// Includes an additional provisioner
{
name: "test-case-2",
jobTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "c": "3"},
},
daemonTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "b": "2", "c": "3"},
},
queueSizes: []int64{3, 3, 3},
queuePositions: []int64{1, 1, 3},
},
// Skips job at index 0
{
name: "test-case-3",
jobTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "c": "3"},
},
daemonTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "b": "2", "c": "3"},
},
queueSizes: []int64{3, 3},
queuePositions: []int64{1, 3},
skipJobIDs: map[int]struct{}{
0: {},
},
},
// Skips job at index 1
{
name: "test-case-4",
jobTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "c": "3"},
},
daemonTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "b": "2", "c": "3"},
},
queueSizes: []int64{3, 3},
queuePositions: []int64{1, 3},
skipJobIDs: map[int]struct{}{
1: {},
},
},
// Skips job at index 2
{
name: "test-case-5",
jobTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "c": "3"},
},
daemonTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "b": "2", "c": "3"},
},
queueSizes: []int64{3, 3},
queuePositions: []int64{1, 1},
skipJobIDs: map[int]struct{}{
2: {},
},
},
// Skips jobs at indexes 0 and 2
{
name: "test-case-6",
jobTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "c": "3"},
},
daemonTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "b": "2", "c": "3"},
},
queueSizes: []int64{3},
queuePositions: []int64{1},
skipJobIDs: map[int]struct{}{
0: {},
2: {},
},
},
// Includes two additional jobs that any provisioner can execute.
{
name: "test-case-7",
jobTags: []database.StringMap{
{},
{},
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "c": "3"},
},
daemonTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "b": "2", "c": "3"},
},
queueSizes: []int64{5, 5, 5, 5, 5},
queuePositions: []int64{1, 2, 3, 3, 5},
},
// Includes two additional jobs that any provisioner can execute, but they are intentionally skipped.
{
name: "test-case-8",
jobTags: []database.StringMap{
{},
{},
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "c": "3"},
},
daemonTags: []database.StringMap{
{"a": "1", "b": "2"},
{"a": "1"},
{"a": "1", "b": "2", "c": "3"},
},
queueSizes: []int64{5, 5, 5},
queuePositions: []int64{3, 3, 5},
skipJobIDs: map[int]struct{}{
0: {},
1: {},
},
},
// N jobs (1 job with 0 tags) & 0 provisioners exist
{
name: "test-case-9",
jobTags: []database.StringMap{
{},
{"a": "1"},
{"b": "2"},
},
daemonTags: []database.StringMap{},
queueSizes: []int64{0, 0, 0},
queuePositions: []int64{0, 0, 0},
},
// N jobs (1 job with 0 tags) & N provisioners
{
name: "test-case-10",
jobTags: []database.StringMap{
{},
{"a": "1"},
{"b": "2"},
},
daemonTags: []database.StringMap{
{},
{"a": "1"},
{"b": "2"},
},
queueSizes: []int64{2, 2, 2},
queuePositions: []int64{1, 2, 2},
},
// (N + 1) jobs (1 job with 0 tags) & N provisioners
// 1 job not matching any provisioner (first in the list)
{
name: "test-case-11",
jobTags: []database.StringMap{
{"c": "3"},
{},
{"a": "1"},
{"b": "2"},
},
daemonTags: []database.StringMap{
{},
{"a": "1"},
{"b": "2"},
},
queueSizes: []int64{0, 2, 2, 2},
queuePositions: []int64{0, 1, 2, 2},
},
// 0 jobs & 0 provisioners
{
name: "test-case-12",
jobTags: []database.StringMap{},
daemonTags: []database.StringMap{},
queueSizes: nil, // TODO(yevhenii): should it be empty array instead?
queuePositions: nil,
},
}
for _, tc := range testCases {
tc := tc // Capture loop variable to avoid data races
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
// Create provisioner jobs based on provided tags:
allJobs := make([]database.ProvisionerJob, len(tc.jobTags))
for idx, tags := range tc.jobTags {
// Make sure jobs are stored in correct order, first job should have the earliest createdAt timestamp.
// Example for 3 jobs:
// job_1 createdAt: now - 3 minutes
// job_2 createdAt: now - 2 minutes
// job_3 createdAt: now - 1 minute
timeOffsetInMinutes := len(tc.jobTags) - idx
timeOffset := time.Duration(timeOffsetInMinutes) * time.Minute
createdAt := now.Add(-timeOffset)
allJobs[idx] = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
CreatedAt: createdAt,
Tags: tags,
})
}
// Create provisioner daemons based on provided tags:
for idx, tags := range tc.daemonTags {
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: fmt.Sprintf("prov_%v", idx),
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
Tags: tags,
})
}
// Assert invariant: the jobs are in pending status
for idx, job := range allJobs {
require.Equal(t, database.ProvisionerJobStatusPending, job.JobStatus, "expected job %d to have status %s", idx, database.ProvisionerJobStatusPending)
}
filteredJobs := make([]database.ProvisionerJob, 0)
filteredJobIDs := make([]uuid.UUID, 0)
for idx, job := range allJobs {
if _, skip := tc.skipJobIDs[idx]; skip {
continue
}
filteredJobs = append(filteredJobs, job)
filteredJobIDs = append(filteredJobIDs, job.ID)
}
// When: we fetch the jobs by their IDs
actualJobs, err := db.GetProvisionerJobsByIDsWithQueuePosition(ctx, filteredJobIDs)
require.NoError(t, err)
require.Len(t, actualJobs, len(filteredJobs), "should return all unskipped jobs")
// Then: the jobs should be returned in the correct order (sorted by createdAt)
sort.Slice(filteredJobs, func(i, j int) bool {
return filteredJobs[i].CreatedAt.Before(filteredJobs[j].CreatedAt)
})
for idx, job := range actualJobs {
assert.EqualValues(t, filteredJobs[idx], job.ProvisionerJob)
}
// Then: the queue size should be set correctly
var queueSizes []int64
for _, job := range actualJobs {
queueSizes = append(queueSizes, job.QueueSize)
}
assert.EqualValues(t, tc.queueSizes, queueSizes, "expected queue positions to be set correctly")
// Then: the queue position should be set correctly:
var queuePositions []int64
for _, job := range actualJobs {
queuePositions = append(queuePositions, job.QueuePosition)
}
assert.EqualValues(t, tc.queuePositions, queuePositions, "expected queue positions to be set correctly")
})
}
}
func TestGetProvisionerJobsByIDsWithQueuePosition_MixedStatuses(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.SkipNow()
}
@ -2167,7 +2477,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
now := dbtime.Now()
ctx := testutil.Context(t, testutil.WaitShort)
// Given the following provisioner jobs:
// Create the following provisioner jobs:
allJobs := []database.ProvisionerJob{
// Pending. This will be the last in the queue because
// it was created most recently.
@ -2177,6 +2487,9 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
CanceledAt: sql.NullTime{},
CompletedAt: sql.NullTime{},
Error: sql.NullString{},
// Ensure the `tags` field is NOT NULL for both provisioner jobs and provisioner daemons;
// otherwise, provisioner daemons won't be able to pick up any jobs.
Tags: database.StringMap{},
}),
// Another pending. This will come first in the queue
@ -2187,6 +2500,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
CanceledAt: sql.NullTime{},
CompletedAt: sql.NullTime{},
Error: sql.NullString{},
Tags: database.StringMap{},
}),
// Running
@ -2196,6 +2510,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
CanceledAt: sql.NullTime{},
CompletedAt: sql.NullTime{},
Error: sql.NullString{},
Tags: database.StringMap{},
}),
// Succeeded
@ -2205,6 +2520,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
CanceledAt: sql.NullTime{},
CompletedAt: sql.NullTime{Valid: true, Time: now},
Error: sql.NullString{},
Tags: database.StringMap{},
}),
// Canceling
@ -2214,6 +2530,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
CanceledAt: sql.NullTime{Valid: true, Time: now},
CompletedAt: sql.NullTime{},
Error: sql.NullString{},
Tags: database.StringMap{},
}),
// Canceled
@ -2223,6 +2540,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
CanceledAt: sql.NullTime{Valid: true, Time: now},
CompletedAt: sql.NullTime{Valid: true, Time: now},
Error: sql.NullString{},
Tags: database.StringMap{},
}),
// Failed
@ -2232,9 +2550,17 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
CanceledAt: sql.NullTime{},
CompletedAt: sql.NullTime{},
Error: sql.NullString{String: "failed", Valid: true},
Tags: database.StringMap{},
}),
}
// Create default provisioner daemon:
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "default_provisioner",
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
Tags: database.StringMap{},
})
// Assert invariant: the jobs are in the expected order
require.Len(t, allJobs, 7, "expected 7 jobs")
for idx, status := range []database.ProvisionerJobStatus{
@ -2259,22 +2585,123 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
require.NoError(t, err)
require.Len(t, actualJobs, len(allJobs), "should return all jobs")
// Then: the jobs should be returned in the correct order (by IDs in the input slice)
// Then: the jobs should be returned in the correct order (sorted by createdAt)
sort.Slice(allJobs, func(i, j int) bool {
return allJobs[i].CreatedAt.Before(allJobs[j].CreatedAt)
})
for idx, job := range actualJobs {
assert.EqualValues(t, allJobs[idx], job.ProvisionerJob)
}
// Then: the queue size should be set correctly
var queueSizes []int64
for _, job := range actualJobs {
assert.EqualValues(t, job.QueueSize, 2, "should have queue size 2")
queueSizes = append(queueSizes, job.QueueSize)
}
assert.EqualValues(t, []int64{0, 0, 0, 0, 0, 2, 2}, queueSizes, "expected queue positions to be set correctly")
// Then: the queue position should be set correctly:
var queuePositions []int64
for _, job := range actualJobs {
queuePositions = append(queuePositions, job.QueuePosition)
}
assert.EqualValues(t, []int64{2, 1, 0, 0, 0, 0, 0}, queuePositions, "expected queue positions to be set correctly")
assert.EqualValues(t, []int64{0, 0, 0, 0, 0, 1, 2}, queuePositions, "expected queue positions to be set correctly")
}
func TestGetProvisionerJobsByIDsWithQueuePosition_OrderValidation(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
now := dbtime.Now()
ctx := testutil.Context(t, testutil.WaitShort)
// Create the following provisioner jobs:
allJobs := []database.ProvisionerJob{
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
CreatedAt: now.Add(-4 * time.Minute),
// Ensure the `tags` field is NOT NULL for both provisioner jobs and provisioner daemons;
// otherwise, provisioner daemons won't be able to pick up any jobs.
Tags: database.StringMap{},
}),
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
CreatedAt: now.Add(-5 * time.Minute),
Tags: database.StringMap{},
}),
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
CreatedAt: now.Add(-6 * time.Minute),
Tags: database.StringMap{},
}),
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
CreatedAt: now.Add(-3 * time.Minute),
Tags: database.StringMap{},
}),
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
CreatedAt: now.Add(-2 * time.Minute),
Tags: database.StringMap{},
}),
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
CreatedAt: now.Add(-1 * time.Minute),
Tags: database.StringMap{},
}),
}
// Create default provisioner daemon:
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "default_provisioner",
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
Tags: database.StringMap{},
})
// Assert invariant: the jobs are in the expected order
require.Len(t, allJobs, 6, "expected 7 jobs")
for idx, status := range []database.ProvisionerJobStatus{
database.ProvisionerJobStatusPending,
database.ProvisionerJobStatusPending,
database.ProvisionerJobStatusPending,
database.ProvisionerJobStatusPending,
database.ProvisionerJobStatusPending,
database.ProvisionerJobStatusPending,
} {
require.Equal(t, status, allJobs[idx].JobStatus, "expected job %d to have status %s", idx, status)
}
var jobIDs []uuid.UUID
for _, job := range allJobs {
jobIDs = append(jobIDs, job.ID)
}
// When: we fetch the jobs by their IDs
actualJobs, err := db.GetProvisionerJobsByIDsWithQueuePosition(ctx, jobIDs)
require.NoError(t, err)
require.Len(t, actualJobs, len(allJobs), "should return all jobs")
// Then: the jobs should be returned in the correct order (sorted by createdAt)
sort.Slice(allJobs, func(i, j int) bool {
return allJobs[i].CreatedAt.Before(allJobs[j].CreatedAt)
})
for idx, job := range actualJobs {
assert.EqualValues(t, allJobs[idx], job.ProvisionerJob)
assert.EqualValues(t, allJobs[idx].CreatedAt, job.ProvisionerJob.CreatedAt)
}
// Then: the queue size should be set correctly
var queueSizes []int64
for _, job := range actualJobs {
queueSizes = append(queueSizes, job.QueueSize)
}
assert.EqualValues(t, []int64{6, 6, 6, 6, 6, 6}, queueSizes, "expected queue positions to be set correctly")
// Then: the queue position should be set correctly:
var queuePositions []int64
for _, job := range actualJobs {
queuePositions = append(queuePositions, job.QueuePosition)
}
assert.EqualValues(t, []int64{1, 2, 3, 4, 5, 6}, queuePositions, "expected queue positions to be set correctly")
}
func TestGroupRemovalTrigger(t *testing.T) {

View File

@ -4298,6 +4298,243 @@ func (q *sqlQuerier) UpsertNotificationReportGeneratorLog(ctx context.Context, a
return err
}
const countUnreadInboxNotificationsByUserID = `-- name: CountUnreadInboxNotificationsByUserID :one
SELECT COUNT(*) FROM inbox_notifications WHERE user_id = $1 AND read_at IS NULL
`
func (q *sqlQuerier) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countUnreadInboxNotificationsByUserID, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const getFilteredInboxNotificationsByUserID = `-- name: GetFilteredInboxNotificationsByUserID :many
SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE
user_id = $1 AND
template_id = ANY($2::UUID[]) AND
targets @> COALESCE($3, ARRAY[]::UUID[]) AND
($4::inbox_notification_read_status = 'all' OR ($4::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($4::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
($5::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $5::TIMESTAMPTZ)
ORDER BY created_at DESC
LIMIT (COALESCE(NULLIF($6 :: INT, 0), 25))
`
type GetFilteredInboxNotificationsByUserIDParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Templates []uuid.UUID `db:"templates" json:"templates"`
Targets []uuid.UUID `db:"targets" json:"targets"`
ReadStatus InboxNotificationReadStatus `db:"read_status" json:"read_status"`
CreatedAtOpt time.Time `db:"created_at_opt" json:"created_at_opt"`
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
}
// Fetches inbox notifications for a user filtered by templates and targets
// param user_id: The user ID
// param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array
// param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array
// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
func (q *sqlQuerier) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error) {
rows, err := q.db.QueryContext(ctx, getFilteredInboxNotificationsByUserID,
arg.UserID,
pq.Array(arg.Templates),
pq.Array(arg.Targets),
arg.ReadStatus,
arg.CreatedAtOpt,
arg.LimitOpt,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []InboxNotification
for rows.Next() {
var i InboxNotification
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.TemplateID,
pq.Array(&i.Targets),
&i.Title,
&i.Content,
&i.Icon,
&i.Actions,
&i.ReadAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getInboxNotificationByID = `-- name: GetInboxNotificationByID :one
SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE id = $1
`
func (q *sqlQuerier) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (InboxNotification, error) {
row := q.db.QueryRowContext(ctx, getInboxNotificationByID, id)
var i InboxNotification
err := row.Scan(
&i.ID,
&i.UserID,
&i.TemplateID,
pq.Array(&i.Targets),
&i.Title,
&i.Content,
&i.Icon,
&i.Actions,
&i.ReadAt,
&i.CreatedAt,
)
return i, err
}
const getInboxNotificationsByUserID = `-- name: GetInboxNotificationsByUserID :many
SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE
user_id = $1 AND
($2::inbox_notification_read_status = 'all' OR ($2::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($2::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
($3::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $3::TIMESTAMPTZ)
ORDER BY created_at DESC
LIMIT (COALESCE(NULLIF($4 :: INT, 0), 25))
`
type GetInboxNotificationsByUserIDParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
ReadStatus InboxNotificationReadStatus `db:"read_status" json:"read_status"`
CreatedAtOpt time.Time `db:"created_at_opt" json:"created_at_opt"`
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
}
// Fetches inbox notifications for a user filtered by templates and targets
// param user_id: The user ID
// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
func (q *sqlQuerier) GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error) {
rows, err := q.db.QueryContext(ctx, getInboxNotificationsByUserID,
arg.UserID,
arg.ReadStatus,
arg.CreatedAtOpt,
arg.LimitOpt,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []InboxNotification
for rows.Next() {
var i InboxNotification
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.TemplateID,
pq.Array(&i.Targets),
&i.Title,
&i.Content,
&i.Icon,
&i.Actions,
&i.ReadAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertInboxNotification = `-- name: InsertInboxNotification :one
INSERT INTO
inbox_notifications (
id,
user_id,
template_id,
targets,
title,
content,
icon,
actions,
created_at
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at
`
type InsertInboxNotificationParams struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
Targets []uuid.UUID `db:"targets" json:"targets"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
Icon string `db:"icon" json:"icon"`
Actions json.RawMessage `db:"actions" json:"actions"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
func (q *sqlQuerier) InsertInboxNotification(ctx context.Context, arg InsertInboxNotificationParams) (InboxNotification, error) {
row := q.db.QueryRowContext(ctx, insertInboxNotification,
arg.ID,
arg.UserID,
arg.TemplateID,
pq.Array(arg.Targets),
arg.Title,
arg.Content,
arg.Icon,
arg.Actions,
arg.CreatedAt,
)
var i InboxNotification
err := row.Scan(
&i.ID,
&i.UserID,
&i.TemplateID,
pq.Array(&i.Targets),
&i.Title,
&i.Content,
&i.Icon,
&i.Actions,
&i.ReadAt,
&i.CreatedAt,
)
return i, err
}
const updateInboxNotificationReadStatus = `-- name: UpdateInboxNotificationReadStatus :exec
UPDATE
inbox_notifications
SET
read_at = $1
WHERE
id = $2
`
type UpdateInboxNotificationReadStatusParams struct {
ReadAt sql.NullTime `db:"read_at" json:"read_at"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateInboxNotificationReadStatus(ctx context.Context, arg UpdateInboxNotificationReadStatusParams) error {
_, err := q.db.ExecContext(ctx, updateInboxNotificationReadStatus, arg.ReadAt, arg.ID)
return err
}
const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec
DELETE FROM oauth2_provider_apps WHERE id = $1
`
@ -6170,7 +6407,7 @@ WHERE
AND (COALESCE(array_length($3::uuid[], 1), 0) = 0 OR pd.id = ANY($3::uuid[]))
AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $4::tagset))
ORDER BY
pd.created_at ASC
pd.created_at DESC
LIMIT
$5::int
`
@ -6715,45 +6952,69 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI
}
const getProvisionerJobsByIDsWithQueuePosition = `-- name: GetProvisionerJobsByIDsWithQueuePosition :many
WITH pending_jobs AS (
SELECT
id, created_at
FROM
provisioner_jobs
WHERE
started_at IS NULL
AND
canceled_at IS NULL
AND
completed_at IS NULL
AND
error IS NULL
WITH filtered_provisioner_jobs AS (
-- Step 1: Filter provisioner_jobs
SELECT
id, created_at
FROM
provisioner_jobs
WHERE
id = ANY($1 :: uuid [ ]) -- Apply filter early to reduce dataset size before expensive JOIN
),
queue_position AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position
FROM
pending_jobs
pending_jobs AS (
-- Step 2: Extract only pending jobs
SELECT
id, created_at, tags
FROM
provisioner_jobs
WHERE
job_status = 'pending'
),
queue_size AS (
SELECT COUNT(*) AS count FROM pending_jobs
ranked_jobs AS (
-- Step 3: Rank only pending jobs based on provisioner availability
SELECT
pj.id,
pj.created_at,
ROW_NUMBER() OVER (PARTITION BY pd.id ORDER BY pj.created_at ASC) AS queue_position,
COUNT(*) OVER (PARTITION BY pd.id) AS queue_size
FROM
pending_jobs pj
INNER JOIN provisioner_daemons pd
ON provisioner_tagset_contains(pd.tags, pj.tags) -- Join only on the small pending set
),
final_jobs AS (
-- Step 4: Compute best queue position and max queue size per job
SELECT
fpj.id,
fpj.created_at,
COALESCE(MIN(rj.queue_position), 0) :: BIGINT AS queue_position, -- Best queue position across provisioners
COALESCE(MAX(rj.queue_size), 0) :: BIGINT AS queue_size -- Max queue size across provisioners
FROM
filtered_provisioner_jobs fpj -- Use the pre-filtered dataset instead of full provisioner_jobs
LEFT JOIN ranked_jobs rj
ON fpj.id = rj.id -- Join with the ranking jobs CTE to assign a rank to each specified provisioner job.
GROUP BY
fpj.id, fpj.created_at
)
SELECT
-- Step 5: Final SELECT with INNER JOIN provisioner_jobs
fj.id,
fj.created_at,
pj.id, pj.created_at, pj.updated_at, pj.started_at, pj.canceled_at, pj.completed_at, pj.error, pj.organization_id, pj.initiator_id, pj.provisioner, pj.storage_method, pj.type, pj.input, pj.worker_id, pj.file_id, pj.tags, pj.error_code, pj.trace_metadata, pj.job_status,
COALESCE(qp.queue_position, 0) AS queue_position,
COALESCE(qs.count, 0) AS queue_size
fj.queue_position,
fj.queue_size
FROM
provisioner_jobs pj
LEFT JOIN
queue_position qp ON qp.id = pj.id
LEFT JOIN
queue_size qs ON TRUE
WHERE
pj.id = ANY($1 :: uuid [ ])
final_jobs fj
INNER JOIN provisioner_jobs pj
ON fj.id = pj.id -- Ensure we retrieve full details from ` + "`" + `provisioner_jobs` + "`" + `.
-- JOIN with pj is required for sqlc.embed(pj) to compile successfully.
ORDER BY
fj.created_at
`
type GetProvisionerJobsByIDsWithQueuePositionRow struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
ProvisionerJob ProvisionerJob `db:"provisioner_job" json:"provisioner_job"`
QueuePosition int64 `db:"queue_position" json:"queue_position"`
QueueSize int64 `db:"queue_size" json:"queue_size"`
@ -6769,6 +7030,8 @@ func (q *sqlQuerier) GetProvisionerJobsByIDsWithQueuePosition(ctx context.Contex
for rows.Next() {
var i GetProvisionerJobsByIDsWithQueuePositionRow
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.ProvisionerJob.ID,
&i.ProvisionerJob.CreatedAt,
&i.ProvisionerJob.UpdatedAt,
@ -8100,25 +8363,25 @@ SELECT
FROM
custom_roles
WHERE
true
-- @lookup_roles will filter for exact (role_name, org_id) pairs
-- To do this manually in SQL, you can construct an array and cast it:
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
AND CASE WHEN array_length($1 :: name_organization_pair[], 1) > 0 THEN
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY ($1::name_organization_pair[])
ELSE true
END
-- This allows fetching all roles, or just site wide roles
AND CASE WHEN $2 :: boolean THEN
organization_id IS null
true
-- @lookup_roles will filter for exact (role_name, org_id) pairs
-- To do this manually in SQL, you can construct an array and cast it:
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
AND CASE WHEN array_length($1 :: name_organization_pair[], 1) > 0 THEN
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY ($1::name_organization_pair[])
ELSE true
END
-- Allows fetching all roles to a particular organization
AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
organization_id = $3
ELSE true
END
END
-- This allows fetching all roles, or just site wide roles
AND CASE WHEN $2 :: boolean THEN
organization_id IS null
ELSE true
END
-- Allows fetching all roles to a particular organization
AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
organization_id = $3
ELSE true
END
`
type CustomRolesParams struct {
@ -8191,16 +8454,16 @@ INSERT INTO
updated_at
)
VALUES (
-- Always force lowercase names
lower($1),
$2,
$3,
$4,
$5,
$6,
now(),
now()
)
-- Always force lowercase names
lower($1),
$2,
$3,
$4,
$5,
$6,
now(),
now()
)
RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id
`
@ -12478,6 +12741,46 @@ func (q *sqlQuerier) FetchMemoryResourceMonitorsByAgentID(ctx context.Context, a
return i, err
}
const fetchMemoryResourceMonitorsUpdatedAfter = `-- name: FetchMemoryResourceMonitorsUpdatedAfter :many
SELECT
agent_id, enabled, threshold, created_at, updated_at, state, debounced_until
FROM
workspace_agent_memory_resource_monitors
WHERE
updated_at > $1
`
func (q *sqlQuerier) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentMemoryResourceMonitor, error) {
rows, err := q.db.QueryContext(ctx, fetchMemoryResourceMonitorsUpdatedAfter, updatedAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkspaceAgentMemoryResourceMonitor
for rows.Next() {
var i WorkspaceAgentMemoryResourceMonitor
if err := rows.Scan(
&i.AgentID,
&i.Enabled,
&i.Threshold,
&i.CreatedAt,
&i.UpdatedAt,
&i.State,
&i.DebouncedUntil,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const fetchVolumesResourceMonitorsByAgentID = `-- name: FetchVolumesResourceMonitorsByAgentID :many
SELECT
agent_id, enabled, threshold, path, created_at, updated_at, state, debounced_until
@ -12519,6 +12822,47 @@ func (q *sqlQuerier) FetchVolumesResourceMonitorsByAgentID(ctx context.Context,
return items, nil
}
const fetchVolumesResourceMonitorsUpdatedAfter = `-- name: FetchVolumesResourceMonitorsUpdatedAfter :many
SELECT
agent_id, enabled, threshold, path, created_at, updated_at, state, debounced_until
FROM
workspace_agent_volume_resource_monitors
WHERE
updated_at > $1
`
func (q *sqlQuerier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error) {
rows, err := q.db.QueryContext(ctx, fetchVolumesResourceMonitorsUpdatedAfter, updatedAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkspaceAgentVolumeResourceMonitor
for rows.Next() {
var i WorkspaceAgentVolumeResourceMonitor
if err := rows.Scan(
&i.AgentID,
&i.Enabled,
&i.Threshold,
&i.Path,
&i.CreatedAt,
&i.UpdatedAt,
&i.State,
&i.DebouncedUntil,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertMemoryResourceMonitor = `-- name: InsertMemoryResourceMonitor :one
INSERT INTO
workspace_agent_memory_resource_monitors (
@ -16596,13 +16940,11 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace
}
const getWorkspaceUniqueOwnerCountByTemplateIDs = `-- name: GetWorkspaceUniqueOwnerCountByTemplateIDs :many
SELECT
template_id, COUNT(DISTINCT owner_id) AS unique_owners_sum
FROM
workspaces
WHERE
template_id = ANY($1 :: uuid[]) AND deleted = false
GROUP BY template_id
SELECT templates.id AS template_id, COUNT(DISTINCT workspaces.owner_id) AS unique_owners_sum
FROM templates
LEFT JOIN workspaces ON workspaces.template_id = templates.id AND workspaces.deleted = false
WHERE templates.id = ANY($1 :: uuid[])
GROUP BY templates.id
`
type GetWorkspaceUniqueOwnerCountByTemplateIDsRow struct {

View File

@ -0,0 +1,59 @@
-- name: GetInboxNotificationsByUserID :many
-- Fetches inbox notifications for a user filtered by templates and targets
-- param user_id: The user ID
-- param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
-- param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
-- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
SELECT * FROM inbox_notifications WHERE
user_id = @user_id AND
(@read_status::inbox_notification_read_status = 'all' OR (@read_status::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR (@read_status::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
(@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ)
ORDER BY created_at DESC
LIMIT (COALESCE(NULLIF(@limit_opt :: INT, 0), 25));
-- name: GetFilteredInboxNotificationsByUserID :many
-- Fetches inbox notifications for a user filtered by templates and targets
-- param user_id: The user ID
-- param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array
-- param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array
-- param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
-- param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
-- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
SELECT * FROM inbox_notifications WHERE
user_id = @user_id AND
template_id = ANY(@templates::UUID[]) AND
targets @> COALESCE(@targets, ARRAY[]::UUID[]) AND
(@read_status::inbox_notification_read_status = 'all' OR (@read_status::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR (@read_status::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
(@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ)
ORDER BY created_at DESC
LIMIT (COALESCE(NULLIF(@limit_opt :: INT, 0), 25));
-- name: GetInboxNotificationByID :one
SELECT * FROM inbox_notifications WHERE id = $1;
-- name: CountUnreadInboxNotificationsByUserID :one
SELECT COUNT(*) FROM inbox_notifications WHERE user_id = $1 AND read_at IS NULL;
-- name: InsertInboxNotification :one
INSERT INTO
inbox_notifications (
id,
user_id,
template_id,
targets,
title,
content,
icon,
actions,
created_at
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *;
-- name: UpdateInboxNotificationReadStatus :exec
UPDATE
inbox_notifications
SET
read_at = $1
WHERE
id = $2;

View File

@ -111,7 +111,7 @@ WHERE
AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pd.id = ANY(@ids::uuid[]))
AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, @tags::tagset))
ORDER BY
pd.created_at ASC
pd.created_at DESC
LIMIT
sqlc.narg('limit')::int;

View File

@ -50,42 +50,64 @@ WHERE
id = ANY(@ids :: uuid [ ]);
-- name: GetProvisionerJobsByIDsWithQueuePosition :many
WITH pending_jobs AS (
SELECT
id, created_at
FROM
provisioner_jobs
WHERE
started_at IS NULL
AND
canceled_at IS NULL
AND
completed_at IS NULL
AND
error IS NULL
WITH filtered_provisioner_jobs AS (
-- Step 1: Filter provisioner_jobs
SELECT
id, created_at
FROM
provisioner_jobs
WHERE
id = ANY(@ids :: uuid [ ]) -- Apply filter early to reduce dataset size before expensive JOIN
),
queue_position AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position
FROM
pending_jobs
pending_jobs AS (
-- Step 2: Extract only pending jobs
SELECT
id, created_at, tags
FROM
provisioner_jobs
WHERE
job_status = 'pending'
),
queue_size AS (
SELECT COUNT(*) AS count FROM pending_jobs
ranked_jobs AS (
-- Step 3: Rank only pending jobs based on provisioner availability
SELECT
pj.id,
pj.created_at,
ROW_NUMBER() OVER (PARTITION BY pd.id ORDER BY pj.created_at ASC) AS queue_position,
COUNT(*) OVER (PARTITION BY pd.id) AS queue_size
FROM
pending_jobs pj
INNER JOIN provisioner_daemons pd
ON provisioner_tagset_contains(pd.tags, pj.tags) -- Join only on the small pending set
),
final_jobs AS (
-- Step 4: Compute best queue position and max queue size per job
SELECT
fpj.id,
fpj.created_at,
COALESCE(MIN(rj.queue_position), 0) :: BIGINT AS queue_position, -- Best queue position across provisioners
COALESCE(MAX(rj.queue_size), 0) :: BIGINT AS queue_size -- Max queue size across provisioners
FROM
filtered_provisioner_jobs fpj -- Use the pre-filtered dataset instead of full provisioner_jobs
LEFT JOIN ranked_jobs rj
ON fpj.id = rj.id -- Join with the ranking jobs CTE to assign a rank to each specified provisioner job.
GROUP BY
fpj.id, fpj.created_at
)
SELECT
-- Step 5: Final SELECT with INNER JOIN provisioner_jobs
fj.id,
fj.created_at,
sqlc.embed(pj),
COALESCE(qp.queue_position, 0) AS queue_position,
COALESCE(qs.count, 0) AS queue_size
fj.queue_position,
fj.queue_size
FROM
provisioner_jobs pj
LEFT JOIN
queue_position qp ON qp.id = pj.id
LEFT JOIN
queue_size qs ON TRUE
WHERE
pj.id = ANY(@ids :: uuid [ ]);
final_jobs fj
INNER JOIN provisioner_jobs pj
ON fj.id = pj.id -- Ensure we retrieve full details from `provisioner_jobs`.
-- JOIN with pj is required for sqlc.embed(pj) to compile successfully.
ORDER BY
fj.created_at;
-- name: GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner :many
WITH pending_jobs AS (

View File

@ -4,25 +4,25 @@ SELECT
FROM
custom_roles
WHERE
true
-- @lookup_roles will filter for exact (role_name, org_id) pairs
-- To do this manually in SQL, you can construct an array and cast it:
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
AND CASE WHEN array_length(@lookup_roles :: name_organization_pair[], 1) > 0 THEN
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY (@lookup_roles::name_organization_pair[])
ELSE true
END
-- This allows fetching all roles, or just site wide roles
AND CASE WHEN @exclude_org_roles :: boolean THEN
organization_id IS null
true
-- @lookup_roles will filter for exact (role_name, org_id) pairs
-- To do this manually in SQL, you can construct an array and cast it:
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
AND CASE WHEN array_length(@lookup_roles :: name_organization_pair[], 1) > 0 THEN
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY (@lookup_roles::name_organization_pair[])
ELSE true
END
-- Allows fetching all roles to a particular organization
AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
organization_id = @organization_id
ELSE true
END
END
-- This allows fetching all roles, or just site wide roles
AND CASE WHEN @exclude_org_roles :: boolean THEN
organization_id IS null
ELSE true
END
-- Allows fetching all roles to a particular organization
AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
organization_id = @organization_id
ELSE true
END
;
-- name: DeleteCustomRole :exec
@ -46,16 +46,16 @@ INSERT INTO
updated_at
)
VALUES (
-- Always force lowercase names
lower(@name),
@display_name,
@organization_id,
@site_permissions,
@org_permissions,
@user_permissions,
now(),
now()
)
-- Always force lowercase names
lower(@name),
@display_name,
@organization_id,
@site_permissions,
@org_permissions,
@user_permissions,
now(),
now()
)
RETURNING *;
-- name: UpdateCustomRole :one

View File

@ -1,3 +1,19 @@
-- name: FetchVolumesResourceMonitorsUpdatedAfter :many
SELECT
*
FROM
workspace_agent_volume_resource_monitors
WHERE
updated_at > $1;
-- name: FetchMemoryResourceMonitorsUpdatedAfter :many
SELECT
*
FROM
workspace_agent_memory_resource_monitors
WHERE
updated_at > $1;
-- name: FetchMemoryResourceMonitorsByAgentID :one
SELECT
*

View File

@ -415,13 +415,11 @@ WHERE
ORDER BY created_at DESC;
-- name: GetWorkspaceUniqueOwnerCountByTemplateIDs :many
SELECT
template_id, COUNT(DISTINCT owner_id) AS unique_owners_sum
FROM
workspaces
WHERE
template_id = ANY(@template_ids :: uuid[]) AND deleted = false
GROUP BY template_id;
SELECT templates.id AS template_id, COUNT(DISTINCT workspaces.owner_id) AS unique_owners_sum
FROM templates
LEFT JOIN workspaces ON workspaces.template_id = templates.id AND workspaces.deleted = false
WHERE templates.id = ANY(@template_ids :: uuid[])
GROUP BY templates.id;
-- name: InsertWorkspace :one
INSERT INTO

View File

@ -21,6 +21,7 @@ const (
UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id);
UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id);
UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
UniqueInboxNotificationsPkey UniqueConstraint = "inbox_notifications_pkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id);
UniqueJfrogXrayScansPkey UniqueConstraint = "jfrog_xray_scans_pkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id);
UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt);
UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id);