Go · 3551 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 func init() {
70 storageCmd.AddCommand(storageCheckCmd)
71 }
72
73 func checkReposRoot(root string) error {
74 if root == "" {
75 return errors.New("not configured")
76 }
77 info, err := os.Stat(root)
78 if err != nil {
79 return fmt.Errorf("stat: %w", err)
80 }
81 if !info.IsDir() {
82 return fmt.Errorf("%s is not a directory", root)
83 }
84 probe, err := os.CreateTemp(root, ".storage-check-")
85 if err != nil {
86 return fmt.Errorf("write probe: %w", err)
87 }
88 name := probe.Name()
89 _ = probe.Close()
90 if err := os.Remove(name); err != nil {
91 return fmt.Errorf("cleanup probe: %w", err)
92 }
93 return nil
94 }
95
96 func checkS3(ctx context.Context, s config.S3StorageConfig) error {
97 store, err := storage.NewS3Store(storage.S3Config{
98 Endpoint: s.Endpoint,
99 Region: s.Region,
100 AccessKeyID: s.AccessKeyID,
101 SecretAccessKey: s.SecretAccessKey,
102 Bucket: s.Bucket,
103 UseSSL: s.UseSSL,
104 ForcePathStyle: s.ForcePathStyle,
105 })
106 if err != nil {
107 return fmt.Errorf("client: %w", err)
108 }
109
110 suffix := make([]byte, 8)
111 if _, err := rand.Read(suffix); err != nil {
112 return fmt.Errorf("random: %w", err)
113 }
114 key := "_storage-check/" + hex.EncodeToString(suffix)
115 body := []byte("storage check at " + time.Now().UTC().Format(time.RFC3339Nano))
116
117 if _, err := store.Put(ctx, key, strings.NewReader(string(body)), storage.PutOpts{ContentType: "text/plain"}); err != nil {
118 return fmt.Errorf("put: %w", err)
119 }
120 defer func() { _ = store.Delete(ctx, key) }()
121
122 rc, _, err := store.Get(ctx, key)
123 if err != nil {
124 return fmt.Errorf("get: %w", err)
125 }
126 defer func() { _ = rc.Close() }()
127 got, err := io.ReadAll(rc)
128 if err != nil {
129 return fmt.Errorf("read body: %w", err)
130 }
131 if string(got) != string(body) {
132 return errors.New("round-trip body mismatch")
133 }
134 return nil
135 }
136