tenseleyflow/shithub / 2439dcb

Browse files

Wire shithubd storage check: repos_root writability + S3 round-trip

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
2439dcb15a15dc8415b8db09524ae6fb420d3b9a
Parents
becf6a3
Tree
be17d0a

2 changed files

StatusFile+-
M cmd/shithubd/root.go 1 1
A cmd/shithubd/storage.go 135 0
cmd/shithubd/root.gomodified
@@ -36,7 +36,7 @@ func init() {
3636
 	rootCmd.AddCommand(stubCmd("worker", "Run background workers", "S14"))
3737
 	rootCmd.AddCommand(stubCmd("ssh-authkeys", "AuthorizedKeysCommand handler", "S07"))
3838
 	rootCmd.AddCommand(stubCmd("ssh-shell", "Forced SSH shell dispatcher", "S07/S13"))
39
-	rootCmd.AddCommand(stubCmd("storage", "Storage health checks", "S04"))
39
+	rootCmd.AddCommand(storageCmd)
4040
 	rootCmd.AddCommand(stubCmd("hook", "Git hook entrypoint", "S14"))
4141
 	rootCmd.AddCommand(stubCmd("admin", "Site-admin CLI", "S34"))
4242
 	rootCmd.AddCommand(configCmd)
cmd/shithubd/storage.goadded
@@ -0,0 +1,135 @@
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
+}