Go · 5038 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package lifecycle
4
5 import (
6 "context"
7 "fmt"
8
9 "github.com/jackc/pgx/v5/pgtype"
10
11 "github.com/tenseleyFlow/shithub/internal/auth/audit"
12 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
13 )
14
15 // HardDelete is the worker-driven cascade that runs after the soft-
16 // delete grace expires. The order is explicit and auditable:
17 //
18 // 1. Anonymize forks-of-this-repo (clear fork_of_repo_id on children).
19 // 2. Drop the redirect rows that point at this repo (other repos
20 // keeping us as their old name; keep redirects pointed away from
21 // us so old URLs of *this* repo's previous identity are still
22 // resolvable from the now-deleted row, until that repo is also
23 // deleted by ON DELETE CASCADE on the FK).
24 // 3. DELETE FROM repos — FK cascades handle push_events,
25 // webhook_events_pending, repo_collaborators, transfer requests,
26 // and any redirect rows pointing at this id.
27 // 4. Remove the bare repo tombstone on disk. For legacy soft-deleted
28 // rows that still occupy the canonical path, remove that path only
29 // when no new active repo has reused the owner/name.
30 // 5. Audit-log the hard-delete with a snapshot of the removed row in
31 // the meta payload so the audit row is self-contained even after
32 // the repo_id is gone.
33 //
34 // The op refuses to run on a repo that isn't past its soft-delete
35 // grace window — defense-in-depth even though the worker query
36 // already gates this. ActorUserID is typically the worker's instance
37 // id encoded in some way; pass 0 to record "system" in audit.
38 func HardDelete(ctx context.Context, deps Deps, actorUserID, repoID int64) error {
39 rq := reposdb.New()
40 tx, err := deps.Pool.Begin(ctx)
41 if err != nil {
42 return fmt.Errorf("begin: %w", err)
43 }
44 committed := false
45 defer func() {
46 if !committed {
47 _ = tx.Rollback(ctx)
48 }
49 }()
50
51 repo, err := rq.GetRepoByID(ctx, tx, repoID)
52 if err != nil {
53 return fmt.Errorf("load repo: %w", err)
54 }
55 if err := lockRepoName(ctx, rq, tx, repo); err != nil {
56 return err
57 }
58 if !repo.DeletedAt.Valid {
59 return ErrNotDeleted
60 }
61 if deps.now().Sub(repo.DeletedAt.Time) < softDeleteGrace {
62 return fmt.Errorf("hard-delete: %w", ErrPastGrace)
63 // Sentinel mismatch on purpose — the only "before grace"
64 // failure is the worker mis-firing. Reuse ErrPastGrace's
65 // inverse semantics to keep the error surface tight.
66 }
67
68 // Snapshot for the audit row before we mutate.
69 snapshot := map[string]any{
70 "id": repo.ID,
71 "name": repo.Name,
72 "visibility": string(repo.Visibility),
73 }
74 if repo.OwnerUserID.Valid {
75 snapshot["owner_user_id"] = repo.OwnerUserID.Int64
76 }
77 if repo.OwnerOrgID.Valid {
78 snapshot["owner_org_id"] = repo.OwnerOrgID.Int64
79 }
80 if ownerSlug, err := ownerSlugForRepo(ctx, deps, repo); err == nil {
81 snapshot["owner"] = ownerSlug
82 }
83 paths, pathErr := diskPathsForRepo(ctx, deps, repo)
84 activeNameExists, err := activeRepoNameExists(ctx, rq, tx, repo)
85 if err != nil {
86 return fmt.Errorf("hard-delete name check: %w", err)
87 }
88
89 // 1. Orphan child forks.
90 if _, err := rq.OrphanForksOf(ctx, tx, pgtype.Int8{Int64: repoID, Valid: true}); err != nil {
91 return fmt.Errorf("orphan forks: %w", err)
92 }
93
94 // 2. (Skipped — see comment in the doc block. The repo's outgoing
95 // redirect rows go in step 3 via FK cascade.)
96
97 // 3. Drop the repo row. FK ON DELETE CASCADE on push_events,
98 // repo_collaborators, repo_redirects, repo_transfer_requests,
99 // and webhook_events_pending makes this a single SQL.
100 if err := rq.HardDeleteRepo(ctx, tx, repoID); err != nil {
101 return fmt.Errorf("delete repo: %w", err)
102 }
103
104 // 4. Remove the bare repo while the owner/name advisory lock is
105 // still held. That matters for legacy soft-deleted rows whose data
106 // is still at the canonical path: a concurrent recreate cannot race
107 // this cleanup and have its new bare repo removed.
108 if pathErr == nil {
109 if err := removeDeletedRepoPath(deps, paths, activeNameExists); err != nil && deps.Logger != nil {
110 deps.Logger.WarnContext(ctx, "hard delete: fs delete failed",
111 "repo_id", repoID, "path", paths.deleted, "canonical_path", paths.canonical, "error", err)
112 }
113 } else if deps.Logger != nil {
114 deps.Logger.WarnContext(ctx, "hard delete: compute fs path failed", "repo_id", repoID, "error", pathErr)
115 }
116
117 if err := tx.Commit(ctx); err != nil {
118 return fmt.Errorf("commit: %w", err)
119 }
120 committed = true
121
122 // 5. Audit. Use a fresh pool conn since the repo_id no longer
123 // exists; the meta blob carries the snapshot.
124 if deps.Audit != nil {
125 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
126 audit.ActionRepoHardDeleted, audit.TargetRepo, repoID, snapshot)
127 }
128 return nil
129 }
130
131 func removeDeletedRepoPath(deps Deps, paths repoDiskPaths, activeNameExists bool) error {
132 deletedExists, err := deps.RepoFS.Exists(paths.deleted)
133 if err != nil {
134 return err
135 }
136 if deletedExists {
137 return deps.RepoFS.Delete(paths.deleted)
138 }
139 if activeNameExists {
140 return nil
141 }
142 return deps.RepoFS.Delete(paths.canonical)
143 }
144