Files
Danny Kopping 6e967780c9 feat: track resource replacements when claiming a prebuilt workspace (#17571)
Closes https://github.com/coder/internal/issues/369

We can't know whether a replacement (i.e. drift of terraform state
leading to a resource needing to be deleted/recreated) will take place
apriori; we can only detect it at `plan` time, because the provider
decides whether a resource must be replaced and it cannot be inferred
through static analysis of the template.

**This is likely to be the most common gotcha with using prebuilds,
since it requires a slight template modification to use prebuilds
effectively**, so let's head this off before it's an issue for
customers.

Drift details will now be logged in the workspace build logs:


![image](https://github.com/user-attachments/assets/da1988b6-2cbe-4a79-a3c5-ea29891f3d6f)

Plus a notification will be sent to template admins when this situation
arises:


![image](https://github.com/user-attachments/assets/39d555b1-a262-4a3e-b529-03b9f23bf66a)

A new metric - `coderd_prebuilt_workspaces_resource_replacements_total`
- will also increment each time a workspace encounters replacements.

We only track _that_ a resource replacement occurred, not how many. Just
one is enough to ruin a prebuild, but we can't know apriori which
replacement would cause this.
For example, say we have 2 replacements: a `docker_container` and a
`null_resource`; we don't know which one might
cause an issue (or indeed if either would), so we just track the
replacement.

---------

Signed-off-by: Danny Kopping <dannykopping@gmail.com>
2025-05-14 14:52:22 +02:00

130 lines
3.5 KiB
Go

package notificationstest
import (
"context"
"fmt"
"sync"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
)
type FakeEnqueuer struct {
authorizer rbac.Authorizer
mu sync.Mutex
sent []*FakeNotification
}
var _ notifications.Enqueuer = &FakeEnqueuer{}
func NewFakeEnqueuer() *FakeEnqueuer {
return &FakeEnqueuer{}
}
type FakeNotification struct {
UserID, TemplateID uuid.UUID
Labels map[string]string
Data map[string]any
CreatedBy string
Targets []uuid.UUID
}
// TODO: replace this with actual calls to dbauthz.
// See: https://github.com/coder/coder/issues/15481
func (f *FakeEnqueuer) assertRBACNoLock(ctx context.Context) {
if f.mu.TryLock() {
panic("Developer error: do not call assertRBACNoLock outside of a mutex lock!")
}
// If we get here, we are locked.
if f.authorizer == nil {
f.authorizer = rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
}
act, ok := dbauthz.ActorFromContext(ctx)
if !ok {
panic("Developer error: no actor in context, you may need to use dbauthz.AsNotifier(ctx)")
}
for _, a := range []policy.Action{policy.ActionCreate, policy.ActionRead} {
err := f.authorizer.Authorize(ctx, act, a, rbac.ResourceNotificationMessage)
if err == nil {
return
}
if rbac.IsUnauthorizedError(err) {
panic(fmt.Sprintf("Developer error: not authorized to %s %s. "+
"Ensure that you are using dbauthz.AsXXX with an actor that has "+
"policy.ActionCreate on rbac.ResourceNotificationMessage", a, rbac.ResourceNotificationMessage.Type))
}
panic("Developer error: failed to check auth:" + err.Error())
}
}
func (f *FakeEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) {
return f.EnqueueWithData(ctx, userID, templateID, labels, nil, createdBy, targets...)
}
func (f *FakeEnqueuer) EnqueueWithData(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) {
return f.enqueueWithDataLock(ctx, userID, templateID, labels, data, createdBy, targets...)
}
func (f *FakeEnqueuer) enqueueWithDataLock(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.assertRBACNoLock(ctx)
f.sent = append(f.sent, &FakeNotification{
UserID: userID,
TemplateID: templateID,
Labels: labels,
Data: data,
CreatedBy: createdBy,
Targets: targets,
})
id := uuid.New()
return []uuid.UUID{id}, nil
}
func (f *FakeEnqueuer) Clear() {
f.mu.Lock()
defer f.mu.Unlock()
f.sent = nil
}
func (f *FakeEnqueuer) Sent(matchers ...func(*FakeNotification) bool) []*FakeNotification {
f.mu.Lock()
defer f.mu.Unlock()
sent := []*FakeNotification{}
for _, notif := range f.sent {
// Check this notification matches all given matchers
matches := true
for _, matcher := range matchers {
if !matcher(notif) {
matches = false
break
}
}
if matches {
sent = append(sent, notif)
}
}
return sent
}
func WithTemplateID(id uuid.UUID) func(*FakeNotification) bool {
return func(n *FakeNotification) bool {
return n.TemplateID == id
}
}