mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add retention config for workspace_agent_logs (#21039)
Replace hardcoded 7-day retention for workspace agent logs with configurable retention from deployment settings. Defaults to 7d to preserve existing behavior. Depends on #21038 Updates #20743
This commit is contained in:
Generated
+4
@@ -17745,6 +17745,10 @@ const docTemplate = `{
|
||||
"connection_logs": {
|
||||
"description": "ConnectionLogs controls how long connection log entries are retained.\nSet to 0 to disable (keep indefinitely).",
|
||||
"type": "integer"
|
||||
},
|
||||
"workspace_agent_logs": {
|
||||
"description": "WorkspaceAgentLogs controls how long workspace agent logs are retained.\nLogs are deleted if the agent hasn't connected within this period.\nLogs from the latest build are always retained regardless of age.\nDefaults to 7 days to preserve existing behavior.",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+4
@@ -16207,6 +16207,10 @@
|
||||
"connection_logs": {
|
||||
"description": "ConnectionLogs controls how long connection log entries are retained.\nSet to 0 to disable (keep indefinitely).",
|
||||
"type": "integer"
|
||||
},
|
||||
"workspace_agent_logs": {
|
||||
"description": "WorkspaceAgentLogs controls how long workspace agent logs are retained.\nLogs are deleted if the agent hasn't connected within this period.\nLogs from the latest build are always retained regardless of age.\nDefaults to 7 days to preserve existing behavior.",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1784,9 +1784,9 @@ func (q *querier) DeleteOldTelemetryLocks(ctx context.Context, beforeTime time.T
|
||||
return q.db.DeleteOldTelemetryLocks(ctx, beforeTime)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) error {
|
||||
func (q *querier) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
return q.db.DeleteOldWorkspaceAgentLogs(ctx, threshold)
|
||||
}
|
||||
|
||||
@@ -3227,7 +3227,7 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
}))
|
||||
s.Run("DeleteOldWorkspaceAgentLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
t := time.Time{}
|
||||
dbm.EXPECT().DeleteOldWorkspaceAgentLogs(gomock.Any(), t).Return(nil).AnyTimes()
|
||||
dbm.EXPECT().DeleteOldWorkspaceAgentLogs(gomock.Any(), t).Return(int64(0), nil).AnyTimes()
|
||||
check.Args(t).Asserts(rbac.ResourceSystem, policy.ActionDelete)
|
||||
}))
|
||||
s.Run("InsertWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
|
||||
@@ -445,11 +445,11 @@ func (m queryMetricsStore) DeleteOldTelemetryLocks(ctx context.Context, periodEn
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteOldWorkspaceAgentLogs(ctx context.Context, arg time.Time) error {
|
||||
func (m queryMetricsStore) DeleteOldWorkspaceAgentLogs(ctx context.Context, arg time.Time) (int64, error) {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteOldWorkspaceAgentLogs(ctx, arg)
|
||||
r0, r1 := m.s.DeleteOldWorkspaceAgentLogs(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("DeleteOldWorkspaceAgentLogs").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteOldWorkspaceAgentStats(ctx context.Context) error {
|
||||
|
||||
@@ -826,11 +826,12 @@ func (mr *MockStoreMockRecorder) DeleteOldTelemetryLocks(ctx, periodEndingAtBefo
|
||||
}
|
||||
|
||||
// DeleteOldWorkspaceAgentLogs mocks base method.
|
||||
func (m *MockStore) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) error {
|
||||
func (m *MockStore) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteOldWorkspaceAgentLogs", ctx, threshold)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeleteOldWorkspaceAgentLogs indicates an expected call of DeleteOldWorkspaceAgentLogs.
|
||||
|
||||
@@ -18,8 +18,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
delay = 10 * time.Minute
|
||||
maxAgentLogAge = 7 * 24 * time.Hour
|
||||
delay = 10 * time.Minute
|
||||
// Connection events are now inserted into the `connection_logs` table.
|
||||
// We'll slowly remove old connection events from the `audit_logs` table.
|
||||
// The `connection_logs` table is purged based on the configured retention.
|
||||
@@ -66,9 +65,14 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder
|
||||
return nil
|
||||
}
|
||||
|
||||
deleteOldWorkspaceAgentLogsBefore := start.Add(-maxAgentLogAge)
|
||||
if err := tx.DeleteOldWorkspaceAgentLogs(ctx, deleteOldWorkspaceAgentLogsBefore); err != nil {
|
||||
return xerrors.Errorf("failed to delete old workspace agent logs: %w", err)
|
||||
var purgedWorkspaceAgentLogs int64
|
||||
workspaceAgentLogsRetention := vals.Retention.WorkspaceAgentLogs.Value()
|
||||
if workspaceAgentLogsRetention > 0 {
|
||||
deleteOldWorkspaceAgentLogsBefore := start.Add(-workspaceAgentLogsRetention)
|
||||
purgedWorkspaceAgentLogs, err = tx.DeleteOldWorkspaceAgentLogs(ctx, deleteOldWorkspaceAgentLogsBefore)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to delete old workspace agent logs: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.DeleteOldWorkspaceAgentStats(ctx); err != nil {
|
||||
return xerrors.Errorf("failed to delete old workspace agent stats: %w", err)
|
||||
@@ -148,6 +152,7 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "purged old database entries",
|
||||
slog.F("workspace_agent_logs", purgedWorkspaceAgentLogs),
|
||||
slog.F("expired_api_keys", expiredAPIKeys),
|
||||
slog.F("aibridge_records", purgedAIBridgeRecords),
|
||||
slog.F("connection_logs", purgedConnectionLogs),
|
||||
|
||||
@@ -246,7 +246,11 @@ func TestDeleteOldWorkspaceAgentLogs(t *testing.T) {
|
||||
// After dbpurge completes, the ticker is reset. Trap this call.
|
||||
|
||||
done := awaitDoTick(ctx, t, clk)
|
||||
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, clk)
|
||||
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{
|
||||
Retention: codersdk.RetentionConfig{
|
||||
WorkspaceAgentLogs: serpent.Duration(7 * 24 * time.Hour),
|
||||
},
|
||||
}, clk)
|
||||
defer closer.Close()
|
||||
<-done // doTick() has now run.
|
||||
|
||||
@@ -392,6 +396,90 @@ func mustCreateAgentLogs(ctx context.Context, t *testing.T, db database.Store, a
|
||||
require.NotEmpty(t, agentLogs, "agent logs must be present")
|
||||
}
|
||||
|
||||
func TestDeleteOldWorkspaceAgentLogsRetention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2025, 1, 15, 7, 30, 0, 0, time.UTC)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
retentionConfig codersdk.RetentionConfig
|
||||
logsAge time.Duration
|
||||
expectDeleted bool
|
||||
}{
|
||||
{
|
||||
name: "RetentionEnabled",
|
||||
retentionConfig: codersdk.RetentionConfig{
|
||||
WorkspaceAgentLogs: serpent.Duration(7 * 24 * time.Hour), // 7 days
|
||||
},
|
||||
logsAge: 8 * 24 * time.Hour, // 8 days ago
|
||||
expectDeleted: true,
|
||||
},
|
||||
{
|
||||
name: "RetentionDisabled",
|
||||
retentionConfig: codersdk.RetentionConfig{
|
||||
WorkspaceAgentLogs: serpent.Duration(0),
|
||||
},
|
||||
logsAge: 60 * 24 * time.Hour, // 60 days ago
|
||||
expectDeleted: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "CustomRetention30Days",
|
||||
retentionConfig: codersdk.RetentionConfig{
|
||||
WorkspaceAgentLogs: serpent.Duration(30 * 24 * time.Hour), // 30 days
|
||||
},
|
||||
logsAge: 31 * 24 * time.Hour, // 31 days ago
|
||||
expectDeleted: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
clk := quartz.NewMock(t)
|
||||
clk.Set(now).MustWait(ctx)
|
||||
|
||||
oldTime := now.Add(-tc.logsAge)
|
||||
|
||||
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
|
||||
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{OrganizationID: org.ID, CreatedBy: user.ID})
|
||||
tmpl := dbgen.Template(t, db, database.Template{OrganizationID: org.ID, ActiveVersionID: tv.ID, CreatedBy: user.ID})
|
||||
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{Name: "test-ws", OwnerID: user.ID, OrganizationID: org.ID, TemplateID: tmpl.ID})
|
||||
wb1 := mustCreateWorkspaceBuild(t, db, org, tv, ws.ID, oldTime, 1)
|
||||
wb2 := mustCreateWorkspaceBuild(t, db, org, tv, ws.ID, oldTime, 2)
|
||||
agent1 := mustCreateAgent(t, db, wb1)
|
||||
agent2 := mustCreateAgent(t, db, wb2)
|
||||
mustCreateAgentLogs(ctx, t, db, agent1, &oldTime, "agent 1 logs")
|
||||
mustCreateAgentLogs(ctx, t, db, agent2, &oldTime, "agent 2 logs")
|
||||
|
||||
// Run the purge.
|
||||
done := awaitDoTick(ctx, t, clk)
|
||||
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{
|
||||
Retention: tc.retentionConfig,
|
||||
}, clk)
|
||||
defer closer.Close()
|
||||
testutil.TryReceive(ctx, t, done)
|
||||
|
||||
// Verify results.
|
||||
if tc.expectDeleted {
|
||||
assertNoWorkspaceAgentLogs(ctx, t, db, agent1.ID)
|
||||
} else {
|
||||
assertWorkspaceAgentLogs(ctx, t, db, agent1.ID, "agent 1 logs")
|
||||
}
|
||||
// Latest build logs are always retained.
|
||||
assertWorkspaceAgentLogs(ctx, t, db, agent2.ID, "agent 2 logs")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // It uses LockIDDBPurge.
|
||||
func TestDeleteOldProvisionerDaemons(t *testing.T) {
|
||||
// TODO: must refactor DeleteOldProvisionerDaemons to allow passing in cutoff
|
||||
|
||||
@@ -120,10 +120,10 @@ type sqlcQuerier interface {
|
||||
DeleteOldProvisionerDaemons(ctx context.Context) error
|
||||
// Deletes old telemetry locks from the telemetry_locks table.
|
||||
DeleteOldTelemetryLocks(ctx context.Context, periodEndingAtBefore time.Time) error
|
||||
// If an agent hasn't connected in the last 7 days, we purge it's logs.
|
||||
// If an agent hasn't connected within the retention period, we purge its logs.
|
||||
// Exception: if the logs are related to the latest build, we keep those around.
|
||||
// Logs can take up a lot of space, so it's important we clean up frequently.
|
||||
DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) error
|
||||
DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) (int64, error)
|
||||
DeleteOldWorkspaceAgentStats(ctx context.Context) error
|
||||
DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) error
|
||||
DeleteProvisionerKey(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
@@ -17846,7 +17846,7 @@ func (q *sqlQuerier) UpdateVolumeResourceMonitor(ctx context.Context, arg Update
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOldWorkspaceAgentLogs = `-- name: DeleteOldWorkspaceAgentLogs :exec
|
||||
const deleteOldWorkspaceAgentLogs = `-- name: DeleteOldWorkspaceAgentLogs :execrows
|
||||
WITH
|
||||
latest_builds AS (
|
||||
SELECT
|
||||
@@ -17889,12 +17889,15 @@ WITH
|
||||
DELETE FROM workspace_agent_logs WHERE agent_id IN (SELECT id FROM old_agents)
|
||||
`
|
||||
|
||||
// If an agent hasn't connected in the last 7 days, we purge it's logs.
|
||||
// If an agent hasn't connected within the retention period, we purge its logs.
|
||||
// Exception: if the logs are related to the latest build, we keep those around.
|
||||
// Logs can take up a lot of space, so it's important we clean up frequently.
|
||||
func (q *sqlQuerier) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOldWorkspaceAgentLogs, threshold)
|
||||
return err
|
||||
func (q *sqlQuerier) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteOldWorkspaceAgentLogs, threshold)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteWorkspaceSubAgentByID = `-- name: DeleteWorkspaceSubAgentByID :exec
|
||||
|
||||
@@ -199,10 +199,10 @@ INSERT INTO
|
||||
-- name: GetWorkspaceAgentLogSourcesByAgentIDs :many
|
||||
SELECT * FROM workspace_agent_log_sources WHERE workspace_agent_id = ANY(@ids :: uuid [ ]);
|
||||
|
||||
-- If an agent hasn't connected in the last 7 days, we purge it's logs.
|
||||
-- If an agent hasn't connected within the retention period, we purge its logs.
|
||||
-- Exception: if the logs are related to the latest build, we keep those around.
|
||||
-- Logs can take up a lot of space, so it's important we clean up frequently.
|
||||
-- name: DeleteOldWorkspaceAgentLogs :exec
|
||||
-- name: DeleteOldWorkspaceAgentLogs :execrows
|
||||
WITH
|
||||
latest_builds AS (
|
||||
SELECT
|
||||
|
||||
Reference in New Issue
Block a user