mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore: add script to calculate workspace 'on' hours in a given time window (#21505)
Calculates how long each workspace has been "on" during a given time window defined by "start/stop".
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
# Workspace Runtime Audit
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Do not run this script unless specifically instructed to do so by Coder support or engineering.
|
||||||
|
>
|
||||||
|
> Always run this script from a postgres user with read-only access to the database.
|
||||||
|
|
||||||
|
A SQL script that analyzes workspace builds to determine how long each workspace spent in a "running" state. It tracks state transitions (start/stop/delete) and calculates the cumulative runtime, only counting time spent inside the audit window period.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
**1:** Edit the date range in `workspace-runtime-audit.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
start_time TIMESTAMPTZ := '2025-12-01 00:00:00+00';
|
||||||
|
end_time TIMESTAMPTZ := '2025-12-31 23:59:59+00';
|
||||||
|
```
|
||||||
|
|
||||||
|
**2:** Run against your Coder database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -d coder -f scripts/workspace-runtime-audit/workspace-runtime-audit.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**3:** Review the output csv at `workspace_usage.csv`.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|------------------------|-------------|----------------------------------------------------|
|
||||||
|
| `workspace_id` | timestamptz | Name of the workspace |
|
||||||
|
| `workspace_created_at` | timestamptz | When the workspace was originally created |
|
||||||
|
| `usage_hours` | int | Total number of usage hours within the time window |
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
// Package runtimeaudit_test implements a test runner for the workspace runtime audit SQL script.
|
||||||
|
// It is intentionally not named with `_test.go` suffix to avoid running `go test` automatically.
|
||||||
|
//
|
||||||
|
// This script is not recommend to be used without guidance from the Coder team.
|
||||||
|
package runtimeaudit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed workspace-runtime-audit.sql
|
||||||
|
var runtimeAuditScript string
|
||||||
|
|
||||||
|
type auditRow struct {
|
||||||
|
WorkspaceID uuid.UUID
|
||||||
|
WorkspaceCreatedAt time.Time
|
||||||
|
UsageHours int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeAudit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Cannot run `/copy` meta command over Exec, so comment it out.
|
||||||
|
runtimeAuditScript = strings.ReplaceAll(runtimeAuditScript, "\\copy", "-- \\copy")
|
||||||
|
|
||||||
|
// Use the SELECT instead
|
||||||
|
runtimeAuditScript = strings.ReplaceAll(runtimeAuditScript, "-- SELECT * FROM _workspace_usage_results", "SELECT * FROM _workspace_usage_results")
|
||||||
|
|
||||||
|
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||||
|
|
||||||
|
// Setup database with some workspace runtimes
|
||||||
|
s := initSetup(t, db)
|
||||||
|
startPeriod := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
endPeriod := time.Date(2025, 12, 31, 23, 58, 59, 0, time.UTC)
|
||||||
|
|
||||||
|
// Shorthands (keeps vectors readable).
|
||||||
|
decUTC := func(d, h, m int) time.Time { return time.Date(2025, 12, d, h, m, 0, 0, time.UTC) }
|
||||||
|
novUTC := func(d, h, m int) time.Time { return time.Date(2025, 11, d, h, m, 0, 0, time.UTC) }
|
||||||
|
janUTC := func(d, h, m int) time.Time { return time.Date(2026, 1, d, h, m, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
roundUpHours := func(end, start time.Time) int {
|
||||||
|
return int(math.Ceil(end.Sub(start).Hours()))
|
||||||
|
}
|
||||||
|
|
||||||
|
type vec struct {
|
||||||
|
name string
|
||||||
|
builds []workspaceBuildArgs
|
||||||
|
expect func(createdAt time.Time, inputs []workspaceBuildArgs) int
|
||||||
|
}
|
||||||
|
|
||||||
|
vectors := []vec{
|
||||||
|
// -------------------------
|
||||||
|
// Happy path / core logic
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "long_run_inside_window_start_to_stop_counts_and_rounds",
|
||||||
|
// Basic succeeded start -> stop within window.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(3, 0, 15), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(20, 12, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(in[1].at, in[0].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed_start_does_not_count_usage",
|
||||||
|
// Only succeeded starts count; failed start means no accumulation.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(5, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusFailed},
|
||||||
|
{at: decUTC(5, 11, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_starts_while_running_ignores_later_start_uses_first_start",
|
||||||
|
// Second succeeded start is ignored while already "on".
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(6, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(6, 10, 5), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(6, 10, 30), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(in[2].at, in[0].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_transition_treated_as_stop",
|
||||||
|
// Non-(start+succeeded) transitions behave like stop when running.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(7, 9, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(7, 9, 20), transition: database.WorkspaceTransitionDelete, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(in[1].at, in[0].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Window clipping
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "started_before_window_clips_start_to_window_start",
|
||||||
|
// Start before period; only count from startPeriod.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: novUTC(27, 23, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(1, 1, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(in[1].at, startPeriod)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stopped_after_window_clips_stop_to_window_end",
|
||||||
|
// Stop after period; only count until endPeriod.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(31, 23, 30), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: janUTC(1, 1, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(endPeriod, in[0].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "window_clips_both_start_and_stop",
|
||||||
|
// Stop after period; only count until endPeriod.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: novUTC(27, 23, 30), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: janUTC(1, 8, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(endPeriod, startPeriod)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stop_exactly_at_window_start_counts_zero_due_to_strict_gt",
|
||||||
|
// Script uses `turned_off > start_time`, so equality yields 0.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: novUTC(30, 23, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: startPeriod, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Still running at end
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "started_in_window_no_stop_accumulates_until_window_end",
|
||||||
|
// Tail case: still on -> add (endPeriod - turned_on).
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(10, 8, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(endPeriod, decUTC(10, 8, 0))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "started_before_window_no_stop_accumulates_full_window",
|
||||||
|
// Tail case + clipping: start before period -> treat as startPeriod.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: novUTC(1, 0, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(endPeriod, startPeriod)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Multi-segment behavior (single ceil at end)
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "two_segments_sum_then_single_round_up",
|
||||||
|
// 20m + 20m => 40m total => ceil(total)=1 hour.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(12, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(12, 10, 20), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(12, 11, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(12, 11, 20), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int {
|
||||||
|
return 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two_segments_sum",
|
||||||
|
// 20m + 20m => 40m total => ceil(total)=1 hour.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(12, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(12, 15, 20), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
|
||||||
|
{at: decUTC(13, 11, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(12, 13, 14), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
|
||||||
|
{at: decUTC(14, 7, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(14, 13, 14), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
|
||||||
|
{at: decUTC(15, 4, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(15, 8, 14), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
|
||||||
|
// Add a failed start/stop to ensure it's ignored.
|
||||||
|
{at: decUTC(16, 0, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusFailed},
|
||||||
|
{at: decUTC(17, 0, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(in[1].at, in[0].at) +
|
||||||
|
roundUpHours(in[3].at, in[2].at) +
|
||||||
|
roundUpHours(in[5].at, in[4].at) +
|
||||||
|
roundUpHours(in[7].at, in[6].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Outside-the-window activity
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "activity_entirely_before_window_counts_zero",
|
||||||
|
// Stop before startPeriod => no overlap.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: novUTC(10, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: novUTC(10, 10, 10), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "activity_entirely_after_window_counts_zero",
|
||||||
|
// Start after endPeriod => no overlap.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: janUTC(2, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: janUTC(2, 10, 10), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Canceled / failed builds
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "canceled_start_does_not_count_usage",
|
||||||
|
// Only start+succeeded counts; canceled start is ignored.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(8, 9, 0), canceled: true, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusCanceled},
|
||||||
|
{at: decUTC(8, 10, 0), canceled: false, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed_start_does_not_count_even_if_later_stop_occurs",
|
||||||
|
// Start failed => never turns on => later stop does nothing.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(9, 9, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusFailed},
|
||||||
|
{at: decUTC(9, 12, 0), canceled: false, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canceled_stop_still_stops_timer_and_counts_time",
|
||||||
|
// Any non-(start+succeeded) is treated as stop while running, regardless of status/canceled.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(10, 9, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(10, 9, 40), canceled: true, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusCanceled},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(in[1].at, in[0].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed_stop_still_stops_timer_and_counts_time",
|
||||||
|
// Same as above: stop is stop even if job failed (ELSE path).
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(11, 10, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(11, 10, 10), canceled: false, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusFailed},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(in[1].at, in[0].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed_transition_stops_timer_and_counts_time",
|
||||||
|
// A failed *non-stop* transition (e.g. delete) still stops if currently on.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(12, 8, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
{at: decUTC(12, 8, 5), canceled: false, transition: database.WorkspaceTransitionDelete, jobStatus: database.ProvisionerJobStatusFailed},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
return roundUpHours(in[1].at, in[0].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start_succeeded_then_start_failed_does_not_reset_start_time",
|
||||||
|
// When already on, a subsequent non-(start+succeeded) build triggers stop logic.
|
||||||
|
// This verifies you *do not* treat start+failed as a "start"; it will stop the running timer.
|
||||||
|
builds: []workspaceBuildArgs{
|
||||||
|
{at: decUTC(13, 9, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
// This goes to ELSE branch (because job_status != succeeded) and will stop the timer.
|
||||||
|
{at: decUTC(13, 9, 30), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusFailed},
|
||||||
|
// Subsequent stop should not add more time because timer was reset.
|
||||||
|
{at: decUTC(13, 10, 0), canceled: false, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded},
|
||||||
|
},
|
||||||
|
expect: func(_ time.Time, in []workspaceBuildArgs) int {
|
||||||
|
// Only counts from first start to failed-start event.
|
||||||
|
return roundUpHours(in[1].at, in[0].at)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all workspaces
|
||||||
|
workspaces := make([]database.WorkspaceTable, len(vectors))
|
||||||
|
|
||||||
|
for i, v := range vectors {
|
||||||
|
wrk := s.createWorkspace(t, db, v.builds)
|
||||||
|
workspaces[i] = wrk
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := sqlDB.Query(runtimeAuditScript)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
found := make(map[uuid.UUID]auditRow)
|
||||||
|
for row.Next() {
|
||||||
|
var r auditRow
|
||||||
|
err = row.Scan(&r.WorkspaceID, &r.WorkspaceCreatedAt, &r.UsageHours)
|
||||||
|
require.NoError(t, err)
|
||||||
|
found[r.WorkspaceID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range vectors {
|
||||||
|
t.Run(v.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
v.expect(workspaces[i].CreatedAt, v.builds)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type setup struct {
|
||||||
|
org database.Organization
|
||||||
|
usr database.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func initSetup(t *testing.T, db database.Store) *setup {
|
||||||
|
usr := dbgen.User(t, db, database.User{})
|
||||||
|
org := dbfake.Organization(t, db).
|
||||||
|
Members(usr).
|
||||||
|
Do()
|
||||||
|
return &setup{
|
||||||
|
org: org.Org,
|
||||||
|
usr: usr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type workspaceBuildArgs struct {
|
||||||
|
at time.Time
|
||||||
|
canceled bool
|
||||||
|
transition database.WorkspaceTransition
|
||||||
|
jobStatus database.ProvisionerJobStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *setup) createWorkspace(t *testing.T, db database.Store, builds []workspaceBuildArgs) database.WorkspaceTable {
|
||||||
|
// Insert the first build
|
||||||
|
tv := dbfake.TemplateVersion(t, db).
|
||||||
|
Seed(database.TemplateVersion{
|
||||||
|
OrganizationID: s.org.ID,
|
||||||
|
CreatedBy: s.usr.ID,
|
||||||
|
}).
|
||||||
|
Do()
|
||||||
|
|
||||||
|
wrk := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||||
|
OwnerID: s.usr.ID,
|
||||||
|
OrganizationID: s.org.ID,
|
||||||
|
TemplateID: tv.Template.ID,
|
||||||
|
CreatedAt: builds[0].at,
|
||||||
|
})
|
||||||
|
|
||||||
|
for i, b := range builds {
|
||||||
|
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||||
|
CreatedAt: b.at,
|
||||||
|
UpdatedAt: b.at,
|
||||||
|
StartedAt: sql.NullTime{
|
||||||
|
Time: b.at,
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
CanceledAt: sql.NullTime{
|
||||||
|
Time: b.at,
|
||||||
|
Valid: b.canceled,
|
||||||
|
},
|
||||||
|
CompletedAt: sql.NullTime{
|
||||||
|
Time: b.at,
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
Error: sql.NullString{},
|
||||||
|
OrganizationID: s.org.ID,
|
||||||
|
InitiatorID: s.usr.ID,
|
||||||
|
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
||||||
|
JobStatus: b.jobStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
||||||
|
CreatedAt: b.at,
|
||||||
|
UpdatedAt: b.at,
|
||||||
|
WorkspaceID: wrk.ID,
|
||||||
|
TemplateVersionID: tv.TemplateVersion.ID,
|
||||||
|
///nolint:gosec // this will not overflow
|
||||||
|
BuildNumber: int32(i) + 1,
|
||||||
|
Transition: b.transition,
|
||||||
|
InitiatorID: s.usr.ID,
|
||||||
|
JobID: job.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrk
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
-- Workspace Runtime Audit Report
|
||||||
|
-- !! CAUTION: Do not run this script unless specifically instructed to do so by Coder support or engineering.
|
||||||
|
--
|
||||||
|
-- This script calculates total workspace runtime within a specified date range.
|
||||||
|
-- It tracks workspace state transitions (start/stop/delete) and sums up the time
|
||||||
|
-- each workspace spent in a "running" state. A "running" state is considered to be
|
||||||
|
-- any workspace that has been started successfully and not yet stopped, deleted, or failed.
|
||||||
|
--
|
||||||
|
-- All usage (per workspace) is rounded up to the nearest hour. So usage is accumulated for the time period
|
||||||
|
-- per workspace, then rounded up to the next hour.
|
||||||
|
-- So a workspace that runs for 40s, will be 1 hour of usage.
|
||||||
|
-- If a workspace runs for 40s, then stops, then runs for another 40s will still be 1 hour.
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- 1. Edit the start_time and end_time in the params below
|
||||||
|
-- 2. Run: psql -f workspace-runtime-audit.sql
|
||||||
|
-- 3. A file called 'workspace_usage.csv' will be generated with the results.
|
||||||
|
BEGIN;
|
||||||
|
-- Temp table to hold the data aggregated from the anonymous function.
|
||||||
|
-- It is dropped automatically at the end of the transaction.
|
||||||
|
CREATE TEMP TABLE _workspace_usage_results (
|
||||||
|
workspace_id uuid,
|
||||||
|
workspace_created_at timestamptz,
|
||||||
|
usage_hours INTEGER
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
-- CHANGE THE START/END TIME HERE FOR YOUR AUDIT
|
||||||
|
start_time TIMESTAMPTZ := '2025-12-01 00:00:00+00';
|
||||||
|
end_time TIMESTAMPTZ := '2025-12-31 23:59:59+00';
|
||||||
|
-- 'debug_mode' emits logging to help trace processing for each workspace
|
||||||
|
debug_mode BOOLEAN := FALSE;
|
||||||
|
-- temporary variables
|
||||||
|
workspace RECORD;
|
||||||
|
workspace_build RECORD;
|
||||||
|
workspace_turned_on TIMESTAMPTZ;
|
||||||
|
workspace_turned_off TIMESTAMPTZ;
|
||||||
|
workspace_usage_duration INTERVAL;
|
||||||
|
latest_build_created_at TIMESTAMPTZ;
|
||||||
|
latest_build_transition workspace_transition;
|
||||||
|
-- Counter for skipped workspaces that are outside the window, used for debugging
|
||||||
|
skipped_workspaces INTEGER := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR workspace IN
|
||||||
|
SELECT
|
||||||
|
workspaces.*
|
||||||
|
FROM
|
||||||
|
workspaces
|
||||||
|
-- Adding a WHERE clause here to limit the workspaces is helpful during testing.
|
||||||
|
LOOP
|
||||||
|
-- Initialize variables for each workspace and prevent variable carry-over between workspaces.
|
||||||
|
workspace_turned_on = '0001-01-01 00:00:00+00';
|
||||||
|
workspace_usage_duration = 0;
|
||||||
|
IF debug_mode THEN
|
||||||
|
RAISE NOTICE 'Processing Workspace ID: %, Created At: %', workspace.id, workspace.created_at;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Fetch the latest build for the workspace to determine if we can skip it entirely.
|
||||||
|
SELECT
|
||||||
|
wb.created_at, wb.transition
|
||||||
|
FROM
|
||||||
|
workspace_builds wb
|
||||||
|
WHERE
|
||||||
|
wb.workspace_id = workspace.id
|
||||||
|
ORDER BY wb.build_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
INTO
|
||||||
|
latest_build_created_at,
|
||||||
|
latest_build_transition
|
||||||
|
;
|
||||||
|
|
||||||
|
-- If the latest build is a stop/delete before our start time, this workspace had no activity in our audit period.
|
||||||
|
-- This is an optimization to skip processing workspaces that are clearly before the audit period. Workspaces created
|
||||||
|
-- after the audit period are still processed. Since this is expected to be run periodically, optimizing out old workspaces
|
||||||
|
-- is likely to be more beneficial than future workspaces.
|
||||||
|
IF
|
||||||
|
latest_build_created_at < start_time AND latest_build_transition != 'start'
|
||||||
|
THEN
|
||||||
|
skipped_workspaces = skipped_workspaces + 1;
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- For every workspace, calculate the total runtime within the specified date range.
|
||||||
|
FOR workspace_build IN
|
||||||
|
SELECT
|
||||||
|
workspace_builds.*,
|
||||||
|
provisioner_jobs.job_status,
|
||||||
|
provisioner_jobs.completed_at
|
||||||
|
FROM workspace_builds
|
||||||
|
LEFT JOIN provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id
|
||||||
|
WHERE workspace_id = workspace.id
|
||||||
|
ORDER BY workspace_builds.build_number ASC
|
||||||
|
LOOP
|
||||||
|
-- Algorithm summary:
|
||||||
|
-- LOOP:
|
||||||
|
-- 1. When a successful start is found, set 'workspace_turned_on'
|
||||||
|
-- 1a. If already turned on, ignore (multiple starts without stops)
|
||||||
|
-- 2. Any other transition (stop/delete/failed) will calculate the duration from 'workspace_turned_on' to this point
|
||||||
|
-- 3. After the loop, if still turned on, calculate duration from 'workspace_turned_on' to 'end_time'
|
||||||
|
|
||||||
|
-- Usage only counts from workspaces that successfully started
|
||||||
|
IF workspace_build.transition = 'start' AND
|
||||||
|
workspace_build.job_status IN ('succeeded')
|
||||||
|
THEN
|
||||||
|
-- If the workspace is already turned on (e.g., multiple starts without stops),
|
||||||
|
-- we consider the previous start time as usage time. So ignore this start.
|
||||||
|
IF workspace_turned_on = '0001-01-01 00:00:00+00' THEN
|
||||||
|
workspace_turned_on = COALESCE(workspace_build.completed_at, workspace_build.created_at);
|
||||||
|
IF debug_mode THEN
|
||||||
|
RAISE NOTICE 'Workspace (build %) turned ON at %', workspace_build.build_number, workspace_turned_on;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
-- All other transitions and job status are treated as a workspace stopping.
|
||||||
|
-- Only accumulate time from the last successful start to this point.
|
||||||
|
-- You could imagine a workspace that was only failing builds and never accumulating time.
|
||||||
|
IF workspace_turned_on != '0001-01-01 00:00:00+00'
|
||||||
|
THEN
|
||||||
|
workspace_turned_off = COALESCE(workspace_build.completed_at, workspace_build.created_at);
|
||||||
|
-- We only track usage within the start_time and end_time range.
|
||||||
|
IF
|
||||||
|
-- Turned off before the start time, so this workspace lived and died before our audit period.
|
||||||
|
workspace_turned_off > start_time AND
|
||||||
|
-- Turned on before the end time. If this is in the future, then it didn't run during our audit period.
|
||||||
|
workspace_turned_on < end_time
|
||||||
|
THEN
|
||||||
|
-- Fix the on/off times to be within the audit period. Ignore any time outside the period.
|
||||||
|
IF workspace_turned_on < start_time THEN
|
||||||
|
-- Started before the audit period, so move the turned_on to start_time.
|
||||||
|
workspace_turned_on = start_time;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF workspace_turned_off > end_time THEN
|
||||||
|
-- Turned off after the audit period, so move the turned_off to end_time.
|
||||||
|
workspace_turned_off = end_time;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
workspace_usage_duration = workspace_usage_duration + (workspace_turned_off - workspace_turned_on);
|
||||||
|
IF debug_mode THEN
|
||||||
|
RAISE NOTICE 'Workspace (build %) turning OFF at % with % duration accumulated',
|
||||||
|
workspace_build.build_number, workspace_turned_off, (workspace_turned_off - workspace_turned_on);
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
IF debug_mode THEN
|
||||||
|
RAISE NOTICE 'Workspace (build %) indicated activity outside the audit period, no duration accumulated',
|
||||||
|
workspace_build.build_number;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Always reset turned_on even if the duration was not accumulated.
|
||||||
|
workspace_turned_on = '0001-01-01 00:00:00+00';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- After processing all builds, if the workspace is still turned on, accumulate time until end_time.
|
||||||
|
-- This handles workspaces that were started but never stopped/deleted.
|
||||||
|
IF workspace_turned_on != '0001-01-01 00:00:00+00' THEN
|
||||||
|
IF workspace_turned_on < start_time THEN
|
||||||
|
workspace_turned_on = start_time;
|
||||||
|
END IF;
|
||||||
|
IF workspace_turned_on < end_time THEN
|
||||||
|
IF debug_mode THEN
|
||||||
|
RAISE NOTICE 'Workspace still on at end of period, adding %', (end_time - workspace_turned_on);
|
||||||
|
END IF;
|
||||||
|
workspace_usage_duration = workspace_usage_duration + (end_time - workspace_turned_on);
|
||||||
|
END IF;
|
||||||
|
workspace_turned_on = '0001-01-01 00:00:00+00';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO _workspace_usage_results (
|
||||||
|
workspace_id,
|
||||||
|
workspace_created_at,
|
||||||
|
usage_hours
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
workspace.id,
|
||||||
|
workspace.created_at,
|
||||||
|
-- Only tracking whole hours for simplicity. Always rounding up to the next hour.
|
||||||
|
CEIL((EXTRACT(EPOCH FROM workspace_usage_duration) / 3600.0))
|
||||||
|
);
|
||||||
|
|
||||||
|
IF debug_mode THEN
|
||||||
|
RAISE NOTICE 'Workspace ID: %, Created At: % has %d usage', workspace.id, workspace.created_at, EXTRACT(EPOCH FROM workspace_usage_duration) / 3600;
|
||||||
|
end if;
|
||||||
|
END LOOP;
|
||||||
|
IF debug_mode THEN
|
||||||
|
RAISE NOTICE 'Skipped % workspaces due to latest build before audit period', skipped_workspaces;
|
||||||
|
END IF;
|
||||||
|
END$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Export the results to a CSV file
|
||||||
|
\copy (SELECT * FROM _workspace_usage_results WHERE usage_hours > 0 ORDER BY usage_hours DESC) TO 'workspace_usage.csv' WITH (FORMAT CSV, HEADER TRUE);
|
||||||
|
|
||||||
|
-- Optionally use a select to view results as a table output
|
||||||
|
-- SELECT * FROM _workspace_usage_results WHERE usage_hours > 0 ORDER BY usage_hours DESC;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user