Skip to content

Commit fa4b99a

Browse files
committed
STAC-23598: Introducing Dependency Injection to test cli commands
1 parent 64d2d47 commit fa4b99a

20 files changed

+410
-381
lines changed

ARCHITECTURE.md

Lines changed: 120 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ The codebase follows several key principles:
1616

1717
```
1818
stackstate-backup-cli/
19-
├── cmd/ # Command-line interface (Layer 3)
19+
├── cmd/ # Command-line interface (Layer 4)
2020
│ ├── root.go # Root command and global flags
2121
│ ├── version/ # Version information command
2222
│ ├── elasticsearch/ # Elasticsearch backup/restore commands
2323
│ └── stackgraph/ # Stackgraph backup/restore commands
2424
25-
├── internal/ # Internal packages (Layers 0-2)
25+
├── internal/ # Internal packages (Layers 0-3)
2626
│ ├── foundation/ # Layer 0: Core utilities
2727
│ │ ├── config/ # Configuration management
2828
│ │ ├── logger/ # Structured logging
@@ -37,6 +37,9 @@ stackstate-backup-cli/
3737
│ │ ├── portforward/ # Port-forwarding orchestration
3838
│ │ └── scale/ # Deployment scaling workflows
3939
│ │
40+
│ ├── app/ # Layer 3: Dependency Container
41+
│ │ └── app.go # Application context and dependency injection
42+
│ │
4043
│ └── scripts/ # Embedded bash scripts
4144
4245
├── main.go # Application entry point
@@ -46,24 +49,61 @@ stackstate-backup-cli/
4649

4750
## Architectural Layers
4851

49-
### Layer 3: Commands (`cmd/`)
52+
### Layer 4: Commands (`cmd/`)
5053

5154
**Purpose**: User-facing CLI commands and application entry points
5255

5356
**Characteristics**:
5457
- Implements the Cobra command structure
5558
- Handles user input validation and flag parsing
56-
- Orchestrates calls to lower layers
59+
- Delegates to orchestration and client layers via app context
60+
- Minimal business logic (thin command layer)
5761
- Formats output for end users
5862

5963
**Key Packages**:
6064
- `cmd/elasticsearch/`: Elasticsearch snapshot/restore commands (configure, list-snapshots, list-indices, restore-snapshot)
6165
- `cmd/stackgraph/`: Stackgraph backup/restore commands (list, restore)
6266
- `cmd/version/`: Version information
6367

68+
**Dependency Rules**:
69+
- ✅ Can import: `internal/app/*` (preferred), all other `internal/` packages
70+
- ❌ Should not: Create clients directly, contain business logic
71+
72+
### Layer 3: Dependency Container (`internal/app/`)
73+
74+
**Purpose**: Centralized dependency initialization and injection
75+
76+
**Characteristics**:
77+
- Creates and wires all application dependencies
78+
- Provides single entry point for dependency creation
79+
- Eliminates boilerplate from commands
80+
- Improves testability through centralized mocking
81+
82+
**Key Components**:
83+
- `Context`: Struct holding all dependencies (K8s client, S3 client, ES client, config, logger, formatter)
84+
- `NewContext()`: Factory function creating production dependencies from global flags
85+
86+
**Usage Pattern**:
87+
```go
88+
// In command files
89+
appCtx, err := app.NewContext(globalFlags)
90+
if err != nil {
91+
return err
92+
}
93+
94+
// All dependencies available via appCtx
95+
appCtx.K8sClient
96+
appCtx.S3Client
97+
appCtx.ESClient
98+
appCtx.Config
99+
appCtx.Logger
100+
appCtx.Formatter
101+
```
102+
64103
**Dependency Rules**:
65104
- ✅ Can import: All `internal/` packages
66-
- ❌ Should not: Contain business logic or direct service calls
105+
- ✅ Used by: `cmd/` layer only
106+
- ❌ Should not: Contain business logic or orchestration
67107

68108
### Layer 2: Orchestration (`internal/orchestration/`)
69109

@@ -130,31 +170,63 @@ stackstate-backup-cli/
130170
└─> cmd/elasticsearch/restore-snapshot.go
131171
132172
2. Parse flags and validate input
173+
└─> Cobra command receives global flags
133174
134-
3. Load configuration
135-
└─> internal/foundation/config/
136-
137-
4. Create clients
138-
└─> internal/clients/k8s/
139-
└─> internal/clients/elasticsearch/
175+
3. Create application context with dependencies
176+
└─> app.NewContext(globalFlags)
177+
├─> internal/clients/k8s/ (K8s client)
178+
├─> internal/foundation/config/ (Load from ConfigMap/Secret)
179+
├─> internal/clients/s3/ (S3/Minio client)
180+
├─> internal/clients/elasticsearch/ (ES client)
181+
├─> internal/foundation/logger/ (Logger)
182+
└─> internal/foundation/output/ (Formatter)
140183
141-
5. Execute orchestration workflow
142-
└─> internal/orchestration/scale/
143-
└─> Scale down deployments
144-
└─> internal/orchestration/portforward/
145-
└─> Setup port-forward to Elasticsearch
146-
└─> internal/clients/elasticsearch/
147-
└─> Perform snapshot restore
148-
└─> internal/orchestration/scale/
149-
└─> Scale up deployments
184+
4. Execute business logic with injected dependencies
185+
└─> runRestore(appCtx)
186+
├─> internal/orchestration/scale/ (Scale down)
187+
├─> internal/orchestration/portforward/ (Port-forward)
188+
├─> internal/clients/elasticsearch/ (Restore snapshot)
189+
└─> internal/orchestration/scale/ (Scale up)
150190
151-
6. Format and display results
152-
└─> internal/foundation/output/
191+
5. Format and display results
192+
└─> appCtx.Formatter.PrintTable() or PrintJSON()
153193
```
154194

155195
## Key Design Patterns
156196

157-
### 1. Configuration Precedence
197+
### 1. Dependency Injection Pattern
198+
199+
All dependencies are created once and injected via `app.Context`:
200+
201+
```go
202+
// Before (repeated in every command)
203+
func runList(globalFlags *config.CLIGlobalFlags) error {
204+
k8sClient, _ := k8s.NewClient(...)
205+
cfg, _ := config.LoadConfig(...)
206+
s3Client, _ := s3.NewClient(...)
207+
log := logger.New(...)
208+
formatter := output.NewFormatter(...)
209+
// ... use dependencies
210+
}
211+
212+
// After (centralized creation)
213+
func runList(appCtx *app.Context) error {
214+
// All dependencies available immediately
215+
appCtx.K8sClient
216+
appCtx.Config
217+
appCtx.S3Client
218+
appCtx.Logger
219+
appCtx.Formatter
220+
}
221+
```
222+
223+
**Benefits**:
224+
- Eliminates boilerplate from commands (30-50 lines per command)
225+
- Centralized dependency creation makes testing easier
226+
- Single source of truth for dependency wiring
227+
- Commands are thinner and more focused on business logic
228+
229+
### 2. Configuration Precedence
158230

159231
Configuration is loaded with the following precedence (highest to lowest):
160232

@@ -166,7 +238,7 @@ Configuration is loaded with the following precedence (highest to lowest):
166238

167239
Implementation: `internal/foundation/config/config.go`
168240

169-
### 2. Client Factory Pattern
241+
### 3. Client Factory Pattern
170242

171243
Clients are created with a consistent factory pattern:
172244

@@ -178,7 +250,7 @@ func NewClient(endpoint string) (*Client, error) {
178250
}
179251
```
180252

181-
### 3. Port-Forward Lifecycle
253+
### 4. Port-Forward Lifecycle
182254

183255
Services running in Kubernetes are accessed via automatic port-forwarding:
184256

@@ -188,7 +260,7 @@ pf, err := SetupPortForward(k8sClient, namespace, service, localPort, remotePort
188260
defer close(pf.StopChan) // Automatic cleanup
189261
```
190262

191-
### 4. Scale Down/Up Pattern
263+
### 5. Scale Down/Up Pattern
192264

193265
Deployments are scaled down before restore operations and scaled up afterward:
194266

@@ -198,7 +270,7 @@ scaledDeployments, _ := scale.ScaleDown(k8sClient, namespace, selector, log)
198270
defer scale.ScaleUp(k8sClient, namespace, scaledDeployments, log)
199271
```
200272

201-
### 5. Structured Logging
273+
### 6. Structured Logging
202274

203275
All operations use structured logging with consistent levels:
204276

@@ -292,6 +364,27 @@ endpoint := "http://localhost:9200"
292364

293365
**Fix**: Use configuration management: `config.Elasticsearch.Service.Name`
294366

367+
### ❌ Don't: Create Clients Directly in Commands
368+
369+
```go
370+
// BAD: cmd/elasticsearch/list-snapshots.go
371+
func runListSnapshots(globalFlags *config.CLIGlobalFlags) error {
372+
k8sClient, _ := k8s.NewClient(globalFlags.Kubeconfig, globalFlags.Debug)
373+
esClient, _ := elasticsearch.NewClient("http://localhost:9200")
374+
// ... use clients
375+
}
376+
```
377+
378+
**Fix**: Use `app.Context` for dependency injection:
379+
```go
380+
// GOOD
381+
func runListSnapshots(appCtx *app.Context) error {
382+
// Dependencies already created
383+
appCtx.K8sClient
384+
appCtx.ESClient
385+
}
386+
```
387+
295388
## Automated Enforcement
296389

297390
Verify architectural rules with these commands:

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ See [internal/foundation/config/testdata/validConfigMapConfig.yaml](internal/fou
186186

187187
```
188188
.
189-
├── cmd/ # CLI commands
189+
├── cmd/ # CLI commands (Layer 4)
190190
│ ├── root.go # Root command and global flags
191191
│ ├── version/ # Version command
192192
│ ├── elasticsearch/ # Elasticsearch subcommands
@@ -197,7 +197,7 @@ See [internal/foundation/config/testdata/validConfigMapConfig.yaml](internal/fou
197197
│ └── stackgraph/ # Stackgraph subcommands
198198
│ ├── list.go # List backups
199199
│ └── restore.go # Restore backup
200-
├── internal/ # Internal packages (layered architecture)
200+
├── internal/ # Internal packages (Layers 0-3)
201201
│ ├── foundation/ # Layer 0: Core utilities
202202
│ │ ├── config/ # Configuration management
203203
│ │ ├── logger/ # Structured logging
@@ -209,11 +209,20 @@ See [internal/foundation/config/testdata/validConfigMapConfig.yaml](internal/fou
209209
│ ├── orchestration/ # Layer 2: Workflows
210210
│ │ ├── portforward/ # Port-forwarding lifecycle
211211
│ │ └── scale/ # Deployment scaling
212+
│ ├── app/ # Layer 3: Dependency container
213+
│ │ └── app.go # Application context and DI
212214
│ └── scripts/ # Embedded bash scripts
213215
├── main.go # Entry point
214216
└── ARCHITECTURE.md # Detailed architecture documentation
215217
```
216218

219+
### Key Architectural Features
220+
221+
- **Layered Architecture**: Clear separation between commands (Layer 4), dependency injection (Layer 3), workflows (Layer 2), clients (Layer 1), and utilities (Layer 0)
222+
- **Dependency Injection**: Centralized dependency creation via `internal/app/` eliminates boilerplate from commands
223+
- **Testability**: All layers use interfaces for external dependencies, enabling comprehensive unit testing
224+
- **Clean Commands**: Commands are thin (50-100 lines) and focused on business logic
225+
217226
See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed information about the layered architecture and design patterns.
218227

219228
## CI/CD

cmd/elasticsearch/configure.go

Lines changed: 24 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,70 +5,52 @@ import (
55
"os"
66

77
"github.com/spf13/cobra"
8-
"github.com/stackvista/stackstate-backup-cli/internal/clients/elasticsearch"
9-
"github.com/stackvista/stackstate-backup-cli/internal/clients/k8s"
8+
"github.com/stackvista/stackstate-backup-cli/internal/app"
109
"github.com/stackvista/stackstate-backup-cli/internal/foundation/config"
11-
"github.com/stackvista/stackstate-backup-cli/internal/foundation/logger"
1210
"github.com/stackvista/stackstate-backup-cli/internal/orchestration/portforward"
1311
)
1412

15-
func configureCmd(cliCtx *config.Context) *cobra.Command {
13+
func configureCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
1614
return &cobra.Command{
1715
Use: "configure",
1816
Short: "Configure Elasticsearch snapshot repository and SLM policy",
1917
Long: `Configure Elasticsearch snapshot repository and Snapshot Lifecycle Management (SLM) policy for automated backups.`,
2018
Run: func(_ *cobra.Command, _ []string) {
21-
if err := runConfigure(cliCtx); err != nil {
19+
appCtx, err := app.NewContext(globalFlags)
20+
if err != nil {
21+
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
22+
os.Exit(1)
23+
}
24+
if err := runConfigure(appCtx); err != nil {
2225
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
2326
os.Exit(1)
2427
}
2528
},
2629
}
2730
}
2831

29-
func runConfigure(cliCtx *config.Context) error {
30-
// Create logger
31-
log := logger.New(cliCtx.Config.Quiet, cliCtx.Config.Debug)
32-
33-
// Create Kubernetes client
34-
k8sClient, err := k8s.NewClient(cliCtx.Config.Kubeconfig, cliCtx.Config.Debug)
35-
if err != nil {
36-
return fmt.Errorf("failed to create Kubernetes client: %w", err)
37-
}
38-
39-
// Load configuration
40-
cfg, err := config.LoadConfig(k8sClient.Clientset(), cliCtx.Config.Namespace, cliCtx.Config.ConfigMapName, cliCtx.Config.SecretName)
41-
if err != nil {
42-
return fmt.Errorf("failed to load configuration: %w", err)
43-
}
44-
32+
func runConfigure(appCtx *app.Context) error {
4533
// Validate required configuration
46-
if cfg.Elasticsearch.SnapshotRepository.AccessKey == "" || cfg.Elasticsearch.SnapshotRepository.SecretKey == "" {
34+
if appCtx.Config.Elasticsearch.SnapshotRepository.AccessKey == "" || appCtx.Config.Elasticsearch.SnapshotRepository.SecretKey == "" {
4735
return fmt.Errorf("accessKey and secretKey are required in the secret configuration")
4836
}
4937

5038
// Setup port-forward to Elasticsearch
51-
serviceName := cfg.Elasticsearch.Service.Name
52-
localPort := cfg.Elasticsearch.Service.LocalPortForwardPort
53-
remotePort := cfg.Elasticsearch.Service.Port
39+
serviceName := appCtx.Config.Elasticsearch.Service.Name
40+
localPort := appCtx.Config.Elasticsearch.Service.LocalPortForwardPort
41+
remotePort := appCtx.Config.Elasticsearch.Service.Port
5442

55-
pf, err := portforward.SetupPortForward(k8sClient, cliCtx.Config.Namespace, serviceName, localPort, remotePort, log)
43+
pf, err := portforward.SetupPortForward(appCtx.K8sClient, appCtx.Namespace, serviceName, localPort, remotePort, appCtx.Logger)
5644
if err != nil {
5745
return err
5846
}
5947
defer close(pf.StopChan)
6048

61-
// Create Elasticsearch client
62-
esClient, err := elasticsearch.NewClient(fmt.Sprintf("http://localhost:%d", pf.LocalPort))
63-
if err != nil {
64-
return fmt.Errorf("failed to create Elasticsearch client: %w", err)
65-
}
66-
6749
// Configure snapshot repository
68-
repo := cfg.Elasticsearch.SnapshotRepository
69-
log.Infof("Configuring snapshot repository '%s' (bucket: %s)...", repo.Name, repo.Bucket)
50+
repo := appCtx.Config.Elasticsearch.SnapshotRepository
51+
appCtx.Logger.Infof("Configuring snapshot repository '%s' (bucket: %s)...", repo.Name, repo.Bucket)
7052

71-
err = esClient.ConfigureSnapshotRepository(
53+
err = appCtx.ESClient.ConfigureSnapshotRepository(
7254
repo.Name,
7355
repo.Bucket,
7456
repo.Endpoint,
@@ -80,13 +62,13 @@ func runConfigure(cliCtx *config.Context) error {
8062
return fmt.Errorf("failed to configure snapshot repository: %w", err)
8163
}
8264

83-
log.Successf("Snapshot repository configured successfully")
65+
appCtx.Logger.Successf("Snapshot repository configured successfully")
8466

8567
// Configure SLM policy
86-
slm := cfg.Elasticsearch.SLM
87-
log.Infof("Configuring SLM policy '%s'...", slm.Name)
68+
slm := appCtx.Config.Elasticsearch.SLM
69+
appCtx.Logger.Infof("Configuring SLM policy '%s'...", slm.Name)
8870

89-
err = esClient.ConfigureSLMPolicy(
71+
err = appCtx.ESClient.ConfigureSLMPolicy(
9072
slm.Name,
9173
slm.Schedule,
9274
slm.SnapshotTemplateName,
@@ -100,9 +82,9 @@ func runConfigure(cliCtx *config.Context) error {
10082
return fmt.Errorf("failed to configure SLM policy: %w", err)
10183
}
10284

103-
log.Successf("SLM policy configured successfully")
104-
log.Println()
105-
log.Successf("Configuration completed successfully")
85+
appCtx.Logger.Successf("SLM policy configured successfully")
86+
appCtx.Logger.Println()
87+
appCtx.Logger.Successf("Configuration completed successfully")
10688

10789
return nil
10890
}

0 commit comments

Comments
 (0)