14 Commits

Author SHA1 Message Date
058aff4019 Добавлена возможность асинхронного создания снапшотов
А также обновлены зависимости
2025-11-21 14:46:16 +03:00
6845219e94 Попытка исправления загрузки локального блоба 2025-10-30 02:10:01 +03:00
99764eb91f Исправление "двоения" ErrNotFound x2 2025-10-30 01:54:30 +03:00
644a94656a Исправление "двоения" ErrNotFound 2025-10-30 01:50:32 +03:00
cd98d1f4a2 Добавлена возможность зарегистрировать локальный снапшот 2025-10-30 01:36:21 +03:00
f34539c06b Добавлена локальная загрузка снапшота 2025-10-30 00:20:28 +03:00
5192658607 Refactor snapshot handling: improve directory creation, parent snapshot cleanup, and diff application logic. Update integration test to cover full and incremental update cycles. 2025-07-14 00:57:42 +03:00
7b670947c3 Add GetCurrentSnapshotID method to retrieve the current snapshot ID 2025-07-13 23:19:41 +03:00
aaf227f25a Remove obsolete gRPC client/server implementations and migrate to remote package 2025-07-13 23:12:39 +03:00
efa2bec38b Add RegisterLocalSnapshot and GetRemoteSnapshotMetadata methods
Introduce methods to register local snapshots from archives and to download or restore snapshot metadata, improving snapshot management capabilities. Update README with usage examples.
2025-07-10 12:49:05 +03:00
8fe593bb6f Refactor snapshot parent updates: replace full metadata reload with UpdateSnapshotParentID method, enhance functional test logging, and add CleanOnRestore option. 2025-07-07 21:04:34 +03:00
223a63ee6d Refactor snapshot management: integrate logging, enhance concurrency with mutex, add clean extraction option, and update gRPC ListSnapshots with ListOptions. 2025-07-07 20:03:40 +03:00
19c02d3573 Quick 2025-05-28 15:38:18 +03:00
1f837afadc Refactor OpenFunc call to use deferred execution 2025-05-28 13:19:57 +03:00
28 changed files with 2170 additions and 2362 deletions

View File

@@ -15,37 +15,33 @@ gen-proto:
--grpc-gateway_out=grpc --grpc-gateway_opt paths=source_relative \ --grpc-gateway_out=grpc --grpc-gateway_opt paths=source_relative \
./grpc/snapshot.proto ./grpc/snapshot.proto
# Запуск всех тестов # Run all tests
test: test:
go test -v ./... go test -v ./...
# Запуск модульных тестов # Run unit tests
test-unit: test-unit:
go test -v ./store/... ./hash/... ./archive/... go test -v ./store/... ./hash/... ./archive/...
# Запуск интеграционных тестов # Run integration tests
test-integration: test-integration:
go test -v -tags=integration ./... go test -v -tags=integration ./...
# Запуск функциональных тестов # Run functional tests
test-functional: test-functional:
go test -v -run TestFull ./... go test -v -run TestFull ./...
# Запуск тестов производительности # Run performance tests
test-performance: test-performance:
go test -v -run TestPerformanceMetrics ./... go test -v -run TestPerformanceMetrics ./...
go test -v -bench=. ./... go test -v -bench=. ./...
# Запуск тестов с покрытием кода # Run tests with code coverage
test-coverage: test-coverage:
go test -v -coverprofile=coverage.out ./... go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html go tool cover -html=coverage.out -o coverage.html
# Запуск линтера # Run all checks (tests + linter)
lint:
golangci-lint run
# Запуск всех проверок (тесты + линтер)
check: test lint check: test lint
.PHONY: download-third-party gen-proto test test-unit test-integration test-functional test-performance test-coverage lint check .PHONY: download-third-party gen-proto test test-unit test-integration test-functional test-performance test-coverage lint check

View File

