-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathexport.go
More file actions
356 lines (312 loc) · 11.2 KB
/
export.go
File metadata and controls
356 lines (312 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
package devnet
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/altuslabsxyz/devnet-builder/internal/application/dto"
"github.com/altuslabsxyz/devnet-builder/internal/application/ports"
domainExport "github.com/altuslabsxyz/devnet-builder/internal/domain/export"
infraExport "github.com/altuslabsxyz/devnet-builder/internal/infrastructure/export"
infraprocess "github.com/altuslabsxyz/devnet-builder/internal/infrastructure/process"
"github.com/altuslabsxyz/devnet-builder/types"
)
// ExportUseCase handles blockchain state export operations.
// It follows Clean Architecture principles by depending on abstractions (ports)
// rather than concrete implementations.
type ExportUseCase struct {
devnetRepo ports.DevnetRepository
nodeRepo ports.NodeRepository
exportRepo ports.ExportRepository
nodeLifecycle ports.NodeLifecycleManager // Injected for stop-export-start workflow
hashCalc *infraExport.HashCalculator
heightResolver *infraExport.HeightResolver
exportExec *infraExport.ExportExecutor
processExec ports.ProcessExecutor
logger ports.Logger
}
// NewExportUseCase creates a new ExportUseCase.
// nodeLifecycle is injected for managing node state during export (stop-export-start workflow).
// This follows Dependency Inversion Principle (DIP).
func NewExportUseCase(
ctx context.Context,
devnetRepo ports.DevnetRepository,
nodeRepo ports.NodeRepository,
exportRepo ports.ExportRepository,
nodeLifecycle ports.NodeLifecycleManager,
logger ports.Logger,
) *ExportUseCase {
processExec := infraprocess.NewLocalExecutor()
return &ExportUseCase{
devnetRepo: devnetRepo,
nodeRepo: nodeRepo,
exportRepo: exportRepo,
nodeLifecycle: nodeLifecycle,
hashCalc: infraExport.NewHashCalculator(),
heightResolver: infraExport.NewHeightResolver(),
exportExec: infraExport.NewExportExecutor(),
processExec: processExec,
logger: logger,
}
}
// Execute performs a blockchain state export at the current height.
// It implements a stop-export-start workflow to avoid database lock issues:
// 1. Get block height while nodes are running
// 2. Stop all nodes (releases database lock)
// 3. Execute export (database is now accessible)
// 4. Restart nodes (if they were running before)
func (uc *ExportUseCase) Execute(ctx context.Context, input dto.ExportInput) (*dto.ExportOutput, error) {
const stopTimeout = 30 * time.Second
const startTimeout = 5 * time.Minute
// Step 1: Load devnet metadata
uc.logger.Info("Loading devnet configuration...")
devnet, err := uc.devnetRepo.Load(ctx, input.HomeDir)
if err != nil {
return nil, fmt.Errorf("failed to load devnet: %w", err)
}
// Step 2: Check if devnet is running and find an active node
nodes, err := uc.nodeRepo.LoadAll(ctx, input.HomeDir)
if err != nil {
return nil, fmt.Errorf("failed to load nodes: %w", err)
}
wasRunning := false
var rpcURL string
var activeNode *ports.NodeMetadata
for _, node := range nodes {
if node.PID != nil && *node.PID > 0 {
wasRunning = true
rpcURL = fmt.Sprintf("http://localhost:%d", node.Ports.RPC)
activeNode = node
break
}
}
// Step 3: Get current block height (MUST do this while nodes are running)
uc.logger.Info("Querying current block height...")
var blockHeight int64
if wasRunning && rpcURL != "" && activeNode != nil {
blockHeight, err = uc.heightResolver.GetCurrentHeight(ctx, rpcURL)
if err != nil {
return nil, fmt.Errorf("failed to get block height: %w", err)
}
uc.logger.Info("Current block height: %d", blockHeight)
} else {
return nil, fmt.Errorf("devnet is not running; cannot determine block height")
}
// Step 4: Stop all nodes to release database lock
// This is critical - the database is locked while nodes are running
if wasRunning {
uc.logger.Info("Stopping nodes for export (releasing database lock)...")
stoppedCount, err := uc.nodeLifecycle.StopAll(ctx, input.HomeDir, stopTimeout)
if err != nil {
return nil, fmt.Errorf("failed to stop nodes for export: %w", err)
}
uc.logger.Info("Stopped %d node(s)", stoppedCount)
// Ensure nodes are restarted even if export fails (using defer)
defer func() {
if wasRunning {
uc.logger.Info("Restarting nodes after export...")
startedCount, allRunning, restartErr := uc.nodeLifecycle.StartAll(ctx, input.HomeDir, startTimeout)
if restartErr != nil {
uc.logger.Warn("Failed to restart nodes: %v", restartErr)
uc.logger.Warn("You may need to manually run 'devnet-builder start'")
} else if !allRunning {
uc.logger.Warn("Some nodes failed to start (%d started)", startedCount)
} else {
uc.logger.Info("Restarted %d node(s) successfully", startedCount)
}
}
}()
// Brief pause to ensure database lock is fully released
time.Sleep(2 * time.Second)
}
// Step 4: Determine binary path and calculate hash
binaryPath := devnet.CustomBinaryPath
if binaryPath == "" && devnet.ExecutionMode == types.ExecutionModeLocal {
// Use symlinked binary from cache
cachePath := filepath.Join(os.Getenv("HOME"), ".stable-devnet", "cache", "binaries", devnet.BinaryName)
if _, err := os.Stat(cachePath); err == nil {
binaryPath = cachePath
}
}
if binaryPath == "" {
return nil, fmt.Errorf("cannot determine binary path for export")
}
uc.logger.Info("Calculating binary hash...")
binaryHash, err := uc.hashCalc.CalculateHash(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to calculate binary hash: %w", err)
}
// Step 5: Get binary version
binaryVersion, err := uc.exportExec.GetBinaryVersion(ctx, binaryPath)
if err != nil {
uc.logger.Debug("Failed to get binary version: %v", err)
binaryVersion = devnet.CurrentVersion
}
// Step 6: Create export entities
timestamp := time.Now()
binaryInfo, err := domainExport.NewBinaryInfo(
binaryPath,
"", // Docker image (empty for local mode)
binaryHash,
binaryVersion,
types.ExecutionModeLocal,
)
if err != nil {
return nil, fmt.Errorf("failed to create binary info: %w", err)
}
metadata, err := domainExport.NewExportMetadata(
timestamp,
blockHeight,
devnet.NetworkName,
blockHeight, // Fork height same as export height
binaryPath,
binaryHash,
binaryVersion,
devnet.DockerImage,
devnet.ChainID,
devnet.NumValidators,
devnet.NumAccounts,
string(devnet.ExecutionMode),
devnet.HomeDir,
)
if err != nil {
return nil, fmt.Errorf("failed to create metadata: %w", err)
}
// Step 7: Determine output directory
outputDir := input.OutputDir
if outputDir == "" {
outputDir = filepath.Join(input.HomeDir, "exports")
}
// Generate directory and file names using utility functions
exportDirName := domainExport.GenerateDirectoryName(devnet.NetworkName, binaryInfo.GetIdentifier(), blockHeight, timestamp)
exportPath := filepath.Join(outputDir, exportDirName)
// Check if export directory already exists
if _, err := os.Stat(exportPath); err == nil && !input.Force {
return nil, fmt.Errorf("export directory already exists: %s (use --force to overwrite)", exportPath)
}
// Step 8: Create export directory
uc.logger.Info("Creating export directory: %s", exportPath)
if err := os.MkdirAll(exportPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create export directory: %w", err)
}
// Step 9: Execute export command
genesisFileName := domainExport.GenerateGenesisFileName(blockHeight, binaryInfo.GetIdentifier())
genesisPath := filepath.Join(exportPath, genesisFileName)
uc.logger.Info("Exporting state at height %d (this may take a few minutes)...", blockHeight)
// Use the active node's home directory (contains config/genesis.json and data/)
_, err = uc.exportExec.ExportAtHeight(ctx, binaryPath, activeNode.HomeDir, blockHeight, genesisPath)
if err != nil {
// Clean up failed export
os.RemoveAll(exportPath)
return nil, fmt.Errorf("export failed: %w", err)
}
// Step 10: Create final export entity with correct paths
export, err := domainExport.NewExport(
exportPath,
timestamp,
blockHeight,
devnet.NetworkName,
binaryInfo,
metadata,
genesisPath,
)
if err != nil {
return nil, fmt.Errorf("failed to create export: %w", err)
}
// Step 11: Save export metadata
uc.logger.Info("Saving export metadata...")
if err := uc.exportRepo.Save(ctx, export); err != nil {
return nil, fmt.Errorf("failed to save export metadata: %w", err)
}
// Step 12: Build output
output := &dto.ExportOutput{
ExportPath: exportPath,
BlockHeight: blockHeight,
GenesisPath: genesisPath,
MetadataPath: export.GetMetadataPath(),
WasRunning: wasRunning,
Warnings: []string{},
}
uc.logger.Success("Export completed successfully!")
uc.logger.Info(" Export directory: %s", exportPath)
uc.logger.Info(" Block height: %d", blockHeight)
uc.logger.Info(" Genesis file: %s", genesisPath)
uc.logger.Info(" Metadata file: %s", output.MetadataPath)
return output, nil
}
// List returns all exports for a devnet.
func (uc *ExportUseCase) List(ctx context.Context, homeDir string) (*dto.ExportListOutput, error) {
// Load all exports
exports, err := uc.exportRepo.ListForDevnet(ctx, homeDir)
if err != nil {
return nil, fmt.Errorf("failed to list exports: %w", err)
}
// Build summaries
summaries := make([]*dto.ExportSummary, 0, len(exports))
var totalSize int64
for _, exp := range exports {
// Calculate directory size
size, err := calculateDirectorySize(exp.DirectoryPath)
if err != nil {
uc.logger.Debug("Failed to calculate size for %s: %v", exp.DirectoryPath, err)
size = 0
}
totalSize += size
summaries = append(summaries, &dto.ExportSummary{
DirectoryName: filepath.Base(exp.DirectoryPath),
DirectoryPath: exp.DirectoryPath,
BlockHeight: exp.BlockHeight,
Timestamp: exp.ExportTimestamp,
BinaryVersion: exp.BinaryInfo.Version,
NetworkSource: exp.NetworkSource,
SizeBytes: size,
})
}
return &dto.ExportListOutput{
Exports: summaries,
TotalCount: len(summaries),
TotalSize: totalSize,
}, nil
}
// Inspect returns detailed information about a specific export.
func (uc *ExportUseCase) Inspect(ctx context.Context, exportPath string) (*dto.ExportInspectOutput, error) {
// Validate export
result, err := uc.exportRepo.Validate(ctx, exportPath)
if err != nil && err != domainExport.ErrExportIncomplete {
return nil, fmt.Errorf("failed to validate export: %w", err)
}
// Calculate directory size
size, _ := calculateDirectorySize(exportPath)
// Calculate genesis file checksum if the file exists
var genesisChecksum string
if result.Export.GenesisFilePath != "" {
checksum, err := uc.hashCalc.CalculateHash(result.Export.GenesisFilePath)
if err == nil {
genesisChecksum = checksum
}
// If file doesn't exist or can't be read, leave checksum empty
}
output := &dto.ExportInspectOutput{
Metadata: result.Export.Metadata,
GenesisChecksum: genesisChecksum,
IsComplete: result.IsComplete,
MissingFiles: result.MissingFiles,
SizeBytes: size,
}
return output, nil
}
// calculateDirectorySize returns the total size of a directory in bytes.
func calculateDirectorySize(path string) (int64, error) {
var size int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
size += info.Size()
}
return nil
})
return size, err
}