Go · 6049 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package main
4
5 import (
6 "context"
7 "crypto/rand"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 "strings"
14 "time"
15
16 "github.com/spf13/cobra"
17
18 "github.com/tenseleyFlow/shithub/internal/infra/config"
19 "github.com/tenseleyFlow/shithub/internal/infra/storage"
20 )
21
22 var storageCmd = &cobra.Command{
23 Use: "storage",
24 Short: "Storage health checks and operational helpers",
25 }
26
27 var storageCheckCmd = &cobra.Command{
28 Use: "check",
29 Short: "Verify S3 round-trip and repos-root writability",
30 Long: `Exits 0 when both:
31 (a) PUT and GET succeed against the configured S3 bucket, and
32 (b) the configured repos_root is writable.
33
34 When the S3 block is unconfigured, only (b) is checked. Used in deploy
35 smoke tests and as a sanity check from the operator's terminal.`,
36 RunE: func(cmd *cobra.Command, _ []string) error {
37 cfg, err := config.Load(nil)
38 if err != nil {
39 return err
40 }
41 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
42 defer cancel()
43
44 var failures []string
45
46 out := cmd.OutOrStdout()
47 if err := checkReposRoot(cfg.Storage.ReposRoot); err != nil {
48 failures = append(failures, fmt.Sprintf("repos_root: %v", err))
49 } else {
50 _, _ = fmt.Fprintf(out, "repos_root: ok (%s)\n", cfg.Storage.ReposRoot)
51 }
52
53 if cfg.Storage.S3.Endpoint == "" {
54 _, _ = fmt.Fprintln(out, "s3: skipped (storage.s3 not configured)")
55 } else if err := checkS3(ctx, cfg.Storage.S3); err != nil {
56 failures = append(failures, fmt.Sprintf("s3: %v", err))
57 } else {
58 _, _ = fmt.Fprintf(out, "s3: ok (endpoint=%s bucket=%s)\n",
59 cfg.Storage.S3.Endpoint, cfg.Storage.S3.Bucket)
60 }
61
62 if len(failures) > 0 {
63 return fmt.Errorf("storage check failed:\n - %s", strings.Join(failures, "\n - "))
64 }
65 return nil
66 },
67 }
68
69 var storageRepairSharedPermsCmd = &cobra.Command{
70 Use: "repair-shared-perms",
71 Short: "Bring existing bare repos to core.sharedRepository=group + g+w mode bits",
72 Long: `One-time backfill for repos created before SR2 #287 landed.
73
74 Pre-fix, bare repos were created with 'git init --bare' (no
75 --shared=group), so objects/ wound up 0755. The SSH-git push path
76 (git-receive-pack runs as the 'git' user, repos owned by 'shithub')
77 hit "unable to create temporary object directory" because the group
78 write bit was missing.
79
80 This subcommand walks every <prefix>/<owner>/<name>.git directory
81 under storage.repos_root and:
82 - sets core.sharedRepository=group in each repo's config
83 - chmods every file g+w
84 - chmods every directory g+w + g+s so future writes inherit group
85
86 Idempotent. Safe to re-run. Reports a per-repo summary.`,
87 RunE: func(cmd *cobra.Command, _ []string) error {
88 cfg, err := config.Load(nil)
89 if err != nil {
90 return err
91 }
92 root := cfg.Storage.ReposRoot
93 if root == "" {
94 return errors.New("storage.repos_root not configured")
95 }
96 fs, err := storage.NewRepoFS(root)
97 if err != nil {
98 return fmt.Errorf("repofs: %w", err)
99 }
100 ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
101 defer cancel()
102 out := cmd.OutOrStdout()
103 var ok, fail int
104 // Walk the canonical layout: <root>/<2-letter-prefix>/<owner>/<name>.git
105 entries, err := os.ReadDir(root)
106 if err != nil {
107 return fmt.Errorf("read repos_root: %w", err)
108 }
109 for _, prefix := range entries {
110 if !prefix.IsDir() {
111 continue
112 }
113 ownerEntries, err := os.ReadDir(root + "/" + prefix.Name())
114 if err != nil {
115 continue
116 }
117 for _, owner := range ownerEntries {
118 if !owner.IsDir() {
119 continue
120 }
121 ownerDir := root + "/" + prefix.Name() + "/" + owner.Name()
122 repos, err := os.ReadDir(ownerDir)
123 if err != nil {
124 continue
125 }
126 for _, repo := range repos {
127 if !repo.IsDir() || !strings.HasSuffix(repo.Name(), ".git") {
128 continue
129 }
130 path := ownerDir + "/" + repo.Name()
131 if err := fs.RepairSharedPerms(ctx, path); err != nil {
132 _, _ = fmt.Fprintf(out, "FAIL %s: %v\n", path, err)
133 fail++
134 continue
135 }
136 _, _ = fmt.Fprintf(out, "ok %s\n", path)
137 ok++
138 }
139 }
140 }
141 _, _ = fmt.Fprintf(out, "\nrepaired %d repo(s); %d failure(s)\n", ok, fail)
142 if fail > 0 {
143 return fmt.Errorf("repair-shared-perms: %d failures", fail)
144 }
145 return nil
146 },
147 }
148
149 func init() {
150 storageCmd.AddCommand(storageCheckCmd)
151 storageCmd.AddCommand(storageRepairSharedPermsCmd)
152 }
153
154 func checkReposRoot(root string) error {
155 if root == "" {
156 return errors.New("not configured")
157 }
158 info, err := os.Stat(root)
159 if err != nil {
160 return fmt.Errorf("stat: %w", err)
161 }
162 if !info.IsDir() {
163 return fmt.Errorf("%s is not a directory", root)
164 }
165 probe, err := os.CreateTemp(root, ".storage-check-")
166 if err != nil {
167 return fmt.Errorf("write probe: %w", err)
168 }
169 name := probe.Name()
170 _ = probe.Close()
171 if err := os.Remove(name); err != nil {
172 return fmt.Errorf("cleanup probe: %w", err)
173 }
174 return nil
175 }
176
177 func checkS3(ctx context.Context, s config.S3StorageConfig) error {
178 store, err := storage.NewS3Store(storage.S3Config{
179 Endpoint: s.Endpoint,
180 Region: s.Region,
181 AccessKeyID: s.AccessKeyID,
182 SecretAccessKey: s.SecretAccessKey,
183 Bucket: s.Bucket,
184 UseSSL: s.UseSSL,
185 ForcePathStyle: s.ForcePathStyle,
186 })
187 if err != nil {
188 return fmt.Errorf("client: %w", err)
189 }
190
191 suffix := make([]byte, 8)
192 if _, err := rand.Read(suffix); err != nil {
193 return fmt.Errorf("random: %w", err)
194 }
195 key := "_storage-check/" + hex.EncodeToString(suffix)
196 body := []byte("storage check at " + time.Now().UTC().Format(time.RFC3339Nano))
197
198 if _, err := store.Put(ctx, key, strings.NewReader(string(body)), storage.PutOpts{ContentType: "text/plain"}); err != nil {
199 return fmt.Errorf("put: %w", err)
200 }
201 defer func() { _ = store.Delete(ctx, key) }()
202
203 rc, _, err := store.Get(ctx, key)
204 if err != nil {
205 return fmt.Errorf("get: %w", err)
206 }
207 defer func() { _ = rc.Close() }()
208 got, err := io.ReadAll(rc)
209 if err != nil {
210 return fmt.Errorf("read body: %w", err)
211 }
212 if string(got) != string(body) {
213 return errors.New("round-trip body mismatch")
214 }
215 return nil
216 }
217