forked from wrenn/wrenn
Fix cascading deletion gaps for user and team cleanup
- Add ON DELETE CASCADE to users_teams, oauth_providers, admin_permissions and ON DELETE SET NULL (with nullable columns) to team_api_keys.created_by, hosts.created_by, host_tokens.created_by so HardDeleteExpiredUsers no longer fails with FK violations - User account deletion now cascades to sole-owned teams via DeleteTeamInternal, preventing orphaned teams with live sandboxes after account removal - ListActiveSandboxesByTeam now includes hibernated sandboxes so their disk snapshots are cleaned up during team deletion - Team soft-delete now hard-deletes sandbox metric points, metric snapshots, API keys, and channels to prevent data accumulation on deleted teams - Extract deleteTeamCore() to deduplicate shared logic across DeleteTeam, AdminDeleteTeam, and DeleteTeamInternal - Fix ListAPIKeysByTeamWithCreator to use LEFT JOIN after created_by became nullable, and update handler to read pgtype.Text.String for creator_email
This commit is contained in:
72
db/migrations/20260415221116_cascade_user_delete.sql
Normal file
72
db/migrations/20260415221116_cascade_user_delete.sql
Normal file
@ -0,0 +1,72 @@
|
||||
-- +goose Up
|
||||
|
||||
-- users_teams: remove membership when user is deleted
|
||||
ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey;
|
||||
ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- oauth_providers: remove auth links when user is deleted
|
||||
ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey;
|
||||
ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- admin_permissions: remove permissions when user is deleted
|
||||
ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey;
|
||||
ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- team_api_keys.created_by: make nullable, SET NULL on user delete
|
||||
ALTER TABLE team_api_keys ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey;
|
||||
ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- hosts.created_by: make nullable, SET NULL on user delete
|
||||
ALTER TABLE hosts ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey;
|
||||
ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- host_tokens.created_by: make nullable, SET NULL on user delete
|
||||
ALTER TABLE host_tokens ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey;
|
||||
ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
-- Revert host_tokens.created_by
|
||||
ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey;
|
||||
UPDATE host_tokens SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||
ALTER TABLE host_tokens ALTER COLUMN created_by SET NOT NULL;
|
||||
ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||
|
||||
-- Revert hosts.created_by
|
||||
ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey;
|
||||
UPDATE hosts SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||
ALTER TABLE hosts ALTER COLUMN created_by SET NOT NULL;
|
||||
ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||
|
||||
-- Revert team_api_keys.created_by
|
||||
ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey;
|
||||
UPDATE team_api_keys SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||
ALTER TABLE team_api_keys ALTER COLUMN created_by SET NOT NULL;
|
||||
ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||
|
||||
-- Revert admin_permissions
|
||||
ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey;
|
||||
ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
-- Revert oauth_providers
|
||||
ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey;
|
||||
ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
-- Revert users_teams
|
||||
ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey;
|
||||
ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
@ -13,7 +13,7 @@ SELECT * FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC;
|
||||
SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used,
|
||||
u.email AS creator_email
|
||||
FROM team_api_keys k
|
||||
JOIN users u ON u.id = k.created_by
|
||||
LEFT JOIN users u ON u.id = k.created_by
|
||||
WHERE k.team_id = $1
|
||||
ORDER BY k.created_at DESC;
|
||||
|
||||
@ -22,3 +22,6 @@ DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2;
|
||||
|
||||
-- name: UpdateAPIKeyLastUsed :exec
|
||||
UPDATE team_api_keys SET last_used = NOW() WHERE id = $1;
|
||||
|
||||
-- name: DeleteAPIKeysByTeam :exec
|
||||
DELETE FROM team_api_keys WHERE team_id = $1;
|
||||
|
||||
@ -22,6 +22,9 @@ RETURNING *;
|
||||
-- name: DeleteChannelByTeam :exec
|
||||
DELETE FROM channels WHERE id = $1 AND team_id = $2;
|
||||
|
||||
-- name: DeleteAllChannelsByTeam :exec
|
||||
DELETE FROM channels WHERE team_id = $1;
|
||||
|
||||
-- name: ListChannelsForEvent :many
|
||||
SELECT * FROM channels
|
||||
WHERE team_id = $1
|
||||
|
||||
@ -51,6 +51,13 @@ WHERE sandbox_id = $1 AND tier = $2;
|
||||
DELETE FROM sandbox_metric_points
|
||||
WHERE ts < EXTRACT(EPOCH FROM NOW() - INTERVAL '30 days')::BIGINT;
|
||||
|
||||
-- name: DeleteMetricsSnapshotsByTeam :exec
|
||||
DELETE FROM sandbox_metrics_snapshots WHERE team_id = $1;
|
||||
|
||||
-- name: DeleteMetricPointsByTeam :exec
|
||||
DELETE FROM sandbox_metric_points
|
||||
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1);
|
||||
|
||||
-- name: SampleSandboxMetrics :many
|
||||
-- Aggregates per-team resource usage from the live sandboxes table.
|
||||
-- Groups by all teams that have any sandbox row (including stopped) so that
|
||||
|
||||
@ -62,7 +62,7 @@ WHERE id = ANY($1::uuid[]);
|
||||
|
||||
-- name: ListActiveSandboxesByTeam :many
|
||||
SELECT * FROM sandboxes
|
||||
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
|
||||
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting', 'hibernated')
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: MarkSandboxesMissingByHost :exec
|
||||
|
||||
@ -74,6 +74,18 @@ WHERE t.id != '00000000-0000-0000-0000-000000000000'
|
||||
ORDER BY t.deleted_at ASC NULLS FIRST, t.created_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListSoleOwnedTeams :many
|
||||
-- Returns teams where the user is the owner and no other members exist.
|
||||
SELECT t.id FROM teams t
|
||||
JOIN users_teams ut ON ut.team_id = t.id
|
||||
WHERE ut.user_id = $1
|
||||
AND ut.role = 'owner'
|
||||
AND t.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users_teams ut2
|
||||
WHERE ut2.team_id = t.id AND ut2.user_id <> $1
|
||||
);
|
||||
|
||||
-- name: CountTeamsAdmin :one
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM teams
|
||||
|
||||
@ -63,7 +63,7 @@ func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyRes
|
||||
Name: k.Name,
|
||||
KeyPrefix: k.KeyPrefix,
|
||||
CreatedBy: id.FormatUserID(k.CreatedBy),
|
||||
CreatorEmail: k.CreatorEmail,
|
||||
CreatorEmail: k.CreatorEmail.String,
|
||||
}
|
||||
if k.CreatedAt.Valid {
|
||||
resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339)
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -37,6 +38,7 @@ type meHandler struct {
|
||||
mailer email.Mailer
|
||||
oauthRegistry *oauth.Registry
|
||||
redirectURL string
|
||||
teamSvc *service.TeamService
|
||||
}
|
||||
|
||||
func newMeHandler(
|
||||
@ -47,6 +49,7 @@ func newMeHandler(
|
||||
mailer email.Mailer,
|
||||
registry *oauth.Registry,
|
||||
redirectURL string,
|
||||
teamSvc *service.TeamService,
|
||||
) *meHandler {
|
||||
return &meHandler{
|
||||
db: db,
|
||||
@ -56,6 +59,7 @@ func newMeHandler(
|
||||
mailer: mailer,
|
||||
oauthRegistry: registry,
|
||||
redirectURL: strings.TrimRight(redirectURL, "/"),
|
||||
teamSvc: teamSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@ -507,6 +511,19 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all teams the user solely owns (no other members).
|
||||
soleTeams, err := h.db.ListSoleOwnedTeams(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list owned teams")
|
||||
return
|
||||
}
|
||||
for _, teamID := range soleTeams {
|
||||
if err := h.teamSvc.DeleteTeamInternal(ctx, teamID); err != nil {
|
||||
slog.Warn("account delete: failed to delete sole-owned team",
|
||||
"team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.db.SoftDeleteUser(ctx, ac.UserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account")
|
||||
return
|
||||
|
||||
@ -84,7 +84,7 @@ func New(
|
||||
ptyH := newPtyHandler(queries, pool)
|
||||
processH := newProcessHandler(queries, pool)
|
||||
adminCapsules := newAdminCapsuleHandler(sandboxSvc, queries, pool, al)
|
||||
meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL)
|
||||
meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL, teamSvc)
|
||||
|
||||
// OpenAPI spec and docs.
|
||||
r.Get("/openapi.yaml", serveOpenAPI)
|
||||
|
||||
@ -25,6 +25,15 @@ func (q *Queries) DeleteAPIKey(ctx context.Context, arg DeleteAPIKeyParams) erro
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteAPIKeysByTeam = `-- name: DeleteAPIKeysByTeam :exec
|
||||
DELETE FROM team_api_keys WHERE team_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAPIKeysByTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteAPIKeysByTeam, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one
|
||||
SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE key_hash = $1
|
||||
`
|
||||
@ -120,7 +129,7 @@ const listAPIKeysByTeamWithCreator = `-- name: ListAPIKeysByTeamWithCreator :man
|
||||
SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used,
|
||||
u.email AS creator_email
|
||||
FROM team_api_keys k
|
||||
JOIN users u ON u.id = k.created_by
|
||||
LEFT JOIN users u ON u.id = k.created_by
|
||||
WHERE k.team_id = $1
|
||||
ORDER BY k.created_at DESC
|
||||
`
|
||||
@ -134,7 +143,7 @@ type ListAPIKeysByTeamWithCreatorRow struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
LastUsed pgtype.Timestamptz `json:"last_used"`
|
||||
CreatorEmail string `json:"creator_email"`
|
||||
CreatorEmail pgtype.Text `json:"creator_email"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID pgtype.UUID) ([]ListAPIKeysByTeamWithCreatorRow, error) {
|
||||
|
||||
@ -11,6 +11,15 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteAllChannelsByTeam = `-- name: DeleteAllChannelsByTeam :exec
|
||||
DELETE FROM channels WHERE team_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAllChannelsByTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteAllChannelsByTeam, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteChannelByTeam = `-- name: DeleteChannelByTeam :exec
|
||||
DELETE FROM channels WHERE id = $1 AND team_id = $2
|
||||
`
|
||||
|
||||
@ -11,6 +11,25 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteMetricPointsByTeam = `-- name: DeleteMetricPointsByTeam :exec
|
||||
DELETE FROM sandbox_metric_points
|
||||
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1)
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteMetricPointsByTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteMetricPointsByTeam, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteMetricsSnapshotsByTeam = `-- name: DeleteMetricsSnapshotsByTeam :exec
|
||||
DELETE FROM sandbox_metrics_snapshots WHERE team_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteMetricsSnapshotsByTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteMetricsSnapshotsByTeam, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec
|
||||
DELETE FROM sandbox_metric_points
|
||||
WHERE sandbox_id = $1
|
||||
|
||||
@ -190,7 +190,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
|
||||
|
||||
const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many
|
||||
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes
|
||||
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
|
||||
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting', 'hibernated')
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
|
||||
@ -284,6 +284,39 @@ func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberPara
|
||||
return err
|
||||
}
|
||||
|
||||
const listSoleOwnedTeams = `-- name: ListSoleOwnedTeams :many
|
||||
SELECT t.id FROM teams t
|
||||
JOIN users_teams ut ON ut.team_id = t.id
|
||||
WHERE ut.user_id = $1
|
||||
AND ut.role = 'owner'
|
||||
AND t.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users_teams ut2
|
||||
WHERE ut2.team_id = t.id AND ut2.user_id <> $1
|
||||
)
|
||||
`
|
||||
|
||||
// Returns teams where the user is the owner and no other members exist.
|
||||
func (q *Queries) ListSoleOwnedTeams(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, listSoleOwnedTeams, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var id pgtype.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTeamsAdmin = `-- name: ListTeamsAdmin :many
|
||||
SELECT
|
||||
t.id,
|
||||
|
||||
@ -169,6 +169,12 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
|
||||
return fmt.Errorf("forbidden: only the owner can delete a team")
|
||||
}
|
||||
|
||||
return s.deleteTeamCore(ctx, teamID)
|
||||
}
|
||||
|
||||
// deleteTeamCore contains the shared team deletion logic:
|
||||
// destroy active sandboxes, clean up templates, soft-delete the team.
|
||||
func (s *TeamService) deleteTeamCore(ctx context.Context, teamID pgtype.UUID) error {
|
||||
// Collect active sandboxes and stop them.
|
||||
sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
@ -202,6 +208,24 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
|
||||
}
|
||||
}
|
||||
|
||||
// Delete sandbox metrics for this team.
|
||||
if err := s.DB.DeleteMetricPointsByTeam(ctx, teamID); err != nil {
|
||||
slog.Warn("team delete: failed to delete metric points", "team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
if err := s.DB.DeleteMetricsSnapshotsByTeam(ctx, teamID); err != nil {
|
||||
slog.Warn("team delete: failed to delete metrics snapshots", "team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
|
||||
// Delete all API keys for this team.
|
||||
if err := s.DB.DeleteAPIKeysByTeam(ctx, teamID); err != nil {
|
||||
slog.Warn("team delete: failed to delete API keys", "team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
|
||||
// Delete all channels for this team.
|
||||
if err := s.DB.DeleteAllChannelsByTeam(ctx, teamID); err != nil {
|
||||
slog.Warn("team delete: failed to delete channels", "team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
|
||||
// Clean up team-owned templates from all hosts in the background.
|
||||
go s.cleanupTeamTemplates(context.Background(), teamID)
|
||||
|
||||
@ -497,6 +521,13 @@ func (s *TeamService) AdminListTeams(ctx context.Context, limit, offset int32) (
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// DeleteTeamInternal soft-deletes a team and destroys all its active sandboxes.
|
||||
// Used for system-initiated deletions (e.g. cascading from user account deletion)
|
||||
// where no caller role check is needed.
|
||||
func (s *TeamService) DeleteTeamInternal(ctx context.Context, teamID pgtype.UUID) error {
|
||||
return s.deleteTeamCore(ctx, teamID)
|
||||
}
|
||||
|
||||
// AdminDeleteTeam soft-deletes a team and destroys all its active sandboxes.
|
||||
// Unlike DeleteTeam, this does not require the caller to be the team owner —
|
||||
// it is admin-only (caller must verify admin status).
|
||||
@ -509,41 +540,5 @@ func (s *TeamService) AdminDeleteTeam(ctx context.Context, teamID pgtype.UUID) e
|
||||
return fmt.Errorf("team not found")
|
||||
}
|
||||
|
||||
// Destroy active sandboxes (same logic as DeleteTeam).
|
||||
sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list active sandboxes: %w", err)
|
||||
}
|
||||
|
||||
var stopIDs []pgtype.UUID
|
||||
for _, sb := range sandboxes {
|
||||
host, hostErr := s.DB.GetHost(ctx, sb.HostID)
|
||||
if hostErr == nil {
|
||||
agent, agentErr := s.HostPool.GetForHost(host)
|
||||
if agentErr == nil {
|
||||
if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
|
||||
SandboxId: id.FormatSandboxID(sb.ID),
|
||||
})); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
|
||||
slog.Warn("admin team delete: failed to destroy sandbox", "sandbox_id", id.FormatSandboxID(sb.ID), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
stopIDs = append(stopIDs, sb.ID)
|
||||
}
|
||||
|
||||
if len(stopIDs) > 0 {
|
||||
if err := s.DB.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{
|
||||
Column1: stopIDs,
|
||||
Status: "stopped",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update sandbox statuses: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
go s.cleanupTeamTemplates(context.Background(), teamID)
|
||||
|
||||
if err := s.DB.SoftDeleteTeam(ctx, teamID); err != nil {
|
||||
return fmt.Errorf("soft delete team: %w", err)
|
||||
}
|
||||
return nil
|
||||
return s.deleteTeamCore(ctx, teamID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user