mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: notify on task auto pause, manual pause and manual resume (#22050)
This commit is contained in:
@@ -21,10 +21,12 @@ import (
|
||||
agentapisdk "github.com/coder/agentapi-sdk-go"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/searchquery"
|
||||
@@ -1300,6 +1302,23 @@ func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := api.NotificationsEnqueuer.Enqueue(
|
||||
// nolint:gocritic // Need notifier actor to enqueue notifications.
|
||||
dbauthz.AsNotifier(ctx),
|
||||
workspace.OwnerID,
|
||||
notifications.TemplateTaskPaused,
|
||||
map[string]string{
|
||||
"task": task.Name,
|
||||
"task_id": task.ID.String(),
|
||||
"workspace": workspace.Name,
|
||||
"pause_reason": "manual",
|
||||
},
|
||||
"api-task-pause",
|
||||
workspace.ID, workspace.OwnerID, workspace.OrganizationID,
|
||||
); err != nil {
|
||||
api.Logger.Warn(ctx, "failed to notify of task paused", slog.Error(err), slog.F("task_id", task.ID), slog.F("workspace_id", workspace.ID))
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.PauseTaskResponse{
|
||||
WorkspaceBuild: &build,
|
||||
})
|
||||
@@ -1387,6 +1406,22 @@ func (api *API) resumeTask(rw http.ResponseWriter, r *http.Request) {
|
||||
httperror.WriteWorkspaceBuildError(ctx, rw, err)
|
||||
return
|
||||
}
|
||||
if _, err := api.NotificationsEnqueuer.Enqueue(
|
||||
// nolint:gocritic // Need notifier actor to enqueue notifications.
|
||||
dbauthz.AsNotifier(ctx),
|
||||
workspace.OwnerID,
|
||||
notifications.TemplateTaskResumed,
|
||||
map[string]string{
|
||||
"task": task.Name,
|
||||
"task_id": task.ID.String(),
|
||||
"workspace": workspace.Name,
|
||||
},
|
||||
"api-task-resume",
|
||||
workspace.ID, workspace.OwnerID, workspace.OrganizationID,
|
||||
); err != nil {
|
||||
api.Logger.Warn(ctx, "failed to notify of task resumed", slog.Error(err), slog.F("task_id", task.ID), slog.F("workspace_id", workspace.ID))
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.ResumeTaskResponse{
|
||||
WorkspaceBuild: &build,
|
||||
})
|
||||
|
||||
+110
-37
@@ -45,10 +45,10 @@ import (
|
||||
)
|
||||
|
||||
// createTaskInState is a helper to create a task in the desired state.
|
||||
// It returns a function that takes context, test, and status, and returns the task ID.
|
||||
// It returns a function that takes context, test, and status, and returns the task.
|
||||
// The caller is responsible for setting up the database, owner, and user.
|
||||
func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID, userID uuid.UUID) func(context.Context, *testing.T, database.TaskStatus) uuid.UUID {
|
||||
return func(ctx context.Context, t *testing.T, status database.TaskStatus) uuid.UUID {
|
||||
func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID, userID uuid.UUID) func(context.Context, *testing.T, database.TaskStatus) database.Task {
|
||||
return func(ctx context.Context, t *testing.T, status database.TaskStatus) database.Task {
|
||||
ctx = dbauthz.As(ctx, ownerSubject)
|
||||
|
||||
builder := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
@@ -65,6 +65,9 @@ func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID,
|
||||
builder = builder.Pending()
|
||||
case database.TaskStatusInitializing:
|
||||
builder = builder.Starting()
|
||||
case database.TaskStatusActive:
|
||||
// Default builder produces a succeeded start build.
|
||||
// Post-processing below sets agent and app to active.
|
||||
case database.TaskStatusPaused:
|
||||
builder = builder.Seed(database.WorkspaceBuild{
|
||||
Transition: database.WorkspaceTransitionStop,
|
||||
@@ -76,31 +79,32 @@ func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID,
|
||||
}
|
||||
|
||||
resp := builder.Do()
|
||||
taskID := resp.Task.ID
|
||||
|
||||
// Post-process by manipulating agent and app state.
|
||||
if status == database.TaskStatusError {
|
||||
// First, set agent to ready state so agent_status returns 'active'.
|
||||
// This ensures the cascade reaches app_status.
|
||||
if status == database.TaskStatusActive || status == database.TaskStatusError {
|
||||
// Set agent to ready state so agent_status returns 'active'.
|
||||
err := db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
|
||||
ID: resp.Agents[0].ID,
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then set workspace app health to unhealthy to trigger error state.
|
||||
apps, err := db.GetWorkspaceAppsByAgentID(ctx, resp.Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apps, 1, "expected exactly one app for task")
|
||||
|
||||
appHealth := database.WorkspaceAppHealthHealthy
|
||||
if status == database.TaskStatusError {
|
||||
appHealth = database.WorkspaceAppHealthUnhealthy
|
||||
}
|
||||
err = db.UpdateWorkspaceAppHealthByID(ctx, database.UpdateWorkspaceAppHealthByIDParams{
|
||||
ID: apps[0].ID,
|
||||
Health: database.WorkspaceAppHealthUnhealthy,
|
||||
Health: appHealth,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return taskID
|
||||
return resp.Task
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,9 +849,9 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusPaused)
|
||||
task := createTask(ctx, t, database.TaskStatusPaused)
|
||||
|
||||
err := client.TaskSend(ctx, "me", taskID, codersdk.TaskSendRequest{
|
||||
err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
Input: "Hello",
|
||||
})
|
||||
|
||||
@@ -863,9 +867,9 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusInitializing)
|
||||
task := createTask(ctx, t, database.TaskStatusInitializing)
|
||||
|
||||
err := client.TaskSend(ctx, "me", taskID, codersdk.TaskSendRequest{
|
||||
err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
Input: "Hello",
|
||||
})
|
||||
|
||||
@@ -881,9 +885,9 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusPending)
|
||||
task := createTask(ctx, t, database.TaskStatusPending)
|
||||
|
||||
err := client.TaskSend(ctx, "me", taskID, codersdk.TaskSendRequest{
|
||||
err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
Input: "Hello",
|
||||
})
|
||||
|
||||
@@ -899,9 +903,9 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusError)
|
||||
task := createTask(ctx, t, database.TaskStatusError)
|
||||
|
||||
err := client.TaskSend(ctx, "me", taskID, codersdk.TaskSendRequest{
|
||||
err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
Input: "Hello",
|
||||
})
|
||||
|
||||
@@ -1120,16 +1124,16 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusPending)
|
||||
task := createTask(ctx, t, database.TaskStatusPending)
|
||||
|
||||
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
|
||||
TaskID: taskID,
|
||||
TaskID: task.ID,
|
||||
LogSnapshot: json.RawMessage(snapshotJSON),
|
||||
LogSnapshotCreatedAt: snapshotTime,
|
||||
})
|
||||
require.NoError(t, err, "upserting task snapshot")
|
||||
|
||||
logsResp, err := client.TaskLogs(ctx, "me", taskID)
|
||||
logsResp, err := client.TaskLogs(ctx, "me", task.ID)
|
||||
require.NoError(t, err, "fetching task logs")
|
||||
verifySnapshotLogs(t, logsResp)
|
||||
})
|
||||
@@ -1138,16 +1142,16 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusInitializing)
|
||||
task := createTask(ctx, t, database.TaskStatusInitializing)
|
||||
|
||||
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
|
||||
TaskID: taskID,
|
||||
TaskID: task.ID,
|
||||
LogSnapshot: json.RawMessage(snapshotJSON),
|
||||
LogSnapshotCreatedAt: snapshotTime,
|
||||
})
|
||||
require.NoError(t, err, "upserting task snapshot")
|
||||
|
||||
logsResp, err := client.TaskLogs(ctx, "me", taskID)
|
||||
logsResp, err := client.TaskLogs(ctx, "me", task.ID)
|
||||
require.NoError(t, err, "fetching task logs")
|
||||
verifySnapshotLogs(t, logsResp)
|
||||
})
|
||||
@@ -1156,16 +1160,16 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusPaused)
|
||||
task := createTask(ctx, t, database.TaskStatusPaused)
|
||||
|
||||
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
|
||||
TaskID: taskID,
|
||||
TaskID: task.ID,
|
||||
LogSnapshot: json.RawMessage(snapshotJSON),
|
||||
LogSnapshotCreatedAt: snapshotTime,
|
||||
})
|
||||
require.NoError(t, err, "upserting task snapshot")
|
||||
|
||||
logsResp, err := client.TaskLogs(ctx, "me", taskID)
|
||||
logsResp, err := client.TaskLogs(ctx, "me", task.ID)
|
||||
require.NoError(t, err, "fetching task logs")
|
||||
verifySnapshotLogs(t, logsResp)
|
||||
})
|
||||
@@ -1174,9 +1178,9 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusPending)
|
||||
task := createTask(ctx, t, database.TaskStatusPending)
|
||||
|
||||
logsResp, err := client.TaskLogs(ctx, "me", taskID)
|
||||
logsResp, err := client.TaskLogs(ctx, "me", task.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, logsResp.Snapshot)
|
||||
@@ -1188,7 +1192,7 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusPending)
|
||||
task := createTask(ctx, t, database.TaskStatusPending)
|
||||
|
||||
invalidEnvelope := coderd.TaskLogSnapshotEnvelope{
|
||||
Format: "unknown-format",
|
||||
@@ -1198,13 +1202,13 @@ func TestTasks(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
|
||||
TaskID: taskID,
|
||||
TaskID: task.ID,
|
||||
LogSnapshot: json.RawMessage(invalidJSON),
|
||||
LogSnapshotCreatedAt: snapshotTime,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.TaskLogs(ctx, "me", taskID)
|
||||
_, err = client.TaskLogs(ctx, "me", task.ID)
|
||||
require.Error(t, err)
|
||||
|
||||
var sdkErr *codersdk.Error
|
||||
@@ -1217,16 +1221,16 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusPending)
|
||||
task := createTask(ctx, t, database.TaskStatusPending)
|
||||
|
||||
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
|
||||
TaskID: taskID,
|
||||
TaskID: task.ID,
|
||||
LogSnapshot: json.RawMessage(`{"format":"agentapi","data":"not an object"}`),
|
||||
LogSnapshotCreatedAt: snapshotTime,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.TaskLogs(ctx, "me", taskID)
|
||||
_, err = client.TaskLogs(ctx, "me", task.ID)
|
||||
require.Error(t, err)
|
||||
|
||||
var sdkErr *codersdk.Error
|
||||
@@ -1238,9 +1242,9 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
taskID := createTask(ctx, t, database.TaskStatusError)
|
||||
task := createTask(ctx, t, database.TaskStatusError)
|
||||
|
||||
_, err := client.TaskLogs(ctx, "me", taskID)
|
||||
_, err := client.TaskLogs(ctx, "me", task.ID)
|
||||
require.Error(t, err)
|
||||
|
||||
var sdkErr *codersdk.Error
|
||||
@@ -2787,6 +2791,41 @@ func TestPauseTask(t *testing.T) {
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Notification", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
notifyEnq = ¬ificationstest.FakeEnqueuer{}
|
||||
ownerClient, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{NotificationsEnqueuer: notifyEnq})
|
||||
owner = coderdtest.CreateFirstUser(t, ownerClient)
|
||||
)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
ownerUser, err := ownerClient.User(ctx, owner.UserID.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
createTask := createTaskInState(db, coderdtest.AuthzUserSubject(ownerUser), owner.OrganizationID, owner.UserID)
|
||||
|
||||
// Given: A task in an active state
|
||||
task := createTask(ctx, t, database.TaskStatusActive)
|
||||
|
||||
workspace, err := ownerClient.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: We pause the task
|
||||
_, err = ownerClient.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: A notification should be sent
|
||||
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskPaused))
|
||||
require.Len(t, sent, 1)
|
||||
require.Equal(t, owner.UserID, sent[0].UserID)
|
||||
require.Equal(t, task.Name, sent[0].Labels["task"])
|
||||
require.Equal(t, task.ID.String(), sent[0].Labels["task_id"])
|
||||
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
|
||||
require.Equal(t, "manual", sent[0].Labels["pause_reason"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestResumeTask(t *testing.T) {
|
||||
@@ -3116,4 +3155,38 @@ func TestResumeTask(t *testing.T) {
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Notification", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
notifyEnq = ¬ificationstest.FakeEnqueuer{}
|
||||
ownerClient, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{NotificationsEnqueuer: notifyEnq})
|
||||
owner = coderdtest.CreateFirstUser(t, ownerClient)
|
||||
)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
ownerUser, err := ownerClient.User(ctx, owner.UserID.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
createTask := createTaskInState(db, coderdtest.AuthzUserSubject(ownerUser), owner.OrganizationID, owner.UserID)
|
||||
|
||||
// Given: A task in a paused state
|
||||
task := createTask(ctx, t, database.TaskStatusPaused)
|
||||
|
||||
workspace, err := ownerClient.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: We resume the task
|
||||
_, err = ownerClient.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: A notification should be sent
|
||||
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskResumed))
|
||||
require.Len(t, sent, 1)
|
||||
require.Equal(t, owner.UserID, sent[0].UserID)
|
||||
require.Equal(t, task.Name, sent[0].Labels["task"])
|
||||
require.Equal(t, task.ID.String(), sent[0].Labels["task_id"])
|
||||
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
job *database.ProvisionerJob
|
||||
auditLog *auditParams
|
||||
shouldNotifyDormancy bool
|
||||
shouldNotifyTaskPause bool
|
||||
nextBuild *database.WorkspaceBuild
|
||||
activeTemplateVersion database.TemplateVersion
|
||||
ws database.Workspace
|
||||
@@ -316,6 +317,10 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
return nil
|
||||
}
|
||||
|
||||
if reason == database.BuildReasonTaskAutoPause {
|
||||
shouldNotifyTaskPause = true
|
||||
}
|
||||
|
||||
// Get the template version job to access tags
|
||||
templateVersionJob, err := tx.GetProvisionerJobByID(e.ctx, activeTemplateVersion.JobID)
|
||||
if err != nil {
|
||||
@@ -482,6 +487,28 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", ws.ID))
|
||||
}
|
||||
}
|
||||
if shouldNotifyTaskPause {
|
||||
task, err := e.db.GetTaskByID(e.ctx, ws.TaskID.UUID)
|
||||
if err != nil {
|
||||
log.Warn(e.ctx, "failed to get task for pause notification", slog.Error(err), slog.F("task_id", ws.TaskID.UUID), slog.F("workspace_id", ws.ID))
|
||||
} else {
|
||||
if _, err := e.notificationsEnqueuer.Enqueue(
|
||||
e.ctx,
|
||||
ws.OwnerID,
|
||||
notifications.TemplateTaskPaused,
|
||||
map[string]string{
|
||||
"task": task.Name,
|
||||
"task_id": task.ID.String(),
|
||||
"workspace": ws.Name,
|
||||
"pause_reason": "inactivity exceeded the dormancy threshold",
|
||||
},
|
||||
"lifecycle_executor",
|
||||
ws.ID, ws.OwnerID, ws.OrganizationID,
|
||||
); err != nil {
|
||||
log.Warn(e.ctx, "failed to notify of task paused", slog.Error(err), slog.F("task_id", ws.TaskID.UUID), slog.F("workspace_id", ws.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if err != nil && !xerrors.Is(err, context.Canceled) {
|
||||
|
||||
@@ -2026,4 +2026,62 @@ func TestExecutorTaskWorkspace(t *testing.T) {
|
||||
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
|
||||
assert.Equal(t, codersdk.BuildReasonTaskAutoPause, workspace.LatestBuild.Reason, "task workspace should use TaskAutoPause build reason")
|
||||
})
|
||||
|
||||
t.Run("AutostopNotification", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
tickCh = make(chan time.Time)
|
||||
statsCh = make(chan autobuild.Stats)
|
||||
notifyEnq = notificationstest.FakeEnqueuer{}
|
||||
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
AutobuildTicker: tickCh,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statsCh,
|
||||
NotificationsEnqueuer: ¬ifyEnq,
|
||||
})
|
||||
admin = coderdtest.CreateFirstUser(t, client)
|
||||
)
|
||||
|
||||
// Given: A task workspace with an 8 hour deadline
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
template := createTaskTemplate(t, client, admin.OrganizationID, ctx, 8*time.Hour)
|
||||
workspace := createTaskWorkspace(t, client, template, ctx, "test task for autostop notification")
|
||||
|
||||
// Given: The workspace is currently running
|
||||
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
require.NotZero(t, workspace.LatestBuild.Deadline, "workspace should have a deadline for autostop")
|
||||
|
||||
p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: the autobuild executor ticks after the deadline
|
||||
go func() {
|
||||
tickTime := workspace.LatestBuild.Deadline.Time.Add(time.Minute)
|
||||
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
|
||||
tickCh <- tickTime
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: We expect to see a stop transition
|
||||
stats := <-statsCh
|
||||
require.Len(t, stats.Transitions, 1, "lifecycle executor should transition the task workspace")
|
||||
assert.Contains(t, stats.Transitions, workspace.ID, "task workspace should be in transitions")
|
||||
assert.Equal(t, database.WorkspaceTransitionStop, stats.Transitions[workspace.ID], "should autostop the workspace")
|
||||
require.Empty(t, stats.Errors, "should have no errors when managing task workspaces")
|
||||
|
||||
// Then: A task paused notification was sent with "idle timeout" reason
|
||||
require.True(t, workspace.TaskID.Valid, "workspace should have a task ID")
|
||||
task, err := db.GetTaskByID(dbauthz.AsSystemRestricted(ctx), workspace.TaskID.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskPaused))
|
||||
require.Len(t, sent, 1)
|
||||
require.Equal(t, workspace.OwnerID, sent[0].UserID)
|
||||
require.Equal(t, task.Name, sent[0].Labels["task"])
|
||||
require.Equal(t, task.ID.String(), sent[0].Labels["task_id"])
|
||||
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
|
||||
require.Equal(t, "inactivity exceeded the dormancy threshold", sent[0].Labels["pause_reason"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Remove Task 'paused' transition template notification
|
||||
DELETE FROM notification_templates WHERE id = '2a74f3d3-ab09-4123-a4a5-ca238f4f65a1';
|
||||
-- Remove Task 'resumed' transition template notification
|
||||
DELETE FROM notification_templates WHERE id = '843ee9c3-a8fb-4846-afa9-977bec578649';
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Task transition to 'paused' status
|
||||
INSERT INTO notification_templates (
|
||||
id,
|
||||
name,
|
||||
title_template,
|
||||
body_template,
|
||||
actions,
|
||||
"group",
|
||||
method,
|
||||
kind,
|
||||
enabled_by_default
|
||||
) VALUES (
|
||||
'2a74f3d3-ab09-4123-a4a5-ca238f4f65a1',
|
||||
'Task Paused',
|
||||
E'Task ''{{.Labels.task}}'' is paused',
|
||||
E'The task ''{{.Labels.task}}'' was paused ({{.Labels.pause_reason}}).',
|
||||
'[
|
||||
{
|
||||
"label": "View task",
|
||||
"url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.task_id}}"
|
||||
},
|
||||
{
|
||||
"label": "View workspace",
|
||||
"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}"
|
||||
}
|
||||
]'::jsonb,
|
||||
'Task Events',
|
||||
NULL,
|
||||
'system'::notification_template_kind,
|
||||
true
|
||||
);
|
||||
|
||||
-- Task transition to 'resumed' status
|
||||
INSERT INTO notification_templates (
|
||||
id,
|
||||
name,
|
||||
title_template,
|
||||
body_template,
|
||||
actions,
|
||||
"group",
|
||||
method,
|
||||
kind,
|
||||
enabled_by_default
|
||||
) VALUES (
|
||||
'843ee9c3-a8fb-4846-afa9-977bec578649',
|
||||
'Task Resumed',
|
||||
E'Task ''{{.Labels.task}}'' has resumed',
|
||||
E'The task ''{{.Labels.task}}'' has resumed.',
|
||||
'[
|
||||
{
|
||||
"label": "View task",
|
||||
"url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.task_id}}"
|
||||
},
|
||||
{
|
||||
"label": "View workspace",
|
||||
"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}"
|
||||
}
|
||||
]'::jsonb,
|
||||
'Task Events',
|
||||
NULL,
|
||||
'system'::notification_template_kind,
|
||||
true
|
||||
);
|
||||
@@ -59,4 +59,6 @@ var (
|
||||
TemplateTaskIdle = uuid.MustParse("d4a6271c-cced-4ed0-84ad-afd02a9c7799")
|
||||
TemplateTaskCompleted = uuid.MustParse("8c5a4d12-9f7e-4b3a-a1c8-6e4f2d9b5a7c")
|
||||
TemplateTaskFailed = uuid.MustParse("3b7e8f1a-4c2d-49a6-b5e9-7f3a1c8d6b4e")
|
||||
TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1")
|
||||
TemplateTaskResumed = uuid.MustParse("843ee9c3-a8fb-4846-afa9-977bec578649")
|
||||
)
|
||||
|
||||
@@ -1302,6 +1302,37 @@ func TestNotificationTemplates_Golden(t *testing.T) {
|
||||
Data: map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateTaskPaused",
|
||||
id: notifications.TemplateTaskPaused,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "Bobby",
|
||||
UserEmail: "bobby@coder.com",
|
||||
UserUsername: "bobby",
|
||||
Labels: map[string]string{
|
||||
"task": "my-task",
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"workspace": "my-workspace",
|
||||
"pause_reason": "idle timeout",
|
||||
},
|
||||
Data: map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateTaskResumed",
|
||||
id: notifications.TemplateTaskResumed,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "Bobby",
|
||||
UserEmail: "bobby@coder.com",
|
||||
UserUsername: "bobby",
|
||||
Labels: map[string]string{
|
||||
"task": "my-task",
|
||||
"task_id": "00000000-0000-0000-0000-000000000001",
|
||||
"workspace": "my-workspace",
|
||||
},
|
||||
Data: map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// We must have a test case for every notification_template. This is enforced below:
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
From: system@coder.com
|
||||
To: bobby@coder.com
|
||||
Subject: Task 'my-task' is paused
|
||||
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
|
||||
Date: Fri, 11 Oct 2024 09:03:06 +0000
|
||||
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
|
||||
MIME-Version: 1.0
|
||||
|
||||
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Hi Bobby,
|
||||
|
||||
The task 'my-task' was paused (idle timeout).
|
||||
|
||||
|
||||
View task: http://test.com/tasks/bobby/00000000-0000-0000-0000-000000000000
|
||||
|
||||
View workspace: http://test.com/@bobby/my-workspace
|
||||
|
||||
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<!doctype html>
|
||||
<html lang=3D"en">
|
||||
<head>
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Task 'my-task' is paused</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
|
||||
; background: #f8fafc;">
|
||||
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
|
||||
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
|
||||
n: left; font-size: 14px; line-height: 1.5;">
|
||||
<div style=3D"text-align: center;">
|
||||
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
|
||||
er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Task 'my-task' is paused
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
<p>The task ‘my-task’ was paused (idle timeout).</p>
|
||||
</div>
|
||||
<div style=3D"text-align: center; margin-top: 32px;">
|
||||
=20
|
||||
<a href=3D"http://test.com/tasks/bobby/00000000-0000-0000-0000-0000=
|
||||
00000000" style=3D"display: inline-block; padding: 13px 24px; background-co=
|
||||
lor: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; ma=
|
||||
rgin: 0 4px;">
|
||||
View task
|
||||
</a>
|
||||
=20
|
||||
<a href=3D"http://test.com/@bobby/my-workspace" style=3D"display: i=
|
||||
nline-block; padding: 13px 24px; background-color: #020617; color: #f8fafc;=
|
||||
text-decoration: none; border-radius: 8px; margin: 0 4px;">
|
||||
View workspace
|
||||
</a>
|
||||
=20
|
||||
</div>
|
||||
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
|
||||
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
|
||||
<p>© 2024 Coder. All rights reserved - <a =
|
||||
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
|
||||
ttp://test.com</a></p>
|
||||
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
|
||||
r: #2563eb; text-decoration: none;">Click here to manage your notification =
|
||||
settings</a></p>
|
||||
<p><a href=3D"http://test.com/settings/notifications?disabled=3D2a7=
|
||||
4f3d3-ab09-4123-a4a5-ca238f4f65a1" style=3D"color: #2563eb; text-decoration=
|
||||
: none;">Stop receiving emails like this</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
From: system@coder.com
|
||||
To: bobby@coder.com
|
||||
Subject: Task 'my-task' has resumed
|
||||
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
|
||||
Date: Fri, 11 Oct 2024 09:03:06 +0000
|
||||
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
|
||||
MIME-Version: 1.0
|
||||
|
||||
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Hi Bobby,
|
||||
|
||||
The task 'my-task' has resumed.
|
||||
|
||||
|
||||
View task: http://test.com/tasks/bobby/00000000-0000-0000-0000-000000000001
|
||||
|
||||
View workspace: http://test.com/@bobby/my-workspace
|
||||
|
||||
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<!doctype html>
|
||||
<html lang=3D"en">
|
||||
<head>
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Task 'my-task' has resumed</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
|
||||
; background: #f8fafc;">
|
||||
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
|
||||
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
|
||||
n: left; font-size: 14px; line-height: 1.5;">
|
||||
<div style=3D"text-align: center;">
|
||||
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
|
||||
er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Task 'my-task' has resumed
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
<p>The task ‘my-task’ has resumed.</p>
|
||||
</div>
|
||||
<div style=3D"text-align: center; margin-top: 32px;">
|
||||
=20
|
||||
<a href=3D"http://test.com/tasks/bobby/00000000-0000-0000-0000-0000=
|
||||
00000001" style=3D"display: inline-block; padding: 13px 24px; background-co=
|
||||
lor: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; ma=
|
||||
rgin: 0 4px;">
|
||||
View task
|
||||
</a>
|
||||
=20
|
||||
<a href=3D"http://test.com/@bobby/my-workspace" style=3D"display: i=
|
||||
nline-block; padding: 13px 24px; background-color: #020617; color: #f8fafc;=
|
||||
text-decoration: none; border-radius: 8px; margin: 0 4px;">
|
||||
View workspace
|
||||
</a>
|
||||
=20
|
||||
</div>
|
||||
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
|
||||
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
|
||||
<p>© 2024 Coder. All rights reserved - <a =
|
||||
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
|
||||
ttp://test.com</a></p>
|
||||
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
|
||||
r: #2563eb; text-decoration: none;">Click here to manage your notification =
|
||||
settings</a></p>
|
||||
<p><a href=3D"http://test.com/settings/notifications?disabled=3D843=
|
||||
ee9c3-a8fb-4846-afa9-977bec578649" style=3D"color: #2563eb; text-decoration=
|
||||
: none;">Stop receiving emails like this</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"_version": "1.1",
|
||||
"msg_id": "00000000-0000-0000-0000-000000000000",
|
||||
"payload": {
|
||||
"_version": "1.2",
|
||||
"notification_name": "Task Paused",
|
||||
"notification_template_id": "00000000-0000-0000-0000-000000000000",
|
||||
"user_id": "00000000-0000-0000-0000-000000000000",
|
||||
"user_email": "bobby@coder.com",
|
||||
"user_name": "Bobby",
|
||||
"user_username": "bobby",
|
||||
"actions": [
|
||||
{
|
||||
"label": "View task",
|
||||
"url": "http://test.com/tasks/bobby/00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"label": "View workspace",
|
||||
"url": "http://test.com/@bobby/my-workspace"
|
||||
}
|
||||
],
|
||||
"labels": {
|
||||
"pause_reason": "idle timeout",
|
||||
"task": "my-task",
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"workspace": "my-workspace"
|
||||
},
|
||||
"data": {},
|
||||
"targets": null
|
||||
},
|
||||
"title": "Task 'my-task' is paused",
|
||||
"title_markdown": "Task 'my-task' is paused",
|
||||
"body": "The task 'my-task' was paused (idle timeout).",
|
||||
"body_markdown": "The task 'my-task' was paused (idle timeout)."
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"_version": "1.1",
|
||||
"msg_id": "00000000-0000-0000-0000-000000000000",
|
||||
"payload": {
|
||||
"_version": "1.2",
|
||||
"notification_name": "Task Resumed",
|
||||
"notification_template_id": "00000000-0000-0000-0000-000000000000",
|
||||
"user_id": "00000000-0000-0000-0000-000000000000",
|
||||
"user_email": "bobby@coder.com",
|
||||
"user_name": "Bobby",
|
||||
"user_username": "bobby",
|
||||
"actions": [
|
||||
{
|
||||
"label": "View task",
|
||||
"url": "http://test.com/tasks/bobby/00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"label": "View workspace",
|
||||
"url": "http://test.com/@bobby/my-workspace"
|
||||
}
|
||||
],
|
||||
"labels": {
|
||||
"task": "my-task",
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"workspace": "my-workspace"
|
||||
},
|
||||
"data": {},
|
||||
"targets": null
|
||||
},
|
||||
"title": "Task 'my-task' has resumed",
|
||||
"title_markdown": "Task 'my-task' has resumed",
|
||||
"body": "The task 'my-task' has resumed.",
|
||||
"body_markdown": "The task 'my-task' has resumed."
|
||||
}
|
||||
Reference in New Issue
Block a user