From c12fe4c4eddd75b7716b2e10ef6dc1354f498ff0 Mon Sep 17 00:00:00 2001 From: YoungJinJung Date: Sat, 28 Mar 2026 15:12:02 +0900 Subject: [PATCH] feat: add debug logging and --verbose flag (M4.2) - Add internal/log package with structured JSON logging to ~/.config/unic/logs/unic.log, size-based rotation (10MB, 3 old files), and pretty stderr output with colored levels and tree-formatted attrs - Add --verbose/-v global CLI flag to enable debug-level logging - Instrument config resolution, auth flow, AWS repository creation, and all AWS service API calls with debug/info logging - Use \r\n line endings in pretty handler for raw-mode terminal compat - Update README with --verbose usage --- README.md | 90 +++++-- cmd/unic/main.go | 13 + go.mod | 2 +- internal/auth/auth.go | 6 + internal/cli/root.go | 7 + internal/cli/root_test.go | 7 + internal/config/config.go | 10 + internal/log/log.go | 307 ++++++++++++++++++++++++ internal/log/log_test.go | 174 ++++++++++++++ internal/services/aws/ec2.go | 3 + internal/services/aws/rds.go | 9 + internal/services/aws/repository.go | 9 + internal/services/aws/route53.go | 4 + internal/services/aws/secretsmanager.go | 4 + internal/services/aws/ssm.go | 4 + internal/services/aws/vpc.go | 5 + 16 files changed, 635 insertions(+), 19 deletions(-) create mode 100644 internal/log/log.go create mode 100644 internal/log/log_test.go diff --git a/README.md b/README.md index df0c5e6..92f8d9d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ unic unic --profile my-profile unic --region ap-northeast-2 +# Enable verbose debug logging (writes to ~/.config/unic/logs/unic.log) +unic --verbose +unic -v + # Initialize config file unic init # Create default config unic init --force # Overwrite existing config @@ -40,50 +44,100 @@ unic init --force # Overwrite existing config `~/.config/unic/config.yaml` (created via `unic init` or auto-generated on first run) +### Legacy Format (Flat) + ```yaml -# Simple format default_profile: my-profile default_region: ap-northeast-2 ``` +### Context-Based Format + ```yaml -# Context-based format current: dev-sso +defaults: + region: us-east-1 + contexts: + # SSO authentication - name: dev-sso - profile: dev-sso region: ap-northeast-2 + auth_type: sso + sso_start_url: https://my-sso-portal.awsapps.com/start + sso_account_id: "123456789012" + sso_role_name: DeveloperRole - - name: prod-admin - profile: prod-admin + # Assume Role (cross-account) + - name: prod-assume region: us-east-1 + auth_type: assume_role + profile: base-profile + role_arn: arn:aws:iam::987654321098:role/CrossAccountRole + external_id: optional-external-id + + # Credential profile + - name: staging-creds + region: eu-west-1 + auth_type: credential + profile: staging ``` +### Auth Types + +| Auth Type | Required Fields | Description | +|-----------|----------------|-------------| +| `sso` | `sso_start_url`, `sso_account_id`, `sso_role_name` | AWS SSO portal login with token caching | +| `credential` | `profile` | Uses `~/.aws/credentials` profile directly | +| `assume_role` | `profile`, `role_arn` | Assumes a cross-account role from a base profile | + **Priority**: CLI flags (`--profile`, `--region`) > context settings > config defaults > hardcoded defaults (`us-east-1`) ## Currently Implemented Features | Service | Feature | Status | |---------|---------|--------| -| EC2 | SSM Session Manager (connect to EC2 instances) | ✅ Implemented | -| VPC | VPC Browser (VPCs → subnets → available IPs) | ✅ Implemented | -| RDS | RDS Browser (list, start/stop, failover, Aurora cluster support) | ✅ Implemented | -| Route53 | ListHostedZones | 🚧 Coming Soon | -| IAM | ListUsers | 🚧 Coming Soon | +| EC2 | SSM Session Manager (connect to running, SSM-managed instances) | ✅ Implemented | +| VPC | VPC Browser (VPCs → Subnets → Available IPs with reserved-IP exclusion) | ✅ Implemented | +| RDS | RDS Browser (list, start/stop, failover, Aurora cluster support, auto-polling) | ✅ Implemented | +| Route53 | DNS Browser (Hosted Zones → Records → Record Detail, public/private zones) | ✅ Implemented | +| Secrets Manager | Secrets Browser (list secrets, view key-value pairs or raw values) | ✅ Implemented | ## TUI Key Bindings +### Global Navigation + +| Key | Action | +|-----|--------| +| `j`/`k` or `↑`/`↓` | Navigate list | +| `Enter` | Select item | +| `Esc` | Go back one screen | +| `q` | Quit (on service list) | +| `H` | Jump to home (service list) | +| `C` | Open context switcher | +| `/` | Toggle filter mode | +| `Ctrl+C` | Force quit | + +### RDS Detail Actions + +| Key | Action | Condition | +|-----|--------|-----------| +| `s` | Start database | Instance/cluster is stopped | +| `x` | Stop database | Instance/cluster is available | +| `f` | Failover database | Multi-AZ standalone or Aurora cluster | +| `r` | Refresh status | Always | + +### Context Switcher + | Key | Action | |-----|--------| -| `j`/`k` or `↑`/`↓` | Navigate | -| `Enter` | Select | -| `Esc`/`q` | Go back | -| `H` | Go to home (service list) | -| `/` | Filter (instances, IPs) | -| `C` | Context switcher | -| `s`/`x`/`f` | Start/Stop/Failover (RDS detail) | -| `q` (on service list) | Quit | +| `Enter` | Switch to selected context | +| `a` | Add new context (wizard) | +| `Esc` | Back | + +### Filtering + +Available on: EC2 instances, VPC/Subnets, RDS instances, Route53 zones/records, Secrets Manager. Press `/` to enter filter mode, type to search, `Esc` or `Enter` to exit filter mode. ## Documentation diff --git a/cmd/unic/main.go b/cmd/unic/main.go index 9f9753f..bc2aa06 100644 --- a/cmd/unic/main.go +++ b/cmd/unic/main.go @@ -10,12 +10,18 @@ import ( "unic/internal/app" "unic/internal/cli" "unic/internal/config" + uniclog "unic/internal/log" ) func main() { rootCmd := cli.NewRootCmd() rootCmd.RunE = func(cmd *cobra.Command, args []string) error { + if err := uniclog.Init(cli.Verbose()); err != nil { + return fmt.Errorf("logger init error: %w", err) + } + defer uniclog.Close() + configPath, err := config.DefaultPath() if err != nil { return err @@ -30,6 +36,13 @@ func main() { return fmt.Errorf("config load error: %w", err) } + uniclog.Info("config", "config loaded", + "profile", cfg.Profile, + "region", cfg.Region, + "context", cfg.ContextName, + "auth_type", string(cfg.AuthType), + ) + p := tea.NewProgram(app.New(cfg, configPath), tea.WithAltScreen()) if _, err := p.Run(); err != nil { return fmt.Errorf("TUI error: %w", err) diff --git a/go.mod b/go.mod index b42321f..b8ccbfe 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 github.com/aws/aws-sdk-go-v2/service/rds v1.116.3 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 @@ -25,7 +26,6 @@ require ( github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aws/smithy-go v1.24.2 // indirect diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f526637..10b5ad6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -13,12 +13,15 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sts" "unic/internal/config" + uniclog "unic/internal/log" awsservice "unic/internal/services/aws" ) // PostSwitch performs the auth action after switching to a context. // Returns a human-readable status message. func PostSwitch(cfg *config.Config) (string, error) { + uniclog.Info("auth", "post-switch started", "context", cfg.ContextName, "auth_type", string(cfg.AuthType)) + var msg string var err error @@ -33,6 +36,7 @@ func PostSwitch(cfg *config.Config) (string, error) { msg = fmt.Sprintf("Context %q activated (profile: %s, region: %s)", cfg.ContextName, cfg.Profile, cfg.Region) } if err != nil { + uniclog.Error("auth", "post-switch failed", "error", err.Error()) return "", err } @@ -46,6 +50,7 @@ func PostSwitch(cfg *config.Config) (string, error) { } func postSwitchSSO(cfg *config.Config) (string, error) { + uniclog.Debug("auth", "starting SSO login", "sso_start_url", cfg.SSOStartURL) if err := awsservice.RunSSOLogin(cfg); err != nil { return "", err } @@ -73,6 +78,7 @@ func postSwitchCredential(cfg *config.Config) (string, error) { } func postSwitchAssumeRole(cfg *config.Config) (string, error) { + uniclog.Debug("auth", "assuming role", "role_arn", cfg.RoleArn) ctx := context.Background() opts := []func(*awsconfig.LoadOptions) error{ diff --git a/internal/cli/root.go b/internal/cli/root.go index 184e847..b5359ed 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -10,6 +10,7 @@ var ( profile string region string + verbose bool ) func NewRootCmd() *cobra.Command { @@ -22,6 +23,7 @@ func NewRootCmd() *cobra.Command { cmd.PersistentFlags().StringVarP(&profile, "profile", "p", "", "AWS profile to use") cmd.PersistentFlags().StringVar(®ion, "region", "", "AWS region to use") + cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose debug logging") cmd.AddCommand(newInitCmd()) @@ -43,3 +45,8 @@ func Region() *string { } return ®ion } + +// Verbose returns true if --verbose was passed. +func Verbose() bool { + return verbose +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index cc03cb6..1f10781 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -24,4 +24,11 @@ func TestRootCmdHasFlags(t *testing.T) { if rf == nil { t.Error("expected --region flag") } + vf := cmd.PersistentFlags().Lookup("verbose") + if vf == nil { + t.Error("expected --verbose flag") + } + if vf != nil && vf.Shorthand != "v" { + t.Errorf("expected --verbose shorthand 'v', got '%s'", vf.Shorthand) + } } diff --git a/internal/config/config.go b/internal/config/config.go index 1b690da..7f4e744 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,8 @@ import ( "path/filepath" "gopkg.in/yaml.v3" + + uniclog "unic/internal/log" ) const ( @@ -139,6 +141,14 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { region = *cliRegion } + uniclog.Debug("config", "config resolved", + "path", configPath, + "profile", profile, + "region", region, + "context", contextName, + "auth_type", string(authType), + ) + return &Config{ Profile: profile, Region: region, diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..2905e1a --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,307 @@ +package log + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + maxLogSize = 10 * 1024 * 1024 // 10 MB + maxOldFiles = 3 + logFileName = "unic.log" + logDirName = "logs" + configDirEnv = "XDG_CONFIG_HOME" + appName = "unic" +) + +var logFile *os.File + +// Init sets up structured logging to ~/.config/unic/logs/unic.log. +// If verbose is true, debug-level text output is also written to stderr. +func Init(verbose bool) error { + logDir, err := logDir() + if err != nil { + return err + } + if err := os.MkdirAll(logDir, 0755); err != nil { + return fmt.Errorf("failed to create log directory: %w", err) + } + + logPath := filepath.Join(logDir, logFileName) + if err := rotate(logPath); err != nil { + return fmt.Errorf("log rotation failed: %w", err) + } + + logFile, err = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + + level := slog.LevelInfo + if verbose { + level = slog.LevelDebug + } + + fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: level}) + + var handler slog.Handler + if verbose { + stderrHandler := newPrettyHandler(os.Stderr, slog.LevelDebug) + handler = &multiHandler{handlers: []slog.Handler{fileHandler, stderrHandler}} + } else { + handler = fileHandler + } + + slog.SetDefault(slog.New(handler)) + return nil +} + +// Close flushes and closes the log file. +func Close() { + if logFile != nil { + _ = logFile.Sync() + _ = logFile.Close() + logFile = nil + } +} + +// Debug logs a debug-level message with a component tag. +func Debug(component, msg string, attrs ...any) { + slog.Debug(msg, prepend(component, attrs)...) +} + +// Info logs an info-level message with a component tag. +func Info(component, msg string, attrs ...any) { + slog.Info(msg, prepend(component, attrs)...) +} + +// Error logs an error-level message with a component tag. +func Error(component, msg string, attrs ...any) { + slog.Error(msg, prepend(component, attrs)...) +} + +func prepend(component string, attrs []any) []any { + return append([]any{slog.String("component", component)}, attrs...) +} + +func logDir() (string, error) { + dir := os.Getenv(configDirEnv) + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, appName, logDirName), nil +} + +// rotate checks the current log file size and rotates if it exceeds maxLogSize. +func rotate(logPath string) error { + info, err := os.Stat(logPath) + if err != nil { + return nil // File doesn't exist yet + } + if info.Size() < maxLogSize { + return nil + } + + // Shift old files: .3 is deleted, .2→.3, .1→.2, current→.1 + for i := maxOldFiles; i >= 1; i-- { + src := logPath + if i > 1 { + src = fmt.Sprintf("%s.%d", logPath, i-1) + } + dst := fmt.Sprintf("%s.%d", logPath, i) + + if i == maxOldFiles { + _ = os.Remove(dst) + } + if _, err := os.Stat(src); err == nil { + if err := os.Rename(src, dst); err != nil { + return fmt.Errorf("failed to rotate %s → %s: %w", src, dst, err) + } + } + } + return nil +} + +// multiHandler fans out log records to multiple slog.Handlers. +type multiHandler struct { + handlers []slog.Handler +} + +func (m *multiHandler) Enabled(_ context.Context, level slog.Level) bool { + for _, h := range m.handlers { + if h.Enabled(nil, level) { + return true + } + } + return false +} + +func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error { + for _, h := range m.handlers { + if h.Enabled(ctx, r.Level) { + if err := h.Handle(ctx, r); err != nil { + return err + } + } + } + return nil +} + +func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + handlers := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + handlers[i] = h.WithAttrs(attrs) + } + return &multiHandler{handlers: handlers} +} + +func (m *multiHandler) WithGroup(name string) slog.Handler { + handlers := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + handlers[i] = h.WithGroup(name) + } + return &multiHandler{handlers: handlers} +} + +// ANSI color codes for pretty stderr output. +const ( + colorReset = "\033[0m" + colorDim = "\033[2m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorCyan = "\033[36m" + colorGreen = "\033[32m" +) + +// prettyHandler writes human-friendly, aligned log lines to a writer. +// +// HH:MM:SS LEVEL [component] message +// ├ key = value +// └ key = value +type prettyHandler struct { + w io.Writer + level slog.Level + attrs []slog.Attr + group string +} + +func newPrettyHandler(w io.Writer, level slog.Level) *prettyHandler { + return &prettyHandler{w: w, level: level} +} + +func (h *prettyHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +// indent is the fixed prefix for attribute lines, matching the width of +// "HH:MM:SS LEVEL " (8 + 2 + 5 + 2 = 17 visible chars). +const indent = " " + +func (h *prettyHandler) Handle(_ context.Context, r slog.Record) error { + var sb strings.Builder + + // Timestamp — short form + ts := r.Time.Format(time.TimeOnly) // "15:04:05" + sb.WriteString(colorDim) + sb.WriteString(ts) + sb.WriteString(colorReset) + sb.WriteString(" ") + + // Level — fixed 5-char width, colored + lvl := r.Level.String() + switch { + case r.Level >= slog.LevelError: + sb.WriteString(colorRed) + case r.Level >= slog.LevelWarn: + sb.WriteString(colorYellow) + case r.Level >= slog.LevelInfo: + sb.WriteString(colorGreen) + default: + sb.WriteString(colorCyan) + } + fmt.Fprintf(&sb, "%-5s", lvl) + sb.WriteString(colorReset) + sb.WriteString(" ") + + // Extract component from pre-attached attrs and record attrs + var component string + type kv struct{ k, v string } + var pairs []kv + + for _, a := range h.attrs { + if a.Key == "component" { + component = a.Value.String() + } else { + pairs = append(pairs, kv{a.Key, a.Value.String()}) + } + } + r.Attrs(func(a slog.Attr) bool { + if a.Key == "component" { + component = a.Value.String() + } else { + pairs = append(pairs, kv{a.Key, a.Value.String()}) + } + return true + }) + + // Component tag + if component != "" { + sb.WriteString(colorDim) + sb.WriteString("[") + sb.WriteString(component) + sb.WriteString("]") + sb.WriteString(colorReset) + sb.WriteString(" ") + } + + // Message + sb.WriteString(r.Message) + sb.WriteString("\r\n") + + // Key-value pairs — each on its own indented line with tree drawing chars + for i, p := range pairs { + sb.WriteString(indent) + sb.WriteString(colorDim) + if i < len(pairs)-1 { + sb.WriteString("├ ") + } else { + sb.WriteString("└ ") + } + sb.WriteString(p.k) + sb.WriteString(colorReset) + sb.WriteString(" = ") + sb.WriteString(p.v) + sb.WriteString("\r\n") + } + + _, err := io.WriteString(h.w, sb.String()) + return err +} + +func (h *prettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &prettyHandler{ + w: h.w, + level: h.level, + attrs: append(h.attrs, attrs...), + group: h.group, + } +} + +func (h *prettyHandler) WithGroup(name string) slog.Handler { + return &prettyHandler{ + w: h.w, + level: h.level, + attrs: h.attrs, + group: name, + } +} diff --git a/internal/log/log_test.go b/internal/log/log_test.go new file mode 100644 index 0000000..658c240 --- /dev/null +++ b/internal/log/log_test.go @@ -0,0 +1,174 @@ +package log + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInitCreatesLogDirAndFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(configDirEnv, tmpDir) + + if err := Init(false); err != nil { + t.Fatalf("Init failed: %v", err) + } + defer Close() + + logPath := filepath.Join(tmpDir, appName, logDirName, logFileName) + if _, err := os.Stat(logPath); err != nil { + t.Fatalf("log file not created: %v", err) + } +} + +func TestInfoWritesJSONToFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(configDirEnv, tmpDir) + + if err := Init(false); err != nil { + t.Fatalf("Init failed: %v", err) + } + + Info("test", "hello world", "key", "value") + Close() + + logPath := filepath.Join(tmpDir, appName, logDirName, logFileName) + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("failed to read log file: %v", err) + } + + var entry map[string]interface{} + if err := json.Unmarshal(data, &entry); err != nil { + t.Fatalf("log entry is not valid JSON: %v\ndata: %s", err, data) + } + + if entry["msg"] != "hello world" { + t.Errorf("expected msg 'hello world', got %v", entry["msg"]) + } + if entry["component"] != "test" { + t.Errorf("expected component 'test', got %v", entry["component"]) + } + if entry["key"] != "value" { + t.Errorf("expected key 'value', got %v", entry["key"]) + } +} + +func TestDebugNotWrittenWithoutVerbose(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(configDirEnv, tmpDir) + + if err := Init(false); err != nil { + t.Fatalf("Init failed: %v", err) + } + + Debug("test", "should not appear") + Close() + + logPath := filepath.Join(tmpDir, appName, logDirName, logFileName) + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("failed to read log file: %v", err) + } + + if len(data) > 0 { + t.Errorf("expected empty log file for debug without verbose, got: %s", data) + } +} + +func TestDebugWrittenWithVerbose(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(configDirEnv, tmpDir) + + if err := Init(true); err != nil { + t.Fatalf("Init failed: %v", err) + } + + Debug("test", "debug message") + Close() + + logPath := filepath.Join(tmpDir, appName, logDirName, logFileName) + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("failed to read log file: %v", err) + } + + var entry map[string]interface{} + if err := json.Unmarshal(data, &entry); err != nil { + t.Fatalf("log entry is not valid JSON: %v", err) + } + + if entry["msg"] != "debug message" { + t.Errorf("expected msg 'debug message', got %v", entry["msg"]) + } +} + +func TestRotation(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, logFileName) + + // Create a file that exceeds maxLogSize + f, err := os.Create(logPath) + if err != nil { + t.Fatal(err) + } + data := make([]byte, maxLogSize+1) + if _, err := f.Write(data); err != nil { + t.Fatal(err) + } + f.Close() + + if err := rotate(logPath); err != nil { + t.Fatalf("rotate failed: %v", err) + } + + // Original should be gone (renamed to .1) + if _, err := os.Stat(logPath); err == nil { + t.Error("original file should have been rotated") + } + + rotated := logPath + ".1" + if _, err := os.Stat(rotated); err != nil { + t.Errorf("rotated file .1 should exist: %v", err) + } +} + +func TestRotationChain(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, logFileName) + + // Create .1 and .2 files, then a large current file + for _, suffix := range []string{".1", ".2"} { + if err := os.WriteFile(logPath+suffix, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + } + + data := make([]byte, maxLogSize+1) + if err := os.WriteFile(logPath, data, 0644); err != nil { + t.Fatal(err) + } + + if err := rotate(logPath); err != nil { + t.Fatalf("rotate failed: %v", err) + } + + // .1 should be new (from current), .2 should be old .1, .3 should be old .2 + for _, suffix := range []string{".1", ".2", ".3"} { + if _, err := os.Stat(logPath + suffix); err != nil { + t.Errorf("expected %s to exist: %v", suffix, err) + } + } +} + +func TestCloseIsIdempotent(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(configDirEnv, tmpDir) + + if err := Init(false); err != nil { + t.Fatalf("Init failed: %v", err) + } + Close() + Close() // should not panic +} diff --git a/internal/services/aws/ec2.go b/internal/services/aws/ec2.go index 5d5420c..0c3f462 100644 --- a/internal/services/aws/ec2.go +++ b/internal/services/aws/ec2.go @@ -9,12 +9,15 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/ssm" ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" + + uniclog "unic/internal/log" ) // ListRunningInstances returns running EC2 instances that are also SSM-managed. // It first queries SSM for managed instance IDs, then fetches EC2 details // only for those instances so that every returned instance can be connected via SSM. func (r *AwsRepository) ListRunningInstances(ctx context.Context) ([]EC2Instance, error) { + uniclog.Debug("aws", "ListRunningInstances called") // Step 1: Get SSM-managed online instance IDs. ssmIDs, err := r.listSSMManagedInstanceIDs(ctx) if err != nil { diff --git a/internal/services/aws/rds.go b/internal/services/aws/rds.go index 0f890e2..85d9f11 100644 --- a/internal/services/aws/rds.go +++ b/internal/services/aws/rds.go @@ -6,10 +6,13 @@ import ( awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/rds" + + uniclog "unic/internal/log" ) // ListDBInstances returns all RDS DB instances in the current account/region. func (r *AwsRepository) ListDBInstances(ctx context.Context) ([]RDSInstance, error) { + uniclog.Debug("aws", "ListDBInstances called") output, err := r.RDSClient.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{}) if err != nil { return nil, fmt.Errorf("failed to describe DB instances: %w", err) @@ -70,6 +73,7 @@ func (r *AwsRepository) DescribeDBInstance(ctx context.Context, dbInstanceID str // StopDBInstance stops a running RDS instance. func (r *AwsRepository) StopDBInstance(ctx context.Context, dbInstanceID string) error { + uniclog.Info("aws", "StopDBInstance called", "instance", dbInstanceID) _, err := r.RDSClient.StopDBInstance(ctx, &rds.StopDBInstanceInput{ DBInstanceIdentifier: awssdk.String(dbInstanceID), }) @@ -81,6 +85,7 @@ func (r *AwsRepository) StopDBInstance(ctx context.Context, dbInstanceID string) // StartDBInstance starts a stopped RDS instance. func (r *AwsRepository) StartDBInstance(ctx context.Context, dbInstanceID string) error { + uniclog.Info("aws", "StartDBInstance called", "instance", dbInstanceID) _, err := r.RDSClient.StartDBInstance(ctx, &rds.StartDBInstanceInput{ DBInstanceIdentifier: awssdk.String(dbInstanceID), }) @@ -93,6 +98,7 @@ func (r *AwsRepository) StartDBInstance(ctx context.Context, dbInstanceID string // RebootDBInstance reboots an RDS instance. If forceFailover is true, // a Multi-AZ failover is triggered. func (r *AwsRepository) RebootDBInstance(ctx context.Context, dbInstanceID string, forceFailover bool) error { + uniclog.Info("aws", "RebootDBInstance called", "instance", dbInstanceID, "force_failover", forceFailover) _, err := r.RDSClient.RebootDBInstance(ctx, &rds.RebootDBInstanceInput{ DBInstanceIdentifier: awssdk.String(dbInstanceID), ForceFailover: awssdk.Bool(forceFailover), @@ -105,6 +111,7 @@ func (r *AwsRepository) RebootDBInstance(ctx context.Context, dbInstanceID strin // StopDBCluster stops an Aurora DB cluster. func (r *AwsRepository) StopDBCluster(ctx context.Context, clusterID string) error { + uniclog.Info("aws", "StopDBCluster called", "cluster", clusterID) _, err := r.RDSClient.StopDBCluster(ctx, &rds.StopDBClusterInput{ DBClusterIdentifier: awssdk.String(clusterID), }) @@ -116,6 +123,7 @@ func (r *AwsRepository) StopDBCluster(ctx context.Context, clusterID string) err // StartDBCluster starts a stopped Aurora DB cluster. func (r *AwsRepository) StartDBCluster(ctx context.Context, clusterID string) error { + uniclog.Info("aws", "StartDBCluster called", "cluster", clusterID) _, err := r.RDSClient.StartDBCluster(ctx, &rds.StartDBClusterInput{ DBClusterIdentifier: awssdk.String(clusterID), }) @@ -127,6 +135,7 @@ func (r *AwsRepository) StartDBCluster(ctx context.Context, clusterID string) er // FailoverDBCluster triggers a failover for an Aurora DB cluster. func (r *AwsRepository) FailoverDBCluster(ctx context.Context, clusterID string) error { + uniclog.Info("aws", "FailoverDBCluster called", "cluster", clusterID) _, err := r.RDSClient.FailoverDBCluster(ctx, &rds.FailoverDBClusterInput{ DBClusterIdentifier: awssdk.String(clusterID), }) diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 3055398..1a96409 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -16,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sts" "unic/internal/config" + uniclog "unic/internal/log" ) // Verify *ssm.Client satisfies SSMClientAPI at compile time. @@ -92,6 +93,8 @@ type AwsRepository struct { // NewAwsRepository creates a new AwsRepository with configured EC2 and SSM clients. func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, error) { + uniclog.Debug("aws", "creating repository", "auth_type", string(cfg.AuthType), "profile", cfg.Profile, "region", cfg.Region) + var awsCfg aws.Config var err error @@ -144,6 +147,8 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, } } + uniclog.Info("aws", "repository created", "region", cfg.Region, "profile", cfg.Profile) + return &AwsRepository{ EC2Client: ec2.NewFromConfig(awsCfg), SSMClient: ssm.NewFromConfig(awsCfg), @@ -158,10 +163,13 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, // GetCallerIdentity returns the AWS identity for this repository's credentials. func (r *AwsRepository) GetCallerIdentity(ctx context.Context) (*CallerIdentity, error) { + uniclog.Debug("aws", "calling GetCallerIdentity") out, err := r.STSClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { + uniclog.Error("aws", "GetCallerIdentity failed", "error", err.Error()) return nil, fmt.Errorf("GetCallerIdentity failed: %w", err) } + uniclog.Debug("aws", "GetCallerIdentity success", "account", aws.ToString(out.Account), "arn", aws.ToString(out.Arn)) return &CallerIdentity{ Account: aws.ToString(out.Account), Arn: aws.ToString(out.Arn), @@ -171,6 +179,7 @@ func (r *AwsRepository) GetCallerIdentity(ctx context.Context) (*CallerIdentity, // resolveAssumeRoleCredentials assumes a role and returns an aws.Config with temporary credentials. func resolveAssumeRoleCredentials(ctx context.Context, cfg *config.Config) (aws.Config, error) { + uniclog.Debug("aws", "resolving assume-role credentials", "role_arn", cfg.RoleArn) opts := []func(*awsconfig.LoadOptions) error{ awsconfig.WithRegion(cfg.Region), } diff --git a/internal/services/aws/route53.go b/internal/services/aws/route53.go index c26e025..ebdff16 100644 --- a/internal/services/aws/route53.go +++ b/internal/services/aws/route53.go @@ -7,10 +7,13 @@ import ( awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/route53" + + uniclog "unic/internal/log" ) // ListHostedZones returns all Route53 hosted zones in the current account. func (r *AwsRepository) ListHostedZones(ctx context.Context) ([]HostedZone, error) { + uniclog.Debug("aws", "ListHostedZones called") var zones []HostedZone var marker *string @@ -46,6 +49,7 @@ func (r *AwsRepository) ListHostedZones(ctx context.Context) ([]HostedZone, erro // ListResourceRecordSets returns all DNS records for a given hosted zone. func (r *AwsRepository) ListResourceRecordSets(ctx context.Context, zoneID string) ([]DNSRecord, error) { + uniclog.Debug("aws", "ListResourceRecordSets called", "zone_id", zoneID) var records []DNSRecord input := &route53.ListResourceRecordSetsInput{ HostedZoneId: awssdk.String(zoneID), diff --git a/internal/services/aws/secretsmanager.go b/internal/services/aws/secretsmanager.go index c33c0c5..4517c2e 100644 --- a/internal/services/aws/secretsmanager.go +++ b/internal/services/aws/secretsmanager.go @@ -7,10 +7,13 @@ import ( awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + + uniclog "unic/internal/log" ) // ListSecrets returns all secrets in the current account/region. func (r *AwsRepository) ListSecrets(ctx context.Context) ([]Secret, error) { + uniclog.Debug("aws", "ListSecrets called") output, err := r.SecretsManagerClient.ListSecrets(ctx, &secretsmanager.ListSecretsInput{}) if err != nil { return nil, fmt.Errorf("failed to list secrets: %w", err) @@ -30,6 +33,7 @@ func (r *AwsRepository) ListSecrets(ctx context.Context) ([]Secret, error) { // GetSecretDetail retrieves the full detail of a secret including its value. func (r *AwsRepository) GetSecretDetail(ctx context.Context, secretName string) (*SecretDetail, error) { + uniclog.Debug("aws", "GetSecretDetail called", "secret", secretName) output, err := r.SecretsManagerClient.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ SecretId: awssdk.String(secretName), }) diff --git a/internal/services/aws/ssm.go b/internal/services/aws/ssm.go index 6e26875..89ed687 100644 --- a/internal/services/aws/ssm.go +++ b/internal/services/aws/ssm.go @@ -5,11 +5,14 @@ import ( "fmt" "github.com/aws/aws-sdk-go-v2/service/ssm" + + uniclog "unic/internal/log" ) // StartSession initiates an SSM session to the given instance. // Returns the StartSessionOutput, the SSM endpoint URL, and any error. func (r *AwsRepository) StartSession(ctx context.Context, instanceID string) (*ssm.StartSessionOutput, string, error) { + uniclog.Info("aws", "StartSession called", "instance", instanceID) input := &ssm.StartSessionInput{ Target: &instanceID, } @@ -26,6 +29,7 @@ func (r *AwsRepository) StartSession(ctx context.Context, instanceID string) (*s // TerminateSession terminates an active SSM session. func (r *AwsRepository) TerminateSession(ctx context.Context, sessionID string) error { + uniclog.Debug("aws", "TerminateSession called", "session", sessionID) _, err := r.SSMClient.TerminateSession(ctx, &ssm.TerminateSessionInput{ SessionId: &sessionID, }) diff --git a/internal/services/aws/vpc.go b/internal/services/aws/vpc.go index e3f49e0..9d738f5 100644 --- a/internal/services/aws/vpc.go +++ b/internal/services/aws/vpc.go @@ -9,10 +9,13 @@ import ( awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" + + uniclog "unic/internal/log" ) // ListVPCs returns all VPCs in the current account/region. func (r *AwsRepository) ListVPCs(ctx context.Context) ([]VPC, error) { + uniclog.Debug("aws", "ListVPCs called") output, err := r.EC2Client.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{}) if err != nil { return nil, err @@ -32,6 +35,7 @@ func (r *AwsRepository) ListVPCs(ctx context.Context) ([]VPC, error) { // ListSubnets returns all subnets belonging to the given VPC. func (r *AwsRepository) ListSubnets(ctx context.Context, vpcID string) ([]Subnet, error) { + uniclog.Debug("aws", "ListSubnets called", "vpc_id", vpcID) input := &ec2.DescribeSubnetsInput{ Filters: []types.Filter{ { @@ -64,6 +68,7 @@ func (r *AwsRepository) ListSubnets(ctx context.Context, vpcID string) ([]Subnet // AWS reserves 5 IPs per subnet: .0 (network), .1 (router), .2 (DNS), // .3 (future use), .255 (broadcast) — these are always excluded. func (r *AwsRepository) ListAvailableIPs(ctx context.Context, subnetID, cidr string) ([]string, error) { + uniclog.Debug("aws", "ListAvailableIPs called", "subnet_id", subnetID, "cidr", cidr) // Parse CIDR to get all IPs in range allIPs, err := cidrUsableIPs(cidr) if err != nil {