@@ -223,6 +223,41 @@ func main() {
## Advanced Usage ## Advanced Usage
### Registering a Local Snapshot
You can register a local snapshot from an existing archive file with a specified UUID:
```go
// Register a local snapshot from an archive file
archivePath := "/path/to/your/archive.zip"
snapshotID := "custom-uuid-for-snapshot"
snapshotName := "My Local Snapshot"
if err := ag.RegisterLocalSnapshot(ctx, archivePath, snapshotID, snapshotName); err != nil {
log.Fatalf("Failed to register local snapshot: %v", err)
}
```
### Downloading Only Snapshot Metadata
You can download only the metadata of a snapshot from a remote server without downloading the actual files:
```go
// Download only the metadata of a snapshot from a remote server
remoteAddress := "remote-server:50051"
snapshotID := "snapshot-id-to-download"
if err := ag.GetRemoteSnapshotMetadata(ctx, remoteAddress, snapshotID); err != nil {
log.Fatalf("Failed to download snapshot metadata: %v", err)
}
// If you have a local blob but missing metadata, you can restore the metadata
// by passing an empty address
if err := ag.GetRemoteSnapshotMetadata(ctx, "", snapshotID); err != nil {
log.Fatalf("Failed to restore snapshot metadata: %v", err)
}
```
### Creating Incremental Snapshots ### Creating Incremental Snapshots
You can create incremental snapshots by specifying a parent snapshot ID: You can create incremental snapshots by specifying a parent snapshot ID:
@@ -294,6 +329,8 @@ The main entry point for the library.
- `ConnectRemote(address string) (*grpc.SnapshotClient, error)` - Connect to a remote server - `ConnectRemote(address string) (*grpc.SnapshotClient, error)` - Connect to a remote server
- `GetRemoteSnapshotList(ctx context.Context, address string) ([]store.SnapshotInfo, error)` - List snapshots from a remote server - `GetRemoteSnapshotList(ctx context.Context, address string) ([]store.SnapshotInfo, error)` - List snapshots from a remote server
- `GetRemoteSnapshot(ctx context.Context, address string, snapshotID string, localParentID string) error` - Download a snapshot from a remote server - `GetRemoteSnapshot(ctx context.Context, address string, snapshotID string, localParentID string) error` - Download a snapshot from a remote server
- `RegisterLocalSnapshot(ctx context.Context, archivePath string, snapshotID string, name string) error` - Register a local snapshot from an archive path with a specified UUID
- `GetRemoteSnapshotMetadata(ctx context.Context, address string, snapshotID string) error` - Download only the metadata of a snapshot from a remote server
### AgateOptions ### AgateOptions
@@ -304,7 +341,3 @@ Configuration options for the Agate library.
- `CloseFunc func() error` - Called before a snapshot is created or restored - `CloseFunc func() error` - Called before a snapshot is created or restored
- `MetadataStore store.MetadataStore` - Implementation of the metadata store - `MetadataStore store.MetadataStore` - Implementation of the metadata store
- `BlobStore store.BlobStore` - Implementation of the blob store - `BlobStore store.BlobStore` - Implementation of the blob store
## License
[Add your license information here]

367
api.go
View File

@@ -4,9 +4,17 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"gitea.unprism.ru/KRBL/Agate/grpc" "io"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time"
"gitea.unprism.ru/KRBL/Agate/archive"
"gitea.unprism.ru/KRBL/Agate/interfaces"
"gitea.unprism.ru/KRBL/Agate/models"
"gitea.unprism.ru/KRBL/Agate/remote"
"gitea.unprism.ru/KRBL/Agate/store" "gitea.unprism.ru/KRBL/Agate/store"
"gitea.unprism.ru/KRBL/Agate/stores" "gitea.unprism.ru/KRBL/Agate/stores"
@@ -35,11 +43,18 @@ type AgateOptions struct {
// Use the stores package to initialize a custom implementation: // Use the stores package to initialize a custom implementation:
// blobStore, err := stores.NewDefaultBlobStore(blobsDir) // blobStore, err := stores.NewDefaultBlobStore(blobsDir)
BlobStore store.BlobStore BlobStore store.BlobStore
// CleanOnRestore specifies whether the target directory should be cleaned before restoring a snapshot.
CleanOnRestore bool
// Logger is the logger to use for output. If nil, logging is disabled.
Logger *log.Logger
} }
// Agate is the main entry point for the snapshot library. // Agate is the main entry point for the snapshot library.
type Agate struct { type Agate struct {
manager SnapshotManager mutex sync.Mutex
manager interfaces.SnapshotManager
options AgateOptions options AgateOptions
metadataDir string metadataDir string
blobsDir string blobsDir string
@@ -53,6 +68,11 @@ func New(options AgateOptions) (*Agate, error) {
return nil, errors.New("work directory cannot be empty") return nil, errors.New("work directory cannot be empty")
} }
// Initialize logger if not provided
if options.Logger == nil {
options.Logger = log.New(io.Discard, "", 0)
}
// Create the work directory if it doesn't exist // Create the work directory if it doesn't exist
if err := os.MkdirAll(options.WorkDir, 0755); err != nil { if err := os.MkdirAll(options.WorkDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create work directory: %w", err) return nil, fmt.Errorf("failed to create work directory: %w", err)
@@ -109,7 +129,7 @@ func New(options AgateOptions) (*Agate, error) {
} }
// Create the snapshot manager // Create the snapshot manager
manager, err := CreateSnapshotManager(metadataStore, blobStore) manager, err := CreateSnapshotManager(metadataStore, blobStore, options.Logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create snapshot manager: %w", err) return nil, fmt.Errorf("failed to create snapshot manager: %w", err)
} }
@@ -143,11 +163,28 @@ func New(options AgateOptions) (*Agate, error) {
return agate, nil return agate, nil
} }
func (a *Agate) GetActiveDir() string {
return a.options.BlobStore.GetActiveDir()
}
func (a *Agate) GetMetadataDir() string {
return a.metadataDir
}
func (a *Agate) GetBlobsDir() string {
return a.blobsDir
}
// SaveSnapshot creates a new snapshot from the current state of the active directory. // SaveSnapshot creates a new snapshot from the current state of the active directory.
// If parentID is provided, it will be set as the parent of the new snapshot. // If parentID is provided, it will be set as the parent of the new snapshot.
// If parentID is empty, it will use the ID of the snapshot currently loaded in the active directory. // If parentID is empty, it will use the ID of the snapshot currently loaded in the active directory.
// Returns the ID of the created snapshot. // Returns the ID of the created snapshot.
func (a *Agate) SaveSnapshot(ctx context.Context, name string, parentID string) (string, error) { func (a *Agate) SaveSnapshot(ctx context.Context, name string, parentID string) (string, error) {
a.mutex.Lock()
defer a.mutex.Unlock()
a.options.Logger.Printf("Creating new snapshot with name: %s", name)
// Call CloseFunc if provided // Call CloseFunc if provided
if a.options.CloseFunc != nil { if a.options.CloseFunc != nil {
if err := a.options.CloseFunc(); err != nil { if err := a.options.CloseFunc(); err != nil {
@@ -158,7 +195,7 @@ func (a *Agate) SaveSnapshot(ctx context.Context, name string, parentID string)
defer func() { defer func() {
if a.options.OpenFunc != nil { if a.options.OpenFunc != nil {
if err := a.options.OpenFunc(a.options.BlobStore.GetActiveDir()); err != nil { if err := a.options.OpenFunc(a.options.BlobStore.GetActiveDir()); err != nil {
fmt.Printf("Failed to open resources after snapshot: %v\n", err) a.options.Logger.Printf("ERROR: failed to open resources after snapshot creation: %v", err)
} }
} }
}() }()
@@ -173,9 +210,12 @@ func (a *Agate) SaveSnapshot(ctx context.Context, name string, parentID string)
// Create the snapshot // Create the snapshot
snapshot, err := a.manager.CreateSnapshot(ctx, a.options.BlobStore.GetActiveDir(), name, effectiveParentID) snapshot, err := a.manager.CreateSnapshot(ctx, a.options.BlobStore.GetActiveDir(), name, effectiveParentID)
if err != nil { if err != nil {
a.options.Logger.Printf("ERROR: failed to create snapshot: %v", err)
return "", fmt.Errorf("failed to create snapshot: %w", err) return "", fmt.Errorf("failed to create snapshot: %w", err)
} }
a.options.Logger.Printf("Successfully created snapshot with ID: %s", snapshot.ID)
// Update the current snapshot ID to the newly created snapshot // Update the current snapshot ID to the newly created snapshot
a.currentSnapshotID = snapshot.ID a.currentSnapshotID = snapshot.ID
@@ -187,8 +227,63 @@ func (a *Agate) SaveSnapshot(ctx context.Context, name string, parentID string)
return snapshot.ID, nil return snapshot.ID, nil
} }
// SnapshotAsync creates a new snapshot asynchronously.
// It returns the job ID (which is also the snapshot ID) immediately.
// The actual snapshot creation happens in a background goroutine.
// Use GetSnapshotStatus to check the progress.
func (a *Agate) SnapshotAsync(ctx context.Context, name string, parentID string) (string, error) {
a.options.Logger.Printf("Starting async snapshot creation with name: %s", name)
// If parentID is not provided, use the current snapshot ID
if parentID == "" {
parentID = a.currentSnapshotID
}
return a.manager.CreateSnapshotAsync(ctx, a.options.BlobStore.GetActiveDir(), name, parentID,
func() {
// onStart: Lock mutex and close resources
a.mutex.Lock()
if a.options.CloseFunc != nil {
if err := a.options.CloseFunc(); err != nil {
a.options.Logger.Printf("ERROR: failed to close resources before async snapshot: %v", err)
}
}
},
func(id string, err error) {
// onFinish: Open resources, update state, and unlock mutex
defer a.mutex.Unlock()
if a.options.OpenFunc != nil {
if err := a.options.OpenFunc(a.options.BlobStore.GetActiveDir()); err != nil {
a.options.Logger.Printf("ERROR: failed to open resources after async snapshot: %v", err)
}
}
if err == nil {
a.currentSnapshotID = id
if err := a.saveCurrentSnapshotID(); err != nil {
a.options.Logger.Printf("ERROR: failed to save current snapshot ID: %v", err)
}
a.options.Logger.Printf("Async snapshot %s created successfully", id)
} else {
a.options.Logger.Printf("Async snapshot creation failed: %v", err)
}
},
)
}
// GetSnapshotStatus returns the status of an asynchronous snapshot creation job.
func (a *Agate) GetSnapshotStatus(ctx context.Context, jobID string) (*store.SnapshotStatus, error) {
return a.manager.GetSnapshotStatus(ctx, jobID)
}
// RestoreSnapshot extracts a snapshot to the active directory. // RestoreSnapshot extracts a snapshot to the active directory.
func (a *Agate) RestoreSnapshot(ctx context.Context, snapshotID string) error { func (a *Agate) RestoreSnapshot(ctx context.Context, snapshotID string) error {
a.mutex.Lock()
defer a.mutex.Unlock()
a.options.Logger.Printf("Restoring snapshot with ID: %s", snapshotID)
// Call CloseFunc if provided // Call CloseFunc if provided
if a.options.CloseFunc != nil { if a.options.CloseFunc != nil {
if err := a.options.CloseFunc(); err != nil { if err := a.options.CloseFunc(); err != nil {
@@ -197,10 +292,13 @@ func (a *Agate) RestoreSnapshot(ctx context.Context, snapshotID string) error {
} }
// Extract the snapshot // Extract the snapshot
if err := a.manager.ExtractSnapshot(ctx, snapshotID, a.options.BlobStore.GetActiveDir()); err != nil { if err := a.manager.ExtractSnapshot(ctx, snapshotID, a.options.BlobStore.GetActiveDir(), a.options.CleanOnRestore); err != nil {
a.options.Logger.Printf("ERROR: failed to extract snapshot: %v", err)
return fmt.Errorf("failed to extract snapshot: %w", err) return fmt.Errorf("failed to extract snapshot: %w", err)
} }
a.options.Logger.Printf("Successfully restored snapshot with ID: %s", snapshotID)
// Save the ID of the snapshot that was restored // Save the ID of the snapshot that was restored
a.currentSnapshotID = snapshotID a.currentSnapshotID = snapshotID
@@ -212,6 +310,7 @@ func (a *Agate) RestoreSnapshot(ctx context.Context, snapshotID string) error {
// Call OpenFunc if provided // Call OpenFunc if provided
if a.options.OpenFunc != nil { if a.options.OpenFunc != nil {
if err := a.options.OpenFunc(a.options.BlobStore.GetActiveDir()); err != nil { if err := a.options.OpenFunc(a.options.BlobStore.GetActiveDir()); err != nil {
a.options.Logger.Printf("ERROR: failed to open resources after restore: %v", err)
return fmt.Errorf("failed to open resources after restore: %w", err) return fmt.Errorf("failed to open resources after restore: %w", err)
} }
} }
@@ -221,6 +320,9 @@ func (a *Agate) RestoreSnapshot(ctx context.Context, snapshotID string) error {
// RestoreSnapshot extracts a snapshot to the directory. // RestoreSnapshot extracts a snapshot to the directory.
func (a *Agate) RestoreSnapshotToDir(ctx context.Context, snapshotID string, dir string) error { func (a *Agate) RestoreSnapshotToDir(ctx context.Context, snapshotID string, dir string) error {
a.mutex.Lock()
defer a.mutex.Unlock()
// Call CloseFunc if provided // Call CloseFunc if provided
if a.options.CloseFunc != nil { if a.options.CloseFunc != nil {
if err := a.options.CloseFunc(); err != nil { if err := a.options.CloseFunc(); err != nil {
@@ -231,13 +333,13 @@ func (a *Agate) RestoreSnapshotToDir(ctx context.Context, snapshotID string, dir
defer func() { defer func() {
if a.options.OpenFunc != nil { if a.options.OpenFunc != nil {
if err := a.options.OpenFunc(dir); err != nil { if err := a.options.OpenFunc(dir); err != nil {
fmt.Printf("Failed to open resources after snapshot: %v\n", err) a.options.Logger.Printf("ERROR: failed to open resources after snapshot restore: %v", err)
} }
} }
}() }()
// Extract the snapshot // Extract the snapshot
if err := a.manager.ExtractSnapshot(ctx, snapshotID, dir); err != nil { if err := a.manager.ExtractSnapshot(ctx, snapshotID, dir, a.options.CleanOnRestore); err != nil {
return fmt.Errorf("failed to extract snapshot: %w", err) return fmt.Errorf("failed to extract snapshot: %w", err)
} }
@@ -256,7 +358,9 @@ func (a *Agate) RestoreSnapshotToDir(ctx context.Context, snapshotID string, dir
// ListSnapshots returns a list of all available snapshots. // ListSnapshots returns a list of all available snapshots.
func (a *Agate) ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error) { func (a *Agate) ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error) {
return a.manager.ListSnapshots(ctx) // Create empty ListOptions since we don't have filtering/pagination in this API yet
opts := store.ListOptions{}
return a.manager.ListSnapshots(ctx, opts)
} }
// GetSnapshotDetails returns detailed information about a specific snapshot. // GetSnapshotDetails returns detailed information about a specific snapshot.
@@ -274,7 +378,9 @@ func (a *Agate) saveCurrentSnapshotID() error {
if a.currentSnapshotID == "" { if a.currentSnapshotID == "" {
// If there's no current snapshot ID, remove the file if it exists // If there's no current snapshot ID, remove the file if it exists
if _, err := os.Stat(a.currentIDFile); err == nil { if _, err := os.Stat(a.currentIDFile); err == nil {
return os.Remove(a.currentIDFile) if err := os.Remove(a.currentIDFile); err != nil {
return err
}
} }
return nil return nil
} }
@@ -283,82 +389,215 @@ func (a *Agate) saveCurrentSnapshotID() error {
return os.WriteFile(a.currentIDFile, []byte(a.currentSnapshotID), 0644) return os.WriteFile(a.currentIDFile, []byte(a.currentSnapshotID), 0644)
} }
func (a *Agate) Open() error {
return a.options.OpenFunc(a.GetActiveDir())
}
// Close releases all resources used by the Agate instance. // Close releases all resources used by the Agate instance.
func (a *Agate) Close() error { func (a *Agate) Close() error {
// Currently, we don't have a way to close the manager directly if a.options.CloseFunc != nil {
// This would be a good addition in the future return a.options.CloseFunc()
}
return nil return nil
} }
// StartServer starts a gRPC server to share snapshots. // StartServer starts a gRPC server to share snapshots.
func (a *Agate) StartServer(ctx context.Context, address string) error { func (a *Agate) StartServer(ctx context.Context, address string) error {
_, err := grpc.RunServer(ctx, a.manager, address) server := remote.NewServer(a.manager)
if err != nil { return server.Start(ctx, address)
return fmt.Errorf("failed to start server: %w", err)
}
// We don't store the server reference because we don't have a way to stop it yet
// In a future version, we could add a StopServer method
return nil
} }
// ConnectRemote connects to a remote snapshot server. // GetRemoteSnapshot downloads a snapshot from a remote server, using an efficient differential update.
// Returns a client that can be used to interact with the remote server.
func (a *Agate) ConnectRemote(address string) (*grpc.SnapshotClient, error) {
client, err := grpc.ConnectToServer(address)
if err != nil {
return nil, fmt.Errorf("failed to connect to remote server: %w", err)
}
return client, nil
}
// GetRemoteSnapshotList retrieves a list of snapshots from a remote server.
func (a *Agate) GetRemoteSnapshotList(ctx context.Context, address string) ([]store.SnapshotInfo, error) {
client, err := a.ConnectRemote(address)
if err != nil {
return nil, err
}
defer client.Close()
return client.ListSnapshots(ctx)
}
// GetRemoteSnapshot downloads a snapshot from a remote server.
// If localParentID is provided, it will be used to optimize the download by skipping files that already exist locally.
func (a *Agate) GetRemoteSnapshot(ctx context.Context, address string, snapshotID string, localParentID string) error { func (a *Agate) GetRemoteSnapshot(ctx context.Context, address string, snapshotID string, localParentID string) error {
client, err := a.ConnectRemote(address) client, err := remote.NewClient(address)
if err != nil { if err != nil {
return err return err
} }
defer client.Close() defer func() {
if err := client.Close(); err != nil {
// Create a temporary directory for the downloaded snapshot a.options.Logger.Printf("ERROR: failed to close client: %v", err)
tempDir := filepath.Join(a.options.WorkDir, "temp", snapshotID)
if err := os.MkdirAll(tempDir, 0755); err != nil {
return fmt.Errorf("failed to create temporary directory: %w", err)
} }
}()
// Download the snapshot remoteSnapshot, err := client.FetchSnapshotDetails(ctx, snapshotID)
if err := client.DownloadSnapshot(ctx, snapshotID, tempDir, localParentID); err != nil {
return fmt.Errorf("failed to download snapshot: %w", err)
}
// Get the snapshot details to create a local copy
details, err := client.FetchSnapshotDetails(ctx, snapshotID)
if err != nil { if err != nil {
return fmt.Errorf("failed to get snapshot details: %w", err) return fmt.Errorf("failed to get remote snapshot details: %w", err)
} }
// Create a local snapshot from the downloaded files tempDownloadDir := filepath.Join(a.options.WorkDir, "temp_download")
_, err = a.manager.CreateSnapshot(ctx, tempDir, details.Name, localParentID) if err := os.MkdirAll(tempDownloadDir, 0755); err != nil {
return fmt.Errorf("failed to create temp download dir: %w", err)
}
newSnapshotDir := filepath.Join(tempDownloadDir, "new_content_"+snapshotID)
if err := os.MkdirAll(newSnapshotDir, 0755); err != nil {
return fmt.Errorf("failed to create new snapshot directory: %w", err)
}
defer func() {
if err := os.RemoveAll(newSnapshotDir); err != nil {
a.options.Logger.Printf("ERROR: failed to remove temp dir: %v", err)
}
}()
if localParentID != "" {
if err := a.manager.ExtractSnapshot(ctx, localParentID, newSnapshotDir, false); err != nil {
a.options.Logger.Printf("Warning: failed to extract local parent snapshot %s: %v", localParentID, err)
} else {
localParentSnap, err := a.GetSnapshotDetails(ctx, localParentID)
if err != nil { if err != nil {
return fmt.Errorf("failed to create local snapshot: %w", err) a.options.Logger.Printf("Warning: failed to get local parent details %s: %v", localParentID, err)
} else {
remoteFilesMap := make(map[string]struct{})
for _, f := range remoteSnapshot.Files {
remoteFilesMap[f.Path] = struct{}{}
} }
// Clean up the temporary directory for _, localFile := range localParentSnap.Files {
os.RemoveAll(tempDir) if _, exists := remoteFilesMap[localFile.Path]; !exists {
pathToDelete := filepath.Join(newSnapshotDir, localFile.Path)
if err := os.RemoveAll(pathToDelete); err != nil {
a.options.Logger.Printf("Warning: failed to delete file %s during diff apply: %v", pathToDelete, err)
}
}
}
}
}
}
diffArchivePath := filepath.Join(tempDownloadDir, snapshotID+"_diff.zip")
diffPartPath := diffArchivePath + ".part"
a.options.Logger.Printf("Downloading diff for snapshot %s from parent %s", snapshotID, localParentID)
if err := client.DownloadSnapshotDiff(ctx, snapshotID, localParentID, diffPartPath); err != nil {
return fmt.Errorf("failed to download snapshot diff: %w", err)
}
if err := os.Rename(diffPartPath, diffArchivePath); err != nil {
return fmt.Errorf("failed to finalize downloaded diff: %w", err)
}
defer func() {
if err := os.Remove(diffArchivePath); err != nil {
a.options.Logger.Printf("ERROR: failed to remove temp file: %v", err)
}
}()
if err := extractArchive(diffArchivePath, newSnapshotDir); err != nil {
return fmt.Errorf("failed to extract diff archive: %w", err)
}
finalArchivePath := filepath.Join(tempDownloadDir, snapshotID+".zip")
if err := archive.CreateArchive(newSnapshotDir, finalArchivePath); err != nil {
return fmt.Errorf("failed to create final snapshot archive: %w", err)
}
defer func() {
if err := os.Remove(finalArchivePath); err != nil {
a.options.Logger.Printf("ERROR: failed to remove temp file: %v", err)
}
}()
finalArchiveFile, err := os.Open(finalArchivePath)
if err != nil {
return fmt.Errorf("failed to open final archive: %w", err)
}
defer finalArchiveFile.Close()
if _, err := a.options.BlobStore.StoreBlob(ctx, snapshotID, finalArchiveFile); err != nil {
return fmt.Errorf("failed to store final blob: %w", err)
}
if err := a.options.MetadataStore.SaveSnapshotMetadata(ctx, *remoteSnapshot); err != nil {
a.options.BlobStore.DeleteBlob(ctx, snapshotID)
return fmt.Errorf("failed to save snapshot metadata: %w", err)
}
a.options.Logger.Printf("Successfully imported remote snapshot %s", snapshotID)
return nil
}
func (a *Agate) GetCurrentSnapshotID() string {
return a.currentSnapshotID
}
// RegisterLocalSnapshot регистрирует локальный файл как блоб снимка и создает
// соответствующую запись в метаданных. Если снимок с таким ID уже существует,
// метод ничего не делает и возвращает nil.
//
// - ctx: Контекст для выполнения операции.
// - snapshotID: ID регистрируемого снимка.
// - parentID: ID родительского снимка. Может быть пустым для полных снимков.
// - name: Описательное имя для снимка.
// - localPath: Абсолютный путь к локальному файлу снимка (полному или дифф-архиву).
func (ag *Agate) RegisterLocalSnapshot(ctx context.Context, snapshotID, parentID, name, localPath string) error {
// 1. Check if snapshot already exists
_, err := ag.manager.GetSnapshotDetails(ctx, snapshotID)
if err == nil {
ag.options.Logger.Printf("snapshot %s already exists, skipping registration", snapshotID)
return nil // Snapshot already exists
}
// We expect ErrNotFound, anything else is a real error.
if !errors.Is(err, models.ErrNotFound) {
return fmt.Errorf("failed to check for existing snapshot: %w", err)
}
// 2. Add the file to the blob store
// Check if blob already exists. If so, we assume it's the correct one and skip overwriting.
// This is to prevent issues when registering a file that is already in the blob store.
_, err = ag.options.BlobStore.GetBlobPath(ctx, snapshotID)
if err == nil {
ag.options.Logger.Printf("blob for snapshot %s already exists, skipping storing it", snapshotID)
} else if errors.Is(err, models.ErrNotFound) {
// Blob does not exist, so we store it.
localFile, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("failed to open local snapshot file: %w", err)
}
defer func() {
if err := localFile.Close(); err != nil {
ag.options.Logger.Printf("ERROR: failed to close local file: %v", err)
}
}()
if _, err = ag.options.BlobStore.StoreBlob(ctx, snapshotID, localFile); err != nil {
return fmt.Errorf("failed to store blob from local file: %w", err)
}
} else {
// Another error occurred when checking for the blob.
return fmt.Errorf("failed to check for existing blob: %w", err)
}
// 3. Create and save snapshot metadata
// We get the file list from the archive to create the metadata.
// Note: This method does not calculate file hashes, so the metadata will be incomplete.
// This is a limitation of the current implementation.
var files []store.FileInfo
archiveFiles, err := archive.ListArchiveContents(localPath)
if err != nil {
// If we can't list the contents, we can't create the metadata.
// We should clean up the blob we just stored.
_ = ag.options.BlobStore.DeleteBlob(ctx, snapshotID)
return fmt.Errorf("failed to list archive contents for metadata creation: %w", err)
}
for _, f := range archiveFiles {
files = append(files, store.FileInfo{
Path: f.Path,
Size: int64(f.Size),
IsDir: f.IsDir,
// SHA256 is intentionally left empty as we don't have it.
})
}
snapshot := store.Snapshot{
ID: snapshotID,
Name: name,
ParentID: parentID,
CreationTime: time.Now(),
Files: files,
}
if err := ag.options.MetadataStore.SaveSnapshotMetadata(ctx, snapshot); err != nil {
// Clean up the blob
_ = ag.options.BlobStore.DeleteBlob(ctx, snapshotID)
return fmt.Errorf("failed to save snapshot metadata: %w", err)
}
ag.options.Logger.Printf("Successfully registered local snapshot %s", snapshotID)
return nil return nil
} }

View File

@@ -1,9 +1,12 @@
package agate package agate
import ( import (
"bytes"
"context" "context"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -252,6 +255,91 @@ func TestAPIListSnapshots(t *testing.T) {
} }
} }
func TestAgate_Logging(t *testing.T) {
// Create a temporary directory for tests
tempDir, err := os.MkdirTemp("", "agate-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create a data directory
dataDir := filepath.Join(tempDir, "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
t.Fatalf("Failed to create data directory: %v", err)
}
// Create test files in the active directory
activeDir := filepath.Join(dataDir, "blobs", "active")
if err := os.MkdirAll(activeDir, 0755); err != nil {
t.Fatalf("Failed to create active directory: %v", err)
}
createAPITestFiles(t, activeDir)
// Create a buffer to capture log output
var logBuffer bytes.Buffer
logger := log.New(&logBuffer, "", 0)
// Create Agate options with the logger
options := AgateOptions{
WorkDir: dataDir,
OpenFunc: func(dir string) error {
return nil
},
CloseFunc: func() error {
return nil
},
Logger: logger,
}
// Create Agate instance
ag, err := New(options)
if err != nil {
t.Fatalf("Failed to create Agate instance: %v", err)
}
defer ag.Close()
// Perform operations that should generate logs
ctx := context.Background()
// Save a snapshot
snapshotID, err := ag.SaveSnapshot(ctx, "Test Snapshot", "")
if err != nil {
t.Fatalf("Failed to create snapshot: %v", err)
}
// Restore the snapshot
err = ag.RestoreSnapshot(ctx, snapshotID)
if err != nil {
t.Fatalf("Failed to restore snapshot: %v", err)
}
// Check that logs were generated
logs := logBuffer.String()
if logs == "" {
t.Errorf("No logs were generated")
}
// Check for expected log messages
expectedLogMessages := []string{
"Creating new snapshot",
"Restoring snapshot",
}
for _, msg := range expectedLogMessages {
if !strings.Contains(logs, msg) {
t.Errorf("Expected log message '%s' not found in logs", msg)
}
}
}
// Note: This test is a placeholder for when the ListSnapshots method is updated to accept ListOptions.
// Currently, the ListSnapshots method in api.go doesn't accept ListOptions, so we can't test that functionality directly.
// The test for ListOptions functionality is covered in TestListSnapshotsMetadata_WithOptions in store/sqlite/sqlite_test.go.
func TestAgate_ListSnapshotsWithOptions(t *testing.T) {
t.Skip("Skipping test as ListSnapshots in api.go doesn't yet support ListOptions")
}
func TestAPIDeleteSnapshot(t *testing.T) { func TestAPIDeleteSnapshot(t *testing.T) {
ag, _, cleanup := setupTestAPI(t) ag, _, cleanup := setupTestAPI(t)
defer cleanup() defer cleanup()

View File

@@ -95,6 +95,104 @@ func CreateArchive(sourceDir, targetPath string) error {
return nil return nil
} }
// CreateArchiveWithProgress creates a ZIP archive with progress reporting.
// onProgress is called with the current number of bytes written and the total size.
func CreateArchiveWithProgress(sourceDir, targetPath string, onProgress func(current, total int64)) error {
info, err := os.Stat(sourceDir)
if err != nil {
return fmt.Errorf("failed to stat source directory %s: %w", sourceDir, err)
}
if !info.IsDir() {
return fmt.Errorf("source %s is not a directory", sourceDir)
}
// Calculate total size
var totalSize int64
err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
totalSize += info.Size()
}
return nil
})
if err != nil {
return fmt.Errorf("failed to calculate total size of %s: %w", sourceDir, err)
}
// Create file for ZIP archive
outFile, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("failed to create target archive file %s: %w", targetPath, err)
}
defer outFile.Close()
// Create zip.Writer
zipWriter := zip.NewWriter(outFile)
defer zipWriter.Close()
var currentSize int64
// Recursively walk sourceDir
err = filepath.Walk(sourceDir, func(filePath string, fileInfo os.FileInfo, walkErr error) error {
if walkErr != nil {
return fmt.Errorf("error walking path %s: %w", filePath, walkErr)
}
// Skip sourceDir itself
if filePath == sourceDir {
return nil
}
// Create relative path
relativePath := strings.TrimPrefix(filePath, sourceDir+string(filepath.Separator))
relativePath = filepath.ToSlash(relativePath)
// Check if directory
if fileInfo.IsDir() {
_, err = zipWriter.Create(relativePath + "/")
if err != nil {
return fmt.Errorf("failed to create directory entry %s in archive: %w", relativePath, err)
}
return nil
}
// Open file for reading
fileToArchive, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file %s for archiving: %w", filePath, err)
}
defer fileToArchive.Close()
// Create archive entry
zipEntryWriter, err := zipWriter.Create(relativePath)
if err != nil {
return fmt.Errorf("failed to create entry %s in archive: %w", relativePath, err)
}
// Copy content
n, err := io.Copy(zipEntryWriter, fileToArchive)
if err != nil {
return fmt.Errorf("failed to copy file content %s to archive: %w", filePath, err)
}
currentSize += n
if onProgress != nil {
onProgress(currentSize, totalSize)
}
return nil
})
if err != nil {
os.Remove(targetPath)
return fmt.Errorf("failed during directory walk for archiving %s: %w", sourceDir, err)
}
return nil
}
// ListArchiveContents читает ZIP-архив и возвращает информацию о его содержимом. // ListArchiveContents читает ZIP-архив и возвращает информацию о его содержимом.
func ListArchiveContents(archivePath string) ([]ArchiveEntryInfo, error) { func ListArchiveContents(archivePath string) ([]ArchiveEntryInfo, error) {
// Открываем ZIP-архив // Открываем ZIP-архив

115
async_test.go Normal file
View File

@@ -0,0 +1,115 @@
package agate
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestSnapshotAsync(t *testing.T) {
// Setup temporary work directory
workDir, err := os.MkdirTemp("", "agate_async_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(workDir)
// Initialize Agate
opts := AgateOptions{
WorkDir: workDir,
Logger: nil, // Disable logging for test
}
ag, err := New(opts)
if err != nil {
t.Fatalf("Failed to initialize Agate: %v", err)
}
defer ag.Close()
// Get active directory and create some dummy files
activeDir := ag.GetActiveDir()
if err := os.MkdirAll(activeDir, 0755); err != nil {
t.Fatalf("Failed to create active dir: %v", err)
}
// Create a large-ish file to ensure it takes some time (though still fast)
dummyFile := filepath.Join(activeDir, "data.bin")
data := make([]byte, 1024*1024) // 1MB
if err := os.WriteFile(dummyFile, data, 0644); err != nil {
t.Fatalf("Failed to create dummy file: %v", err)
}
// Start async snapshot
ctx := context.Background()
snapshotID, err := ag.SnapshotAsync(ctx, "async-snap", "")
if err != nil {
t.Fatalf("SnapshotAsync failed: %v", err)
}
if snapshotID == "" {
t.Fatal("SnapshotAsync returned empty ID")
}
// Check status immediately
status, err := ag.GetSnapshotStatus(ctx, snapshotID)
if err != nil {
t.Fatalf("GetSnapshotStatus failed: %v", err)
}
// Status should be pending or running
if status.Status != "pending" && status.Status != "running" {
t.Errorf("Initial status should be pending or running, got: %s", status.Status)
}
// Poll for completion
timeout := time.After(5 * time.Second)
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
done := false
for !done {
select {
case <-timeout:
t.Fatal("Timeout waiting for snapshot completion")
case <-ticker.C:
status, err := ag.GetSnapshotStatus(ctx, snapshotID)
if err != nil {
t.Fatalf("GetSnapshotStatus failed during polling: %v", err)
}
if status.Status == "done" {
done = true
if status.Progress != 1.0 {
t.Errorf("Expected progress 1.0, got %f", status.Progress)
}
} else if status.Status == "failed" {
t.Fatalf("Snapshot creation failed: %s", status.Error)
}
}
}
// Verify snapshot exists
snaps, err := ag.ListSnapshots(ctx)
if err != nil {
t.Fatalf("ListSnapshots failed: %v", err)
}
found := false
for _, s := range snaps {
if s.ID == snapshotID {
found = true
break
}
}
if !found {
t.Errorf("Snapshot %s not found in list", snapshotID)
}
// Verify current snapshot ID is updated
if ag.GetCurrentSnapshotID() != snapshotID {
t.Errorf("Current snapshot ID not updated. Expected %s, got %s", snapshotID, ag.GetCurrentSnapshotID())
}
}

View File

@@ -21,6 +21,7 @@ func TestFullWorkflow(t *testing.T) {
// Create Agate options // Create Agate options
options := AgateOptions{ options := AgateOptions{
WorkDir: tempDir, WorkDir: tempDir,
CleanOnRestore: true,
} }
// Create Agate instance // Create Agate instance
@@ -123,12 +124,20 @@ func TestFullWorkflow(t *testing.T) {
} }
if string(content) != expectedContent { if string(content) != expectedContent {
t.Errorf("Restored file %s has wrong content: got %s, want %s", path, string(content), expectedContent) t.Errorf("Restored file %s has wrong content: got %s, want %s", path, string(content), expectedContent)
} else {
t.Logf("SUCCESS: Restored file %s has correct content after restoring first snapshot", path)
} }
} }
// Check that file4.txt doesn't exist // Check that file4.txt doesn't exist
if _, err := os.Stat(filepath.Join(dataDir, "file4.txt")); !os.IsNotExist(err) { file4Path := filepath.Join(dataDir, "file4.txt")
_, err = os.Stat(file4Path)
if err == nil {
t.Errorf("File4.txt should not exist after restoring first snapshot") t.Errorf("File4.txt should not exist after restoring first snapshot")
} else if !os.IsNotExist(err) {
t.Errorf("Unexpected error checking if File4.txt exists: %v", err)
} else {
t.Logf("SUCCESS: File4.txt correctly does not exist after restoring first snapshot")
} }
// Step 9: Restore the third snapshot // Step 9: Restore the third snapshot
@@ -152,12 +161,20 @@ func TestFullWorkflow(t *testing.T) {
} }
if string(content) != expectedContent { if string(content) != expectedContent {
t.Errorf("Restored file %s has wrong content: got %s, want %s", path, string(content), expectedContent) t.Errorf("Restored file %s has wrong content: got %s, want %s", path, string(content), expectedContent)
} else {
t.Logf("SUCCESS: Restored file %s has correct content after restoring third snapshot", path)
} }
} }
// Check that file2.txt doesn't exist // Check that file2.txt doesn't exist
if _, err := os.Stat(filepath.Join(dataDir, "file2.txt")); !os.IsNotExist(err) { file2Path := filepath.Join(dataDir, "file2.txt")
_, err = os.Stat(file2Path)
if err == nil {
t.Errorf("File2.txt should not exist after restoring third snapshot") t.Errorf("File2.txt should not exist after restoring third snapshot")
} else if !os.IsNotExist(err) {
t.Errorf("Unexpected error checking if File2.txt exists: %v", err)
} else {
t.Logf("SUCCESS: File2.txt correctly does not exist after restoring third snapshot")
} }
// Step 11: Delete a snapshot // Step 11: Delete a snapshot
@@ -173,15 +190,43 @@ func TestFullWorkflow(t *testing.T) {
t.Fatalf("Failed to list snapshots: %v", err) t.Fatalf("Failed to list snapshots: %v", err)
} }
if len(snapshots) != 2 { // Debug output
t.Errorf("Expected 2 snapshots after deletion, got %d", len(snapshots)) t.Logf("After deletion, found %d snapshots:", len(snapshots))
for i, snap := range snapshots {
t.Logf(" Snapshot %d: ID=%s, Name=%s, ParentID=%s", i+1, snap.ID, snap.Name, snap.ParentID)
} }
// Get detailed information about snapshot 3
snapshot3, err := ag.GetSnapshotDetails(ctx, snapshot3ID)
if err != nil {
t.Logf("Failed to get snapshot 3 details: %v", err)
} else {
t.Logf("Snapshot 3 details: ID=%s, Name=%s, ParentID=%s", snapshot3.ID, snapshot3.Name, snapshot3.ParentID)
}
// Verify that snapshot 3's parent ID has been updated to point to snapshot 1
if snapshot3 != nil && snapshot3.ParentID != snapshot1ID {
t.Errorf("Snapshot 3's parent ID should be updated to point to Snapshot 1 after Snapshot 2 is deleted. Got ParentID=%s, want ParentID=%s", snapshot3.ParentID, snapshot1ID)
} else {
t.Logf("SUCCESS: Snapshot 3's parent ID has been correctly updated to point to Snapshot 1: %s", snapshot3.ParentID)
}
if len(snapshots) != 2 {
t.Errorf("Expected 2 snapshots after deletion, got %d", len(snapshots))
} else {
t.Logf("SUCCESS: Found correct number of snapshots after deletion: %d", len(snapshots))
}
foundDeletedSnapshot := false
for _, snap := range snapshots { for _, snap := range snapshots {
if snap.ID == snapshot2ID { if snap.ID == snapshot2ID {
t.Errorf("Snapshot 2 should have been deleted") foundDeletedSnapshot = true
t.Errorf("Snapshot 2 (ID=%s) should have been deleted", snapshot2ID)
} }
} }
if !foundDeletedSnapshot {
t.Logf("SUCCESS: Snapshot 2 (ID=%s) was correctly deleted", snapshot2ID)
}
} }
// TestLargeFiles tests creating and restoring snapshots with large files // TestLargeFiles tests creating and restoring snapshots with large files
@@ -201,6 +246,7 @@ func TestLargeFiles(t *testing.T) {
// Create Agate options // Create Agate options
options := AgateOptions{ options := AgateOptions{
WorkDir: tempDir, WorkDir: tempDir,
CleanOnRestore: true,
OpenFunc: func(dir string) error { OpenFunc: func(dir string) error {
return nil return nil
}, },

215
go.mod
View File

@@ -2,215 +2,18 @@ module gitea.unprism.ru/KRBL/Agate
go 1.24.3 go 1.24.3
tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint
require ( require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3
github.com/mattn/go-sqlite3 v1.14.28 github.com/mattn/go-sqlite3 v1.14.32
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba
google.golang.org/grpc v1.72.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.6 google.golang.org/protobuf v1.36.10
) )
require ( require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect golang.org/x/net v0.47.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect golang.org/x/sys v0.38.0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect golang.org/x/text v0.31.0 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
github.com/Antonboom/errname v1.1.0 // indirect
github.com/Antonboom/nilnil v1.1.0 // indirect
github.com/Antonboom/testifylint v1.6.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/alecthomas/chroma/v2 v2.17.2 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/ashanbrown/forbidigo v1.6.0 // indirect
github.com/ashanbrown/makezero v1.2.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.0 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.9.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/chavacava/garif v0.1.0 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.6 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.15 // indirect
github.com/go-critic/go-critic v0.13.0 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.1.6 // indirect
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
github.com/golangci/misspell v0.6.0 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.8.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.4 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.14 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.3 // indirect
github.com/ldez/gomoddirectives v0.6.1 // indirect
github.com/ldez/grignotin v0.9.0 // indirect
github.com/ldez/tagliatelle v0.7.1 // indirect
github.com/ldez/usetesting v0.4.3 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/manuelarte/funcorder v0.2.1 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.9.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.8.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.4 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
github.com/securego/gosec/v2 v2.22.3 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.1.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tetafro/godot v1.5.1 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/iface v1.3.1 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.1 // indirect
go-simpler.org/sloglint v0.11.0 // indirect
go.augendre.info/fatcontext v0.8.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect
mvdan.cc/gofumpt v0.8.0 // indirect
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect
) )

1055
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -1,236 +0,0 @@
package grpc
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"gitea.unprism.ru/KRBL/Agate/store"
)
// SnapshotClient implements the client for connecting to a remote snapshot server
type SnapshotClient struct {
conn *grpc.ClientConn
client SnapshotServiceClient
}
// NewSnapshotClient creates a new client connected to the specified address
func NewSnapshotClient(address string) (*SnapshotClient, error) {
// Connect to the server with insecure credentials (for simplicity)
conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("failed to connect to server at %s: %w", address, err)
}
// Create the gRPC client
client := NewSnapshotServiceClient(conn)
return &SnapshotClient{
conn: conn,
client: client,
}, nil
}
// Close closes the connection to the server
func (c *SnapshotClient) Close() error {
if c.conn != nil {
return c.conn.Close()
}
return nil
}
// ListSnapshots retrieves a list of snapshots from the remote server
func (c *SnapshotClient) ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error) {
response, err := c.client.ListSnapshots(ctx, &ListSnapshotsRequest{})
if err != nil {
return nil, fmt.Errorf("failed to list snapshots: %w", err)
}
// Convert gRPC snapshot info to store.SnapshotInfo
snapshots := make([]store.SnapshotInfo, 0, len(response.Snapshots))
for _, snapshot := range response.Snapshots {
snapshots = append(snapshots, store.SnapshotInfo{
ID: snapshot.Id,
Name: snapshot.Name,
ParentID: snapshot.ParentId,
CreationTime: snapshot.CreationTime.AsTime(),
})
}
return snapshots, nil
}
// FetchSnapshotDetails retrieves detailed information about a specific snapshot
func (c *SnapshotClient) FetchSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error) {
response, err := c.client.GetSnapshotDetails(ctx, &GetSnapshotDetailsRequest{
SnapshotId: snapshotID,
})
if err != nil {
return nil, fmt.Errorf("failed to get snapshot details: %w", err)
}
// Convert gRPC snapshot details to store.Snapshot
snapshot := &store.Snapshot{
ID: response.Info.Id,
Name: response.Info.Name,
ParentID: response.Info.ParentId,
CreationTime: response.Info.CreationTime.AsTime(),
Files: make([]store.FileInfo, 0, len(response.Files)),
}
// Convert file info
for _, file := range response.Files {
snapshot.Files = append(snapshot.Files, store.FileInfo{
Path: file.Path,
Size: file.SizeBytes,
IsDir: file.IsDir,
SHA256: file.Sha256Hash,
})
}
return snapshot, nil
}
// DownloadSnapshot downloads a snapshot from the server
// This implementation downloads each file individually to optimize bandwidth usage
func (c *SnapshotClient) DownloadSnapshot(ctx context.Context, snapshotID string, targetDir string, localParentID string) error {
// Get snapshot details
snapshot, err := c.FetchSnapshotDetails(ctx, snapshotID)
if err != nil {
return fmt.Errorf("failed to get snapshot details: %w", err)
}
// Create target directory if it doesn't exist
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("failed to create target directory: %w", err)
}
// If a local parent is specified, get its details to compare files
var localParentFiles map[string]store.FileInfo
if localParentID != "" {
localParent, err := c.FetchSnapshotDetails(ctx, localParentID)
if err == nil {
// Create a map of file paths to file info for quick lookup
localParentFiles = make(map[string]store.FileInfo, len(localParent.Files))
for _, file := range localParent.Files {
localParentFiles[file.Path] = file
}
}
}
// Download each file
for _, file := range snapshot.Files {
// Skip directories, we'll create them when needed
if file.IsDir {
// Create directory
dirPath := filepath.Join(targetDir, file.Path)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dirPath, err)
}
continue
}
// Check if we can skip downloading this file
if localParentFiles != nil {
if parentFile, exists := localParentFiles[file.Path]; exists && parentFile.SHA256 == file.SHA256 {
// File exists in parent with same hash, copy it instead of downloading
parentFilePath := filepath.Join(targetDir, "..", localParentID, file.Path)
targetFilePath := filepath.Join(targetDir, file.Path)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(targetFilePath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", targetFilePath, err)
}
// Copy the file
if err := copyFile(parentFilePath, targetFilePath); err != nil {
// If copy fails, fall back to downloading
fmt.Printf("Failed to copy file %s, will download instead: %v\n", file.Path, err)
} else {
// Skip to next file
continue
}
}
}
// Download the file
if err := c.downloadFile(ctx, snapshotID, file.Path, filepath.Join(targetDir, file.Path)); err != nil {
return fmt.Errorf("failed to download file %s: %w", file.Path, err)
}
}
return nil
}
// downloadFile downloads a single file from the server
func (c *SnapshotClient) downloadFile(ctx context.Context, snapshotID, filePath, targetPath string) error {
// Create the request
req := &DownloadFileRequest{
SnapshotId: snapshotID,
FilePath: filePath,
}
// Start streaming the file
stream, err := c.client.DownloadFile(ctx, req)
if err != nil {
return fmt.Errorf("failed to start file download: %w", err)
}
// Ensure the target directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", targetPath, err)
}
// Create the target file
file, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
}
defer file.Close()
// Receive and write chunks
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("error receiving file chunk: %w", err)
}
// Write the chunk to the file
if _, err := file.Write(resp.ChunkData); err != nil {
return fmt.Errorf("error writing to file: %w", err)
}
}
return nil
}
// Helper function to copy a file
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
return err
}
// ConnectToServer creates a new client connected to the specified address
func ConnectToServer(address string) (*SnapshotClient, error) {
return NewSnapshotClient(address)
}

