Add initial implementation of Agate snapshot library
Introduces core functionality for the Agate library, including snapshot creation, restoration, listing, and deletion. Adds examples for basic usage, gRPC proto definitions, and build/configuration files such as `go.mod` and `Makefile`. The implementation establishes the framework for store integration and placeholder server functionality.
This commit is contained in:
108
store/filesystem/filesystem.go
Normal file
108
store/filesystem/filesystem.go
Normal file
@ -0,0 +1,108 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unprism.ru/KRBL/agate"
|
||||
"unprism.ru/KRBL/agate/store"
|
||||
)
|
||||
|
||||
const blobExtension = ".zip"
|
||||
|
||||
// fileSystemStore реализует интерфейс store.BlobStore с использованием локальной файловой системы.
|
||||
type fileSystemStore struct {
|
||||
baseDir string // Директория для хранения блобов (архивов)
|
||||
}
|
||||
|
||||
// NewFileSystemStore создает новое хранилище блобов в указанной директории.
|
||||
func NewFileSystemStore(baseDir string) (store.BlobStore, error) {
|
||||
// Убедимся, что директория существует
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create base directory %s for filesystem blob store: %w", baseDir, err)
|
||||
}
|
||||
return &fileSystemStore{baseDir: baseDir}, nil
|
||||
}
|
||||
|
||||
// getBlobPath формирует полный путь к файлу блоба.
|
||||
func (fs *fileSystemStore) getBlobPath(snapshotID string) string {
|
||||
// Используем ID снапшота в качестве имени файла
|
||||
return filepath.Join(fs.baseDir, snapshotID+blobExtension)
|
||||
}
|
||||
|
||||
// StoreBlob сохраняет данные из reader в файл в baseDir.
|
||||
func (fs *fileSystemStore) StoreBlob(ctx context.Context, snapshotID string, reader io.Reader) (string, error) {
|
||||
blobPath := fs.getBlobPath(snapshotID)
|
||||
|
||||
// Создаем или перезаписываем файл
|
||||
file, err := os.Create(blobPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create blob file %s: %w", blobPath, err)
|
||||
}
|
||||
defer file.Close() // Гарантируем закрытие файла
|
||||
|
||||
// Копируем данные из ридера в файл
|
||||
_, err = io.Copy(file, reader)
|
||||
if err != nil {
|
||||
// Если произошла ошибка копирования, удаляем неполный файл
|
||||
os.Remove(blobPath)
|
||||
return "", fmt.Errorf("failed to write data to blob file %s: %w", blobPath, err)
|
||||
}
|
||||
|
||||
// Возвращаем путь к созданному файлу
|
||||
return blobPath, nil
|
||||
}
|
||||
|
||||
// RetrieveBlob открывает файл блоба и возвращает его как io.ReadCloser.
|
||||
func (fs *fileSystemStore) RetrieveBlob(ctx context.Context, snapshotID string) (io.ReadCloser, error) {
|
||||
blobPath := fs.getBlobPath(snapshotID)
|
||||
|
||||
// Открываем файл для чтения
|
||||
file, err := os.Open(blobPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Если файл не найден, возвращаем кастомную ошибку
|
||||
return nil, agate.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to open blob file %s: %w", blobPath, err)
|
||||
}
|
||||
|
||||
// Возвращаем открытый файл (*os.File реализует io.ReadCloser)
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// DeleteBlob удаляет файл блоба из файловой системы.
|
||||
func (fs *fileSystemStore) DeleteBlob(ctx context.Context, snapshotID string) error {
|
||||
blobPath := fs.getBlobPath(snapshotID)
|
||||
|
||||
// Удаляем файл
|
||||
err := os.Remove(blobPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Если файл и так не существует, это не ошибка
|
||||
return nil
|
||||
}
|
||||
// Если произошла другая ошибка при удалении
|
||||
return fmt.Errorf("failed to delete blob file %s: %w", blobPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBlobPath возвращает путь к файлу блоба, если он существует.
|
||||
func (fs *fileSystemStore) GetBlobPath(ctx context.Context, snapshotID string) (string, error) {
|
||||
blobPath := fs.getBlobPath(snapshotID)
|
||||
|
||||
// Проверяем существование файла
|
||||
if _, err := os.Stat(blobPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", agate.ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("failed to stat blob file %s: %w", blobPath, err)
|
||||
}
|
||||
|
||||
// Файл существует, возвращаем путь
|
||||
return blobPath, nil
|
||||
}
|
243
store/sqlite/sqlite.go
Normal file
243
store/sqlite/sqlite.go
Normal file
@ -0,0 +1,243 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"unprism.ru/KRBL/agate"
|
||||
"unprism.ru/KRBL/agate/store"
|
||||
)
|
||||
|
||||
const (
|
||||
createSnapshotsTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
creation_time DATETIME NOT NULL,
|
||||
files_json TEXT -- Храним список файлов как JSON blob
|
||||
);`
|
||||
)
|
||||
|
||||
// sqliteStore реализует интерфейс store.MetadataStore с использованием SQLite.
|
||||
type sqliteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteStore создает и инициализирует новое хранилище метаданных на SQLite.
|
||||
func NewSQLiteStore(dbPath string) (store.MetadataStore, error) {
|
||||
// Убедимся, что директория для файла БД существует
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory for sqlite db %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Открываем или создаем файл БД SQLite
|
||||
// Опции _journal=WAL и _busy_timeout=5000 могут улучшить производительность при конкурентном доступе
|
||||
dsn := fmt.Sprintf("file:%s?_journal=WAL&_busy_timeout=5000&_foreign_keys=on", dbPath)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open sqlite database %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
// Проверяем соединение
|
||||
if err := db.PingContext(context.Background()); err != nil { // Используем Background т.к. это инициализация
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to ping sqlite database %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
// Применяем схему БД
|
||||
if err := createSchema(context.Background(), db); err != nil { // Используем Background т.к. это инициализация
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to apply sqlite schema: %w", err)
|
||||
}
|
||||
|
||||
return &sqliteStore{db: db}, nil
|
||||
}
|
||||
|
||||
// createSchema создает необходимые таблицы, если они не существуют.
|
||||
func createSchema(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, createSnapshotsTableSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute schema creation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close закрывает соединение с БД.
|
||||
func (s *sqliteStore) Close() error {
|
||||
if s.db != nil {
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveSnapshotMetadata сохраняет метаданные снапшота в БД.
|
||||
func (s *sqliteStore) SaveSnapshotMetadata(ctx context.Context, snap store.Snapshot) error {
|
||||
// Сериализуем список файлов в JSON
|
||||
filesJSON, err := json.Marshal(snap.Files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal files to JSON for snapshot %s: %w", snap.ID, err)
|
||||
}
|
||||
|
||||
// Используем INSERT OR REPLACE для атомарной вставки или обновления
|
||||
query := `
|
||||
INSERT OR REPLACE INTO snapshots (id, name, parent_id, creation_time, files_json)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
`
|
||||
_, err = s.db.ExecContext(ctx, query,
|
||||
snap.ID,
|
||||
snap.Name,
|
||||
sql.NullString{String: snap.ParentID, Valid: snap.ParentID != ""}, // Используем NullString для опционального parent_id
|
||||
snap.CreationTime.UTC(), // Сохраняем в UTC
|
||||
string(filesJSON),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save snapshot %s metadata: %w", snap.ID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSnapshotMetadata извлекает метаданные снапшота из БД.
|
||||
func (s *sqliteStore) GetSnapshotMetadata(ctx context.Context, snapshotID string) (*store.Snapshot, error) {
|
||||
query := `
|
||||
SELECT id, name, parent_id, creation_time, files_json
|
||||
FROM snapshots
|
||||
WHERE id = ?;
|
||||
`
|
||||
row := s.db.QueryRowContext(ctx, query, snapshotID)
|
||||
|
||||
var snap store.Snapshot
|
||||
var parentID sql.NullString
|
||||
var filesJSON string
|
||||
var creationTimeStr string // Читаем время как строку, потом парсим
|
||||
|
||||
err := row.Scan(
|
||||
&snap.ID,
|
||||
&snap.Name,
|
||||
&parentID,
|
||||
&creationTimeStr,
|
||||
&filesJSON,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Если запись не найдена, возвращаем кастомную ошибку
|
||||
return nil, agate.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query snapshot %s: %w", snapshotID, err)
|
||||
}
|
||||
|
||||
// Устанавливаем ParentID, если он не NULL
|
||||
snap.ParentID = parentID.String
|
||||
|
||||
// Парсим время из строки (SQLite хранит DATETIME как текст)
|
||||
// Используем формат, совместимый с SQLite и Go's time.RFC3339Nano, или стандартный SQLite формат
|
||||
// 'YYYY-MM-DD HH:MM:SS' - требует парсинга с нужным layout
|
||||
// Проще сохранять и читать как RFC3339 или Unix timestamp. Мы сохраняли как UTC().
|
||||
// Попробуем стандартный формат SQLite
|
||||
const sqliteLayout = "2006-01-02 15:04:05" // Стандартный формат SQLite DATETIME без таймзоны
|
||||
t, parseErr := time.Parse(sqliteLayout, creationTimeStr)
|
||||
if parseErr != nil {
|
||||
// Попробуем формат с долями секунд, если первый не сработал
|
||||
const sqliteLayoutWithMs = "2006-01-02 15:04:05.999999999"
|
||||
t, parseErr = time.Parse(sqliteLayoutWithMs, creationTimeStr)
|
||||
if parseErr != nil {
|
||||
// Попробуем RFC3339, если сохраняли как 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, snapshotID, parseErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
snap.CreationTime = t.UTC() // Сохраняем как UTC
|
||||
|
||||
// Десериализуем список файлов из JSON
|
||||
if err := json.Unmarshal([]byte(filesJSON), &snap.Files); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal files JSON for snapshot %s: %w", snapshotID, err)
|
||||
}
|
||||
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
// ListSnapshotsMetadata извлекает краткую информацию обо всех снапшотах.
|
||||
func (s *sqliteStore) ListSnapshotsMetadata(ctx context.Context) ([]store.SnapshotInfo, error) {
|
||||
// Simplified implementation to debug the issue
|
||||
fmt.Println("ListSnapshotsMetadata called")
|
||||
|
||||
// Get all snapshot IDs first
|
||||
query := `SELECT id FROM snapshots ORDER BY creation_time DESC;`
|
||||
fmt.Println("Executing query:", query)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query snapshot IDs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var snapshots []store.SnapshotInfo
|
||||
|
||||
// For each ID, get the full snapshot details
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan snapshot ID: %w", err)
|
||||
}
|
||||
|
||||
// Get the full snapshot details
|
||||
snapshot, err := s.GetSnapshotMetadata(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get snapshot details for ID %s: %w", id, err)
|
||||
}
|
||||
|
||||
// Convert to SnapshotInfo
|
||||
info := store.SnapshotInfo{
|
||||
ID: snapshot.ID,
|
||||
Name: snapshot.Name,
|
||||
ParentID: snapshot.ParentID,
|
||||
CreationTime: snapshot.CreationTime,
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, info)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating snapshot IDs: %w", err)
|
||||
}
|
||||
|
||||
// If no snapshots found, return an empty slice
|
||||
if len(snapshots) == 0 {
|
||||
fmt.Println("No snapshots found")
|
||||
return []store.SnapshotInfo{}, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d snapshots\n", len(snapshots))
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// DeleteSnapshotMetadata удаляет метаданные снапшота.
|
||||
func (s *sqliteStore) DeleteSnapshotMetadata(ctx context.Context, snapshotID string) error {
|
||||
query := `DELETE FROM snapshots WHERE id = ?;`
|
||||
result, err := s.db.ExecContext(ctx, query, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete snapshot %s metadata: %w", snapshotID, err)
|
||||
}
|
||||
|
||||
// Проверяем, была ли запись реально удалена (опционально)
|
||||
_, err = result.RowsAffected()
|
||||
if err != nil {
|
||||
// Не критично, если не можем получить количество удаленных строк
|
||||
fmt.Printf("Warning: could not get rows affected after deleting snapshot %s: %v\n", snapshotID, err)
|
||||
}
|
||||
// fmt.Printf("Deleted %d metadata rows for snapshot %s\n", rowsAffected, snapshotID)
|
||||
|
||||
return nil // Не возвращаем ошибку, если запись не найдена
|
||||
}
|
74
store/store.go
Normal file
74
store/store.go
Normal file
@ -0,0 +1,74 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileInfo represents metadata and attributes of a file or directory.
|
||||
type FileInfo struct {
|
||||
Path string // Path represents the relative or absolute location of the file or directory in the filesystem.
|
||||
Size int64 // Size represents the size of the file in bytes.
|
||||
IsDir bool // IsDir indicates whether the FileInfo represents a directory.
|
||||
SHA256 string // SHA256 represents the SHA-256 checksum of the file for integrity verification.
|
||||
}
|
||||
|
||||
// Snapshot represents a point-in-time capture of file system metadata and hierarchy.
|
||||
type Snapshot struct {
|
||||
ID string // ID is the unique identifier of the snapshot.
|
||||
Name string // Name is the user-defined name of the snapshot.
|
||||
ParentID string // ParentID is the unique identifier of the parent snapshot, if one exists.
|
||||
CreationTime time.Time // CreationTime is the timestamp indicating when the snapshot was created.
|
||||
Files []FileInfo // Files represents a list of file metadata contained in the snapshot.
|
||||
}
|
||||
|
||||
// SnapshotInfo provides basic metadata about a snapshot, including its ID, name, parent snapshot, and creation time.
|
||||
type SnapshotInfo struct {
|
||||
ID string // Уникальный идентификатор
|
||||
Name string // Имя снапшота
|
||||
ParentID string // ID родительского снапшота
|
||||
CreationTime time.Time // Время создания
|
||||
}
|
||||
|
||||
// MetadataStore определяет интерфейс для хранения и извлечения метаданных снапшотов.
|
||||
type MetadataStore interface {
|
||||
// SaveSnapshotMetadata сохраняет полные метаданные снапшота, включая список файлов.
|
||||
// Если снапшот с таким ID уже существует, он должен быть перезаписан.
|
||||
SaveSnapshotMetadata(ctx context.Context, snap Snapshot) error
|
||||
|
||||
// GetSnapshotMetadata извлекает полные метаданные снапшота по его ID.
|
||||
// Возвращает agate.ErrNotFound, если снапшот не найден.
|
||||
GetSnapshotMetadata(ctx context.Context, snapshotID string) (*Snapshot, error)
|
||||
|
||||
// ListSnapshotsMetadata извлекает краткую информацию обо всех снапшотах.
|
||||
ListSnapshotsMetadata(ctx context.Context) ([]SnapshotInfo, error)
|
||||
|
||||
// DeleteSnapshotMetadata удаляет метаданные снапшота по его ID.
|
||||
// Не должен возвращать ошибку, если снапшот не найден.
|
||||
DeleteSnapshotMetadata(ctx context.Context, snapshotID string) error
|
||||
|
||||
// Close закрывает соединение с хранилищем метаданных.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// BlobStore определяет интерфейс для хранения и извлечения самих данных снапшотов (архивов).
|
||||
type BlobStore interface {
|
||||
// StoreBlob сохраняет данные из reader как блоб для указанного snapshotID.
|
||||
// Возвращает путь или идентификатор сохраненного блоба и ошибку.
|
||||
StoreBlob(ctx context.Context, snapshotID string, reader io.Reader) (path string, err error)
|
||||
|
||||
// RetrieveBlob возвращает io.ReadCloser для чтения содержимого блоба по snapshotID.
|
||||
// Вызывающая сторона ОБЯЗАНА закрыть ReadCloser.
|
||||
// Возвращает snapshot.ErrNotFound, если блоб не найден.
|
||||
RetrieveBlob(ctx context.Context, snapshotID string) (io.ReadCloser, error)
|
||||
|
||||
// DeleteBlob удаляет блоб, связанный с snapshotID.
|
||||
// Не должен возвращать ошибку, если блоб не найден.
|
||||
DeleteBlob(ctx context.Context, snapshotID string) error
|
||||
|
||||
// GetBlobPath возвращает путь к файлу блоба в файловой системе.
|
||||
// Это может быть полезно для функций пакета archive, которые работают с путями.
|
||||
// Возвращает agate.ErrNotFound, если блоб не найден.
|
||||
GetBlobPath(ctx context.Context, snapshotID string) (string, error)
|
||||
}
|
Reference in New Issue
Block a user