Storage backend: - Add internal/storage package with Storer interface - Implement FileStorer for local filesystem storage (file:// URLs) - Implement S3Storer wrapping existing s3.Client - Support storage_url config field (s3:// or file://) - Migrate all consumers to use storage.Storer interface PID locking: - Add internal/pidlock package to prevent concurrent instances - Acquire lock before app start, release on exit - Detect stale locks from crashed processes Scan progress improvements: - Add fast file enumeration pass before stat() phase - Use enumerated set for deletion detection (no extra filesystem access) - Show progress with percentage, files/sec, elapsed time, and ETA - Change "changed" to "changed/new" for clarity Config improvements: - Add tilde expansion for paths (~/) - Use xdg library for platform-specific default index path
158 lines
3.9 KiB
Go
158 lines
3.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
"github.com/spf13/cobra"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
// StoreApp contains dependencies for store commands
|
|
type StoreApp struct {
|
|
Storage storage.Storer
|
|
Shutdowner fx.Shutdowner
|
|
}
|
|
|
|
// NewStoreCommand creates the store command and subcommands
|
|
func NewStoreCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "store",
|
|
Short: "Storage information commands",
|
|
Long: "Commands for viewing information about the S3 storage backend",
|
|
}
|
|
|
|
// Add subcommands
|
|
cmd.AddCommand(newStoreInfoCommand())
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newStoreInfoCommand creates the 'store info' subcommand
|
|
func newStoreInfoCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "info",
|
|
Short: "Display storage information",
|
|
Long: "Shows S3 bucket configuration and storage statistics including snapshots and blobs",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runWithApp(cmd.Context(), func(app *StoreApp) error {
|
|
return app.Info(cmd.Context())
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
// Info displays storage information
|
|
func (app *StoreApp) Info(ctx context.Context) error {
|
|
// Get storage info
|
|
storageInfo := app.Storage.Info()
|
|
|
|
fmt.Printf("Storage Information\n")
|
|
fmt.Printf("==================\n\n")
|
|
fmt.Printf("Storage Configuration:\n")
|
|
fmt.Printf(" Type: %s\n", storageInfo.Type)
|
|
fmt.Printf(" Location: %s\n\n", storageInfo.Location)
|
|
|
|
// Count snapshots by listing metadata/ prefix
|
|
snapshotCount := 0
|
|
snapshotCh := app.Storage.ListStream(ctx, "metadata/")
|
|
snapshotDirs := make(map[string]bool)
|
|
|
|
for object := range snapshotCh {
|
|
if object.Err != nil {
|
|
return fmt.Errorf("listing snapshots: %w", object.Err)
|
|
}
|
|
// Extract snapshot ID from path like metadata/2024-01-15-143052-hostname/
|
|
parts := strings.Split(object.Key, "/")
|
|
if len(parts) >= 2 && parts[0] == "metadata" && parts[1] != "" {
|
|
snapshotDirs[parts[1]] = true
|
|
}
|
|
}
|
|
snapshotCount = len(snapshotDirs)
|
|
|
|
// Count blobs and calculate total size by listing blobs/ prefix
|
|
blobCount := 0
|
|
var totalSize int64
|
|
|
|
blobCh := app.Storage.ListStream(ctx, "blobs/")
|
|
for object := range blobCh {
|
|
if object.Err != nil {
|
|
return fmt.Errorf("listing blobs: %w", object.Err)
|
|
}
|
|
if !strings.HasSuffix(object.Key, "/") { // Skip directories
|
|
blobCount++
|
|
totalSize += object.Size
|
|
}
|
|
}
|
|
|
|
fmt.Printf("Storage Statistics:\n")
|
|
fmt.Printf(" Snapshots: %d\n", snapshotCount)
|
|
fmt.Printf(" Blobs: %d\n", blobCount)
|
|
fmt.Printf(" Total Size: %s\n", formatBytes(totalSize))
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatBytes formats bytes into human-readable format
|
|
func formatBytes(bytes int64) string {
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
// runWithApp creates the FX app and runs the given function
|
|
func runWithApp(ctx context.Context, fn func(*StoreApp) error) error {
|
|
var result error
|
|
rootFlags := GetRootFlags()
|
|
|
|
// Use unified config resolution
|
|
configPath, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = RunWithApp(ctx, AppOptions{
|
|
ConfigPath: configPath,
|
|
LogOptions: log.LogOptions{
|
|
Verbose: rootFlags.Verbose,
|
|
Debug: rootFlags.Debug,
|
|
},
|
|
Modules: []fx.Option{
|
|
fx.Provide(func(storer storage.Storer, shutdowner fx.Shutdowner) *StoreApp {
|
|
return &StoreApp{
|
|
Storage: storer,
|
|
Shutdowner: shutdowner,
|
|
}
|
|
}),
|
|
},
|
|
Invokes: []fx.Option{
|
|
fx.Invoke(func(app *StoreApp, shutdowner fx.Shutdowner) {
|
|
result = fn(app)
|
|
// Shutdown after command completes
|
|
go func() {
|
|
time.Sleep(100 * time.Millisecond) // Brief delay to ensure clean shutdown
|
|
if err := shutdowner.Shutdown(); err != nil {
|
|
log.Error("Failed to shutdown", "error", err)
|
|
}
|
|
}()
|
|
}),
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return result
|
|
}
|