View File

@@ -1,154 +0,0 @@
package grpc
import (
"context"
"fmt"
"io"
"net"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/timestamppb"
"gitea.unprism.ru/KRBL/Agate/interfaces"
"gitea.unprism.ru/KRBL/Agate/store"
)
// SnapshotServer implements the gRPC server for snapshots
type SnapshotServer struct {
UnimplementedSnapshotServiceServer
manager interfaces.SnapshotManager
server *grpc.Server
}
// NewSnapshotServer creates a new snapshot server
func NewSnapshotServer(manager interfaces.SnapshotManager) *SnapshotServer {
return &SnapshotServer{
manager: manager,
}
}
// Start starts the gRPC server on the specified address
func (s *SnapshotServer) Start(ctx context.Context, address string) error {
lis, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", address, err)
}
s.server = grpc.NewServer()
RegisterSnapshotServiceServer(s.server, s)
go func() {
if err := s.server.Serve(lis); err != nil {
fmt.Printf("Server error: %v\n", err)
}
}()
fmt.Printf("Server started on %s\n", address)
return nil
}
// Stop gracefully stops the server
func (s *SnapshotServer) Stop(ctx context.Context) error {
if s.server != nil {
s.server.GracefulStop()
fmt.Println("Server stopped")
}
return nil
}
// ListSnapshots implements the gRPC ListSnapshots method
func (s *SnapshotServer) ListSnapshots(ctx context.Context, req *ListSnapshotsRequest) (*ListSnapshotsResponse, error) {
snapshots, err := s.manager.ListSnapshots(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list snapshots: %w", err)
}
response := &ListSnapshotsResponse{
Snapshots: make([]*SnapshotInfo, 0, len(snapshots)),
}
for _, snapshot := range snapshots {
response.Snapshots = append(response.Snapshots, convertToGrpcSnapshotInfo(snapshot))
}
return response, nil
}
// GetSnapshotDetails implements the gRPC GetSnapshotDetails method
func (s *SnapshotServer) GetSnapshotDetails(ctx context.Context, req *GetSnapshotDetailsRequest) (*SnapshotDetails, error) {
snapshot, err := s.manager.GetSnapshotDetails(ctx, req.SnapshotId)
if err != nil {
return nil, fmt.Errorf("failed to get snapshot details: %w", err)
}
response := &SnapshotDetails{
Info: convertToGrpcSnapshotInfo(store.SnapshotInfo{
ID: snapshot.ID,
Name: snapshot.Name,
ParentID: snapshot.ParentID,
CreationTime: snapshot.CreationTime,
}),
Files: make([]*FileInfo, 0, len(snapshot.Files)),
}
for _, file := range snapshot.Files {
response.Files = append(response.Files, &FileInfo{
Path: file.Path,
SizeBytes: file.Size,
Sha256Hash: file.SHA256,
IsDir: file.IsDir,
})
}
return response, nil
}
// DownloadFile implements the gRPC DownloadFile method
func (s *SnapshotServer) DownloadFile(req *DownloadFileRequest, stream grpc.ServerStreamingServer[DownloadFileResponse]) error {
// Open the file from the snapshot
fileReader, err := s.manager.OpenFile(context.Background(), req.SnapshotId, req.FilePath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer fileReader.Close()
// Read the file in chunks and send them to the client
buffer := make([]byte, 64*1024) // 64KB chunks
for {
n, err := fileReader.Read(buffer)
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Send the chunk to the client
if err := stream.Send(&DownloadFileResponse{
ChunkData: buffer[:n],
}); err != nil {
return fmt.Errorf("failed to send chunk: %w", err)
}
}
return nil
}
// Helper function to convert store.SnapshotInfo to grpc.SnapshotInfo
func convertToGrpcSnapshotInfo(info store.SnapshotInfo) *SnapshotInfo {
return &SnapshotInfo{
Id: info.ID,
Name: info.Name,
ParentId: info.ParentID,
CreationTime: timestamppb.New(info.CreationTime),
}
}
// RunServer is a helper function to create and start a snapshot server
func RunServer(ctx context.Context, manager interfaces.SnapshotManager, address string) (*SnapshotServer, error) {
server := NewSnapshotServer(manager)
if err := server.Start(ctx, address); err != nil {
return nil, err
}
return server, nil
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.6 // protoc-gen-go v1.36.8
// protoc v4.25.3 // protoc v6.32.0
// source: snapshot.proto // source: snapshot.proto
package grpc package grpc
@@ -26,10 +26,10 @@ const (
// Метаданные файла внутри снапшота // Метаданные файла внутри снапшота
type FileInfo struct { type FileInfo struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // Относительный путь файла внутри снапшота Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
SizeBytes int64 `protobuf:"varint,2,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` // Размер файла в байтах SizeBytes int64 `protobuf:"varint,2,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"`
Sha256Hash string `protobuf:"bytes,3,opt,name=sha256_hash,json=sha256Hash,proto3" json:"sha256_hash,omitempty"` // Хеш-сумма файла (SHA256) Sha256Hash string `protobuf:"bytes,3,opt,name=sha256_hash,json=sha256Hash,proto3" json:"sha256_hash,omitempty"`
IsDir bool `protobuf:"varint,4,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"` // Является ли запись директорией IsDir bool `protobuf:"varint,4,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -95,10 +95,10 @@ func (x *FileInfo) GetIsDir() bool {
// Краткая информация о снапшоте // Краткая информация о снапшоте
type SnapshotInfo struct { type SnapshotInfo struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Уникальный ID снапшота (UUID) Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // Имя снапшота Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
ParentId string `protobuf:"bytes,3,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"` // ID родительского снапшота (может быть пустым) ParentId string `protobuf:"bytes,3,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
CreationTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=creation_time,json=creationTime,proto3" json:"creation_time,omitempty"` // Время создания CreationTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=creation_time,json=creationTime,proto3" json:"creation_time,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -164,8 +164,8 @@ func (x *SnapshotInfo) GetCreationTime() *timestamppb.Timestamp {
// Детальная информация о снапшоте // Детальная информация о снапшоте
type SnapshotDetails struct { type SnapshotDetails struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Info *SnapshotInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` // Краткая информация Info *SnapshotInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"`
Files []*FileInfo `protobuf:"bytes,2,rep,name=files,proto3" json:"files,omitempty"` // Список файлов в снапшоте Files []*FileInfo `protobuf:"bytes,2,rep,name=files,proto3" json:"files,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -214,7 +214,7 @@ func (x *SnapshotDetails) GetFiles() []*FileInfo {
return nil return nil
} }
// Запрос на получение списка снапшотов (можно добавить фильтры/пагинацию) // Запрос на получение списка снапшотов
type ListSnapshotsRequest struct { type ListSnapshotsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
@@ -254,7 +254,7 @@ func (*ListSnapshotsRequest) Descriptor() ([]byte, []int) {
// Ответ со списком снапшотов // Ответ со списком снапшотов
type ListSnapshotsResponse struct { type ListSnapshotsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Snapshots []*SnapshotInfo `protobuf:"bytes,1,rep,name=snapshots,proto3" json:"snapshots,omitempty"` // string next_page_token = 2; Snapshots []*SnapshotInfo `protobuf:"bytes,1,rep,name=snapshots,proto3" json:"snapshots,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -299,7 +299,7 @@ func (x *ListSnapshotsResponse) GetSnapshots() []*SnapshotInfo {
// Запрос на получение деталей снапшота // Запрос на получение деталей снапшота
type GetSnapshotDetailsRequest struct { type GetSnapshotDetailsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SnapshotId string `protobuf:"bytes,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"` // ID нужного снапшота SnapshotId string `protobuf:"bytes,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -344,8 +344,8 @@ func (x *GetSnapshotDetailsRequest) GetSnapshotId() string {
// Запрос на скачивание файла // Запрос на скачивание файла
type DownloadFileRequest struct { type DownloadFileRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SnapshotId string `protobuf:"bytes,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"` // ID снапшота SnapshotId string `protobuf:"bytes,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"`
FilePath string `protobuf:"bytes,2,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"` // Путь к файлу внутри снапшота FilePath string `protobuf:"bytes,2,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -397,7 +397,7 @@ func (x *DownloadFileRequest) GetFilePath() string {
// Ответ (часть файла) при скачивании // Ответ (часть файла) при скачивании
type DownloadFileResponse struct { type DownloadFileResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ChunkData []byte `protobuf:"bytes,1,opt,name=chunk_data,json=chunkData,proto3" json:"chunk_data,omitempty"` // Кусочек данных файла ChunkData []byte `protobuf:"bytes,1,opt,name=chunk_data,json=chunkData,proto3" json:"chunk_data,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -439,6 +439,173 @@ func (x *DownloadFileResponse) GetChunkData() []byte {
return nil return nil
} }
// Запрос на скачивание разницы между снапшотами
type DownloadSnapshotDiffRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SnapshotId string `protobuf:"bytes,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"` // ID целевого снапшота
LocalParentId string `protobuf:"bytes,2,opt,name=local_parent_id,json=localParentId,proto3" json:"local_parent_id,omitempty"` // ID снапшота, который уже есть у клиента
Offset int64 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` // Смещение в байтах для докачки
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DownloadSnapshotDiffRequest) Reset() {
*x = DownloadSnapshotDiffRequest{}
mi := &file_snapshot_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DownloadSnapshotDiffRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DownloadSnapshotDiffRequest) ProtoMessage() {}
func (x *DownloadSnapshotDiffRequest) ProtoReflect() protoreflect.Message {
mi := &file_snapshot_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DownloadSnapshotDiffRequest.ProtoReflect.Descriptor instead.
func (*DownloadSnapshotDiffRequest) Descriptor() ([]byte, []int) {
return file_snapshot_proto_rawDescGZIP(), []int{8}
}
func (x *DownloadSnapshotDiffRequest) GetSnapshotId() string {
if x != nil {
return x.SnapshotId
}
return ""
}
func (x *DownloadSnapshotDiffRequest) GetLocalParentId() string {
if x != nil {
return x.LocalParentId
}
return ""
}
func (x *DownloadSnapshotDiffRequest) GetOffset() int64 {
if x != nil {
return x.Offset
}
return 0
}
// Запрос на получение информации о дифе
type GetDiffInfoRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SnapshotId string `protobuf:"bytes,1,opt,name=snapshot_id,json=snapshotId,proto3" json:"snapshot_id,omitempty"`
LocalParentId string `protobuf:"bytes,2,opt,name=local_parent_id,json=localParentId,proto3" json:"local_parent_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetDiffInfoRequest) Reset() {
*x = GetDiffInfoRequest{}
mi := &file_snapshot_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetDiffInfoRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetDiffInfoRequest) ProtoMessage() {}
func (x *GetDiffInfoRequest) ProtoReflect() protoreflect.Message {
mi := &file_snapshot_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetDiffInfoRequest.ProtoReflect.Descriptor instead.
func (*GetDiffInfoRequest) Descriptor() ([]byte, []int) {
return file_snapshot_proto_rawDescGZIP(), []int{9}
}
func (x *GetDiffInfoRequest) GetSnapshotId() string {
if x != nil {
return x.SnapshotId
}
return ""
}
func (x *GetDiffInfoRequest) GetLocalParentId() string {
if x != nil {
return x.LocalParentId
}
return ""
}
// Информация о дифе
type DiffInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Sha256Hash string `protobuf:"bytes,1,opt,name=sha256_hash,json=sha256Hash,proto3" json:"sha256_hash,omitempty"`
SizeBytes int64 `protobuf:"varint,2,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiffInfo) Reset() {
*x = DiffInfo{}
mi := &file_snapshot_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiffInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiffInfo) ProtoMessage() {}
func (x *DiffInfo) ProtoReflect() protoreflect.Message {
mi := &file_snapshot_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiffInfo.ProtoReflect.Descriptor instead.
func (*DiffInfo) Descriptor() ([]byte, []int) {
return file_snapshot_proto_rawDescGZIP(), []int{10}
}
func (x *DiffInfo) GetSha256Hash() string {
if x != nil {
return x.Sha256Hash
}
return ""
}
func (x *DiffInfo) GetSizeBytes() int64 {
if x != nil {
return x.SizeBytes
}
return 0
}
var File_snapshot_proto protoreflect.FileDescriptor var File_snapshot_proto protoreflect.FileDescriptor
const file_snapshot_proto_rawDesc = "" + const file_snapshot_proto_rawDesc = "" +
@@ -472,11 +639,27 @@ const file_snapshot_proto_rawDesc = "" +
"\tfile_path\x18\x02 \x01(\tR\bfilePath\"5\n" + "\tfile_path\x18\x02 \x01(\tR\bfilePath\"5\n" +
"\x14DownloadFileResponse\x12\x1d\n" + "\x14DownloadFileResponse\x12\x1d\n" +
"\n" + "\n" +
"chunk_data\x18\x01 \x01(\fR\tchunkData2\x8a\x03\n" + "chunk_data\x18\x01 \x01(\fR\tchunkData\"~\n" +
"\x1bDownloadSnapshotDiffRequest\x12\x1f\n" +
"\vsnapshot_id\x18\x01 \x01(\tR\n" +
"snapshotId\x12&\n" +
"\x0flocal_parent_id\x18\x02 \x01(\tR\rlocalParentId\x12\x16\n" +
"\x06offset\x18\x03 \x01(\x03R\x06offset\"]\n" +
"\x12GetDiffInfoRequest\x12\x1f\n" +
"\vsnapshot_id\x18\x01 \x01(\tR\n" +
"snapshotId\x12&\n" +
"\x0flocal_parent_id\x18\x02 \x01(\tR\rlocalParentId\"J\n" +
"\bDiffInfo\x12\x1f\n" +
"\vsha256_hash\x18\x01 \x01(\tR\n" +
"sha256Hash\x12\x1d\n" +
"\n" +
"size_bytes\x18\x02 \x01(\x03R\tsizeBytes2\xb8\x04\n" +
"\x0fSnapshotService\x12k\n" + "\x0fSnapshotService\x12k\n" +
"\rListSnapshots\x12 .agate.grpc.ListSnapshotsRequest\x1a!.agate.grpc.ListSnapshotsResponse\"\x15\x82\xd3\xe4\x93\x02\x0f\x12\r/v1/snapshots\x12}\n" + "\rListSnapshots\x12 .agate.grpc.ListSnapshotsRequest\x1a!.agate.grpc.ListSnapshotsResponse\"\x15\x82\xd3\xe4\x93\x02\x0f\x12\r/v1/snapshots\x12}\n" +
"\x12GetSnapshotDetails\x12%.agate.grpc.GetSnapshotDetailsRequest\x1a\x1b.agate.grpc.SnapshotDetails\"#\x82\xd3\xe4\x93\x02\x1d\x12\x1b/v1/snapshots/{snapshot_id}\x12\x8a\x01\n" + "\x12GetSnapshotDetails\x12%.agate.grpc.GetSnapshotDetailsRequest\x1a\x1b.agate.grpc.SnapshotDetails\"#\x82\xd3\xe4\x93\x02\x1d\x12\x1b/v1/snapshots/{snapshot_id}\x12\x8a\x01\n" +
"\fDownloadFile\x12\x1f.agate.grpc.DownloadFileRequest\x1a .agate.grpc.DownloadFileResponse\"5\x82\xd3\xe4\x93\x02/\x12-/v1/snapshots/{snapshot_id}/files/{file_path}0\x01B\"Z gitea.unprism.ru/KRBL/Agate/grpcb\x06proto3" "\fDownloadFile\x12\x1f.agate.grpc.DownloadFileRequest\x1a .agate.grpc.DownloadFileResponse\"5\x82\xd3\xe4\x93\x02/\x12-/v1/snapshots/{snapshot_id}/files/{file_path}0\x01\x12e\n" +
"\x14DownloadSnapshotDiff\x12'.agate.grpc.DownloadSnapshotDiffRequest\x1a .agate.grpc.DownloadFileResponse\"\x000\x01\x12E\n" +
"\vGetDiffInfo\x12\x1e.agate.grpc.GetDiffInfoRequest\x1a\x14.agate.grpc.DiffInfo\"\x00B\"Z gitea.unprism.ru/KRBL/Agate/grpcb\x06proto3"
var ( var (
file_snapshot_proto_rawDescOnce sync.Once file_snapshot_proto_rawDescOnce sync.Once
@@ -490,7 +673,7 @@ func file_snapshot_proto_rawDescGZIP() []byte {
return file_snapshot_proto_rawDescData return file_snapshot_proto_rawDescData
} }
var file_snapshot_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_snapshot_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_snapshot_proto_goTypes = []any{ var file_snapshot_proto_goTypes = []any{
(*FileInfo)(nil), // 0: agate.grpc.FileInfo (*FileInfo)(nil), // 0: agate.grpc.FileInfo
(*SnapshotInfo)(nil), // 1: agate.grpc.SnapshotInfo (*SnapshotInfo)(nil), // 1: agate.grpc.SnapshotInfo
@@ -500,21 +683,28 @@ var file_snapshot_proto_goTypes = []any{
(*GetSnapshotDetailsRequest)(nil), // 5: agate.grpc.GetSnapshotDetailsRequest (*GetSnapshotDetailsRequest)(nil), // 5: agate.grpc.GetSnapshotDetailsRequest
(*DownloadFileRequest)(nil), // 6: agate.grpc.DownloadFileRequest (*DownloadFileRequest)(nil), // 6: agate.grpc.DownloadFileRequest
(*DownloadFileResponse)(nil), // 7: agate.grpc.DownloadFileResponse (*DownloadFileResponse)(nil), // 7: agate.grpc.DownloadFileResponse
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp (*DownloadSnapshotDiffRequest)(nil), // 8: agate.grpc.DownloadSnapshotDiffRequest
(*GetDiffInfoRequest)(nil), // 9: agate.grpc.GetDiffInfoRequest
(*DiffInfo)(nil), // 10: agate.grpc.DiffInfo
(*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp
} }
var file_snapshot_proto_depIdxs = []int32{ var file_snapshot_proto_depIdxs = []int32{
8, // 0: agate.grpc.SnapshotInfo.creation_time:type_name -> google.protobuf.Timestamp 11, // 0: agate.grpc.SnapshotInfo.creation_time:type_name -> google.protobuf.Timestamp
1, // 1: agate.grpc.SnapshotDetails.info:type_name -> agate.grpc.SnapshotInfo 1, // 1: agate.grpc.SnapshotDetails.info:type_name -> agate.grpc.SnapshotInfo
0, // 2: agate.grpc.SnapshotDetails.files:type_name -> agate.grpc.FileInfo 0, // 2: agate.grpc.SnapshotDetails.files:type_name -> agate.grpc.FileInfo
1, // 3: agate.grpc.ListSnapshotsResponse.snapshots:type_name -> agate.grpc.SnapshotInfo 1, // 3: agate.grpc.ListSnapshotsResponse.snapshots:type_name -> agate.grpc.SnapshotInfo
3, // 4: agate.grpc.SnapshotService.ListSnapshots:input_type -> agate.grpc.ListSnapshotsRequest 3, // 4: agate.grpc.SnapshotService.ListSnapshots:input_type -> agate.grpc.ListSnapshotsRequest
5, // 5: agate.grpc.SnapshotService.GetSnapshotDetails:input_type -> agate.grpc.GetSnapshotDetailsRequest 5, // 5: agate.grpc.SnapshotService.GetSnapshotDetails:input_type -> agate.grpc.GetSnapshotDetailsRequest
6, // 6: agate.grpc.SnapshotService.DownloadFile:input_type -> agate.grpc.DownloadFileRequest 6, // 6: agate.grpc.SnapshotService.DownloadFile:input_type -> agate.grpc.DownloadFileRequest
4, // 7: agate.grpc.SnapshotService.ListSnapshots:output_type -> agate.grpc.ListSnapshotsResponse 8, // 7: agate.grpc.SnapshotService.DownloadSnapshotDiff:input_type -> agate.grpc.DownloadSnapshotDiffRequest
2, // 8: agate.grpc.SnapshotService.GetSnapshotDetails:output_type -> agate.grpc.SnapshotDetails 9, // 8: agate.grpc.SnapshotService.GetDiffInfo:input_type -> agate.grpc.GetDiffInfoRequest
7, // 9: agate.grpc.SnapshotService.DownloadFile:output_type -> agate.grpc.DownloadFileResponse 4, // 9: agate.grpc.SnapshotService.ListSnapshots:output_type -> agate.grpc.ListSnapshotsResponse
7, // [7:10] is the sub-list for method output_type 2, // 10: agate.grpc.SnapshotService.GetSnapshotDetails:output_type -> agate.grpc.SnapshotDetails
4, // [4:7] is the sub-list for method input_type 7, // 11: agate.grpc.SnapshotService.DownloadFile:output_type -> agate.grpc.DownloadFileResponse
7, // 12: agate.grpc.SnapshotService.DownloadSnapshotDiff:output_type -> agate.grpc.DownloadFileResponse
10, // 13: agate.grpc.SnapshotService.GetDiffInfo:output_type -> agate.grpc.DiffInfo
9, // [9:14] is the sub-list for method output_type
4, // [4:9] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee 4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name 0, // [0:4] is the sub-list for field type_name
@@ -531,7 +721,7 @@ func file_snapshot_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_snapshot_proto_rawDesc), len(file_snapshot_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_snapshot_proto_rawDesc), len(file_snapshot_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 8, NumMessages: 11,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@@ -40,7 +40,9 @@ func request_SnapshotService_ListSnapshots_0(ctx context.Context, marshaler runt
protoReq ListSnapshotsRequest protoReq ListSnapshotsRequest
metadata runtime.ServerMetadata metadata runtime.ServerMetadata
) )
io.Copy(io.Discard, req.Body) if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.ListSnapshots(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) msg, err := client.ListSnapshots(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err return msg, metadata, err
} }
@@ -60,7 +62,9 @@ func request_SnapshotService_GetSnapshotDetails_0(ctx context.Context, marshaler
metadata runtime.ServerMetadata metadata runtime.ServerMetadata
err error err error
) )
io.Copy(io.Discard, req.Body) if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
val, ok := pathParams["snapshot_id"] val, ok := pathParams["snapshot_id"]
if !ok { if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "snapshot_id") return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "snapshot_id")
@@ -97,7 +101,9 @@ func request_SnapshotService_DownloadFile_0(ctx context.Context, marshaler runti
metadata runtime.ServerMetadata metadata runtime.ServerMetadata
err error err error
) )
io.Copy(io.Discard, req.Body) if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
val, ok := pathParams["snapshot_id"] val, ok := pathParams["snapshot_id"]
if !ok { if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "snapshot_id") return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "snapshot_id")

View File

@@ -3,7 +3,7 @@ syntax = "proto3";
package agate.grpc; package agate.grpc;
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "google/api/annotations.proto"; // Добавлено для HTTP mapping import "google/api/annotations.proto";
option go_package = "gitea.unprism.ru/KRBL/Agate/grpc"; option go_package = "gitea.unprism.ru/KRBL/Agate/grpc";
@@ -30,77 +30,74 @@ service SnapshotService {
}; };
} }
// --- Методы для управления (опционально, можно не включать в публичный API клиента) --- // Скачать архив, содержащий только разницу между двумя снапшотами
// Создать новый снапшот из директории (если серверу позволено инициировать) rpc DownloadSnapshotDiff(DownloadSnapshotDiffRequest) returns (stream DownloadFileResponse) {}
// rpc CreateSnapshot(CreateSnapshotRequest) returns (Snapshot);
// Удалить снапшот (если требуется) // Получить информацию о дифе (хеш и размер)
// rpc DeleteSnapshot(DeleteSnapshotRequest) returns (DeleteSnapshotResponse); rpc GetDiffInfo(GetDiffInfoRequest) returns (DiffInfo) {}
} }
// Метаданные файла внутри снапшота // Метаданные файла внутри снапшота
message FileInfo { message FileInfo {
string path = 1; // Относительный путь файла внутри снапшота string path = 1;
int64 size_bytes = 2; // Размер файла в байтах int64 size_bytes = 2;
string sha256_hash = 3; // Хеш-сумма файла (SHA256) string sha256_hash = 3;
bool is_dir = 4; // Является ли запись директорией bool is_dir = 4;
} }
// Краткая информация о снапшоте // Краткая информация о снапшоте
message SnapshotInfo { message SnapshotInfo {
string id = 1; // Уникальный ID снапшота (UUID) string id = 1;
string name = 2; // Имя снапшота string name = 2;
string parent_id = 3; // ID родительского снапшота (может быть пустым) string parent_id = 3;
google.protobuf.Timestamp creation_time = 4; // Время создания google.protobuf.Timestamp creation_time = 4;
} }
// Детальная информация о снапшоте // Детальная информация о снапшоте
message SnapshotDetails { message SnapshotDetails {
SnapshotInfo info = 1; // Краткая информация SnapshotInfo info = 1;
repeated FileInfo files = 2; // Список файлов в снапшоте repeated FileInfo files = 2;
} }
// Запрос на получение списка снапшотов (можно добавить фильтры/пагинацию) // Запрос на получение списка снапшотов
message ListSnapshotsRequest { message ListSnapshotsRequest {}
// string filter_by_name = 1;
// int32 page_size = 2;
// string page_token = 3;
}
// Ответ со списком снапшотов // Ответ со списком снапшотов
message ListSnapshotsResponse { message ListSnapshotsResponse {
repeated SnapshotInfo snapshots = 1; repeated SnapshotInfo snapshots = 1;
// string next_page_token = 2;
} }
// Запрос на получение деталей снапшота // Запрос на получение деталей снапшота
message GetSnapshotDetailsRequest { message GetSnapshotDetailsRequest {
string snapshot_id = 1; // ID нужного снапшота string snapshot_id = 1;
} }
// Запрос на скачивание файла // Запрос на скачивание файла
message DownloadFileRequest { message DownloadFileRequest {
string snapshot_id = 1; // ID снапшота string snapshot_id = 1;
string file_path = 2; // Путь к файлу внутри снапшота string file_path = 2;
} }
// Ответ (часть файла) при скачивании // Ответ (часть файла) при скачивании
message DownloadFileResponse { message DownloadFileResponse {
bytes chunk_data = 1; // Кусочек данных файла bytes chunk_data = 1;
} }
// --- Сообщения для опциональных методов управления --- // Запрос на скачивание разницы между снапшотами
/* message DownloadSnapshotDiffRequest {
message CreateSnapshotRequest { string snapshot_id = 1; // ID целевого снапшота
string source_path = 1; // Путь к директории на сервере string local_parent_id = 2; // ID снапшота, который уже есть у клиента
string name = 2; int64 offset = 3; // Смещение в байтах для докачки
string parent_id = 3; // Опционально
} }
message DeleteSnapshotRequest { // Запрос на получение информации о дифе
message GetDiffInfoRequest {
string snapshot_id = 1; string snapshot_id = 1;
string local_parent_id = 2;
} }
message DeleteSnapshotResponse { // Информация о дифе
bool success = 1; message DiffInfo {
string sha256_hash = 1;
int64 size_bytes = 2;
} }
*/

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.5.1 // - protoc-gen-go-grpc v1.5.1
// - protoc v4.25.3 // - protoc v6.32.0
// source: snapshot.proto // source: snapshot.proto
package grpc package grpc
@@ -22,6 +22,8 @@ const (
SnapshotService_ListSnapshots_FullMethodName = "/agate.grpc.SnapshotService/ListSnapshots" SnapshotService_ListSnapshots_FullMethodName = "/agate.grpc.SnapshotService/ListSnapshots"
SnapshotService_GetSnapshotDetails_FullMethodName = "/agate.grpc.SnapshotService/GetSnapshotDetails" SnapshotService_GetSnapshotDetails_FullMethodName = "/agate.grpc.SnapshotService/GetSnapshotDetails"
SnapshotService_DownloadFile_FullMethodName = "/agate.grpc.SnapshotService/DownloadFile" SnapshotService_DownloadFile_FullMethodName = "/agate.grpc.SnapshotService/DownloadFile"
SnapshotService_DownloadSnapshotDiff_FullMethodName = "/agate.grpc.SnapshotService/DownloadSnapshotDiff"
SnapshotService_GetDiffInfo_FullMethodName = "/agate.grpc.SnapshotService/GetDiffInfo"
) )
// SnapshotServiceClient is the client API for SnapshotService service. // SnapshotServiceClient is the client API for SnapshotService service.
@@ -36,6 +38,10 @@ type SnapshotServiceClient interface {
GetSnapshotDetails(ctx context.Context, in *GetSnapshotDetailsRequest, opts ...grpc.CallOption) (*SnapshotDetails, error) GetSnapshotDetails(ctx context.Context, in *GetSnapshotDetailsRequest, opts ...grpc.CallOption) (*SnapshotDetails, error)
// Скачать конкретный файл из снапшота (потоковая передача) // Скачать конкретный файл из снапшота (потоковая передача)
DownloadFile(ctx context.Context, in *DownloadFileRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DownloadFileResponse], error) DownloadFile(ctx context.Context, in *DownloadFileRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DownloadFileResponse], error)
// Скачать архив, содержащий только разницу между двумя снапшотами
DownloadSnapshotDiff(ctx context.Context, in *DownloadSnapshotDiffRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DownloadFileResponse], error)
// Получить информацию о дифе (хеш и размер)
GetDiffInfo(ctx context.Context, in *GetDiffInfoRequest, opts ...grpc.CallOption) (*DiffInfo, error)
} }
type snapshotServiceClient struct { type snapshotServiceClient struct {
@@ -85,6 +91,35 @@ func (c *snapshotServiceClient) DownloadFile(ctx context.Context, in *DownloadFi
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SnapshotService_DownloadFileClient = grpc.ServerStreamingClient[DownloadFileResponse] type SnapshotService_DownloadFileClient = grpc.ServerStreamingClient[DownloadFileResponse]
func (c *snapshotServiceClient) DownloadSnapshotDiff(ctx context.Context, in *DownloadSnapshotDiffRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DownloadFileResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &SnapshotService_ServiceDesc.Streams[1], SnapshotService_DownloadSnapshotDiff_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[DownloadSnapshotDiffRequest, DownloadFileResponse]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SnapshotService_DownloadSnapshotDiffClient = grpc.ServerStreamingClient[DownloadFileResponse]
func (c *snapshotServiceClient) GetDiffInfo(ctx context.Context, in *GetDiffInfoRequest, opts ...grpc.CallOption) (*DiffInfo, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiffInfo)
err := c.cc.Invoke(ctx, SnapshotService_GetDiffInfo_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// SnapshotServiceServer is the server API for SnapshotService service. // SnapshotServiceServer is the server API for SnapshotService service.
// All implementations must embed UnimplementedSnapshotServiceServer // All implementations must embed UnimplementedSnapshotServiceServer
// for forward compatibility. // for forward compatibility.
@@ -97,6 +132,10 @@ type SnapshotServiceServer interface {
GetSnapshotDetails(context.Context, *GetSnapshotDetailsRequest) (*SnapshotDetails, error) GetSnapshotDetails(context.Context, *GetSnapshotDetailsRequest) (*SnapshotDetails, error)
// Скачать конкретный файл из снапшота (потоковая передача) // Скачать конкретный файл из снапшота (потоковая передача)
DownloadFile(*DownloadFileRequest, grpc.ServerStreamingServer[DownloadFileResponse]) error DownloadFile(*DownloadFileRequest, grpc.ServerStreamingServer[DownloadFileResponse]) error
// Скачать архив, содержащий только разницу между двумя снапшотами
DownloadSnapshotDiff(*DownloadSnapshotDiffRequest, grpc.ServerStreamingServer[DownloadFileResponse]) error
// Получить информацию о дифе (хеш и размер)
GetDiffInfo(context.Context, *GetDiffInfoRequest) (*DiffInfo, error)
mustEmbedUnimplementedSnapshotServiceServer() mustEmbedUnimplementedSnapshotServiceServer()
} }
@@ -116,6 +155,12 @@ func (UnimplementedSnapshotServiceServer) GetSnapshotDetails(context.Context, *G
func (UnimplementedSnapshotServiceServer) DownloadFile(*DownloadFileRequest, grpc.ServerStreamingServer[DownloadFileResponse]) error { func (UnimplementedSnapshotServiceServer) DownloadFile(*DownloadFileRequest, grpc.ServerStreamingServer[DownloadFileResponse]) error {
return status.Errorf(codes.Unimplemented, "method DownloadFile not implemented") return status.Errorf(codes.Unimplemented, "method DownloadFile not implemented")
} }
func (UnimplementedSnapshotServiceServer) DownloadSnapshotDiff(*DownloadSnapshotDiffRequest, grpc.ServerStreamingServer[DownloadFileResponse]) error {
return status.Errorf(codes.Unimplemented, "method DownloadSnapshotDiff not implemented")
}
func (UnimplementedSnapshotServiceServer) GetDiffInfo(context.Context, *GetDiffInfoRequest) (*DiffInfo, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetDiffInfo not implemented")
}
func (UnimplementedSnapshotServiceServer) mustEmbedUnimplementedSnapshotServiceServer() {} func (UnimplementedSnapshotServiceServer) mustEmbedUnimplementedSnapshotServiceServer() {}
func (UnimplementedSnapshotServiceServer) testEmbeddedByValue() {} func (UnimplementedSnapshotServiceServer) testEmbeddedByValue() {}
@@ -184,6 +229,35 @@ func _SnapshotService_DownloadFile_Handler(srv interface{}, stream grpc.ServerSt
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SnapshotService_DownloadFileServer = grpc.ServerStreamingServer[DownloadFileResponse] type SnapshotService_DownloadFileServer = grpc.ServerStreamingServer[DownloadFileResponse]
func _SnapshotService_DownloadSnapshotDiff_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(DownloadSnapshotDiffRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(SnapshotServiceServer).DownloadSnapshotDiff(m, &grpc.GenericServerStream[DownloadSnapshotDiffRequest, DownloadFileResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SnapshotService_DownloadSnapshotDiffServer = grpc.ServerStreamingServer[DownloadFileResponse]
func _SnapshotService_GetDiffInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiffInfoRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SnapshotServiceServer).GetDiffInfo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SnapshotService_GetDiffInfo_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SnapshotServiceServer).GetDiffInfo(ctx, req.(*GetDiffInfoRequest))
}
return interceptor(ctx, in, info, handler)
}
// SnapshotService_ServiceDesc is the grpc.ServiceDesc for SnapshotService service. // SnapshotService_ServiceDesc is the grpc.ServiceDesc for SnapshotService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -199,6 +273,10 @@ var SnapshotService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetSnapshotDetails", MethodName: "GetSnapshotDetails",
Handler: _SnapshotService_GetSnapshotDetails_Handler, Handler: _SnapshotService_GetSnapshotDetails_Handler,
}, },
{
MethodName: "GetDiffInfo",
Handler: _SnapshotService_GetDiffInfo_Handler,
},
}, },
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {
@@ -206,6 +284,11 @@ var SnapshotService_ServiceDesc = grpc.ServiceDesc{
Handler: _SnapshotService_DownloadFile_Handler, Handler: _SnapshotService_DownloadFile_Handler,
ServerStreams: true, ServerStreams: true,
}, },
{
StreamName: "DownloadSnapshotDiff",
Handler: _SnapshotService_DownloadSnapshotDiff_Handler,
ServerStreams: true,
},
}, },
Metadata: "snapshot.proto", Metadata: "snapshot.proto",
} }

View File

@@ -2,203 +2,141 @@ package agate
import ( import (
"context" "context"
"log"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"gitea.unprism.ru/KRBL/Agate/remote" "gitea.unprism.ru/KRBL/Agate/remote"
"gitea.unprism.ru/KRBL/Agate/store"
) )
// TestGRPCServerClient tests the interaction between a gRPC server and client. // TestFullUpdateCycle tests a complete workflow: full download, then incremental update.
// It creates multiple snapshots with different content on the server, func TestFullUpdateCycle(t *testing.T) {
// connects a client to the server, downloads the latest snapshot,
// and verifies the contents of the files.
func TestGRPCServerClient(t *testing.T) {
// Skip this test in short mode
if testing.Short() { if testing.Short() {
t.Skip("Skipping gRPC server-client test in short mode") t.Skip("Skipping full gRPC update cycle test in short mode")
} }
// Create a temporary directory for the server // --- 1. Setup Server ---
serverDir, err := os.MkdirTemp("", "agate-server-*") serverDir, err := os.MkdirTemp("", "agate-server-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create server temp directory: %v", err) t.Fatalf("Failed to create server temp directory: %v", err)
} }
defer os.RemoveAll(serverDir) defer os.RemoveAll(serverDir)
// Create a temporary directory for the client serverAgate, err := New(AgateOptions{WorkDir: serverDir})
if err != nil {
t.Fatalf("Failed to create server Agate instance: %v", err)
}
defer serverAgate.Close()
// --- 2. Create Initial Snapshot (A) ---
dataDir := serverAgate.GetActiveDir()
if err := os.WriteFile(filepath.Join(dataDir, "file1.txt"), []byte("Version 1"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "file2.txt"), []byte("Original Content"), 0644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
snapshotAID, err := serverAgate.SaveSnapshot(ctx, "Snapshot A", "")
if err != nil {
t.Fatalf("Failed to create Snapshot A: %v", err)
}
t.Logf("Created Snapshot A with ID: %s", snapshotAID)
// --- 3. Start Server ---
serverAddress := "localhost:50051"
server := remote.NewServer(serverAgate.manager)
go func() {
if err := server.Start(ctx, serverAddress); err != nil && err != context.Canceled {
log.Printf("Server start error: %v", err)
}
}()
defer server.Stop()
time.Sleep(100 * time.Millisecond)
// --- 4. Setup Client ---
clientDir, err := os.MkdirTemp("", "agate-client-*") clientDir, err := os.MkdirTemp("", "agate-client-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create client temp directory: %v", err) t.Fatalf("Failed to create client temp directory: %v", err)
} }
defer os.RemoveAll(clientDir) defer os.RemoveAll(clientDir)
// Create Agate options for the server clientAgate, err := New(AgateOptions{WorkDir: clientDir, CleanOnRestore: true})
serverOptions := AgateOptions{
WorkDir: serverDir,
}
// Create Agate instance for the server
serverAgate, err := New(serverOptions)
if err != nil { if err != nil {
t.Fatalf("Failed to create server Agate instance: %v", err) t.Fatalf("Failed to create client Agate instance: %v", err)
} }
defer serverAgate.Close() defer clientAgate.Close()
// Create a data directory // --- 5. Client Performs Full Download of Snapshot A ---
dataDir := serverAgate.options.BlobStore.GetActiveDir() t.Log("Client performing full download of Snapshot A...")
if err := os.MkdirAll(dataDir, 0755); err != nil { if err := clientAgate.GetRemoteSnapshot(ctx, serverAddress, snapshotAID, ""); err != nil {
t.Fatalf("Failed to create data directory: %v", err) t.Fatalf("Client failed to get Snapshot A: %v", err)
} }
// Create initial test files for the first snapshot // Verify content of Snapshot A on client
initialFiles := map[string]string{ if err := clientAgate.RestoreSnapshot(ctx, snapshotAID); err != nil {
filepath.Join(dataDir, "file1.txt"): "Initial content of file 1", t.Fatalf("Failed to restore Snapshot A: %v", err)
filepath.Join(dataDir, "file2.txt"): "Initial content of file 2",
filepath.Join(dataDir, "subdir", "file3.txt"): "Initial content of file 3",
} }
verifyFileContent(t, clientAgate.GetActiveDir(), "file1.txt", "Version 1")
verifyFileContent(t, clientAgate.GetActiveDir(), "file2.txt", "Original Content")
t.Log("Snapshot A verified on client.")
// Create subdirectory // --- 6. Server Creates Incremental Snapshot (B) ---
if err := os.MkdirAll(filepath.Join(dataDir, "subdir"), 0755); err != nil { if err := os.WriteFile(filepath.Join(dataDir, "file1.txt"), []byte("Version 2"), 0644); err != nil {
t.Fatalf("Failed to create subdirectory: %v", err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(dataDir, "file3.txt"), []byte("New File"), 0644); err != nil {
// Create the files t.Fatal(err)
for path, content := range initialFiles {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file %s: %v", path, err)
} }
}
// Create the first snapshot
ctx := context.Background()
snapshot1ID, err := serverAgate.SaveSnapshot(ctx, "Snapshot 1", "")
if err != nil {
t.Fatalf("Failed to create first snapshot: %v", err)
}
t.Logf("Created first snapshot with ID: %s", snapshot1ID)
// Modify some files and add a new file for the second snapshot
modifiedFiles := map[string]string{
filepath.Join(dataDir, "file1.txt"): "Modified content of file 1",
filepath.Join(dataDir, "file4.txt"): "Content of new file 4",
}
for path, content := range modifiedFiles {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("Failed to modify/create test file %s: %v", path, err)
}
}
// Create the second snapshot
snapshot2ID, err := serverAgate.SaveSnapshot(ctx, "Snapshot 2", snapshot1ID)
if err != nil {
t.Fatalf("Failed to create second snapshot: %v", err)
}
t.Logf("Created second snapshot with ID: %s", snapshot2ID)
// Delete a file and modify another for the third snapshot
if err := os.Remove(filepath.Join(dataDir, "file2.txt")); err != nil { if err := os.Remove(filepath.Join(dataDir, "file2.txt")); err != nil {
t.Fatalf("Failed to delete test file: %v", err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(dataDir, "subdir/file3.txt"), []byte("Modified content of file 3"), 0644); err != nil { snapshotBID, err := serverAgate.SaveSnapshot(ctx, "Snapshot B", snapshotAID)
t.Fatalf("Failed to modify test file: %v", err)
}
// Create the third snapshot
snapshot3ID, err := serverAgate.SaveSnapshot(ctx, "Snapshot 3", snapshot2ID)
if err != nil { if err != nil {
t.Fatalf("Failed to create third snapshot: %v", err) t.Fatalf("Failed to create Snapshot B: %v", err)
} }
t.Logf("Created third snapshot with ID: %s", snapshot3ID) t.Logf("Created Snapshot B with ID: %s", snapshotBID)
// Start the gRPC server // --- 7. Client Performs Incremental Download of Snapshot B ---
serverAddress := "localhost:50051" t.Log("Client performing incremental download of Snapshot B...")
server, err := remote.RunServer(ctx, serverAgate.manager, serverAddress) parentIDOnClient := clientAgate.GetCurrentSnapshotID()
if parentIDOnClient != snapshotAID {
t.Fatalf("Client has incorrect current snapshot ID. Got %s, want %s", parentIDOnClient, snapshotAID)
}
if err := clientAgate.GetRemoteSnapshot(ctx, serverAddress, snapshotBID, parentIDOnClient); err != nil {
t.Fatalf("Client failed to get Snapshot B: %v", err)
}
// --- 8. Verify Final State on Client ---
if err := clientAgate.RestoreSnapshot(ctx, snapshotBID); err != nil {
t.Fatalf("Failed to restore Snapshot B: %v", err)
}
t.Log("Snapshot B restored on client. Verifying content...")
verifyFileContent(t, clientAgate.GetActiveDir(), "file1.txt", "Version 2")
verifyFileContent(t, clientAgate.GetActiveDir(), "file3.txt", "New File")
// Verify that file2.txt was removed because CleanOnRestore is true
if _, err := os.Stat(filepath.Join(clientAgate.GetActiveDir(), "file2.txt")); !os.IsNotExist(err) {
t.Errorf("file2.txt should have been removed after restoring Snapshot B, but it still exists.")
}
t.Log("Final state verified successfully!")
}
func verifyFileContent(t *testing.T, dir, filename, expectedContent string) {
t.Helper()
content, err := os.ReadFile(filepath.Join(dir, filename))
if err != nil { if err != nil {
t.Fatalf("Failed to start gRPC server: %v", err) t.Fatalf("Failed to read file %s: %v", filename, err)
}
defer server.Stop(ctx)
// Give the server a moment to start
time.Sleep(100 * time.Millisecond)
// Connect a client to the server
client, err := remote.NewClient(serverAddress)
if err != nil {
t.Fatalf("Failed to connect client to server: %v", err)
}
defer client.Close()
// List snapshots from the client
snapshots, err := client.ListSnapshots(ctx)
if err != nil {
t.Fatalf("Failed to list snapshots from client: %v", err)
}
// Verify we have 3 snapshots
if len(snapshots) != 3 {
t.Errorf("Expected 3 snapshots, got %d", len(snapshots))
}
// Find the latest snapshot (should be snapshot3)
var latestSnapshot store.SnapshotInfo
for _, snapshot := range snapshots {
if latestSnapshot.CreationTime.Before(snapshot.CreationTime) {
latestSnapshot = snapshot
}
}
// Verify the latest snapshot is snapshot3
if latestSnapshot.ID != snapshot3ID {
t.Errorf("Latest snapshot ID is %s, expected %s", latestSnapshot.ID, snapshot3ID)
}
// Get detailed information about the latest snapshot
snapshotDetails, err := client.FetchSnapshotDetails(ctx, latestSnapshot.ID)
if err != nil {
t.Fatalf("Failed to fetch snapshot details: %v", err)
}
// Verify the snapshot details
if snapshotDetails.ID != snapshot3ID {
t.Errorf("Snapshot details ID is %s, expected %s", snapshotDetails.ID, snapshot3ID)
}
// Create a directory to download the snapshot to
downloadDir := filepath.Join(clientDir, "download")
if err := os.MkdirAll(downloadDir, 0755); err != nil {
t.Fatalf("Failed to create download directory: %v", err)
}
// Download the snapshot
err = client.DownloadSnapshot(ctx, latestSnapshot.ID, downloadDir, "")
if err != nil {
t.Fatalf("Failed to download snapshot: %v", err)
}
// Verify the downloaded files match the expected content
expectedFiles := map[string]string{
filepath.Join(downloadDir, "file1.txt"): "Modified content of file 1",
filepath.Join(downloadDir, "file4.txt"): "Content of new file 4",
filepath.Join(downloadDir, "subdir/file3.txt"): "Modified content of file 3",
}
for path, expectedContent := range expectedFiles {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read downloaded file %s: %v", path, err)
} }
if string(content) != expectedContent { if string(content) != expectedContent {
t.Errorf("Downloaded file %s has wrong content: got %s, want %s", path, string(content), expectedContent) t.Errorf("File %s has wrong content: got '%s', want '%s'", filename, string(content), expectedContent)
}
}
// Verify that file2.txt doesn't exist in the downloaded snapshot
if _, err := os.Stat(filepath.Join(downloadDir, "file2.txt")); !os.IsNotExist(err) {
t.Errorf("file2.txt should not exist in the downloaded snapshot")
} }
} }

View File

@@ -7,27 +7,57 @@ import (
"gitea.unprism.ru/KRBL/Agate/store" "gitea.unprism.ru/KRBL/Agate/store"
) )
// SnapshotManager defines the interface that the server needs to interact with snapshots // SnapshotManager is an interface that defines operations for managing and interacting with snapshots.
type SnapshotManager interface { type SnapshotManager interface {
// GetSnapshotDetails retrieves detailed metadata for a specific snapshot // CreateSnapshot creates a new snapshot from the specified source directory, associating it with a given name and parent ID.
// Returns the created Snapshot with its metadata or an error if the process fails.
CreateSnapshot(ctx context.Context, sourceDir string, name string, parentID string) (*store.Snapshot, error)
// CreateSnapshotAsync initiates a background process to create a snapshot.
// Returns the job ID (which is also the snapshot ID) or an error if the process couldn't start.
// onStart is called in the background goroutine before the snapshot creation starts.
// onFinish is called in the background goroutine after the snapshot creation finishes (successfully or with error).
CreateSnapshotAsync(ctx context.Context, sourceDir string, name string, parentID string, onStart func(), onFinish func(string, error)) (string, error)
// GetSnapshotStatus retrieves the status of an asynchronous snapshot creation job.
GetSnapshotStatus(ctx context.Context, jobID string) (*store.SnapshotStatus, error)
// GetSnapshotDetails retrieves detailed metadata for a specific snapshot identified by its unique snapshotID.
// Returns a Snapshot object containing metadata
GetSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error) GetSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error)
// ListSnapshots retrieves a list of all available snapshots // ListSnapshots retrieves a list of available snapshots with filtering and pagination options.
ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error) ListSnapshots(ctx context.Context, opts store.ListOptions) ([]store.SnapshotInfo, error)
// OpenFile retrieves and opens a file from the specified snapshot // DeleteSnapshot removes a snapshot identified by snapshotID. Returns an error if the snapshot does not exist or cannot be deleted.
DeleteSnapshot(ctx context.Context, snapshotID string) error
// OpenFile retrieves and opens a file from the specified snapshot, returning a readable stream and an error, if any.
OpenFile(ctx context.Context, snapshotID string, filePath string) (io.ReadCloser, error) OpenFile(ctx context.Context, snapshotID string, filePath string) (io.ReadCloser, error)
// CreateSnapshot creates a new snapshot from the specified source directory // ExtractSnapshot extracts the contents of a specified snapshot to a target directory at the given path.
CreateSnapshot(ctx context.Context, sourceDir string, name string, parentID string) (*store.Snapshot, error) // If cleanTarget is true, the target directory will be cleaned before extraction.
// Returns an error if the snapshot ID is invalid or the extraction fails.
ExtractSnapshot(ctx context.Context, snapshotID string, path string, cleanTarget bool) error
// UpdateSnapshotMetadata updates the metadata of an existing snapshot, allowing changes to its name.
UpdateSnapshotMetadata(ctx context.Context, snapshotID string, newName string) error
// StreamSnapshotDiff creates and streams a differential archive between two snapshots.
// It returns an io.ReadCloser for the archive stream and an error.
// The caller is responsible for closing the reader, which will also handle cleanup of temporary resources.
StreamSnapshotDiff(ctx context.Context, snapshotID, parentID string, offset int64) (io.ReadCloser, error)
// GetSnapshotDiffInfo calculates the hash and size of a differential archive between two snapshots.
GetSnapshotDiffInfo(ctx context.Context, snapshotID, parentID string) (*store.DiffInfo, error)
} }
// SnapshotServer defines the interface for a server that can share snapshots // SnapshotServer defines the interface for a server that can share snapshots
type SnapshotServer interface { type SnapshotServer interface {
// Start initializes and begins the server's operation // Start initializes and begins the server's operation, handling incoming requests or processes within the provided context.
Start(ctx context.Context) error Start(ctx context.Context) error
// Stop gracefully shuts down the server // Stop gracefully shuts down the server, releasing any allocated resources and ensuring all operations are completed.
Stop(ctx context.Context) error Stop(ctx context.Context) error
} }
@@ -39,8 +69,8 @@ type SnapshotClient interface {
// FetchSnapshotDetails retrieves detailed information about a specific snapshot // FetchSnapshotDetails retrieves detailed information about a specific snapshot
FetchSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error) FetchSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error)
// DownloadSnapshot downloads a snapshot from the server // DownloadSnapshotDiff downloads a differential archive between two snapshots to a target directory
DownloadSnapshot(ctx context.Context, snapshotID string, targetDir string, localParentID string) error DownloadSnapshotDiff(ctx context.Context, snapshotID, localParentID, targetPath string) error
// Close closes the connection to the server // Close closes the connection to the server
Close() error Close() error

View File

@@ -2,34 +2,51 @@ package agate
import ( import (
"archive/zip" "archive/zip"
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync"
"time" "time"
"gitea.unprism.ru/KRBL/Agate/models"
"github.com/google/uuid" "github.com/google/uuid"
"gitea.unprism.ru/KRBL/Agate/archive" "gitea.unprism.ru/KRBL/Agate/archive"
"gitea.unprism.ru/KRBL/Agate/hash" "gitea.unprism.ru/KRBL/Agate/hash"
"gitea.unprism.ru/KRBL/Agate/interfaces"
"gitea.unprism.ru/KRBL/Agate/store" "gitea.unprism.ru/KRBL/Agate/store"
) )
type SnapshotManagerData struct { type SnapshotManagerData struct {
metadataStore store.MetadataStore metadataStore store.MetadataStore
blobStore store.BlobStore blobStore store.BlobStore
logger *log.Logger
jobs map[string]*store.SnapshotStatus
jobsMutex sync.RWMutex
} }
func CreateSnapshotManager(metadataStore store.MetadataStore, blobStore store.BlobStore) (SnapshotManager, error) { func CreateSnapshotManager(metadataStore store.MetadataStore, blobStore store.BlobStore, logger *log.Logger) (interfaces.SnapshotManager, error) {
if metadataStore == nil || blobStore == nil { if metadataStore == nil || blobStore == nil {
return nil, errors.New("parameters can't be nil") return nil, errors.New("parameters can't be nil")
} }
return &SnapshotManagerData{metadataStore, blobStore}, nil // Ensure logger is never nil.
if logger == nil {
logger = log.New(io.Discard, "", 0)
}
return &SnapshotManagerData{
metadataStore: metadataStore,
blobStore: blobStore,
logger: logger,
jobs: make(map[string]*store.SnapshotStatus),
}, nil
} }
func (data *SnapshotManagerData) CreateSnapshot(ctx context.Context, sourceDir string, name string, parentID string) (*store.Snapshot, error) { func (data *SnapshotManagerData) CreateSnapshot(ctx context.Context, sourceDir string, name string, parentID string) (*store.Snapshot, error) {
@@ -37,13 +54,13 @@ func (data *SnapshotManagerData) CreateSnapshot(ctx context.Context, sourceDir s
info, err := os.Stat(sourceDir) info, err := os.Stat(sourceDir)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, ErrSourceNotFound return nil, models.ErrSourceNotFound
} }
return nil, fmt.Errorf("failed to access source directory: %w", err) return nil, fmt.Errorf("failed to access source directory: %w", err)
} }
if !info.IsDir() { if !info.IsDir() {
return nil, ErrSourceNotDirectory return nil, models.ErrSourceNotDirectory
} }
// Check if parent exists if specified // Check if parent exists if specified
@@ -58,6 +75,98 @@ func (data *SnapshotManagerData) CreateSnapshot(ctx context.Context, sourceDir s
// Generate a unique ID for the snapshot // Generate a unique ID for the snapshot
snapshotID := uuid.New().String() snapshotID := uuid.New().String()
return data.createSnapshotInternal(ctx, sourceDir, name, parentID, snapshotID, nil)
}
func (data *SnapshotManagerData) CreateSnapshotAsync(ctx context.Context, sourceDir string, name string, parentID string, onStart func(), onFinish func(string, error)) (string, error) {
// Validate source directory
info, err := os.Stat(sourceDir)
if err != nil {
if os.IsNotExist(err) {
return "", models.ErrSourceNotFound
}
return "", fmt.Errorf("failed to access source directory: %w", err)
}
if !info.IsDir() {
return "", models.ErrSourceNotDirectory
}
// Check if parent exists if specified
if parentID != "" {
_, err := data.metadataStore.GetSnapshotMetadata(ctx, parentID)
if err != nil {
fmt.Println("failed to check parent snapshot: %w", err)
parentID = ""
}
}
snapshotID := uuid.New().String()
data.jobsMutex.Lock()
data.jobs[snapshotID] = &store.SnapshotStatus{
ID: snapshotID,
Status: "pending",
Progress: 0,
}
data.jobsMutex.Unlock()
go func() {
if onStart != nil {
onStart()
}
data.jobsMutex.Lock()
if job, ok := data.jobs[snapshotID]; ok {
job.Status = "running"
}
data.jobsMutex.Unlock()
_, err := data.createSnapshotInternal(context.Background(), sourceDir, name, parentID, snapshotID, func(current, total int64) {
data.jobsMutex.Lock()
defer data.jobsMutex.Unlock()
if job, ok := data.jobs[snapshotID]; ok {
if total > 0 {
job.Progress = float64(current) / float64(total)
}
}
})
data.jobsMutex.Lock()
if job, ok := data.jobs[snapshotID]; ok {
if err != nil {
job.Status = "failed"
job.Error = err.Error()
} else {
job.Status = "done"
job.Progress = 1.0
}
}
data.jobsMutex.Unlock()
if onFinish != nil {
onFinish(snapshotID, err)
}
}()
return snapshotID, nil
}
func (data *SnapshotManagerData) GetSnapshotStatus(ctx context.Context, jobID string) (*store.SnapshotStatus, error) {
data.jobsMutex.RLock()
defer data.jobsMutex.RUnlock()
job, ok := data.jobs[jobID]
if !ok {
return nil, models.ErrNotFound
}
// Return a copy to avoid race conditions
statusCopy := *job
return &statusCopy, nil
}
func (data *SnapshotManagerData) createSnapshotInternal(ctx context.Context, sourceDir, name, parentID, snapshotID string, onProgress func(current, total int64)) (*store.Snapshot, error) {
// Create a temporary file for the archive in the working directory // Create a temporary file for the archive in the working directory
tempFilePath := filepath.Join(data.blobStore.GetBaseDir(), "temp-"+snapshotID+".zip") tempFilePath := filepath.Join(data.blobStore.GetBaseDir(), "temp-"+snapshotID+".zip")
tempFile, err := os.Create(tempFilePath) tempFile, err := os.Create(tempFilePath)
@@ -68,7 +177,7 @@ func (data *SnapshotManagerData) CreateSnapshot(ctx context.Context, sourceDir s
defer os.Remove(tempFilePath) // Clean up temp file after we're done defer os.Remove(tempFilePath) // Clean up temp file after we're done
// Create archive of the source directory // Create archive of the source directory
if err := archive.CreateArchive(sourceDir, tempFilePath); err != nil { if err := archive.CreateArchiveWithProgress(sourceDir, tempFilePath, onProgress); err != nil {
return nil, fmt.Errorf("failed to create archive: %w", err) return nil, fmt.Errorf("failed to create archive: %w", err)
} }
@@ -153,8 +262,8 @@ func (data *SnapshotManagerData) GetSnapshotDetails(ctx context.Context, snapsho
// Retrieve snapshot metadata from the store // Retrieve snapshot metadata from the store
snapshot, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID) snapshot, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, models.ErrNotFound) {
return nil, ErrNotFound return nil, models.ErrNotFound
} }
return nil, fmt.Errorf("failed to retrieve snapshot details: %w", err) return nil, fmt.Errorf("failed to retrieve snapshot details: %w", err)
} }
@@ -162,9 +271,9 @@ func (data *SnapshotManagerData) GetSnapshotDetails(ctx context.Context, snapsho
return snapshot, nil return snapshot, nil
} }
func (data *SnapshotManagerData) ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error) { func (data *SnapshotManagerData) ListSnapshots(ctx context.Context, opts store.ListOptions) ([]store.SnapshotInfo, error) {
// Retrieve list of snapshots from the metadata store // Retrieve list of snapshots from the metadata store with the provided options
snapshots, err := data.metadataStore.ListSnapshotsMetadata(ctx) snapshots, err := data.metadataStore.ListSnapshotsMetadata(ctx, opts)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list snapshots: %w", err) return nil, fmt.Errorf("failed to list snapshots: %w", err)
} }
@@ -177,27 +286,38 @@ func (data *SnapshotManagerData) DeleteSnapshot(ctx context.Context, snapshotID
return errors.New("snapshot ID cannot be empty") return errors.New("snapshot ID cannot be empty")
} }
// First check if the snapshot exists snapshot, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID)
_, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, models.ErrNotFound) {
// If snapshot doesn't exist, return success (idempotent operation)
return nil return nil
} }
return fmt.Errorf("failed to check if snapshot exists: %w", err) return fmt.Errorf("failed to check if snapshot exists: %w", err)
} }
// Delete the metadata first parentID := snapshot.ParentID
opts := store.ListOptions{}
allSnapshots, err := data.metadataStore.ListSnapshotsMetadata(ctx, opts)
if err != nil {
return fmt.Errorf("failed to list snapshots: %w", err)
}
for _, info := range allSnapshots {
if info.ParentID == snapshotID {
if err := data.metadataStore.UpdateSnapshotParentID(ctx, info.ID, parentID); err != nil {
data.logger.Printf("WARNING: failed to update parent reference for snapshot %s: %v", info.ID, err)
} else {
data.logger.Printf("Updated parent reference for snapshot %s from %s to %s", info.ID, snapshotID, parentID)
}
}
}
if err := data.metadataStore.DeleteSnapshotMetadata(ctx, snapshotID); err != nil { if err := data.metadataStore.DeleteSnapshotMetadata(ctx, snapshotID); err != nil {
return fmt.Errorf("failed to delete snapshot metadata: %w", err) return fmt.Errorf("failed to delete snapshot metadata: %w", err)
} }
// Then delete the blob
if err := data.blobStore.DeleteBlob(ctx, snapshotID); err != nil { if err := data.blobStore.DeleteBlob(ctx, snapshotID); err != nil {
// Note: We don't return here because we've already deleted the metadata data.logger.Printf("WARNING: failed to delete snapshot blob: %v", err)
// and the blob store should handle the case where the blob doesn't exist
// Log the error instead
fmt.Printf("Warning: failed to delete snapshot blob: %v\n", err)
} }
return nil return nil
@@ -214,8 +334,8 @@ func (data *SnapshotManagerData) OpenFile(ctx context.Context, snapshotID string
// First check if the snapshot exists // First check if the snapshot exists
_, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID) _, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, models.ErrNotFound) {
return nil, ErrNotFound return nil, models.ErrNotFound
} }
return nil, fmt.Errorf("failed to check if snapshot exists: %w", err) return nil, fmt.Errorf("failed to check if snapshot exists: %w", err)
} }
@@ -238,7 +358,7 @@ func (data *SnapshotManagerData) OpenFile(ctx context.Context, snapshotID string
err := archive.ExtractFileFromArchive(blobPath, filePath, pw) err := archive.ExtractFileFromArchive(blobPath, filePath, pw)
if err != nil { if err != nil {
if errors.Is(err, archive.ErrFileNotFoundInArchive) { if errors.Is(err, archive.ErrFileNotFoundInArchive) {
pw.CloseWithError(ErrFileNotFound) pw.CloseWithError(models.ErrFileNotFound)
return return
} }
pw.CloseWithError(fmt.Errorf("failed to extract file from archive: %w", err)) pw.CloseWithError(fmt.Errorf("failed to extract file from archive: %w", err))
@@ -248,7 +368,7 @@ func (data *SnapshotManagerData) OpenFile(ctx context.Context, snapshotID string
return pr, nil return pr, nil
} }
func (data *SnapshotManagerData) ExtractSnapshot(ctx context.Context, snapshotID string, path string) error { func (data *SnapshotManagerData) ExtractSnapshot(ctx context.Context, snapshotID string, path string, cleanTarget bool) error {
if snapshotID == "" { if snapshotID == "" {
return errors.New("snapshot ID cannot be empty") return errors.New("snapshot ID cannot be empty")
} }
@@ -258,11 +378,11 @@ func (data *SnapshotManagerData) ExtractSnapshot(ctx context.Context, snapshotID
path = data.blobStore.GetActiveDir() path = data.blobStore.GetActiveDir()
} }
// First check if the snapshot exists and get its metadata // First check if the snapshot exists
snapshot, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID) _, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, models.ErrNotFound) {
return ErrNotFound return models.ErrNotFound
} }
return fmt.Errorf("failed to check if snapshot exists: %w", err) return fmt.Errorf("failed to check if snapshot exists: %w", err)
} }
@@ -273,104 +393,27 @@ func (data *SnapshotManagerData) ExtractSnapshot(ctx context.Context, snapshotID
return fmt.Errorf("failed to get blob path: %w", err) return fmt.Errorf("failed to get blob path: %w", err)
} }
// Ensure the target directory exists // If cleanTarget is true, clean the target directory before extraction
if cleanTarget {
// Remove the directory and recreate it
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to clean target directory: %w", err)
}
if err := os.MkdirAll(path, 0755); err != nil { if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create target directory: %w", err) return fmt.Errorf("failed to create target directory: %w", err)
} }
} else {
// Just ensure the target directory exists
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create target directory: %w", err)
}
}
// Extract the archive to the target directory // Extract the archive to the target directory
if err := extractArchive(blobPath, path); err != nil { if err := extractArchive(blobPath, path); err != nil {
return fmt.Errorf("failed to extract snapshot: %w", err) return fmt.Errorf("failed to extract snapshot: %w", err)
} }
// Create maps for files and directories in the snapshot for quick lookup
snapshotFiles := make(map[string]bool)
snapshotDirs := make(map[string]bool)
for _, file := range snapshot.Files {
if file.IsDir {
snapshotDirs[file.Path] = true
} else {
snapshotFiles[file.Path] = true
}
}
// First pass: Collect all files and directories in the target
var allPaths []string
err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip the root directory itself
if filePath == path {
return nil
}
// Create relative path
relPath, err := filepath.Rel(path, filePath)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
relPath = filepath.ToSlash(relPath)
allPaths = append(allPaths, filePath)
return nil
})
if err != nil {
return fmt.Errorf("failed to scan target directory: %w", err)
}
// Sort paths by length in descending order to process deepest paths first
// This ensures we process files before their parent directories
sort.Slice(allPaths, func(i, j int) bool {
return len(allPaths[i]) > len(allPaths[j])
})
// Second pass: Remove files and directories that aren't in the snapshot
for _, filePath := range allPaths {
info, err := os.Stat(filePath)
if err != nil {
// Skip if file no longer exists (might have been in a directory we already removed)
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("failed to stat file %s: %w", filePath, err)
}
// Create relative path
relPath, err := filepath.Rel(path, filePath)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
relPath = filepath.ToSlash(relPath)
if info.IsDir() {
// For directories, check if it's in the snapshot or if it's empty
if !snapshotDirs[relPath] {
// Check if directory is empty
entries, err := os.ReadDir(filePath)
if err != nil {
return fmt.Errorf("failed to read directory %s: %w", filePath, err)
}
// If directory is empty, remove it
if len(entries) == 0 {
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove directory %s: %w", filePath, err)
}
}
}
} else {
// For files, remove if not in the snapshot
if !snapshotFiles[relPath] {
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove file %s: %w", filePath, err)
}
}
}
}
return nil return nil
} }
@@ -440,22 +483,178 @@ func (data *SnapshotManagerData) UpdateSnapshotMetadata(ctx context.Context, sna
return errors.New("new name cannot be empty") return errors.New("new name cannot be empty")
} }
// Get the current snapshot metadata
snapshot, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID) snapshot, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, models.ErrNotFound) {
return ErrNotFound return models.ErrNotFound
} }
return fmt.Errorf("failed to get snapshot metadata: %w", err) return fmt.Errorf("failed to get snapshot metadata: %w", err)
} }
// Update the name
snapshot.Name = newName snapshot.Name = newName
// Save the updated metadata
if err := data.metadataStore.SaveSnapshotMetadata(ctx, *snapshot); err != nil { if err := data.metadataStore.SaveSnapshotMetadata(ctx, *snapshot); err != nil {
return fmt.Errorf("failed to update snapshot metadata: %w", err) return fmt.Errorf("failed to update snapshot metadata: %w", err)
} }
return nil return nil
} }
func (data *SnapshotManagerData) GetSnapshotDiffInfo(ctx context.Context, snapshotID, parentID string) (*store.DiffInfo, error) {
tempArchivePath, tempStagingDir, err := data.createDiffArchive(ctx, snapshotID, parentID)
if err != nil {
return nil, fmt.Errorf("failed to create diff archive for info: %w", err)
}
if tempArchivePath == "" {
return &store.DiffInfo{SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", Size: 0}, nil // sha256 of empty string
}
defer os.Remove(tempArchivePath)
if tempStagingDir != "" {
defer os.RemoveAll(tempStagingDir)
}
hash, err := hash.CalculateFileHash(tempArchivePath)
if err != nil {
return nil, fmt.Errorf("failed to calculate hash for diff archive: %w", err)
}
stat, err := os.Stat(tempArchivePath)
if err != nil {
return nil, fmt.Errorf("failed to get size of diff archive: %w", err)
}
return &store.DiffInfo{
SHA256: hash,
Size: stat.Size(),
}, nil
}
// diffArchiveReader is a wrapper around an *os.File that handles cleanup of temporary files.
type diffArchiveReader struct {
*os.File
tempArchive string
tempStaging string
}
// Close closes the file and removes the temporary archive and staging directory.
func (r *diffArchiveReader) Close() error {
err := r.File.Close()
_ = os.Remove(r.tempArchive)
_ = os.RemoveAll(r.tempStaging)
return err
}
func (data *SnapshotManagerData) createDiffArchive(ctx context.Context, snapshotID, parentID string) (string, string, error) {
targetSnap, err := data.metadataStore.GetSnapshotMetadata(ctx, snapshotID)
if err != nil {
return "", "", fmt.Errorf("failed to get target snapshot metadata: %w", err)
}
parentFiles := make(map[string]string)
if parentID != "" {
parentSnap, err := data.metadataStore.GetSnapshotMetadata(ctx, parentID)
if err == nil {
for _, file := range parentSnap.Files {
if !file.IsDir {
parentFiles[file.Path] = file.SHA256
}
}
} else {
data.logger.Printf("Warning: failed to get parent snapshot %s, creating full diff: %v", parentID, err)
}
}
var filesToInclude []string
for _, file := range targetSnap.Files {
if file.IsDir {
continue
}
if parentHash, ok := parentFiles[file.Path]; !ok || parentHash != file.SHA256 {
filesToInclude = append(filesToInclude, file.Path)
}
}
if len(filesToInclude) == 0 {
return "", "", nil
}
tempStagingDir, err := os.MkdirTemp(data.blobStore.GetBaseDir(), "diff-staging-*")
if err != nil {
return "", "", fmt.Errorf("failed to create temp staging directory: %w", err)
}
targetBlobPath, err := data.blobStore.GetBlobPath(ctx, snapshotID)
if err != nil {
os.RemoveAll(tempStagingDir)
return "", "", err
}
for _, filePath := range filesToInclude {
destPath := filepath.Join(tempStagingDir, filePath)
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
os.RemoveAll(tempStagingDir)
return "", "", fmt.Errorf("failed to create dir for diff file: %w", err)
}
fileWriter, err := os.Create(destPath)
if err != nil {
os.RemoveAll(tempStagingDir)
return "", "", err
}
err = archive.ExtractFileFromArchive(targetBlobPath, filePath, fileWriter)
fileWriter.Close()
if err != nil {
os.RemoveAll(tempStagingDir)
return "", "", fmt.Errorf("failed to extract file %s for diff: %w", filePath, err)
}
}
tempArchivePath := filepath.Join(data.blobStore.GetBaseDir(), "diff-"+snapshotID+".zip")
if err := archive.CreateArchive(tempStagingDir, tempArchivePath); err != nil {
_ = os.RemoveAll(tempStagingDir)
_ = os.Remove(tempArchivePath)
return "", "", fmt.Errorf("failed to create diff archive: %w", err)
}
return tempArchivePath, tempStagingDir, nil
}
func (data *SnapshotManagerData) StreamSnapshotDiff(ctx context.Context, snapshotID, parentID string, offset int64) (io.ReadCloser, error) {
tempArchivePath, tempStagingDir, err := data.createDiffArchive(ctx, snapshotID, parentID)
if err != nil {
return nil, fmt.Errorf("failed to create diff archive for streaming: %w", err)
}
if tempArchivePath == "" {
return io.NopCloser(bytes.NewReader(nil)), nil
}
archiveFile, err := os.Open(tempArchivePath)
if err != nil {
if tempStagingDir != "" {
os.RemoveAll(tempStagingDir)
}
os.Remove(tempArchivePath)
return nil, err
}
if offset > 0 {
if _, err := archiveFile.Seek(offset, io.SeekStart); err != nil {
archiveFile.Close()
if tempStagingDir != "" {
os.RemoveAll(tempStagingDir)
}
os.Remove(tempArchivePath)
return nil, fmt.Errorf("failed to seek in diff archive: %w", err)
}
}
return &diffArchiveReader{
File: archiveFile,
tempArchive: tempArchivePath,
tempStaging: tempStagingDir,
}, nil
}

View File

@@ -90,8 +90,8 @@ func TestCreateAndGetSnapshot(t *testing.T) {
} }
createTestFiles(t, sourceDir) createTestFiles(t, sourceDir)
// Create a snapshot manager // Create a snapshot manager with nil logger
manager, err := CreateSnapshotManager(metadataStore, blobStore) manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err) t.Fatalf("Failed to create snapshot manager: %v", err)
} }
@@ -142,8 +142,8 @@ func TestListSnapshots(t *testing.T) {
} }
createTestFiles(t, sourceDir) createTestFiles(t, sourceDir)
// Create a snapshot manager // Create a snapshot manager with nil logger
manager, err := CreateSnapshotManager(metadataStore, blobStore) manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err) t.Fatalf("Failed to create snapshot manager: %v", err)
} }
@@ -165,8 +165,8 @@ func TestListSnapshots(t *testing.T) {
t.Fatalf("Failed to create snapshot: %v", err) t.Fatalf("Failed to create snapshot: %v", err)
} }
// List the snapshots // List the snapshots with empty options
snapshots, err := manager.ListSnapshots(ctx) snapshots, err := manager.ListSnapshots(ctx, store.ListOptions{})
if err != nil { if err != nil {
t.Fatalf("Failed to list snapshots: %v", err) t.Fatalf("Failed to list snapshots: %v", err)
} }
@@ -209,8 +209,8 @@ func TestDeleteSnapshot(t *testing.T) {
} }
createTestFiles(t, sourceDir) createTestFiles(t, sourceDir)
// Create a snapshot manager // Create a snapshot manager with nil logger
manager, err := CreateSnapshotManager(metadataStore, blobStore) manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err) t.Fatalf("Failed to create snapshot manager: %v", err)
} }
@@ -235,7 +235,7 @@ func TestDeleteSnapshot(t *testing.T) {
} }
// List snapshots to confirm it's gone // List snapshots to confirm it's gone
snapshots, err := manager.ListSnapshots(ctx) snapshots, err := manager.ListSnapshots(ctx, store.ListOptions{})
if err != nil { if err != nil {
t.Fatalf("Failed to list snapshots: %v", err) t.Fatalf("Failed to list snapshots: %v", err)
} }
@@ -255,8 +255,8 @@ func TestOpenFile(t *testing.T) {
} }
createTestFiles(t, sourceDir) createTestFiles(t, sourceDir)
// Create a snapshot manager // Create a snapshot manager with nil logger
manager, err := CreateSnapshotManager(metadataStore, blobStore) manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err) t.Fatalf("Failed to create snapshot manager: %v", err)
} }
@@ -309,8 +309,8 @@ func TestExtractSnapshot(t *testing.T) {
} }
createTestFiles(t, sourceDir) createTestFiles(t, sourceDir)
// Create a snapshot manager // Create a snapshot manager with nil logger
manager, err := CreateSnapshotManager(metadataStore, blobStore) manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err) t.Fatalf("Failed to create snapshot manager: %v", err)
} }
@@ -328,8 +328,8 @@ func TestExtractSnapshot(t *testing.T) {
t.Fatalf("Failed to create target directory: %v", err) t.Fatalf("Failed to create target directory: %v", err)
} }
// Extract the snapshot // Extract the snapshot with default behavior (cleanTarget=false)
err = manager.ExtractSnapshot(ctx, snapshot.ID, targetDir) err = manager.ExtractSnapshot(ctx, snapshot.ID, targetDir, false)
if err != nil { if err != nil {
t.Fatalf("Failed to extract snapshot: %v", err) t.Fatalf("Failed to extract snapshot: %v", err)
} }
@@ -353,12 +353,159 @@ func TestExtractSnapshot(t *testing.T) {
} }
// Try to extract a non-existent snapshot // Try to extract a non-existent snapshot
err = manager.ExtractSnapshot(ctx, "nonexistent-id", targetDir) err = manager.ExtractSnapshot(ctx, "nonexistent-id", targetDir, false)
if err == nil { if err == nil {
t.Fatalf("Expected error when extracting non-existent snapshot, got nil") t.Fatalf("Expected error when extracting non-existent snapshot, got nil")
} }
} }
// TestExtractSnapshot_SafeRestore tests that ExtractSnapshot with cleanTarget=false
// does not remove extra files in the target directory
func TestExtractSnapshot_SafeRestore(t *testing.T) {
tempDir, metadataStore, blobStore, cleanup := setupTestEnvironment(t)
defer cleanup()
// Create a source directory with test files
sourceDir := filepath.Join(tempDir, "source")
if err := os.MkdirAll(sourceDir, 0755); err != nil {
t.Fatalf("Failed to create source directory: %v", err)
}
createTestFiles(t, sourceDir)
// Create a snapshot manager with nil logger
manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err)
}
// Create a snapshot (snapshot A)
ctx := context.Background()
snapshot, err := manager.CreateSnapshot(ctx, sourceDir, "Snapshot A", "")
if err != nil {
t.Fatalf("Failed to create snapshot: %v", err)
}
// Create a target directory and place an "extra" file in it
targetDir := filepath.Join(tempDir, "target")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatalf("Failed to create target directory: %v", err)
}
extraFilePath := filepath.Join(targetDir, "extra.txt")
if err := os.WriteFile(extraFilePath, []byte("This is an extra file"), 0644); err != nil {
t.Fatalf("Failed to create extra file: %v", err)
}
// Extract the snapshot with cleanTarget=false
err = manager.ExtractSnapshot(ctx, snapshot.ID, targetDir, false)
if err != nil {
t.Fatalf("Failed to extract snapshot: %v", err)
}
// Check that all files from the snapshot were restored
testFiles := map[string]string{
filepath.Join(targetDir, "file1.txt"): "This is file 1",
filepath.Join(targetDir, "file2.txt"): "This is file 2",
filepath.Join(targetDir, "subdir/subfile1.txt"): "This is subfile 1",
filepath.Join(targetDir, "subdir/subfile2.txt"): "This is subfile 2",
}
for path, expectedContent := range testFiles {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read extracted file %s: %v", path, err)
}
if string(content) != expectedContent {
t.Errorf("Extracted file %s has wrong content: got %s, want %s", path, string(content), expectedContent)
}
}
// Check that the extra file was NOT deleted
if _, err := os.Stat(extraFilePath); os.IsNotExist(err) {
t.Errorf("Extra file was deleted, but should have been preserved with cleanTarget=false")
} else if err != nil {
t.Fatalf("Failed to check if extra file exists: %v", err)
} else {
// Read the content to make sure it wasn't modified
content, err := os.ReadFile(extraFilePath)
if err != nil {
t.Fatalf("Failed to read extra file: %v", err)
}
if string(content) != "This is an extra file" {
t.Errorf("Extra file content was modified: got %s, want %s", string(content), "This is an extra file")
}
}
}
// TestExtractSnapshot_CleanRestore tests that ExtractSnapshot with cleanTarget=true
// completely cleans the target directory before restoration
func TestExtractSnapshot_CleanRestore(t *testing.T) {
tempDir, metadataStore, blobStore, cleanup := setupTestEnvironment(t)
defer cleanup()
// Create a source directory with test files
sourceDir := filepath.Join(tempDir, "source")
if err := os.MkdirAll(sourceDir, 0755); err != nil {
t.Fatalf("Failed to create source directory: %v", err)
}
createTestFiles(t, sourceDir)
// Create a snapshot manager with nil logger
manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err)
}
// Create a snapshot (snapshot A)
ctx := context.Background()
snapshot, err := manager.CreateSnapshot(ctx, sourceDir, "Snapshot A", "")
if err != nil {
t.Fatalf("Failed to create snapshot: %v", err)
}
// Create a target directory and place an "extra" file in it
targetDir := filepath.Join(tempDir, "target")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatalf("Failed to create target directory: %v", err)
}
extraFilePath := filepath.Join(targetDir, "extra.txt")
if err := os.WriteFile(extraFilePath, []byte("This is an extra file"), 0644); err != nil {
t.Fatalf("Failed to create extra file: %v", err)
}
// Extract the snapshot with cleanTarget=true
err = manager.ExtractSnapshot(ctx, snapshot.ID, targetDir, true)
if err != nil {
t.Fatalf("Failed to extract snapshot: %v", err)
}
// Check that all files from the snapshot were restored
testFiles := map[string]string{
filepath.Join(targetDir, "file1.txt"): "This is file 1",
filepath.Join(targetDir, "file2.txt"): "This is file 2",
filepath.Join(targetDir, "subdir/subfile1.txt"): "This is subfile 1",
filepath.Join(targetDir, "subdir/subfile2.txt"): "This is subfile 2",
}
for path, expectedContent := range testFiles {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read extracted file %s: %v", path, err)
}
if string(content) != expectedContent {
t.Errorf("Extracted file %s has wrong content: got %s, want %s", path, string(content), expectedContent)
}
}
// Check that the extra file WAS deleted
if _, err := os.Stat(extraFilePath); os.IsNotExist(err) {
// This is the expected behavior
} else if err != nil {
t.Fatalf("Failed to check if extra file exists: %v", err)
} else {
t.Errorf("Extra file was not deleted, but should have been removed with cleanTarget=true")
}
}
func TestUpdateSnapshotMetadata(t *testing.T) { func TestUpdateSnapshotMetadata(t *testing.T) {
tempDir, metadataStore, blobStore, cleanup := setupTestEnvironment(t) tempDir, metadataStore, blobStore, cleanup := setupTestEnvironment(t)
defer cleanup() defer cleanup()
@@ -370,8 +517,8 @@ func TestUpdateSnapshotMetadata(t *testing.T) {
} }
createTestFiles(t, sourceDir) createTestFiles(t, sourceDir)
// Create a snapshot manager // Create a snapshot manager with nil logger
manager, err := CreateSnapshotManager(metadataStore, blobStore) manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err) t.Fatalf("Failed to create snapshot manager: %v", err)
} }
@@ -407,3 +554,60 @@ func TestUpdateSnapshotMetadata(t *testing.T) {
t.Fatalf("Expected error when updating non-existent snapshot, got nil") t.Fatalf("Expected error when updating non-existent snapshot, got nil")
} }
} }
func TestStreamSnapshotDiff_EdgeCases(t *testing.T) {
tempDir, metadataStore, blobStore, cleanup := setupTestEnvironment(t)
defer cleanup()
sourceDir := filepath.Join(tempDir, "source")
os.MkdirAll(sourceDir, 0755)
createTestFiles(t, sourceDir)
manager, err := CreateSnapshotManager(metadataStore, blobStore, nil)
if err != nil {
t.Fatalf("Failed to create snapshot manager: %v", err)
}
ctx := context.Background()
// Create two identical snapshots
snap1, _ := manager.CreateSnapshot(ctx, sourceDir, "Snap1", "")
snap2, _ := manager.CreateSnapshot(ctx, sourceDir, "Snap2", snap1.ID)
// Test 1: Diff between identical snapshots should be empty
reader, err := manager.StreamSnapshotDiff(ctx, snap2.ID, snap1.ID, 0)
if err != nil {
t.Fatalf("Expected no error for identical snapshots, got %v", err)
}
defer reader.Close()
data, _ := io.ReadAll(reader)
if len(data) != 0 {
t.Errorf("Expected empty diff for identical snapshots, got %d bytes", len(data))
}
// Test 2: Diff with a non-existent parent should be a full archive
reader, err = manager.StreamSnapshotDiff(ctx, snap1.ID, "non-existent-parent", 0)
if err != nil {
t.Fatalf("Expected no error for non-existent parent, got %v", err)
}
defer reader.Close()
data, _ = io.ReadAll(reader)
if len(data) == 0 {
t.Error("Expected full archive for non-existent parent, got empty diff")
}
// Create an empty source dir
emptyDir := filepath.Join(tempDir, "empty_source")
os.MkdirAll(emptyDir, 0755)
emptySnap, _ := manager.CreateSnapshot(ctx, emptyDir, "EmptySnap", "")
// Test 3: Diff of an empty snapshot should be empty
reader, err = manager.StreamSnapshotDiff(ctx, emptySnap.ID, "", 0)
if err != nil {
t.Fatalf("Expected no error for empty snapshot, got %v", err)
}
defer reader.Close()
data, _ = io.ReadAll(reader)
if len(data) != 0 {
t.Errorf("Expected empty diff for empty snapshot, got %d bytes", len(data))
}
}

View File

@@ -1,4 +1,4 @@
package agate package models
import "errors" import "errors"

View File

@@ -11,38 +11,31 @@ import (
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
agateGrpc "gitea.unprism.ru/KRBL/Agate/grpc" agateGrpc "gitea.unprism.ru/KRBL/Agate/grpc"
"gitea.unprism.ru/KRBL/Agate/hash"
"gitea.unprism.ru/KRBL/Agate/interfaces" "gitea.unprism.ru/KRBL/Agate/interfaces"
"gitea.unprism.ru/KRBL/Agate/store" "gitea.unprism.ru/KRBL/Agate/store"
) )
// Client represents a client for connecting to a remote snapshot server // Client представляет клиент для подключения к удаленному серверу снапшотов.
// It implements the interfaces.SnapshotClient interface
type Client struct { type Client struct {
conn *stdgrpc.ClientConn conn *stdgrpc.ClientConn
client agateGrpc.SnapshotServiceClient client agateGrpc.SnapshotServiceClient
} }
// Ensure Client implements interfaces.SnapshotClient // Убедимся, что Client реализует интерфейс interfaces.SnapshotClient
var _ interfaces.SnapshotClient = (*Client)(nil) var _ interfaces.SnapshotClient = (*Client)(nil)
// NewClient creates a new client connected to the specified address // NewClient создает нового клиента, подключенного к указанному адресу.
func NewClient(address string) (*Client, error) { func NewClient(address string) (*Client, error) {
// Connect to the server with insecure credentials (for simplicity)
conn, err := stdgrpc.Dial(address, stdgrpc.WithTransportCredentials(insecure.NewCredentials())) conn, err := stdgrpc.Dial(address, stdgrpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to server at %s: %w", address, err) return nil, fmt.Errorf("failed to connect to server at %s: %w", address, err)
} }
// Create the gRPC client
client := agateGrpc.NewSnapshotServiceClient(conn) client := agateGrpc.NewSnapshotServiceClient(conn)
return &Client{conn: conn, client: client}, nil
return &Client{
conn: conn,
client: client,
}, nil
} }
// Close closes the connection to the server // Close закрывает соединение с сервером.
func (c *Client) Close() error { func (c *Client) Close() error {
if c.conn != nil { if c.conn != nil {
return c.conn.Close() return c.conn.Close()
@@ -50,14 +43,13 @@ func (c *Client) Close() error {
return nil return nil
} }
// ListSnapshots retrieves a list of snapshots from the remote server // ListSnapshots получает список снапшотов с удаленного сервера.
func (c *Client) ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error) { func (c *Client) ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error) {
response, err := c.client.ListSnapshots(ctx, &agateGrpc.ListSnapshotsRequest{}) response, err := c.client.ListSnapshots(ctx, &agateGrpc.ListSnapshotsRequest{})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list snapshots: %w", err) return nil, fmt.Errorf("failed to list snapshots: %w", err)
} }
// Convert gRPC snapshot info to store.SnapshotInfo
snapshots := make([]store.SnapshotInfo, 0, len(response.Snapshots)) snapshots := make([]store.SnapshotInfo, 0, len(response.Snapshots))
for _, snapshot := range response.Snapshots { for _, snapshot := range response.Snapshots {
snapshots = append(snapshots, store.SnapshotInfo{ snapshots = append(snapshots, store.SnapshotInfo{
@@ -67,11 +59,10 @@ func (c *Client) ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error
CreationTime: snapshot.CreationTime.AsTime(), CreationTime: snapshot.CreationTime.AsTime(),
}) })
} }
return snapshots, nil return snapshots, nil
} }
// FetchSnapshotDetails retrieves detailed information about a specific snapshot // FetchSnapshotDetails получает детальную информацию о конкретном снапшоте.
func (c *Client) FetchSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error) { func (c *Client) FetchSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error) {
response, err := c.client.GetSnapshotDetails(ctx, &agateGrpc.GetSnapshotDetailsRequest{ response, err := c.client.GetSnapshotDetails(ctx, &agateGrpc.GetSnapshotDetailsRequest{
SnapshotId: snapshotID, SnapshotId: snapshotID,
@@ -80,7 +71,6 @@ func (c *Client) FetchSnapshotDetails(ctx context.Context, snapshotID string) (*
return nil, fmt.Errorf("failed to get snapshot details: %w", err) return nil, fmt.Errorf("failed to get snapshot details: %w", err)
} }
// Convert gRPC snapshot details to store.Snapshot
snapshot := &store.Snapshot{ snapshot := &store.Snapshot{
ID: response.Info.Id, ID: response.Info.Id,
Name: response.Info.Name, Name: response.Info.Name,
@@ -89,7 +79,6 @@ func (c *Client) FetchSnapshotDetails(ctx context.Context, snapshotID string) (*
Files: make([]store.FileInfo, 0, len(response.Files)), Files: make([]store.FileInfo, 0, len(response.Files)),
} }
// Convert file info
for _, file := range response.Files { for _, file := range response.Files {
snapshot.Files = append(snapshot.Files, store.FileInfo{ snapshot.Files = append(snapshot.Files, store.FileInfo{
Path: file.Path, Path: file.Path,
@@ -98,118 +87,87 @@ func (c *Client) FetchSnapshotDetails(ctx context.Context, snapshotID string) (*
SHA256: file.Sha256Hash, SHA256: file.Sha256Hash,
}) })
} }
return snapshot, nil return snapshot, nil
} }
// DownloadSnapshot downloads a snapshot from the server // GetDiffInfo gets the hash and size of a differential archive.
// This implementation downloads each file individually to optimize bandwidth usage func (c *Client) GetDiffInfo(ctx context.Context, snapshotID, localParentID string) (*store.DiffInfo, error) {
func (c *Client) DownloadSnapshot(ctx context.Context, snapshotID string, targetDir string, localParentID string) error { req := &agateGrpc.GetDiffInfoRequest{
// Get snapshot details SnapshotId: snapshotID,
snapshot, err := c.FetchSnapshotDetails(ctx, snapshotID) LocalParentId: localParentID,
}
info, err := c.client.GetDiffInfo(ctx, req)
if err != nil { if err != nil {
return fmt.Errorf("failed to get snapshot details: %w", err) return nil, fmt.Errorf("failed to get diff info: %w", err)
} }
// Create target directory if it doesn't exist return &store.DiffInfo{
if err := os.MkdirAll(targetDir, 0755); err != nil { SHA256: info.Sha256Hash,
return fmt.Errorf("failed to create target directory: %w", err) Size: info.SizeBytes,
} }, nil
// If a local parent is specified, get its details to compare files
var localParentFiles map[string]store.FileInfo
if localParentID != "" {
localParent, err := c.FetchSnapshotDetails(ctx, localParentID)
if err == nil {
// Create a map of file paths to file info for quick lookup
localParentFiles = make(map[string]store.FileInfo, len(localParent.Files))
for _, file := range localParent.Files {
localParentFiles[file.Path] = file
}
}
}
// Download each file
for _, file := range snapshot.Files {
// Skip directories, we'll create them when needed
if file.IsDir {
// Create directory
dirPath := filepath.Join(targetDir, file.Path)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dirPath, err)
}
continue
}
// Check if we can skip downloading this file
if localParentFiles != nil {
if parentFile, exists := localParentFiles[file.Path]; exists && parentFile.SHA256 == file.SHA256 {
// File exists in parent with same hash, copy it instead of downloading
parentFilePath := filepath.Join(targetDir, "..", localParentID, file.Path)
targetFilePath := filepath.Join(targetDir, file.Path)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(targetFilePath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", targetFilePath, err)
}
// Copy the file
if err := copyFile(parentFilePath, targetFilePath); err != nil {
// If copy fails, fall back to downloading
fmt.Printf("Failed to copy file %s, will download instead: %v\n", file.Path, err)
} else {
// Skip to next file
continue
}
}
}
// Download the file
if err := c.downloadFile(ctx, snapshotID, file.Path, filepath.Join(targetDir, file.Path)); err != nil {
return fmt.Errorf("failed to download file %s: %w", file.Path, err)
}
}
return nil
} }
// downloadFile downloads a single file from the server // DownloadSnapshotDiff скачивает архив с разницей между снапшотами.
func (c *Client) downloadFile(ctx context.Context, snapshotID, filePath, targetPath string) error { func (c *Client) DownloadSnapshotDiff(ctx context.Context, snapshotID, localParentID, targetPath string) error {
// Create the request // Check for local file and validate it
req := &agateGrpc.DownloadFileRequest{ if fileInfo, err := os.Stat(targetPath); err == nil {
SnapshotId: snapshotID, remoteDiffInfo, err := c.GetDiffInfo(ctx, snapshotID, localParentID)
FilePath: filePath,
}
// Start streaming the file
stream, err := c.client.DownloadFile(ctx, req)
if err != nil { if err != nil {
return fmt.Errorf("failed to start file download: %w", err) // Log the error but proceed with download
fmt.Printf("could not get remote diff info: %v. proceeding with download.", err)
} else {
if fileInfo.Size() == remoteDiffInfo.Size {
localHash, err := hash.CalculateFileHash(targetPath)
if err == nil && localHash == remoteDiffInfo.SHA256 {
fmt.Printf("local snapshot archive %s is valid, skipping download.", targetPath)
return nil // File is valid, skip download
}
}
}
}
var offset int64
fileInfo, err := os.Stat(targetPath)
if err == nil {
offset = fileInfo.Size()
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to stat temporary file: %w", err)
}
req := &agateGrpc.DownloadSnapshotDiffRequest{
SnapshotId: snapshotID,
LocalParentId: localParentID,
Offset: offset,
}
stream, err := c.client.DownloadSnapshotDiff(ctx, req)
if err != nil {
return fmt.Errorf("failed to start snapshot diff download: %w", err)
} }
// Ensure the target directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", targetPath, err) return fmt.Errorf("failed to create directory for %s: %w", targetPath, err)
} }
// Create the target file file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
file, err := os.Create(targetPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file %s: %w", targetPath, err) return fmt.Errorf("failed to open file %s: %w", targetPath, err)
} }
defer file.Close() defer func() {
if err := file.Close(); err != nil {
fmt.Printf("failed to close file: %v", err)
}
}()
// Receive and write chunks
for { for {
resp, err := stream.Recv() resp, err := stream.Recv()
if err == io.EOF { if err == io.EOF {
break break
} }
if err != nil { if err != nil {
return fmt.Errorf("error receiving file chunk: %w", err) return fmt.Errorf("error receiving diff chunk: %w", err)
} }
// Write the chunk to the file
if _, err := file.Write(resp.ChunkData); err != nil { if _, err := file.Write(resp.ChunkData); err != nil {
return fmt.Errorf("error writing to file: %w", err) return fmt.Errorf("error writing to file: %w", err)
} }
@@ -217,26 +175,3 @@ func (c *Client) downloadFile(ctx context.Context, snapshotID, filePath, targetP
return nil return nil
} }
// Helper function to copy a file
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
return err
}
// Connect creates a new client connected to the specified address
func Connect(address string) (*Client, error) {
return NewClient(address)
}

View File

@@ -14,21 +14,21 @@ import (
"gitea.unprism.ru/KRBL/Agate/store" "gitea.unprism.ru/KRBL/Agate/store"
) )
// Server implements the gRPC server for snapshots // Server реализует gRPC-сервер для снапшотов.
type Server struct { type Server struct {
agateGrpc.UnimplementedSnapshotServiceServer agateGrpc.UnimplementedSnapshotServiceServer
manager interfaces.SnapshotManager manager interfaces.SnapshotManager
server *stdgrpc.Server server *stdgrpc.Server
} }
// NewServer creates a new snapshot server // NewServer создает новый сервер снапшотов.
func NewServer(manager interfaces.SnapshotManager) *Server { func NewServer(manager interfaces.SnapshotManager) *Server {
return &Server{ return &Server{
manager: manager, manager: manager,
} }
} }
// Start starts the gRPC server on the specified address // Start запускает gRPC-сервер на указанном адресе.
func (s *Server) Start(ctx context.Context, address string) error { func (s *Server) Start(ctx context.Context, address string) error {
lis, err := net.Listen("tcp", address) lis, err := net.Listen("tcp", address)
if err != nil { if err != nil {
@@ -45,21 +45,25 @@ func (s *Server) Start(ctx context.Context, address string) error {
}() }()
fmt.Printf("Server started on %s\n", address) fmt.Printf("Server started on %s\n", address)
// Ждем отмены контекста для остановки сервера
<-ctx.Done()
s.Stop()
return nil return nil
} }
// Stop gracefully stops the server // Stop изящно останавливает сервер.
func (s *Server) Stop(ctx context.Context) error { func (s *Server) Stop() {
if s.server != nil { if s.server != nil {
s.server.GracefulStop() s.server.GracefulStop()
fmt.Println("Server stopped") fmt.Println("Server stopped")
} }
return nil
} }
// ListSnapshots implements the gRPC ListSnapshots method // ListSnapshots реализует gRPC-метод ListSnapshots.
func (s *Server) ListSnapshots(ctx context.Context, req *agateGrpc.ListSnapshotsRequest) (*agateGrpc.ListSnapshotsResponse, error) { func (s *Server) ListSnapshots(ctx context.Context, req *agateGrpc.ListSnapshotsRequest) (*agateGrpc.ListSnapshotsResponse, error) {
snapshots, err := s.manager.ListSnapshots(ctx) opts := store.ListOptions{}
snapshots, err := s.manager.ListSnapshots(ctx, opts)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list snapshots: %w", err) return nil, fmt.Errorf("failed to list snapshots: %w", err)
} }
@@ -75,7 +79,7 @@ func (s *Server) ListSnapshots(ctx context.Context, req *agateGrpc.ListSnapshots
return response, nil return response, nil
} }
// GetSnapshotDetails implements the gRPC GetSnapshotDetails method // GetSnapshotDetails реализует gRPC-метод GetSnapshotDetails.
func (s *Server) GetSnapshotDetails(ctx context.Context, req *agateGrpc.GetSnapshotDetailsRequest) (*agateGrpc.SnapshotDetails, error) { func (s *Server) GetSnapshotDetails(ctx context.Context, req *agateGrpc.GetSnapshotDetailsRequest) (*agateGrpc.SnapshotDetails, error) {
snapshot, err := s.manager.GetSnapshotDetails(ctx, req.SnapshotId) snapshot, err := s.manager.GetSnapshotDetails(ctx, req.SnapshotId)
if err != nil { if err != nil {
@@ -104,17 +108,15 @@ func (s *Server) GetSnapshotDetails(ctx context.Context, req *agateGrpc.GetSnaps
return response, nil return response, nil
} }
// DownloadFile implements the gRPC DownloadFile method // DownloadFile реализует gRPC-метод DownloadFile.
func (s *Server) DownloadFile(req *agateGrpc.DownloadFileRequest, stream agateGrpc.SnapshotService_DownloadFileServer) error { func (s *Server) DownloadFile(req *agateGrpc.DownloadFileRequest, stream agateGrpc.SnapshotService_DownloadFileServer) error {
// Open the file from the snapshot
fileReader, err := s.manager.OpenFile(context.Background(), req.SnapshotId, req.FilePath) fileReader, err := s.manager.OpenFile(context.Background(), req.SnapshotId, req.FilePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to open file: %w", err) return fmt.Errorf("failed to open file: %w", err)
} }
defer fileReader.Close() defer fileReader.Close()
// Read the file in chunks and send them to the client buffer := make([]byte, 64*1024)
buffer := make([]byte, 64*1024) // 64KB chunks
for { for {
n, err := fileReader.Read(buffer) n, err := fileReader.Read(buffer)
if err == io.EOF { if err == io.EOF {
@@ -123,19 +125,53 @@ func (s *Server) DownloadFile(req *agateGrpc.DownloadFileRequest, stream agateGr
if err != nil { if err != nil {
return fmt.Errorf("failed to read file: %w", err) return fmt.Errorf("failed to read file: %w", err)
} }
if err := stream.Send(&agateGrpc.DownloadFileResponse{ChunkData: buffer[:n]}); err != nil {
// Send the chunk to the client
if err := stream.Send(&agateGrpc.DownloadFileResponse{
ChunkData: buffer[:n],
}); err != nil {
return fmt.Errorf("failed to send chunk: %w", err) return fmt.Errorf("failed to send chunk: %w", err)
} }
} }
return nil return nil
} }
// Helper function to convert store.SnapshotInfo to grpc.SnapshotInfo // DownloadSnapshotDiff реализует gRPC-метод DownloadSnapshotDiff.
func (s *Server) DownloadSnapshotDiff(req *agateGrpc.DownloadSnapshotDiffRequest, stream agateGrpc.SnapshotService_DownloadSnapshotDiffServer) error {
diffReader, err := s.manager.StreamSnapshotDiff(context.Background(), req.SnapshotId, req.LocalParentId, req.Offset)
if err != nil {
return fmt.Errorf("failed to stream snapshot diff: %w", err)
}
defer diffReader.Close()
buffer := make([]byte, 64*1024)
for {
n, err := diffReader.Read(buffer)
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read from diff stream: %w", err)
}
if n > 0 {
if err := stream.Send(&agateGrpc.DownloadFileResponse{ChunkData: buffer[:n]}); err != nil {
return fmt.Errorf("failed to send diff chunk: %w", err)
}
}
}
return nil
}
// GetDiffInfo реализует gRPC-метод GetDiffInfo.
func (s *Server) GetDiffInfo(ctx context.Context, req *agateGrpc.GetDiffInfoRequest) (*agateGrpc.DiffInfo, error) {
diffInfo, err := s.manager.GetSnapshotDiffInfo(ctx, req.SnapshotId, req.LocalParentId)
if err != nil {
return nil, fmt.Errorf("failed to get diff info: %w", err)
}
return &agateGrpc.DiffInfo{
Sha256Hash: diffInfo.SHA256,
SizeBytes: diffInfo.Size,
}, nil
}
// Вспомогательная функция для конвертации store.SnapshotInfo в grpc.SnapshotInfo
func convertToGrpcSnapshotInfo(info store.SnapshotInfo) *agateGrpc.SnapshotInfo { func convertToGrpcSnapshotInfo(info store.SnapshotInfo) *agateGrpc.SnapshotInfo {
return &agateGrpc.SnapshotInfo{ return &agateGrpc.SnapshotInfo{
Id: info.ID, Id: info.ID,
@@ -144,12 +180,3 @@ func convertToGrpcSnapshotInfo(info store.SnapshotInfo) *agateGrpc.SnapshotInfo
CreationTime: timestamppb.New(info.CreationTime), CreationTime: timestamppb.New(info.CreationTime),
} }
} }
// RunServer is a helper function to create and start a snapshot server
func RunServer(ctx context.Context, manager interfaces.SnapshotManager, address string) (*Server, error) {
server := NewServer(manager)
if err := server.Start(ctx, address); err != nil {
return nil, err
}
return server, nil
}

View File

@@ -1,53 +0,0 @@
package agate
import (
"context"
"gitea.unprism.ru/KRBL/Agate/store"
"io"
)
// SnapshotManager is an interface that defines operations for managing and interacting with snapshots.
type SnapshotManager interface {
// CreateSnapshot creates a new snapshot from the specified source directory, associating it with a given name and parent ID.
// Returns the created Snapshot with its metadata or an error if the process fails.
CreateSnapshot(ctx context.Context, sourceDir string, name string, parentID string) (*store.Snapshot, error)
// GetSnapshotDetails retrieves detailed metadata for a specific snapshot identified by its unique snapshotID.
// Returns a Snapshot object containing metadata
GetSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error)
// ListSnapshots retrieves a list of all available snapshots, returning their basic information as SnapshotInfo.
ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error)
// DeleteSnapshot removes a snapshot identified by snapshotID. Returns an error if the snapshot does not exist or cannot be deleted.
DeleteSnapshot(ctx context.Context, snapshotID string) error
// OpenFile retrieves and opens a file from the specified snapshot, returning a readable stream and an error, if any.
OpenFile(ctx context.Context, snapshotID string, filePath string) (io.ReadCloser, error)
// ExtractSnapshot extracts the contents of a specified snapshot to a target directory at the given path.
// Returns an error if the snapshot ID is invalid or the extraction fails.
ExtractSnapshot(ctx context.Context, snapshotID string, path string) error
// UpdateSnapshotMetadata updates the metadata of an existing snapshot, allowing changes to its name.
UpdateSnapshotMetadata(ctx context.Context, snapshotID string, newName string) error
}
type SnapshotServer interface {
// Start initializes and begins the server's operation, handling incoming requests or processes within the provided context.
Start(ctx context.Context) error
// Stop gracefully shuts down the server, releasing any allocated resources and ensuring all operations are completed.
Stop(ctx context.Context) error
}
type SnapshotClient interface {
// ListSnapshots retrieves a list of snapshots containing basic metadata, such as ID, name, parent ID, and creation time.
ListSnapshots(ctx context.Context) ([]store.SnapshotInfo, error)
// FetchSnapshotDetails retrieves detailed metadata about a specific snapshot identified by snapshotID.
FetchSnapshotDetails(ctx context.Context, snapshotID string) (*store.Snapshot, error)
// DownloadSnapshot retrieves the snapshot content for the given snapshotID and returns it as an io.ReadCloser.
DownloadSnapshot(ctx context.Context, snapshotID string) (io.ReadCloser, error)
}

View File

@@ -1,9 +0,0 @@
package store
import "errors"
// Common errors that can be used by store implementations
var (
// ErrNotFound means that a requested resource was not found
ErrNotFound = errors.New("resource not found")
)

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"gitea.unprism.ru/KRBL/Agate/models"
"gitea.unprism.ru/KRBL/Agate/store" "gitea.unprism.ru/KRBL/Agate/store"
) )
@@ -75,7 +76,7 @@ func (fs *fileSystemStore) RetrieveBlob(ctx context.Context, snapshotID string)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
// Если файл не найден, возвращаем кастомную ошибку // Если файл не найден, возвращаем кастомную ошибку
return nil, store.ErrNotFound return nil, models.ErrNotFound
} }
return nil, fmt.Errorf("failed to open blob file %s: %w", blobPath, err) return nil, fmt.Errorf("failed to open blob file %s: %w", blobPath, err)
} }
@@ -109,7 +110,7 @@ func (fs *fileSystemStore) GetBlobPath(ctx context.Context, snapshotID string) (
// Проверяем существование файла // Проверяем существование файла
if _, err := os.Stat(blobPath); err != nil { if _, err := os.Stat(blobPath); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return "", store.ErrNotFound return "", models.ErrNotFound
} }
return "", fmt.Errorf("failed to stat blob file %s: %w", blobPath, err) return "", fmt.Errorf("failed to stat blob file %s: %w", blobPath, err)
} }

View File

@@ -6,11 +6,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"gitea.unprism.ru/KRBL/Agate/store"
_ "github.com/mattn/go-sqlite3"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"gitea.unprism.ru/KRBL/Agate/models"
"gitea.unprism.ru/KRBL/Agate/store"
_ "github.com/mattn/go-sqlite3"
) )
const ( const (
@@ -130,7 +132,7 @@ func (s *sqliteStore) GetSnapshotMetadata(ctx context.Context, snapshotID string
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
// Если запись не найдена, возвращаем кастомную ошибку // Если запись не найдена, возвращаем кастомную ошибку
return nil, store.ErrNotFound return nil, models.ErrNotFound
} }
return nil, fmt.Errorf("failed to query snapshot %s: %w", snapshotID, err) return nil, fmt.Errorf("failed to query snapshot %s: %w", snapshotID, err)
} }
@@ -167,58 +169,83 @@ func (s *sqliteStore) GetSnapshotMetadata(ctx context.Context, snapshotID string
return &snap, nil return &snap, nil
} }
// ListSnapshotsMetadata извлекает краткую информацию обо всех снапшотах. // ListSnapshotsMetadata retrieves basic information about snapshots with filtering and pagination.
func (s *sqliteStore) ListSnapshotsMetadata(ctx context.Context) ([]store.SnapshotInfo, error) { func (s *sqliteStore) ListSnapshotsMetadata(ctx context.Context, opts store.ListOptions) ([]store.SnapshotInfo, error) {
// Simplified implementation to debug the issue // Build the query with optional filtering
fmt.Println("ListSnapshotsMetadata called") var query string
var args []interface{}
// Get all snapshot IDs first if opts.FilterByName != "" {
query := `SELECT id FROM snapshots ORDER BY creation_time DESC;` query = `SELECT id, name, parent_id, creation_time FROM snapshots WHERE name LIKE ? ORDER BY creation_time DESC`
fmt.Println("Executing query:", query) args = append(args, "%"+opts.FilterByName+"%")
} else {
query = `SELECT id, name, parent_id, creation_time FROM snapshots ORDER BY creation_time DESC`
}
rows, err := s.db.QueryContext(ctx, query) // Add pagination if specified
if opts.Limit > 0 {
query += " LIMIT ?"
args = append(args, opts.Limit)
if opts.Offset > 0 {
query += " OFFSET ?"
args = append(args, opts.Offset)
}
}
// Execute the query
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query snapshot IDs: %w", err) return nil, fmt.Errorf("failed to query snapshots: %w", err)
} }
defer rows.Close() defer rows.Close()
var snapshots []store.SnapshotInfo var snapshots []store.SnapshotInfo
// For each ID, get the full snapshot details // Iterate through the results
for rows.Next() { for rows.Next() {
var id string var info store.SnapshotInfo
if err := rows.Scan(&id); err != nil { var parentID sql.NullString
return nil, fmt.Errorf("failed to scan snapshot ID: %w", err) var creationTimeStr string
if err := rows.Scan(&info.ID, &info.Name, &parentID, &creationTimeStr); err != nil {
return nil, fmt.Errorf("failed to scan snapshot row: %w", err)
} }
// Get the full snapshot details // Set parent ID if not NULL
snapshot, err := s.GetSnapshotMetadata(ctx, id) if parentID.Valid {
if err != nil { info.ParentID = parentID.String
return nil, fmt.Errorf("failed to get snapshot details for ID %s: %w", id, err)
} }
// Convert to SnapshotInfo // Parse creation time
info := store.SnapshotInfo{ const sqliteLayout = "2006-01-02 15:04:05" // Standard SQLite DATETIME format without timezone
ID: snapshot.ID, t, parseErr := time.Parse(sqliteLayout, creationTimeStr)
Name: snapshot.Name, if parseErr != nil {
ParentID: snapshot.ParentID, // Try format with milliseconds if the first one didn't work
CreationTime: snapshot.CreationTime, const sqliteLayoutWithMs = "2006-01-02 15:04:05.999999999"
t, parseErr = time.Parse(sqliteLayoutWithMs, creationTimeStr)
if parseErr != nil {
// Try RFC3339 if saved as UTC().Format(time.RFC3339)
t, parseErr = time.Parse(time.RFC3339, creationTimeStr)
if parseErr != nil {
return nil, fmt.Errorf("failed to parse creation time '%s' for snapshot %s: %w", creationTimeStr, info.ID, parseErr)
} }
}
}
info.CreationTime = t.UTC() // Store as UTC
snapshots = append(snapshots, info) snapshots = append(snapshots, info)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating snapshot IDs: %w", err) return nil, fmt.Errorf("error iterating snapshot rows: %w", err)
} }
// If no snapshots found, return an empty slice // If no snapshots found, return an empty slice
if len(snapshots) == 0 { if len(snapshots) == 0 {
fmt.Println("No snapshots found")
return []store.SnapshotInfo{}, nil return []store.SnapshotInfo{}, nil
} }
fmt.Printf("Found %d snapshots\n", len(snapshots))
return snapshots, nil return snapshots, nil
} }
@@ -240,3 +267,13 @@ func (s *sqliteStore) DeleteSnapshotMetadata(ctx context.Context, snapshotID str
return nil // Не возвращаем ошибку, если запись не найдена return nil // Не возвращаем ошибку, если запись не найдена
} }
// UpdateSnapshotParentID обновляет ParentID для указанного снапшота.
func (s *sqliteStore) UpdateSnapshotParentID(ctx context.Context, snapshotID, newParentID string) error {
query := `UPDATE snapshots SET parent_id = ? WHERE id = ?;`
_, err := s.db.ExecContext(ctx, query, newParentID, snapshotID)
if err != nil {
return fmt.Errorf("failed to update parent ID for snapshot %s: %w", snapshotID, err)
}
return nil
}

View File

@@ -154,8 +154,8 @@ func TestListSnapshotsMetadata(t *testing.T) {
} }
} }
// List the snapshots // List the snapshots with empty options
snapshots, err := s.ListSnapshotsMetadata(ctx) snapshots, err := s.ListSnapshotsMetadata(ctx, store.ListOptions{})
if err != nil { if err != nil {
t.Fatalf("Failed to list snapshots: %v", err) t.Fatalf("Failed to list snapshots: %v", err)
} }
@@ -189,6 +189,164 @@ func TestListSnapshotsMetadata(t *testing.T) {
} }
} }
func TestListSnapshotsMetadata_WithOptions(t *testing.T) {
// Create a temporary directory for tests
tempDir, err := os.MkdirTemp("", "agate-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir) // Clean up after test
// Create a new store
dbPath := filepath.Join(tempDir, "test.db")
s, err := NewSQLiteStore(dbPath)
if err != nil {
t.Fatalf("Failed to create SQLite store: %v", err)
}
defer s.Close()
// Create test snapshots with different names
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
testSnapshots := []store.Snapshot{
{
ID: "alpha-1",
Name: "alpha-1",
ParentID: "",
CreationTime: now.Add(-3 * time.Hour),
Files: []store.FileInfo{},
},
{
ID: "alpha-2",
Name: "alpha-2",
ParentID: "alpha-1",
CreationTime: now.Add(-2 * time.Hour),
Files: []store.FileInfo{},
},
{
ID: "beta-1",
Name: "beta-1",
ParentID: "",
CreationTime: now.Add(-1 * time.Hour),
Files: []store.FileInfo{},
},
}
// Save the snapshots
for _, snap := range testSnapshots {
err = s.SaveSnapshotMetadata(ctx, snap)
if err != nil {
t.Fatalf("Failed to save snapshot metadata: %v", err)
}
}
// Test different ListOptions scenarios
t.Run("FilterByName", func(t *testing.T) {
// Filter snapshots by name "alpha"
opts := store.ListOptions{
FilterByName: "alpha",
}
snapshots, err := s.ListSnapshotsMetadata(ctx, opts)
if err != nil {
t.Fatalf("Failed to list snapshots with filter: %v", err)
}
// Should return 2 snapshots (alpha-1 and alpha-2)
if len(snapshots) != 2 {
t.Errorf("Wrong number of snapshots returned: got %d, want %d", len(snapshots), 2)
}
// Check that only alpha snapshots are returned
for _, snap := range snapshots {
if snap.ID != "alpha-1" && snap.ID != "alpha-2" {
t.Errorf("Unexpected snapshot ID in filtered results: %s", snap.ID)
}
}
})
t.Run("Limit", func(t *testing.T) {
// Limit to 1 snapshot (should return the newest one)
opts := store.ListOptions{
Limit: 1,
}
snapshots, err := s.ListSnapshotsMetadata(ctx, opts)
if err != nil {
t.Fatalf("Failed to list snapshots with limit: %v", err)
}
// Should return 1 snapshot
if len(snapshots) != 1 {
t.Errorf("Wrong number of snapshots returned: got %d, want %d", len(snapshots), 1)
}
// The newest snapshot should be beta-1
if snapshots[0].ID != "beta-1" {
t.Errorf("Wrong snapshot returned with limit: got %s, want %s", snapshots[0].ID, "beta-1")
}
})
t.Run("Offset", func(t *testing.T) {
// Limit to 1 snapshot with offset 1 (should return the second newest)
opts := store.ListOptions{
Limit: 1,
Offset: 1,
}
snapshots, err := s.ListSnapshotsMetadata(ctx, opts)
if err != nil {
t.Fatalf("Failed to list snapshots with offset: %v", err)
}
// Should return 1 snapshot
if len(snapshots) != 1 {
t.Errorf("Wrong number of snapshots returned: got %d, want %d", len(snapshots), 1)
}
// The second newest snapshot should be alpha-2
if snapshots[0].ID != "alpha-2" {
t.Errorf("Wrong snapshot returned with offset: got %s, want %s", snapshots[0].ID, "alpha-2")
}
})
t.Run("FilterAndPagination", func(t *testing.T) {
// Filter by "alpha" with limit 1
opts := store.ListOptions{
FilterByName: "alpha",
Limit: 1,
}
snapshots, err := s.ListSnapshotsMetadata(ctx, opts)
if err != nil {
t.Fatalf("Failed to list snapshots with filter and pagination: %v", err)
}
// Should return 1 snapshot
if len(snapshots) != 1 {
t.Errorf("Wrong number of snapshots returned: got %d, want %d", len(snapshots), 1)
}
// The newest alpha snapshot should be alpha-2
if snapshots[0].ID != "alpha-2" {
t.Errorf("Wrong snapshot returned with filter and limit: got %s, want %s", snapshots[0].ID, "alpha-2")
}
})
t.Run("NoResults", func(t *testing.T) {
// Filter by a name that doesn't exist
opts := store.ListOptions{
FilterByName: "gamma",
}
snapshots, err := s.ListSnapshotsMetadata(ctx, opts)
if err != nil {
t.Fatalf("Failed to list snapshots with non-matching filter: %v", err)
}
// Should return 0 snapshots
if len(snapshots) != 0 {
t.Errorf("Expected 0 snapshots, got %d", len(snapshots))
}
})
}
func TestDeleteSnapshotMetadata(t *testing.T) { func TestDeleteSnapshotMetadata(t *testing.T) {
// Create a temporary directory for tests // Create a temporary directory for tests
tempDir, err := os.MkdirTemp("", "agate-test-*") tempDir, err := os.MkdirTemp("", "agate-test-*")

View File

@@ -6,6 +6,12 @@ import (
"time" "time"
) )
// DiffInfo represents metadata about a differential archive.
type DiffInfo struct {
SHA256 string
Size int64
}
// FileInfo represents metadata and attributes of a file or directory. // FileInfo represents metadata and attributes of a file or directory.
type FileInfo struct { type FileInfo struct {
Path string // Path represents the relative or absolute location of the file or directory in the filesystem. Path string // Path represents the relative or absolute location of the file or directory in the filesystem.
@@ -31,6 +37,21 @@ type SnapshotInfo struct {
CreationTime time.Time // Время создания CreationTime time.Time // Время создания
} }
// SnapshotStatus represents the status of an asynchronous snapshot creation process.
type SnapshotStatus struct {
ID string // Unique identifier of the job (usually same as Snapshot ID)
Status string // Current status: "pending", "running", "done", "failed"
Progress float64 // Progress from 0.0 to 1.0
Error string // Error message if failed
}
// ListOptions provides options for filtering and paginating snapshot lists
type ListOptions struct {
FilterByName string // Filter snapshots by name (substring match)
Limit int // Maximum number of snapshots to return
Offset int // Number of snapshots to skip
}
// MetadataStore определяет интерфейс для хранения и извлечения метаданных снапшотов. // MetadataStore определяет интерфейс для хранения и извлечения метаданных снапшотов.
type MetadataStore interface { type MetadataStore interface {
// SaveSnapshotMetadata сохраняет полные метаданные снапшота, включая список файлов. // SaveSnapshotMetadata сохраняет полные метаданные снапшота, включая список файлов.
@@ -41,13 +62,16 @@ type MetadataStore interface {
// Возвращает agate.ErrNotFound, если снапшот не найден. // Возвращает agate.ErrNotFound, если снапшот не найден.
GetSnapshotMetadata(ctx context.Context, snapshotID string) (*Snapshot, error) GetSnapshotMetadata(ctx context.Context, snapshotID string) (*Snapshot, error)
// ListSnapshotsMetadata извлекает краткую информацию обо всех снапшотах. // ListSnapshotsMetadata извлекает краткую информацию о снапшотах с фильтрацией и пагинацией.
ListSnapshotsMetadata(ctx context.Context) ([]SnapshotInfo, error) ListSnapshotsMetadata(ctx context.Context, opts ListOptions) ([]SnapshotInfo, error)
// DeleteSnapshotMetadata удаляет метаданные снапшота по его ID. // DeleteSnapshotMetadata удаляет метаданные снапшота по его ID.
// Не должен возвращать ошибку, если снапшот не найден. // Не должен возвращать ошибку, если снапшот не найден.
DeleteSnapshotMetadata(ctx context.Context, snapshotID string) error DeleteSnapshotMetadata(ctx context.Context, snapshotID string) error
// UpdateSnapshotParentID обновляет ParentID для указанного снапшота.
UpdateSnapshotParentID(ctx context.Context, snapshotID, newParentID string) error
// Close закрывает соединение с хранилищем метаданных. // Close закрывает соединение с хранилищем метаданных.
Close() error Close() error
} }