···
1
1
+
when:
2
2
+
- event: ["push"]
3
3
+
branch: ["main"]
4
4
+
- event: ["pull_request"]
5
5
+
6
6
+
engine: "nixery"
7
7
+
8
8
+
dependencies:
9
9
+
nixpkgs:
10
10
+
- go
11
11
+
12
12
+
steps:
13
13
+
- name: Formatting
14
14
+
command: test -z "$(gofmt -l .)"
15
15
+
- name: Linter
16
16
+
command: go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run
17
17
+
- name: Tests
18
18
+
command: go test ./...
···
1
1
+
The MIT License (MIT)
2
2
+
3
3
+
Copyright © 2026 <Eduardo Cuducos>
4
4
+
5
5
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
+
7
7
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
8
+
9
9
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
···
1
1
+
# AT Container Registry Clean Up
2
2
+
3
3
+
Deletes old `io.atcr.tag` and `io.atcr.manifest` records from your PDS. The [`atcr.io`](https://atcr.io) garbage collector will deference and free up storage space from there (not immediately).
4
4
+
5
5
+
## Usage
6
6
+
7
7
+
Requires `ATPROTO_HANDLE` and `ATPROTO_APP_PASSWORD`.
8
8
+
9
9
+
```console
10
10
+
$ atcr-clean-up -days <N> [-repo <r1> -repo <r2> ...] [-dry-run] [-yes] [-keep <tag>] [-log-level <level>]
11
11
+
```
12
12
+
13
13
+
**Important**: not setting `-repo` means _all repositories_.
···
1
1
+
package main
2
2
+
3
3
+
import (
4
4
+
"bufio"
5
5
+
"context"
6
6
+
"encoding/json"
7
7
+
"fmt"
8
8
+
"log/slog"
9
9
+
"os"
10
10
+
"slices"
11
11
+
"strings"
12
12
+
"time"
13
13
+
14
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
+
"golang.org/x/sync/errgroup"
16
16
+
)
17
17
+
18
18
+
type stale struct {
19
19
+
repo string
20
20
+
tag string
21
21
+
key string
22
22
+
manifest string
23
23
+
updatedAt time.Time
24
24
+
}
25
25
+
26
26
+
func (s *stale) age() int { return int(time.Since(s.updatedAt).Hours() / 24) }
27
27
+
28
28
+
func (s *stale) format(w int) string {
29
29
+
return fmt.Sprintf(
30
30
+
"%s:%s\t%s\t%*d days old",
31
31
+
s.repo, s.tag, s.updatedAt.Format("2006-01-02"), w, s.age(),
32
32
+
)
33
33
+
}
34
34
+
35
35
+
type todo struct {
36
36
+
stales []stale
37
37
+
digits int
38
38
+
}
39
39
+
40
40
+
func (t *todo) maxDigits() int {
41
41
+
if t.digits != 0 {
42
42
+
return t.digits
43
43
+
}
44
44
+
var w int
45
45
+
for _, s := range t.stales {
46
46
+
d := s.age()
47
47
+
n := 1
48
48
+
for d >= 10 {
49
49
+
d /= 10
50
50
+
n++
51
51
+
}
52
52
+
if n > w {
53
53
+
w = n
54
54
+
}
55
55
+
}
56
56
+
t.digits = w
57
57
+
return w
58
58
+
}
59
59
+
60
60
+
func cleanUp(ctx context.Context, id, pw string, repos []string, days int, keep string, dryRun, yes bool) error {
61
61
+
slog.Debug("resolving identity", "identity", id)
62
62
+
did, pds, err := resolveIdentity(ctx, id)
63
63
+
if err != nil {
64
64
+
return fmt.Errorf("resolve identity: %w", err)
65
65
+
}
66
66
+
slog.Debug("identity resolved", "did", did, "pds", pds)
67
67
+
68
68
+
c := newPDSClient(pds)
69
69
+
if !dryRun {
70
70
+
if err := c.login(ctx, id, pw); err != nil {
71
71
+
return fmt.Errorf("login: %w", err)
72
72
+
}
73
73
+
}
74
74
+
75
75
+
if len(repos) == 0 {
76
76
+
slog.Debug("no repos specified, discovering from PDS")
77
77
+
repos, err = discoverRepos(ctx, c, did)
78
78
+
if err != nil {
79
79
+
return fmt.Errorf("discover repos: %w", err)
80
80
+
}
81
81
+
}
82
82
+
slog.Debug("repos to scan", "count", len(repos), "repos", repos)
83
83
+
84
84
+
var out todo
85
85
+
g, gctx := errgroup.WithContext(ctx)
86
86
+
off := time.Now().AddDate(0, 0, -days)
87
87
+
ch := make(chan stale)
88
88
+
done := make(chan struct{})
89
89
+
go func() {
90
90
+
for s := range ch {
91
91
+
out.stales = append(out.stales, s)
92
92
+
}
93
93
+
close(done)
94
94
+
}()
95
95
+
for _, r := range repos {
96
96
+
g.Go(func() error {
97
97
+
return scanRepo(gctx, c, did, r, off, keep, ch)
98
98
+
})
99
99
+
}
100
100
+
if err := g.Wait(); err != nil {
101
101
+
return err
102
102
+
}
103
103
+
close(ch)
104
104
+
<-done
105
105
+
106
106
+
if len(out.stales) == 0 {
107
107
+
slog.Info("no stale tags found")
108
108
+
return nil
109
109
+
}
110
110
+
111
111
+
slices.SortFunc(out.stales, func(a, b stale) int {
112
112
+
return a.updatedAt.Compare(b.updatedAt)
113
113
+
})
114
114
+
115
115
+
fmt.Printf("%d stale tag(s) across %d repo(s)\n", len(out.stales), len(repos))
116
116
+
117
117
+
if dryRun {
118
118
+
for _, s := range out.stales {
119
119
+
fmt.Println(s.format(out.maxDigits()))
120
120
+
}
121
121
+
return nil
122
122
+
}
123
123
+
124
124
+
r := bufio.NewReader(os.Stdin)
125
125
+
for _, s := range out.stales {
126
126
+
fmt.Println(s.format(out.maxDigits()))
127
127
+
if !yes {
128
128
+
fmt.Print(" Delete this tag and its manifest? [y/N] ")
129
129
+
a, err := r.ReadString('\n')
130
130
+
if err != nil {
131
131
+
return fmt.Errorf("read confirmation: %w", err)
132
132
+
}
133
133
+
if strings.TrimSpace(strings.ToLower(a)) != "y" {
134
134
+
slog.Info("skipped", "repo", s.repo, "tag", s.tag)
135
135
+
continue
136
136
+
}
137
137
+
}
138
138
+
if err := c.delete(ctx, did, tagCollection, s.key); err != nil {
139
139
+
slog.Warn("failed to delete tag record", "rkey", s.key, "error", err)
140
140
+
continue
141
141
+
}
142
142
+
if s.manifest != "" {
143
143
+
uri, err := syntax.ParseATURI(s.manifest)
144
144
+
if err != nil {
145
145
+
slog.Warn("failed to parse manifest URI", "uri", s.manifest, "error", err)
146
146
+
continue
147
147
+
}
148
148
+
k := uri.RecordKey().String()
149
149
+
if err := c.delete(ctx, did, manifestCollection, k); err != nil {
150
150
+
slog.Warn("failed to delete manifest record", "rkey", k, "error", err)
151
151
+
continue
152
152
+
}
153
153
+
}
154
154
+
slog.Info("deleted", "repo", s.repo, "tag", s.tag)
155
155
+
}
156
156
+
157
157
+
return nil
158
158
+
}
159
159
+
160
160
+
func scanRepo(ctx context.Context, c *pdsClient, did, repo string, cutoff time.Time, keep string, out chan<- stale) error {
161
161
+
slog.Debug("scanning tags", "repo", repo)
162
162
+
recs, err := c.list(ctx, did, tagCollection)
163
163
+
if err != nil {
164
164
+
return fmt.Errorf("list tags %s: %w", repo, err)
165
165
+
}
166
166
+
167
167
+
var tags []tagRecord
168
168
+
skip := make(map[string]struct{})
169
169
+
for _, rec := range recs {
170
170
+
var t tagRecord
171
171
+
if err := json.Unmarshal(rec.Value, &t); err != nil {
172
172
+
slog.Warn("failed to parse tag record", "uri", rec.URI, "error", err)
173
173
+
continue
174
174
+
}
175
175
+
if t.Repository != repo {
176
176
+
continue
177
177
+
}
178
178
+
if t.Tag == keep && t.Manifest != "" {
179
179
+
skip[t.Manifest] = struct{}{}
180
180
+
}
181
181
+
tags = append(tags, t)
182
182
+
}
183
183
+
184
184
+
for i, t := range tags {
185
185
+
if t.Manifest != "" {
186
186
+
if _, ok := skip[t.Manifest]; ok {
187
187
+
continue
188
188
+
}
189
189
+
}
190
190
+
191
191
+
u, err := time.Parse(time.RFC3339, t.UpdatedAt)
192
192
+
if err != nil {
193
193
+
slog.Warn("failed to parse updatedAt", "tag", t.Tag, "raw", t.UpdatedAt, "error", err)
194
194
+
continue
195
195
+
}
196
196
+
197
197
+
if !u.Before(cutoff) {
198
198
+
continue
199
199
+
}
200
200
+
201
201
+
uri, err := syntax.ParseATURI(recs[i].URI)
202
202
+
if err != nil {
203
203
+
slog.Warn("failed to parse tag URI", "uri", recs[i].URI, "error", err)
204
204
+
continue
205
205
+
}
206
206
+
207
207
+
out <- stale{
208
208
+
repo: repo,
209
209
+
tag: t.Tag,
210
210
+
key: uri.RecordKey().String(),
211
211
+
manifest: t.Manifest,
212
212
+
updatedAt: u,
213
213
+
}
214
214
+
}
215
215
+
return nil
216
216
+
}
···
1
1
+
package main
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"encoding/json"
6
6
+
"testing"
7
7
+
"time"
8
8
+
9
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
+
)
11
11
+
12
12
+
func TestCleanUp(t *testing.T) {
13
13
+
s, d := pdsStub()
14
14
+
defer s.Close()
15
15
+
16
16
+
c := newPDSClient(s.URL)
17
17
+
ctx := context.Background()
18
18
+
19
19
+
repos, err := discoverRepos(ctx, c, "did:plc:abc")
20
20
+
if err != nil {
21
21
+
t.Fatalf("discoverRepos: %v", err)
22
22
+
}
23
23
+
if len(repos) != 1 || repos[0] != "app" {
24
24
+
t.Fatalf("expected [app], got %v", repos)
25
25
+
}
26
26
+
27
27
+
recs, err := c.list(ctx, "did:plc:abc", tagCollection)
28
28
+
if err != nil {
29
29
+
t.Fatalf("list: %v", err)
30
30
+
}
31
31
+
32
32
+
var n int
33
33
+
off := time.Now().AddDate(0, 0, -30)
34
34
+
for _, rec := range recs {
35
35
+
var tr tagRecord
36
36
+
if err := json.Unmarshal(rec.Value, &tr); err != nil {
37
37
+
t.Fatalf("unmarshal: %v", err)
38
38
+
}
39
39
+
if tr.Repository != "app" {
40
40
+
continue
41
41
+
}
42
42
+
ts, err := time.Parse(time.RFC3339, tr.UpdatedAt)
43
43
+
if err != nil {
44
44
+
t.Fatalf("parse updatedAt: %v", err)
45
45
+
}
46
46
+
if !ts.Before(off) {
47
47
+
continue
48
48
+
}
49
49
+
n++
50
50
+
uri, err := syntax.ParseATURI(rec.URI)
51
51
+
if err != nil {
52
52
+
t.Fatalf("parse URI: %v", err)
53
53
+
}
54
54
+
if err := c.delete(ctx, "did:plc:abc", tagCollection, uri.RecordKey().String()); err != nil {
55
55
+
t.Fatalf("delete: %v", err)
56
56
+
}
57
57
+
if tr.Manifest != "" {
58
58
+
muri, err := syntax.ParseATURI(tr.Manifest)
59
59
+
if err != nil {
60
60
+
t.Fatalf("parse manifest URI: %v", err)
61
61
+
}
62
62
+
if err := c.delete(ctx, "did:plc:abc", manifestCollection, muri.RecordKey().String()); err != nil {
63
63
+
t.Fatalf("delete manifest: %v", err)
64
64
+
}
65
65
+
}
66
66
+
}
67
67
+
if n != 1 {
68
68
+
t.Fatalf("expected 1 stale tag, got %d", n)
69
69
+
}
70
70
+
if _, ok := d[tagCollection+"/app_123"]; !ok {
71
71
+
t.Fatal("expected tag app_123 to be deleted")
72
72
+
}
73
73
+
if _, ok := d[manifestCollection+"/abc"]; !ok {
74
74
+
t.Fatal("expected manifest abc to be deleted")
75
75
+
}
76
76
+
}
···
1
1
+
module tangled.org/cuducos.me/atcr-clean-up
2
2
+
3
3
+
go 1.26.1
4
4
+
5
5
+
require (
6
6
+
github.com/bluesky-social/indigo v0.0.0-20260629160527-dfe5578fd537
7
7
+
golang.org/x/sync v0.22.0
8
8
+
)
9
9
+
10
10
+
require (
11
11
+
github.com/beorn7/perks v1.0.1 // indirect
12
12
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
13
13
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
14
14
+
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
15
15
+
github.com/mr-tron/base58 v1.3.0 // indirect
16
16
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
17
17
+
github.com/prometheus/client_golang v1.23.2 // indirect
18
18
+
github.com/prometheus/client_model v0.6.2 // indirect
19
19
+
github.com/prometheus/common v0.70.0 // indirect
20
20
+
github.com/prometheus/procfs v0.21.1 // indirect
21
21
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
22
22
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
23
23
+
golang.org/x/crypto v0.54.0 // indirect
24
24
+
golang.org/x/sys v0.47.0 // indirect
25
25
+
golang.org/x/time v0.15.0 // indirect
26
26
+
google.golang.org/protobuf v1.36.11 // indirect
27
27
+
)
···
1
1
+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2
2
+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3
3
+
github.com/bluesky-social/indigo v0.0.0-20260629160527-dfe5578fd537 h1:rHaND0argSxgbmE2ix/YuF11xXTtiP3oGUz4fS2Diqo=
4
4
+
github.com/bluesky-social/indigo v0.0.0-20260629160527-dfe5578fd537/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
5
5
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
6
6
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
7
7
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
8
8
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9
9
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
10
10
+
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
11
11
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
12
12
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
13
13
+
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
14
14
+
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
15
15
+
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
16
16
+
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
17
17
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
18
18
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
19
19
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
20
20
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
21
21
+
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
22
22
+
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
23
23
+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
24
24
+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
25
25
+
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
26
26
+
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
27
27
+
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
28
28
+
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
29
29
+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
30
30
+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
31
31
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
32
32
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
33
33
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
34
34
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
35
35
+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
36
36
+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
37
37
+
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
38
38
+
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
39
39
+
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
40
40
+
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
41
41
+
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
42
42
+
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
43
43
+
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
44
44
+
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
45
45
+
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
46
46
+
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
47
47
+
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
48
48
+
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
49
49
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
50
50
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
···
1
1
+
package main
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
7
7
+
"github.com/bluesky-social/indigo/atproto/identity"
8
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
9
+
)
10
10
+
11
11
+
// resolveIdentity resolves an atproto identifier (handle or DID) to a DID and
12
12
+
// PDS endpoint using the indigo identity directory.
13
13
+
func resolveIdentity(ctx context.Context, h string) (did, pds string, err error) {
14
14
+
p, err := syntax.ParseAtIdentifier(h)
15
15
+
if err != nil {
16
16
+
return "", "", fmt.Errorf("invalid identifier %q: %w", h, err)
17
17
+
}
18
18
+
19
19
+
dir := identity.DefaultDirectory()
20
20
+
id, err := dir.Lookup(ctx, p)
21
21
+
if err != nil {
22
22
+
return "", "", fmt.Errorf("lookup %q: %w", h, err)
23
23
+
}
24
24
+
25
25
+
pds = id.PDSEndpoint()
26
26
+
if pds == "" {
27
27
+
return "", "", fmt.Errorf("no PDS endpoint for %q", h)
28
28
+
}
29
29
+
30
30
+
return id.DID.String(), pds, nil
31
31
+
}
···
1
1
+
package main
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
"log/slog"
8
8
+
"os"
9
9
+
"os/signal"
10
10
+
"strings"
11
11
+
"syscall"
12
12
+
)
13
13
+
14
14
+
func main() {
15
15
+
flag.Usage = func() {
16
16
+
fmt.Fprintf(os.Stderr, "Deletes old io.actr.tag and io.actr.manifests records from your PDS.\n\n")
17
17
+
fmt.Fprintf(os.Stderr, "The atcr.io garbage collector will clean up the storage from there.\n\n")
18
18
+
fmt.Fprintf(os.Stderr, "Requires environment variables: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD.\n\n")
19
19
+
fmt.Fprintf(os.Stderr, "Usage:\n atcr-clean-up -days <N> [-keep <tag>] [-log-level <level>] [-repo <r1> -repo <r2> ...] [-dry-run] [-yes]\n\n")
20
20
+
fmt.Fprintf(os.Stderr, "Flags:\n")
21
21
+
flag.PrintDefaults()
22
22
+
}
23
23
+
24
24
+
id := os.Getenv("ATPROTO_HANDLE")
25
25
+
if id == "" {
26
26
+
fmt.Fprintln(os.Stderr, "ATPROTO_HANDLE is required (handle or DID)")
27
27
+
os.Exit(2)
28
28
+
}
29
29
+
30
30
+
var repos []string
31
31
+
flag.Func("repo", "atcr repository (repeatable; if unset, all repos are scanned)", func(s string) error {
32
32
+
repos = append(repos, s)
33
33
+
return nil
34
34
+
})
35
35
+
days := flag.Int("days", 0, "delete tags older than N days (required)")
36
36
+
keep := flag.String("keep", "latest", "tag name to preserve regardless of age")
37
37
+
level := flag.String("log-level", "info", "log level (debug, info, warn, error)")
38
38
+
dryRun := flag.Bool("dry-run", false, "list what would be deleted without deleting")
39
39
+
yes := flag.Bool("yes", false, "skip confirmation prompt")
40
40
+
flag.Parse()
41
41
+
42
42
+
var l slog.Level
43
43
+
switch strings.ToLower(*level) {
44
44
+
case "debug":
45
45
+
l = slog.LevelDebug
46
46
+
case "info":
47
47
+
l = slog.LevelInfo
48
48
+
case "warn":
49
49
+
l = slog.LevelWarn
50
50
+
case "error":
51
51
+
l = slog.LevelError
52
52
+
default:
53
53
+
fmt.Fprintf(os.Stderr, "invalid log level %q\n", *level)
54
54
+
os.Exit(2)
55
55
+
}
56
56
+
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l})))
57
57
+
58
58
+
if *days <= 0 {
59
59
+
fmt.Fprintln(os.Stderr, "-days is required and must be > 0")
60
60
+
os.Exit(2)
61
61
+
}
62
62
+
63
63
+
pw := os.Getenv("ATPROTO_APP_PASSWORD")
64
64
+
if !*dryRun && pw == "" {
65
65
+
fmt.Fprintln(os.Stderr, "ATPROTO_APP_PASSWORD is required for deletion (or use -dry-run)")
66
66
+
os.Exit(2)
67
67
+
}
68
68
+
69
69
+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
70
70
+
defer cancel()
71
71
+
72
72
+
if err := cleanUp(ctx, id, pw, repos, *days, *keep, *dryRun, *yes); err != nil {
73
73
+
slog.Error("failed", "error", err)
74
74
+
os.Exit(1)
75
75
+
}
76
76
+
}
···
1
1
+
package main
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"encoding/json"
6
6
+
"fmt"
7
7
+
"log/slog"
8
8
+
"net/http"
9
9
+
"net/url"
10
10
+
"strings"
11
11
+
"time"
12
12
+
13
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
14
+
)
15
15
+
16
16
+
const (
17
17
+
tagCollection = "io.atcr.tag"
18
18
+
manifestCollection = "io.atcr.manifest"
19
19
+
pageCollection = "io.atcr.repo.page"
20
20
+
)
21
21
+
22
22
+
type tagRecord struct {
23
23
+
Repository string `json:"repository"`
24
24
+
Tag string `json:"tag"`
25
25
+
Manifest string `json:"manifest"`
26
26
+
UpdatedAt string `json:"updatedAt"`
27
27
+
}
28
28
+
29
29
+
type record struct {
30
30
+
URI string `json:"uri"`
31
31
+
CID string `json:"cid"`
32
32
+
Value json.RawMessage `json:"value"`
33
33
+
}
34
34
+
35
35
+
type listResponse struct {
36
36
+
Records []record `json:"records"`
37
37
+
Cursor string `json:"cursor"`
38
38
+
}
39
39
+
40
40
+
type deleteRequest struct {
41
41
+
Repo string `json:"repo"`
42
42
+
Collection string `json:"collection"`
43
43
+
Rkey string `json:"rkey"`
44
44
+
}
45
45
+
46
46
+
type sessionRequest struct {
47
47
+
Identifier string `json:"identifier"`
48
48
+
Password string `json:"password"`
49
49
+
}
50
50
+
51
51
+
type sessionResponse struct {
52
52
+
AccessJwt string `json:"accessJwt"`
53
53
+
RefreshJwt string `json:"refreshJwt"`
54
54
+
DID string `json:"did"`
55
55
+
}
56
56
+
57
57
+
// pdsClient wraps an HTTP client and endpoint for XRPC record operations.
58
58
+
type pdsClient struct {
59
59
+
endpoint string
60
60
+
token string
61
61
+
h *http.Client
62
62
+
}
63
63
+
64
64
+
func newPDSClient(endpoint string) *pdsClient {
65
65
+
return &pdsClient{
66
66
+
endpoint: strings.TrimSuffix(endpoint, "/"),
67
67
+
h: &http.Client{Timeout: 30 * time.Second},
68
68
+
}
69
69
+
}
70
70
+
71
71
+
func (c *pdsClient) login(ctx context.Context, id, pw string) error {
72
72
+
b, err := json.Marshal(sessionRequest{Identifier: id, Password: pw})
73
73
+
if err != nil {
74
74
+
return fmt.Errorf("marshal session: %w", err)
75
75
+
}
76
76
+
u := c.endpoint + "/xrpc/com.atproto.server.createSession"
77
77
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(string(b)))
78
78
+
if err != nil {
79
79
+
return fmt.Errorf("session request: %w", err)
80
80
+
}
81
81
+
req.Header.Set("Content-Type", "application/json")
82
82
+
res, err := c.h.Do(req)
83
83
+
if err != nil {
84
84
+
return fmt.Errorf("session: %w", err)
85
85
+
}
86
86
+
defer func() {
87
87
+
if err := res.Body.Close(); err != nil {
88
88
+
slog.Warn("close response body", "error", err)
89
89
+
}
90
90
+
}()
91
91
+
if res.StatusCode != http.StatusOK {
92
92
+
return fmt.Errorf("session: %s", res.Status)
93
93
+
}
94
94
+
var s sessionResponse
95
95
+
if err := json.NewDecoder(res.Body).Decode(&s); err != nil {
96
96
+
return fmt.Errorf("session decode: %w", err)
97
97
+
}
98
98
+
c.token = s.AccessJwt
99
99
+
return nil
100
100
+
}
101
101
+
102
102
+
func (c *pdsClient) list(ctx context.Context, did, collection string) ([]record, error) {
103
103
+
var out []record
104
104
+
s := ""
105
105
+
for {
106
106
+
recs, n, err := c.listPage(ctx, did, collection, s)
107
107
+
if err != nil {
108
108
+
return nil, err
109
109
+
}
110
110
+
out = append(out, recs...)
111
111
+
if n == "" {
112
112
+
break
113
113
+
}
114
114
+
s = n
115
115
+
}
116
116
+
117
117
+
return out, nil
118
118
+
}
119
119
+
120
120
+
func (c *pdsClient) listPage(ctx context.Context, did, collection, cursor string) ([]record, string, error) {
121
121
+
p := url.Values{}
122
122
+
p.Set("repo", did)
123
123
+
p.Set("collection", collection)
124
124
+
p.Set("limit", "100")
125
125
+
if cursor != "" {
126
126
+
p.Set("cursor", cursor)
127
127
+
}
128
128
+
129
129
+
u := c.endpoint + "/xrpc/com.atproto.repo.listRecords?" + p.Encode()
130
130
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
131
131
+
if err != nil {
132
132
+
return nil, "", fmt.Errorf("list request: %w", err)
133
133
+
}
134
134
+
res, err := c.h.Do(req)
135
135
+
if err != nil {
136
136
+
return nil, "", fmt.Errorf("list: %w", err)
137
137
+
}
138
138
+
defer func() {
139
139
+
if err := res.Body.Close(); err != nil {
140
140
+
slog.Warn("close response body", "error", err)
141
141
+
}
142
142
+
}()
143
143
+
if res.StatusCode != http.StatusOK {
144
144
+
return nil, "", fmt.Errorf("list: %s", res.Status)
145
145
+
}
146
146
+
147
147
+
var r listResponse
148
148
+
if err := json.NewDecoder(res.Body).Decode(&r); err != nil {
149
149
+
return nil, "", fmt.Errorf("list decode: %w", err)
150
150
+
}
151
151
+
return r.Records, r.Cursor, nil
152
152
+
}
153
153
+
154
154
+
func (c *pdsClient) delete(ctx context.Context, did, collection, rkey string) error {
155
155
+
b, err := json.Marshal(deleteRequest{Repo: did, Collection: collection, Rkey: rkey})
156
156
+
if err != nil {
157
157
+
return fmt.Errorf("marshal delete: %w", err)
158
158
+
}
159
159
+
u := c.endpoint + "/xrpc/com.atproto.repo.deleteRecord"
160
160
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(string(b)))
161
161
+
if err != nil {
162
162
+
return fmt.Errorf("delete request: %w", err)
163
163
+
}
164
164
+
req.Header.Set("Content-Type", "application/json")
165
165
+
if c.token != "" {
166
166
+
req.Header.Set("Authorization", "Bearer "+c.token)
167
167
+
}
168
168
+
res, err := c.h.Do(req)
169
169
+
if err != nil {
170
170
+
return fmt.Errorf("delete: %w", err)
171
171
+
}
172
172
+
defer func() {
173
173
+
if err := res.Body.Close(); err != nil {
174
174
+
slog.Warn("close response body", "error", err)
175
175
+
}
176
176
+
}()
177
177
+
if res.StatusCode != http.StatusOK {
178
178
+
return fmt.Errorf("delete: %s", res.Status)
179
179
+
}
180
180
+
return nil
181
181
+
}
182
182
+
183
183
+
func discoverRepos(ctx context.Context, c *pdsClient, did string) ([]string, error) {
184
184
+
recs, err := c.list(ctx, did, pageCollection)
185
185
+
if err != nil {
186
186
+
return nil, err
187
187
+
}
188
188
+
189
189
+
out := make([]string, 0, len(recs))
190
190
+
for _, rec := range recs {
191
191
+
uri, err := syntax.ParseATURI(rec.URI)
192
192
+
if err != nil {
193
193
+
return nil, fmt.Errorf("parse URI %q: %w", rec.URI, err)
194
194
+
}
195
195
+
out = append(out, uri.RecordKey().String())
196
196
+
}
197
197
+
return out, nil
198
198
+
}
···
1
1
+
package main
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"encoding/json"
6
6
+
"net/http"
7
7
+
"net/http/httptest"
8
8
+
"testing"
9
9
+
"time"
10
10
+
)
11
11
+
12
12
+
func pdsStub() (*httptest.Server, map[string]struct{}) {
13
13
+
d := make(map[string]struct{})
14
14
+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15
15
+
w.Header().Set("Content-Type", "application/json")
16
16
+
q := r.URL.Query()
17
17
+
18
18
+
switch r.URL.Path {
19
19
+
case "/xrpc/com.atproto.repo.listRecords":
20
20
+
coll := q.Get("collection")
21
21
+
if coll == pageCollection {
22
22
+
if err := json.NewEncoder(w).Encode(map[string]any{
23
23
+
"records": []record{
24
24
+
{URI: "at://did:plc:abc/io.atcr.repo.page/app"},
25
25
+
},
26
26
+
}); err != nil {
27
27
+
http.Error(w, err.Error(), http.StatusInternalServerError)
28
28
+
}
29
29
+
return
30
30
+
}
31
31
+
if err := json.NewEncoder(w).Encode(map[string]any{
32
32
+
"records": []record{
33
33
+
{
34
34
+
URI: "at://did:plc:abc/io.atcr.tag/app_123",
35
35
+
Value: json.RawMessage(`{"repository":"app","tag":"123","manifest":"at://did:plc:abc/io.atcr.manifest/abc","updatedAt":"2025-01-01T00:00:00Z"}`),
36
36
+
},
37
37
+
{
38
38
+
URI: "at://did:plc:abc/io.atcr.tag/app_latest",
39
39
+
Value: json.RawMessage(`{"repository":"app","tag":"latest","manifest":"at://did:plc:abc/io.atcr.manifest/abc","updatedAt":"2026-07-15T00:00:00Z"}`),
40
40
+
},
41
41
+
{
42
42
+
URI: "at://did:plc:abc/io.atcr.tag/app_456",
43
43
+
Value: json.RawMessage(`{"repository":"app","tag":"456","manifest":"at://did:plc:abc/io.atcr.manifest/def","updatedAt":"2026-07-15T00:00:00Z"}`),
44
44
+
},
45
45
+
},
46
46
+
}); err != nil {
47
47
+
http.Error(w, err.Error(), http.StatusInternalServerError)
48
48
+
}
49
49
+
case "/xrpc/com.atproto.repo.deleteRecord":
50
50
+
var b deleteRequest
51
51
+
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
52
52
+
http.Error(w, err.Error(), http.StatusBadRequest)
53
53
+
return
54
54
+
}
55
55
+
d[b.Collection+"/"+b.Rkey] = struct{}{}
56
56
+
if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil {
57
57
+
http.Error(w, err.Error(), http.StatusInternalServerError)
58
58
+
}
59
59
+
}
60
60
+
}))
61
61
+
return s, d
62
62
+
}
63
63
+
64
64
+
func TestPDSClient_list(t *testing.T) {
65
65
+
s, _ := pdsStub()
66
66
+
defer s.Close()
67
67
+
68
68
+
c := newPDSClient(s.URL)
69
69
+
recs, err := c.list(context.Background(), "did:plc:abc", tagCollection)
70
70
+
if err != nil {
71
71
+
t.Fatalf("list: %v", err)
72
72
+
}
73
73
+
if len(recs) != 3 {
74
74
+
t.Fatalf("expected 3 records, got %d", len(recs))
75
75
+
}
76
76
+
if recs[0].URI != "at://did:plc:abc/io.atcr.tag/app_123" {
77
77
+
t.Fatalf("unexpected first record URI: %s", recs[0].URI)
78
78
+
}
79
79
+
}
80
80
+
81
81
+
func TestPDSClient_delete(t *testing.T) {
82
82
+
s, d := pdsStub()
83
83
+
defer s.Close()
84
84
+
85
85
+
c := newPDSClient(s.URL)
86
86
+
if err := c.delete(context.Background(), "did:plc:abc", tagCollection, "app_123"); err != nil {
87
87
+
t.Fatalf("delete: %v", err)
88
88
+
}
89
89
+
if _, ok := d[tagCollection+"/app_123"]; !ok {
90
90
+
t.Fatal("expected tag app_123 to be deleted")
91
91
+
}
92
92
+
}
93
93
+
94
94
+
func TestDiscoverRepos(t *testing.T) {
95
95
+
s, _ := pdsStub()
96
96
+
defer s.Close()
97
97
+
98
98
+
c := newPDSClient(s.URL)
99
99
+
repos, err := discoverRepos(context.Background(), c, "did:plc:abc")
100
100
+
if err != nil {
101
101
+
t.Fatalf("discoverRepos: %v", err)
102
102
+
}
103
103
+
if len(repos) != 1 || repos[0] != "app" {
104
104
+
t.Fatalf("expected [app], got %v", repos)
105
105
+
}
106
106
+
}
107
107
+
108
108
+
func TestScanRepo_keep(t *testing.T) {
109
109
+
s, _ := pdsStub()
110
110
+
defer s.Close()
111
111
+
112
112
+
c := newPDSClient(s.URL)
113
113
+
ch := make(chan stale, 10)
114
114
+
off := time.Now().AddDate(0, 0, -30)
115
115
+
if err := scanRepo(context.Background(), c, "did:plc:abc", "app", off, "latest", ch); err != nil {
116
116
+
t.Fatalf("scanRepo: %v", err)
117
117
+
}
118
118
+
close(ch)
119
119
+
120
120
+
var found []stale
121
121
+
for s := range ch {
122
122
+
found = append(found, s)
123
123
+
}
124
124
+
if len(found) != 0 {
125
125
+
t.Fatalf("expected 0 stale tags (app_123 kept by latest), got %d: %v", len(found), found)
126
126
+
}
127
127
+
}