From 9ab970bea3d0e3841be5591542cf0da316f2493b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Tue, 17 Mar 2026 18:57:22 +0100 Subject: [PATCH] feat: improve config command output --- cmd/cloudstic/cmd_auth.go | 34 +-- cmd/cloudstic/cmd_auth_test.go | 6 +- cmd/cloudstic/cmd_profile.go | 98 +----- cmd/cloudstic/cmd_profile_test.go | 57 ++-- cmd/cloudstic/cmd_store.go | 93 +----- cmd/cloudstic/cmd_store_test.go | 64 ++-- cmd/cloudstic/config_tables.go | 483 ++++++++++++++++++++++++++++++ 7 files changed, 553 insertions(+), 282 deletions(-) create mode 100644 cmd/cloudstic/config_tables.go diff --git a/cmd/cloudstic/cmd_auth.go b/cmd/cloudstic/cmd_auth.go index c93bde4..77fe957 100644 --- a/cmd/cloudstic/cmd_auth.go +++ b/cmd/cloudstic/cmd_auth.go @@ -48,23 +48,7 @@ func (r *runner) runAuthList() int { return r.fail("Failed to load profiles: %v", err) } - names := sortedKeys(cfg.Auth) - - _, _ = fmt.Fprintf(r.out, "%d auth entries\n", len(names)) - for _, name := range names { - auth := cfg.Auth[name] - _, _ = fmt.Fprintf(r.out, "- %s", name) - if auth.Provider != "" { - _, _ = fmt.Fprintf(r.out, " provider=%s", auth.Provider) - } - if auth.Provider == "google" && auth.GoogleTokenFile != "" { - _, _ = fmt.Fprintf(r.out, " token=%s", auth.GoogleTokenFile) - } - if auth.Provider == "onedrive" && auth.OneDriveTokenFile != "" { - _, _ = fmt.Fprintf(r.out, " token=%s", auth.OneDriveTokenFile) - } - _, _ = fmt.Fprintln(r.out) - } + r.renderAuthList(cfg) return 0 } @@ -100,21 +84,7 @@ func (r *runner) runAuthShow() int { if !ok { return r.fail("Unknown auth %q", name) } - - _, _ = fmt.Fprintf(r.out, "auth: %s\n", name) - _, _ = fmt.Fprintf(r.out, " provider: %s\n", auth.Provider) - if auth.GoogleCreds != "" { - _, _ = fmt.Fprintf(r.out, " google_credentials: %s\n", auth.GoogleCreds) - } - if auth.GoogleTokenFile != "" { - _, _ = fmt.Fprintf(r.out, " google_token_file: %s\n", auth.GoogleTokenFile) - } - if auth.OneDriveClientID != "" { - _, _ = fmt.Fprintf(r.out, " onedrive_client_id: %s\n", auth.OneDriveClientID) - } - if auth.OneDriveTokenFile != "" { - _, _ = fmt.Fprintf(r.out, " onedrive_token_file: %s\n", auth.OneDriveTokenFile) - } + r.renderAuthShow(cfg, name, auth) return 0 } diff --git a/cmd/cloudstic/cmd_auth_test.go b/cmd/cloudstic/cmd_auth_test.go index 1bccfba..d5f81b1 100644 --- a/cmd/cloudstic/cmd_auth_test.go +++ b/cmd/cloudstic/cmd_auth_test.go @@ -31,7 +31,7 @@ func TestRunAuthNewAndListAndShow(t *testing.T) { if code := r.runAuth(); code != 0 { t.Fatalf("auth list failed: %s", errOut.String()) } - if !strings.Contains(out.String(), "1 auth entries") || !strings.Contains(out.String(), "google-work") { + if !strings.Contains(out.String(), "Auth") || !strings.Contains(out.String(), "google-work") || !strings.Contains(out.String(), "PROVIDER") { t.Fatalf("unexpected auth list output:\n%s", out.String()) } @@ -41,7 +41,7 @@ func TestRunAuthNewAndListAndShow(t *testing.T) { if code := r.runAuth(); code != 0 { t.Fatalf("auth show failed: %s", errOut.String()) } - if !strings.Contains(out.String(), "provider: google") || !strings.Contains(out.String(), "google_token_file: /tmp/google-work.json") { + if !strings.Contains(out.String(), "Auth google-work") || !strings.Contains(out.String(), "Provider Details") || !strings.Contains(out.String(), "/tmp/google-work.json") { t.Fatalf("unexpected auth show output:\n%s", out.String()) } } @@ -99,7 +99,7 @@ func TestRunAuthNew_OneDriveProvider(t *testing.T) { t.Fatalf("auth show failed: %s", errOut.String()) } got := out.String() - for _, want := range []string{"provider: onedrive", "onedrive_token_file: /tmp/od-personal.json", "onedrive_client_id: my-client-id-123"} { + for _, want := range []string{"Auth od-personal", "Provider Details", "/tmp/od-personal.json", "my-client-id-123"} { if !strings.Contains(got, want) { t.Fatalf("expected %q in show output:\n%s", want, got) } diff --git a/cmd/cloudstic/cmd_profile.go b/cmd/cloudstic/cmd_profile.go index c2e4ed9..ef3e7d7 100644 --- a/cmd/cloudstic/cmd_profile.go +++ b/cmd/cloudstic/cmd_profile.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "sort" - "strings" cloudstic "github.com/cloudstic/cli" "github.com/cloudstic/cli/internal/paths" @@ -91,52 +90,7 @@ func (r *runner) runProfileShow() int { if !ok { return r.fail("Unknown profile %q", a.name) } - - _, _ = fmt.Fprintf(r.out, "profile: %s\n", a.name) - _, _ = fmt.Fprintf(r.out, " source: %s\n", p.Source) - if p.Store != "" { - _, _ = fmt.Fprintf(r.out, " store_ref: %s\n", p.Store) - if s, ok := cfg.Stores[p.Store]; ok { - _, _ = fmt.Fprintf(r.out, " store_uri: %s\n", s.URI) - if uri, parseErr := parseStoreURI(s.URI); parseErr == nil && uri.scheme == "s3" { - if s.S3Region != "" { - _, _ = fmt.Fprintf(r.out, " store_s3_region: %s\n", s.S3Region) - } - if s.S3Profile != "" { - _, _ = fmt.Fprintf(r.out, " store_s3_profile: %s\n", s.S3Profile) - } - if s.S3Endpoint != "" { - _, _ = fmt.Fprintf(r.out, " store_s3_endpoint: %s\n", s.S3Endpoint) - } - } - _, _ = fmt.Fprintf(r.out, " store_auth_mode: %s\n", profileStoreAuthMode(s)) - } else { - _, _ = fmt.Fprintf(r.out, " store_uri: \n") - } - } - if p.AuthRef != "" { - _, _ = fmt.Fprintf(r.out, " auth_ref: %s\n", p.AuthRef) - if auth, ok := cfg.Auth[p.AuthRef]; ok { - _, _ = fmt.Fprintf(r.out, " auth_provider: %s\n", auth.Provider) - if auth.GoogleTokenFile != "" { - _, _ = fmt.Fprintf(r.out, " google_token_file: %s\n", auth.GoogleTokenFile) - } - if auth.OneDriveTokenFile != "" { - _, _ = fmt.Fprintf(r.out, " onedrive_token_file: %s\n", auth.OneDriveTokenFile) - } - } else { - _, _ = fmt.Fprintf(r.out, " auth_provider: \n") - } - } - if len(p.Tags) > 0 { - _, _ = fmt.Fprintf(r.out, " tags: %s\n", strings.Join(p.Tags, ", ")) - } - if len(p.Excludes) > 0 { - _, _ = fmt.Fprintf(r.out, " excludes: %s\n", strings.Join(p.Excludes, ", ")) - } - if p.ExcludeFile != "" { - _, _ = fmt.Fprintf(r.out, " exclude_file: %s\n", p.ExcludeFile) - } + r.renderProfileShow(cfg, a.name, p) return 0 } @@ -180,55 +134,11 @@ func (r *runner) runProfileList() int { return r.fail("Failed to load profiles: %v", err) } - storeNames := sortedKeys(cfg.Stores) - - _, _ = fmt.Fprintf(r.out, "%d stores\n", len(storeNames)) - for _, name := range storeNames { - s := cfg.Stores[name] - _, _ = fmt.Fprintf(r.out, "- %s", name) - if s.URI != "" { - _, _ = fmt.Fprintf(r.out, " uri=%s", s.URI) - } - _, _ = fmt.Fprintln(r.out) - } + r.renderStoreList(cfg) _, _ = fmt.Fprintln(r.out) - - authNames := sortedKeys(cfg.Auth) - - _, _ = fmt.Fprintf(r.out, "%d auth entries\n", len(authNames)) - for _, name := range authNames { - a := cfg.Auth[name] - _, _ = fmt.Fprintf(r.out, "- %s", name) - if a.Provider != "" { - _, _ = fmt.Fprintf(r.out, " provider=%s", a.Provider) - } - if a.Provider == "google" && a.GoogleTokenFile != "" { - _, _ = fmt.Fprintf(r.out, " token=%s", a.GoogleTokenFile) - } - if a.Provider == "onedrive" && a.OneDriveTokenFile != "" { - _, _ = fmt.Fprintf(r.out, " token=%s", a.OneDriveTokenFile) - } - _, _ = fmt.Fprintln(r.out) - } + r.renderAuthList(cfg) _, _ = fmt.Fprintln(r.out) - - names := sortedKeys(cfg.Profiles) - - _, _ = fmt.Fprintf(r.out, "%d profiles\n", len(names)) - for _, name := range names { - p := cfg.Profiles[name] - _, _ = fmt.Fprintf(r.out, "- %s", name) - if p.Source != "" { - _, _ = fmt.Fprintf(r.out, " source=%s", p.Source) - } - if p.Store != "" { - _, _ = fmt.Fprintf(r.out, " store=%s", p.Store) - } - if p.AuthRef != "" { - _, _ = fmt.Fprintf(r.out, " auth=%s", p.AuthRef) - } - _, _ = fmt.Fprintln(r.out) - } + r.renderProfileList(cfg) return 0 } diff --git a/cmd/cloudstic/cmd_profile_test.go b/cmd/cloudstic/cmd_profile_test.go index 16ebd4a..d3f63de 100644 --- a/cmd/cloudstic/cmd_profile_test.go +++ b/cmd/cloudstic/cmd_profile_test.go @@ -45,29 +45,10 @@ profiles: } got := out.String() - if !strings.Contains(got, "1 stores") { - t.Fatalf("expected store count, got:\n%s", got) - } - if !strings.Contains(got, "- home-s3 uri=s3:my-bucket/cloudstic") { - t.Fatalf("expected home-s3 store line, got:\n%s", got) - } - if !strings.Contains(got, "1 auth entries") { - t.Fatalf("expected auth count, got:\n%s", got) - } - if !strings.Contains(got, "- google-work provider=google token=/tmp/google-work.json") { - t.Fatalf("expected google-work auth line, got:\n%s", got) - } - if !strings.Contains(got, "2 profiles") { - t.Fatalf("expected profile count, got:\n%s", got) - } - if !strings.Contains(got, "- photos source=local:/Volumes/Photos store=home-s3") { - t.Fatalf("expected photos profile line, got:\n%s", got) - } - if !strings.Contains(got, "- work-drive source=gdrive-changes://Company Data/Engineering store=home-s3") { - t.Fatalf("expected work-drive profile line, got:\n%s", got) - } - if !strings.Contains(got, "auth=google-work") { - t.Fatalf("expected auth reference in profile line, got:\n%s", got) + for _, want := range []string{"Stores", "Auth", "Profiles", "home-s3", "google-work", "photos", "work-drive"} { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in output:\n%s", want, got) + } } } @@ -103,10 +84,10 @@ profiles: t.Fatalf("runProfile() code=%d err=%s", code, errOut.String()) } got := out.String() - if !strings.Contains(got, "profile: work-drive") || !strings.Contains(got, "store_uri: s3:my-bucket/cloudstic") || !strings.Contains(got, "auth_provider: google") { + if !strings.Contains(got, "Profile work-drive") || !strings.Contains(got, "Resolved References") || !strings.Contains(got, "s3:my-bucket/cloudstic") || !strings.Contains(got, "google") { t.Fatalf("unexpected show output:\n%s", got) } - if !strings.Contains(got, "store_s3_profile: prod") || !strings.Contains(got, "store_auth_mode: aws-shared-profile") { + if !strings.Contains(got, "Options") || !strings.Contains(got, "aws-shared-profile") || !strings.Contains(got, "prod") { t.Fatalf("expected store auth details in show output:\n%s", got) } } @@ -458,10 +439,10 @@ profiles: t.Fatalf("runProfile() code=%d err=%s", code, errOut.String()) } got := out.String() - if !strings.Contains(got, "auth_provider: onedrive") { - t.Fatalf("expected auth_provider: onedrive in output:\n%s", got) + if !strings.Contains(got, "Auth Provider") || !strings.Contains(got, "onedrive") { + t.Fatalf("expected auth provider details in output:\n%s", got) } - if !strings.Contains(got, "onedrive_token_file: /tmp/od-token.json") { + if !strings.Contains(got, "/tmp/od-token.json") { t.Fatalf("expected onedrive_token_file in output:\n%s", got) } } @@ -489,8 +470,8 @@ profiles: t.Fatalf("runProfile() code=%d err=%s", code, errOut.String()) } got := out.String() - if !strings.Contains(got, "store_uri: ") { - t.Fatalf("expected '' for store_uri in output:\n%s", got) + if !strings.Contains(got, "Store URI") || !strings.Contains(got, "") { + t.Fatalf("expected missing store marker in output:\n%s", got) } } @@ -521,8 +502,8 @@ profiles: t.Fatalf("runProfile() code=%d err=%s", code, errOut.String()) } got := out.String() - if !strings.Contains(got, "auth_provider: ") { - t.Fatalf("expected '' for auth_provider in output:\n%s", got) + if !strings.Contains(got, "Auth Provider") || !strings.Contains(got, "") { + t.Fatalf("expected missing auth marker in output:\n%s", got) } } @@ -657,10 +638,10 @@ profiles: t.Fatalf("runProfile() code=%d err=%s", code, errOut.String()) } got := out.String() - if !strings.Contains(got, "provider=onedrive") { - t.Fatalf("expected provider=onedrive in list output:\n%s", got) + if !strings.Contains(got, "onedrive") { + t.Fatalf("expected onedrive provider in list output:\n%s", got) } - if !strings.Contains(got, "token=/home/user/.config/od-token.json") { + if !strings.Contains(got, "/home/user/.config/od-token.json") { t.Fatalf("expected onedrive token path in list output:\n%s", got) } } @@ -699,13 +680,13 @@ profiles: t.Fatalf("runProfile() code=%d err=%s", code, errOut.String()) } got := out.String() - if !strings.Contains(got, "tags: daily, critical") { + if !strings.Contains(got, "Tags") || !strings.Contains(got, "daily, critical") { t.Fatalf("expected tags in output:\n%s", got) } - if !strings.Contains(got, "excludes: *.log, *.tmp") { + if !strings.Contains(got, "Exclude Patterns") || !strings.Contains(got, "*.log") || !strings.Contains(got, "*.tmp") { t.Fatalf("expected excludes in output:\n%s", got) } - if !strings.Contains(got, "exclude_file: /etc/my-excludes.txt") { + if !strings.Contains(got, "/etc/my-excludes.txt") { t.Fatalf("expected exclude_file in output:\n%s", got) } } diff --git a/cmd/cloudstic/cmd_store.go b/cmd/cloudstic/cmd_store.go index 7b7fca1..522f0ff 100644 --- a/cmd/cloudstic/cmd_store.go +++ b/cmd/cloudstic/cmd_store.go @@ -8,7 +8,6 @@ import ( "io" "os" "runtime" - "sort" "strings" cloudstic "github.com/cloudstic/cli" @@ -54,17 +53,7 @@ func (r *runner) runStoreList() int { return r.fail("Failed to load profiles: %v", err) } - names := sortedKeys(cfg.Stores) - - _, _ = fmt.Fprintf(r.out, "%d stores\n", len(names)) - for _, name := range names { - s := cfg.Stores[name] - _, _ = fmt.Fprintf(r.out, "- %s", name) - if s.URI != "" { - _, _ = fmt.Fprintf(r.out, " uri=%s", s.URI) - } - _, _ = fmt.Fprintln(r.out) - } + r.renderStoreList(cfg) return 0 } @@ -101,85 +90,7 @@ func (r *runner) runStoreShow() int { if !ok { return r.fail("Unknown store %q", name) } - - _, _ = fmt.Fprintf(r.out, "store: %s\n", name) - _, _ = fmt.Fprintf(r.out, " uri: %s\n", s.URI) - _, _ = fmt.Fprintf(r.out, " auth_mode: %s\n", profileStoreAuthMode(s)) - if s.S3Region != "" { - _, _ = fmt.Fprintf(r.out, " s3_region: %s\n", s.S3Region) - } - if s.S3Profile != "" { - _, _ = fmt.Fprintf(r.out, " s3_profile: %s\n", s.S3Profile) - } - if s.S3Endpoint != "" { - _, _ = fmt.Fprintf(r.out, " s3_endpoint: %s\n", s.S3Endpoint) - } - if s.S3AccessKeyEnv != "" { - _, _ = fmt.Fprintf(r.out, " s3_access_key_env (deprecated): %s\n", s.S3AccessKeyEnv) - } - if s.S3AccessKeySecret != "" { - _, _ = fmt.Fprintf(r.out, " s3_access_key_secret: %s\n", s.S3AccessKeySecret) - } - if s.S3SecretKeyEnv != "" { - _, _ = fmt.Fprintf(r.out, " s3_secret_key_env (deprecated): %s\n", s.S3SecretKeyEnv) - } - if s.S3SecretKeySecret != "" { - _, _ = fmt.Fprintf(r.out, " s3_secret_key_secret: %s\n", s.S3SecretKeySecret) - } - if s.S3ProfileEnv != "" { - _, _ = fmt.Fprintf(r.out, " s3_profile_env: %s\n", s.S3ProfileEnv) - } - if s.StoreSFTPPasswordEnv != "" { - _, _ = fmt.Fprintf(r.out, " store_sftp_password_env (deprecated): %s\n", s.StoreSFTPPasswordEnv) - } - if s.StoreSFTPPasswordSecret != "" { - _, _ = fmt.Fprintf(r.out, " store_sftp_password_secret: %s\n", s.StoreSFTPPasswordSecret) - } - if s.StoreSFTPKeyEnv != "" { - _, _ = fmt.Fprintf(r.out, " store_sftp_key_env (deprecated): %s\n", s.StoreSFTPKeyEnv) - } - if s.StoreSFTPKeySecret != "" { - _, _ = fmt.Fprintf(r.out, " store_sftp_key_secret: %s\n", s.StoreSFTPKeySecret) - } - if s.PasswordEnv != "" { - _, _ = fmt.Fprintf(r.out, " password_env (deprecated): %s\n", s.PasswordEnv) - } - if s.PasswordSecret != "" { - _, _ = fmt.Fprintf(r.out, " password_secret: %s\n", s.PasswordSecret) - } - if s.EncryptionKeyEnv != "" { - _, _ = fmt.Fprintf(r.out, " encryption_key_env (deprecated): %s\n", s.EncryptionKeyEnv) - } - if s.EncryptionKeySecret != "" { - _, _ = fmt.Fprintf(r.out, " encryption_key_secret: %s\n", s.EncryptionKeySecret) - } - if s.RecoveryKeyEnv != "" { - _, _ = fmt.Fprintf(r.out, " recovery_key_env (deprecated): %s\n", s.RecoveryKeyEnv) - } - if s.RecoveryKeySecret != "" { - _, _ = fmt.Fprintf(r.out, " recovery_key_secret: %s\n", s.RecoveryKeySecret) - } - if s.KMSKeyARN != "" { - _, _ = fmt.Fprintf(r.out, " kms_key_arn: %s\n", s.KMSKeyARN) - } - if s.KMSRegion != "" { - _, _ = fmt.Fprintf(r.out, " kms_region: %s\n", s.KMSRegion) - } - if s.KMSEndpoint != "" { - _, _ = fmt.Fprintf(r.out, " kms_endpoint: %s\n", s.KMSEndpoint) - } - - // Show which profiles reference this store. - var refs []string - for pName, p := range cfg.Profiles { - if p.Store == name { - refs = append(refs, pName) - } - } - if len(refs) > 0 { - sort.Strings(refs) - _, _ = fmt.Fprintf(r.out, " used_by: %v\n", refs) - } + r.renderStoreShow(cfg, name, s) return 0 } diff --git a/cmd/cloudstic/cmd_store_test.go b/cmd/cloudstic/cmd_store_test.go index 2cb545b..43fa1b2 100644 --- a/cmd/cloudstic/cmd_store_test.go +++ b/cmd/cloudstic/cmd_store_test.go @@ -43,7 +43,7 @@ func TestRunStoreNewAndListAndShow(t *testing.T) { if code := r.runStore(); code != 0 { t.Fatalf("store list failed: %s", errOut.String()) } - if !strings.Contains(out.String(), "1 stores") || !strings.Contains(out.String(), "prod-s3") { + if !strings.Contains(out.String(), "Stores") || !strings.Contains(out.String(), "prod-s3") || !strings.Contains(out.String(), "AUTH") { t.Fatalf("unexpected store list output:\n%s", out.String()) } @@ -55,16 +55,16 @@ func TestRunStoreNewAndListAndShow(t *testing.T) { t.Fatalf("store show failed: %s", errOut.String()) } got := out.String() - if !strings.Contains(got, "store: prod-s3") { + if !strings.Contains(got, "Store prod-s3") { t.Fatalf("expected store name in show output:\n%s", got) } - if !strings.Contains(got, "uri: s3:my-bucket/backups") { + if !strings.Contains(got, "s3:my-bucket/backups") { t.Fatalf("expected URI in show output:\n%s", got) } - if !strings.Contains(got, "s3_region: eu-west-1") { + if !strings.Contains(got, "Connection") || !strings.Contains(got, "eu-west-1") { t.Fatalf("expected region in show output:\n%s", got) } - if !strings.Contains(got, "s3_profile: prod") { + if !strings.Contains(got, "prod") || !strings.Contains(got, "Used By") { t.Fatalf("expected profile in show output:\n%s", got) } } @@ -207,7 +207,7 @@ profiles: t.Fatalf("store show failed: %s", errOut.String()) } got := out.String() - if !strings.Contains(got, "used_by:") || !strings.Contains(got, "docs") || !strings.Contains(got, "photos") { + if !strings.Contains(got, "Used By") || !strings.Contains(got, "docs") || !strings.Contains(got, "photos") { t.Fatalf("expected used_by with both profiles:\n%s", got) } } @@ -251,9 +251,12 @@ func TestRunStoreNew_WithEncryption(t *testing.T) { } got := out.String() for _, want := range []string{ - "password_secret: env://MY_BACKUP_PASSWORD", - "kms_key_arn: arn:aws:kms:us-east-1:123456:key/abcd", - "kms_region: us-east-1", + "Password Secret", + "env://MY_BACKUP_PASSWORD", + "KMS Key ARN", + "arn:aws:kms:us-east-1:123456:key/abcd", + "KMS Region", + "us-east-1", } { if !strings.Contains(got, want) { t.Fatalf("expected %q in show output:\n%s", want, got) @@ -534,12 +537,18 @@ stores: } got := out.String() for _, want := range []string{ - "password_env (deprecated): MY_PW", - "encryption_key_env (deprecated): MY_EK", - "recovery_key_env (deprecated): MY_RK", - "kms_key_arn: arn:aws:kms:us-east-1:111:key/xyz", - "kms_region: us-west-2", - "kms_endpoint: https://kms.custom.endpoint", + "Password Env (deprecated)", + "MY_PW", + "Encryption Key Env (deprecated)", + "MY_EK", + "Recovery Key Env (deprecated)", + "MY_RK", + "KMS Key ARN", + "arn:aws:kms:us-east-1:111:key/xyz", + "KMS Region", + "us-west-2", + "KMS Endpoint", + "https://kms.custom.endpoint", } { if !strings.Contains(got, want) { t.Fatalf("expected %q in show output:\n%s", want, got) @@ -570,8 +579,10 @@ stores: } got := out.String() for _, want := range []string{ - "store_sftp_password_env (deprecated): SFTP_PW_ENV", - "store_sftp_key_env (deprecated): SFTP_KEY_ENV", + "SFTP Password Env (deprecated)", + "SFTP_PW_ENV", + "SFTP Key Env (deprecated)", + "SFTP_KEY_ENV", } { if !strings.Contains(got, want) { t.Fatalf("expected %q in show output:\n%s", want, got) @@ -603,9 +614,12 @@ stores: } got := out.String() for _, want := range []string{ - "s3_access_key_env (deprecated): AK_ENV", - "s3_secret_key_env (deprecated): SK_ENV", - "s3_profile_env: PROF_ENV", + "S3 Access Key Env (deprecated)", + "AK_ENV", + "S3 Secret Key Env (deprecated)", + "SK_ENV", + "S3 Profile Env", + "PROF_ENV", } { if !strings.Contains(got, want) { t.Fatalf("expected %q in show output:\n%s", want, got) @@ -641,8 +655,10 @@ func TestRunStoreNew_WithSFTPOptions(t *testing.T) { } got := out.String() for _, want := range []string{ - "store_sftp_password_secret: env://SFTP_PW", - "store_sftp_key_secret: env://SFTP_KEY", + "SFTP Password Secret", + "env://SFTP_PW", + "SFTP Key Secret", + "env://SFTP_KEY", } { if !strings.Contains(got, want) { t.Fatalf("expected %q in show output:\n%s", want, got) @@ -1232,8 +1248,8 @@ stores: t.Fatalf("store list failed: %s", errOut.String()) } got := out.String() - if !strings.Contains(got, "3 stores") { - t.Fatalf("expected '3 stores' in output:\n%s", got) + if !strings.Contains(got, "Stores") || !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") || !strings.Contains(got, "gamma") { + t.Fatalf("expected table output with all stores:\n%s", got) } for _, name := range []string{"alpha", "beta", "gamma"} { if !strings.Contains(got, name) { diff --git a/cmd/cloudstic/config_tables.go b/cmd/cloudstic/config_tables.go new file mode 100644 index 0000000..1936156 --- /dev/null +++ b/cmd/cloudstic/config_tables.go @@ -0,0 +1,483 @@ +package main + +import ( + "fmt" + "io" + "sort" + "strings" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/logger" + "github.com/cloudstic/cli/internal/ui" + "github.com/jedib0t/go-pretty/v6/table" +) + +func newConfigTableWriter(out io.Writer) table.Writer { + t := table.NewWriter() + t.SetOutputMirror(out) + t.SetStyle(table.StyleRounded) + return t +} + +func renderSectionHeading(out io.Writer, title string, count int) { + tw := ui.NewTermWriter(out) + if count >= 0 { + tw.HeadingSub(title, fmt.Sprintf("%d", count)) + return + } + tw.Heading(title) +} + +func renderKVTable(out io.Writer, rows []table.Row) { + t := newConfigTableWriter(out) + t.AppendHeader(table.Row{"Field", "Value"}) + for _, row := range rows { + t.AppendRow(row) + } + t.Render() +} + +func renderMessageRow(out io.Writer, msg string) { + _, _ = fmt.Fprintf(out, "%s%s%s\n", ui.Dim, msg, ui.Reset) +} + +func statusLabel(kind string) string { + switch kind { + case "ready", "ok": + return logger.ColorGreen + "OK" + logger.ColorReset + case "warning", "disabled": + return logger.ColorYellow + strings.ToUpper(kind) + logger.ColorReset + default: + return logger.ColorRed + strings.ToUpper(kind) + logger.ColorReset + } +} + +func sourceScheme(raw string) string { + uri, err := parseSourceURI(raw) + if err != nil { + return "unknown" + } + return uri.scheme +} + +func storeScheme(raw string) string { + uri, err := parseStoreURI(raw) + if err != nil { + return "unknown" + } + return uri.scheme +} + +func joinOrDash(values []string) string { + if len(values) == 0 { + return "-" + } + return strings.Join(values, ", ") +} + +func shortList(values []string, limit int) string { + if len(values) == 0 { + return "-" + } + if len(values) <= limit { + return strings.Join(values, ", ") + } + return strings.Join(values[:limit], ", ") + fmt.Sprintf(" +%d", len(values)-limit) +} + +func boolLabel(v bool) string { + if v { + return "yes" + } + return "no" +} + +func profileHealth(cfg *cloudstic.ProfilesConfig, p cloudstic.BackupProfile) (status string, details []string) { + status = "ready" + provider := profileProviderFromSource(p.Source) + if !p.IsEnabled() { + status = "disabled" + } + if p.Store == "" { + return "error", []string{"no store ref"} + } + if _, ok := cfg.Stores[p.Store]; !ok { + return "error", []string{"missing store"} + } + if p.AuthRef != "" { + auth, ok := cfg.Auth[p.AuthRef] + if !ok { + return "error", []string{"missing auth ref"} + } + if provider != "" && auth.Provider != "" && auth.Provider != provider { + return "error", []string{"provider mismatch"} + } + } + if provider != "" { + if p.AuthRef == "" { + return "error", []string{"missing auth"} + } + } + return status, details +} + +func authHealth(auth cloudstic.ProfileAuth) (string, []string) { + switch auth.Provider { + case "google": + if auth.GoogleTokenFile == "" { + return "warning", []string{"missing token file"} + } + return "ready", nil + case "onedrive": + if auth.OneDriveTokenFile == "" { + return "warning", []string{"missing token file"} + } + return "ready", nil + default: + return "error", []string{"unknown provider"} + } +} + +func storeHealth(s cloudstic.ProfileStore) (string, []string) { + if s.URI == "" { + return "error", []string{"missing uri"} + } + if _, err := parseStoreURI(s.URI); err != nil { + return "error", []string{"invalid uri"} + } + return "ready", nil +} + +func profilesUsingStore(cfg *cloudstic.ProfilesConfig, storeName string) []string { + var refs []string + for pName, p := range cfg.Profiles { + if p.Store == storeName { + refs = append(refs, pName) + } + } + sort.Strings(refs) + return refs +} + +func profilesUsingAuth(cfg *cloudstic.ProfilesConfig, authName string) []string { + var refs []string + for pName, p := range cfg.Profiles { + if p.AuthRef == authName { + refs = append(refs, pName) + } + } + sort.Strings(refs) + return refs +} + +func appendWarningRow(rows []table.Row, warnings []string) []table.Row { + if len(warnings) == 0 { + return rows + } + return append(rows, table.Row{"Warnings", strings.Join(warnings, ", ")}) +} + +func (r *runner) renderStoreList(cfg *cloudstic.ProfilesConfig) { + names := sortedKeys(cfg.Stores) + renderSectionHeading(r.out, "Stores", len(names)) + if len(names) == 0 { + renderMessageRow(r.out, "No stores configured.") + return + } + t := newConfigTableWriter(r.out) + t.AppendHeader(table.Row{"Name", "Type", "Target", "Auth", "Used By", "Status"}) + for _, name := range names { + s := cfg.Stores[name] + status, warnings := storeHealth(s) + t.AppendRow(table.Row{ + name, + storeScheme(s.URI), + s.URI, + profileStoreAuthMode(s), + len(profilesUsingStore(cfg, name)), + statusLabel(status) + warningSuffix(warnings), + }) + } + t.Render() +} + +func (r *runner) renderAuthList(cfg *cloudstic.ProfilesConfig) { + names := sortedKeys(cfg.Auth) + renderSectionHeading(r.out, "Auth", len(names)) + if len(names) == 0 { + renderMessageRow(r.out, "No auth entries configured.") + return + } + t := newConfigTableWriter(r.out) + t.AppendHeader(table.Row{"Name", "Provider", "Token", "Used By", "Status"}) + for _, name := range names { + auth := cfg.Auth[name] + status, warnings := authHealth(auth) + t.AppendRow(table.Row{ + name, + auth.Provider, + authTokenPath(auth), + len(profilesUsingAuth(cfg, name)), + statusLabel(status) + warningSuffix(warnings), + }) + } + t.Render() +} + +func (r *runner) renderProfileList(cfg *cloudstic.ProfilesConfig) { + names := sortedKeys(cfg.Profiles) + renderSectionHeading(r.out, "Profiles", len(names)) + if len(names) == 0 { + renderMessageRow(r.out, "No profiles configured.") + return + } + t := newConfigTableWriter(r.out) + t.AppendHeader(table.Row{"Name", "Source", "Store", "Auth", "Tags", "Status"}) + for _, name := range names { + p := cfg.Profiles[name] + status, warnings := profileHealth(cfg, p) + t.AppendRow(table.Row{ + name, + p.Source, + dashIfEmpty(p.Store), + dashIfEmpty(p.AuthRef), + shortList(p.Tags, 2), + statusLabel(status) + warningSuffix(warnings), + }) + } + t.Render() +} + +func warningSuffix(warnings []string) string { + if len(warnings) == 0 { + return "" + } + return " " + logger.ColorYellow + "(" + strings.Join(warnings, ", ") + ")" + logger.ColorReset +} + +func dashIfEmpty(v string) string { + if strings.TrimSpace(v) == "" { + return "-" + } + return v +} + +func authTokenPath(auth cloudstic.ProfileAuth) string { + if auth.GoogleTokenFile != "" { + return auth.GoogleTokenFile + } + if auth.OneDriveTokenFile != "" { + return auth.OneDriveTokenFile + } + return "-" +} + +func (r *runner) renderStoreShow(cfg *cloudstic.ProfilesConfig, name string, s cloudstic.ProfileStore) { + status, warnings := storeHealth(s) + renderSectionHeading(r.out, fmt.Sprintf("Store %s", name), -1) + renderKVTable(r.out, appendWarningRow([]table.Row{ + {"URI", s.URI}, + {"Type", storeScheme(s.URI)}, + {"Auth Mode", profileStoreAuthMode(s)}, + {"Status", statusLabel(status)}, + }, warnings)) + + connection := []table.Row{} + if s.S3Region != "" { + connection = append(connection, table.Row{"S3 Region", s.S3Region}) + } + if s.S3Profile != "" { + connection = append(connection, table.Row{"S3 Profile", s.S3Profile}) + } + if s.S3Endpoint != "" { + connection = append(connection, table.Row{"S3 Endpoint", s.S3Endpoint}) + } + if s.KMSKeyARN != "" { + connection = append(connection, table.Row{"KMS Key ARN", s.KMSKeyARN}) + } + if s.KMSRegion != "" { + connection = append(connection, table.Row{"KMS Region", s.KMSRegion}) + } + if s.KMSEndpoint != "" { + connection = append(connection, table.Row{"KMS Endpoint", s.KMSEndpoint}) + } + if len(connection) > 0 { + renderSectionHeading(r.out, "Connection", -1) + renderKVTable(r.out, connection) + } + + credentials := secretDisplayRows(s) + if len(credentials) > 0 { + renderSectionHeading(r.out, "Credential References", -1) + renderKVTable(r.out, credentials) + } + + usedBy := profilesUsingStore(cfg, name) + renderSectionHeading(r.out, "Used By", len(usedBy)) + if len(usedBy) == 0 { + renderMessageRow(r.out, "No profiles reference this store.") + return + } + t := newConfigTableWriter(r.out) + t.AppendHeader(table.Row{"Profile"}) + for _, ref := range usedBy { + t.AppendRow(table.Row{ref}) + } + t.Render() +} + +func secretDisplayRows(s cloudstic.ProfileStore) []table.Row { + var rows []table.Row + appendRow := func(label, value string, deprecated bool) { + if value == "" { + return + } + if deprecated { + label += " (deprecated)" + } + rows = append(rows, table.Row{label, value}) + } + appendRow("S3 Access Key Secret", s.S3AccessKeySecret, false) + appendRow("S3 Access Key Env", s.S3AccessKeyEnv, true) + appendRow("S3 Secret Key Secret", s.S3SecretKeySecret, false) + appendRow("S3 Secret Key Env", s.S3SecretKeyEnv, true) + appendRow("S3 Profile Env", s.S3ProfileEnv, false) + appendRow("SFTP Password Secret", s.StoreSFTPPasswordSecret, false) + appendRow("SFTP Password Env", s.StoreSFTPPasswordEnv, true) + appendRow("SFTP Key Secret", s.StoreSFTPKeySecret, false) + appendRow("SFTP Key Env", s.StoreSFTPKeyEnv, true) + appendRow("Password Secret", s.PasswordSecret, false) + appendRow("Password Env", s.PasswordEnv, true) + appendRow("Encryption Key Secret", s.EncryptionKeySecret, false) + appendRow("Encryption Key Env", s.EncryptionKeyEnv, true) + appendRow("Recovery Key Secret", s.RecoveryKeySecret, false) + appendRow("Recovery Key Env", s.RecoveryKeyEnv, true) + return rows +} + +func (r *runner) renderAuthShow(cfg *cloudstic.ProfilesConfig, name string, auth cloudstic.ProfileAuth) { + status, warnings := authHealth(auth) + renderSectionHeading(r.out, fmt.Sprintf("Auth %s", name), -1) + renderKVTable(r.out, appendWarningRow([]table.Row{ + {"Provider", auth.Provider}, + {"Token File", authTokenPath(auth)}, + {"Status", statusLabel(status)}, + }, warnings)) + + providerRows := []table.Row{} + if auth.GoogleCreds != "" { + providerRows = append(providerRows, table.Row{"Google Credentials", auth.GoogleCreds}) + } + if auth.GoogleTokenFile != "" { + providerRows = append(providerRows, table.Row{"Google Token File", auth.GoogleTokenFile}) + } + if auth.OneDriveClientID != "" { + providerRows = append(providerRows, table.Row{"OneDrive Client ID", auth.OneDriveClientID}) + } + if auth.OneDriveTokenFile != "" { + providerRows = append(providerRows, table.Row{"OneDrive Token File", auth.OneDriveTokenFile}) + } + if len(providerRows) > 0 { + renderSectionHeading(r.out, "Provider Details", -1) + renderKVTable(r.out, providerRows) + } + + usedBy := profilesUsingAuth(cfg, name) + renderSectionHeading(r.out, "Used By", len(usedBy)) + if len(usedBy) == 0 { + renderMessageRow(r.out, "No profiles reference this auth entry.") + return + } + t := newConfigTableWriter(r.out) + t.AppendHeader(table.Row{"Profile"}) + for _, ref := range usedBy { + t.AppendRow(table.Row{ref}) + } + t.Render() +} + +func (r *runner) renderProfileShow(cfg *cloudstic.ProfilesConfig, name string, p cloudstic.BackupProfile) { + status, warnings := profileHealth(cfg, p) + renderSectionHeading(r.out, fmt.Sprintf("Profile %s", name), -1) + renderKVTable(r.out, appendWarningRow([]table.Row{ + {"Source", p.Source}, + {"Source Type", sourceScheme(p.Source)}, + {"Provider", dashIfEmpty(profileProviderFromSource(p.Source))}, + {"Enabled", boolLabel(p.IsEnabled())}, + {"Status", statusLabel(status)}, + }, warnings)) + + storeValue := "" + storeAuthMode := "-" + storeExtraRows := []table.Row{} + if p.Store == "" { + storeValue = "-" + } else if s, ok := cfg.Stores[p.Store]; ok { + storeValue = s.URI + storeAuthMode = profileStoreAuthMode(s) + if s.S3Region != "" { + storeExtraRows = append(storeExtraRows, table.Row{"Store S3 Region", s.S3Region}) + } + if s.S3Profile != "" { + storeExtraRows = append(storeExtraRows, table.Row{"Store S3 Profile", s.S3Profile}) + } + if s.S3Endpoint != "" { + storeExtraRows = append(storeExtraRows, table.Row{"Store S3 Endpoint", s.S3Endpoint}) + } + } + authProvider := "-" + authToken := "-" + if p.AuthRef != "" { + if auth, ok := cfg.Auth[p.AuthRef]; ok { + authProvider = auth.Provider + authToken = authTokenPath(auth) + } else { + authProvider = "" + } + } + renderSectionHeading(r.out, "Resolved References", -1) + resolvedRows := []table.Row{ + {"Store Ref", dashIfEmpty(p.Store)}, + {"Store URI", storeValue}, + {"Store Auth Mode", storeAuthMode}, + {"Auth Ref", dashIfEmpty(p.AuthRef)}, + {"Auth Provider", authProvider}, + {"Auth Token", authToken}, + } + resolvedRows = append(resolvedRows, storeExtraRows...) + renderKVTable(r.out, resolvedRows) + + optionRows := []table.Row{ + {"Tags", joinOrDash(p.Tags)}, + {"Excludes", fmt.Sprintf("%d pattern(s)", len(p.Excludes))}, + {"Exclude File", dashIfEmpty(p.ExcludeFile)}, + {"Skip Native Files", boolLabel(p.SkipNativeFiles)}, + } + if p.VolumeUUID != "" { + optionRows = append(optionRows, table.Row{"Volume UUID", p.VolumeUUID}) + } + if p.GoogleCreds != "" { + optionRows = append(optionRows, table.Row{"Google Credentials", p.GoogleCreds}) + } + if p.GoogleTokenFile != "" { + optionRows = append(optionRows, table.Row{"Google Token File", p.GoogleTokenFile}) + } + if p.OneDriveClientID != "" { + optionRows = append(optionRows, table.Row{"OneDrive Client ID", p.OneDriveClientID}) + } + if p.OneDriveTokenFile != "" { + optionRows = append(optionRows, table.Row{"OneDrive Token File", p.OneDriveTokenFile}) + } + renderSectionHeading(r.out, "Options", -1) + renderKVTable(r.out, optionRows) + + if len(p.Excludes) > 0 { + renderSectionHeading(r.out, "Exclude Patterns", len(p.Excludes)) + t := newConfigTableWriter(r.out) + t.AppendHeader(table.Row{"Pattern"}) + for _, pattern := range p.Excludes { + t.AppendRow(table.Row{pattern}) + } + t.Render() + } +}