diff --git a/ocp/data/internal.go b/ocp/data/internal.go index 4031b5a..0851afb 100644 --- a/ocp/data/internal.go +++ b/ocp/data/internal.go @@ -34,6 +34,7 @@ import ( "github.com/code-payments/ocp-server/ocp/data/timelock" "github.com/code-payments/ocp-server/ocp/data/transaction" "github.com/code-payments/ocp-server/ocp/data/vault" + vm_metadata "github.com/code-payments/ocp-server/ocp/data/vm/metadata" vm_ram "github.com/code-payments/ocp-server/ocp/data/vm/ram" vm_storage "github.com/code-payments/ocp-server/ocp/data/vm/storage" @@ -51,6 +52,7 @@ import ( timelock_memory_client "github.com/code-payments/ocp-server/ocp/data/timelock/memory" transaction_memory_client "github.com/code-payments/ocp-server/ocp/data/transaction/memory" vault_memory_client "github.com/code-payments/ocp-server/ocp/data/vault/memory" + vm_metadata_memory_client "github.com/code-payments/ocp-server/ocp/data/vm/metadata/memory" vm_ram_memory_client "github.com/code-payments/ocp-server/ocp/data/vm/ram/memory" vm_storage_memory_client "github.com/code-payments/ocp-server/ocp/data/vm/storage/memory" @@ -68,6 +70,7 @@ import ( timelock_postgres_client "github.com/code-payments/ocp-server/ocp/data/timelock/postgres" transaction_postgres_client "github.com/code-payments/ocp-server/ocp/data/transaction/postgres" vault_postgres_client "github.com/code-payments/ocp-server/ocp/data/vault/postgres" + vm_metadata_postgres_client "github.com/code-payments/ocp-server/ocp/data/vm/metadata/postgres" vm_ram_postgres_client "github.com/code-payments/ocp-server/ocp/data/vm/ram/postgres" vm_storage_postgres_client "github.com/code-payments/ocp-server/ocp/data/vm/storage/postgres" ) @@ -239,6 +242,11 @@ type DatabaseData interface { ReserveVmMemory(ctx context.Context, vm string, accountType vm.VirtualAccountType, address string) (string, uint16, error) GetVmMemoryLocationByAddress(ctx context.Context, address string) (string, uint16, error) + // VM Metadata + // -------------------------------------------------------------------------------- + PutVmMetadata(ctx context.Context, record *vm_metadata.Record) error + GetVmMetadataByMint(ctx context.Context, mint string) (*vm_metadata.Record, error) + // VM Storage // -------------------------------------------------------------------------------- InitializeVmStorage(ctx context.Context, record *vm_storage.Record) error @@ -269,6 +277,7 @@ type DatabaseProvider struct { timelocks timelock.Store transactions transaction.Store vault vault.Store + vmMetadata vm_metadata.Store vmRam vm_ram.Store vmStorage vm_storage.Store @@ -314,6 +323,7 @@ func NewDatabaseProvider(dbConfig *pg.Config) (DatabaseData, error) { timelocks: timelock_postgres_client.New(db), transactions: transaction_postgres_client.New(db), vault: vault_postgres_client.New(db), + vmMetadata: vm_metadata_postgres_client.New(db), vmRam: vm_ram_postgres_client.New(db), vmStorage: vm_storage_postgres_client.New(db), @@ -340,6 +350,7 @@ func NewTestDatabaseProvider() DatabaseData { timelocks: timelock_memory_client.New(), transactions: transaction_memory_client.New(), vault: vault_memory_client.New(), + vmMetadata: vm_metadata_memory_client.New(), vmRam: vm_ram_memory_client.New(), vmStorage: vm_storage_memory_client.New(), @@ -863,6 +874,15 @@ func (dp *DatabaseProvider) SaveKey(ctx context.Context, record *vault.Record) e return dp.vault.Save(ctx, record) } +// VM Metadata +// -------------------------------------------------------------------------------- +func (dp *DatabaseProvider) PutVmMetadata(ctx context.Context, record *vm_metadata.Record) error { + return dp.vmMetadata.Put(ctx, record) +} +func (dp *DatabaseProvider) GetVmMetadataByMint(ctx context.Context, mint string) (*vm_metadata.Record, error) { + return dp.vmMetadata.GetByMint(ctx, mint) +} + // VM RAM // -------------------------------------------------------------------------------- func (dp *DatabaseProvider) InitializeVmMemory(ctx context.Context, record *vm_ram.Record) error { diff --git a/ocp/data/vm/metadata/memory/store.go b/ocp/data/vm/metadata/memory/store.go new file mode 100644 index 0000000..0b332d1 --- /dev/null +++ b/ocp/data/vm/metadata/memory/store.go @@ -0,0 +1,81 @@ +package memory + +import ( + "context" + "sync" + "time" + + "github.com/code-payments/ocp-server/ocp/data/vm/metadata" +) + +type store struct { + mu sync.Mutex + last uint64 + records []*metadata.Record +} + +// New returns a new in memory vm.metadata.Store +func New() metadata.Store { + return &store{} +} + +// Put implements vm.metadata.Store.Put +func (s *store) Put(_ context.Context, record *metadata.Record) error { + if err := record.Validate(); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if item := s.find(record); item != nil { + return metadata.ErrAlreadyExists + } + + s.last++ + record.Id = s.last + if record.CreatedAt.IsZero() { + record.CreatedAt = time.Now() + } + + cloned := record.Clone() + s.records = append(s.records, &cloned) + + return nil +} + +// GetByMint implements vm.metadata.Store.GetByMint +func (s *store) GetByMint(_ context.Context, mint string) (*metadata.Record, error) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, item := range s.records { + if item.Mint == mint { + cloned := item.Clone() + return &cloned, nil + } + } + + return nil, metadata.ErrNotFound +} + +func (s *store) find(data *metadata.Record) *metadata.Record { + for _, item := range s.records { + if item.Id == data.Id && data.Id != 0 { + return item + } + + if item.Mint == data.Mint { + return item + } + } + + return nil +} + +func (s *store) reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.last = 0 + s.records = nil +} diff --git a/ocp/data/vm/metadata/memory/store_test.go b/ocp/data/vm/metadata/memory/store_test.go new file mode 100644 index 0000000..56e30b8 --- /dev/null +++ b/ocp/data/vm/metadata/memory/store_test.go @@ -0,0 +1,15 @@ +package memory + +import ( + "testing" + + "github.com/code-payments/ocp-server/ocp/data/vm/metadata/tests" +) + +func TestVmMetadataMemoryStore(t *testing.T) { + testStore := New() + teardown := func() { + testStore.(*store).reset() + } + tests.RunTests(t, testStore, teardown) +} diff --git a/ocp/data/vm/metadata/postgres/model.go b/ocp/data/vm/metadata/postgres/model.go new file mode 100644 index 0000000..e733894 --- /dev/null +++ b/ocp/data/vm/metadata/postgres/model.go @@ -0,0 +1,104 @@ +package postgres + +import ( + "context" + "database/sql" + "time" + + "github.com/jmoiron/sqlx" + + pgutil "github.com/code-payments/ocp-server/database/postgres" + "github.com/code-payments/ocp-server/ocp/data/vm/metadata" +) + +const ( + tableName = "ocp__core_vmmetadata" +) + +type model struct { + Id sql.NullInt64 `db:"id"` + + Mint string `db:"mint"` + Authority string `db:"authority"` + Vm string `db:"vm"` + VmBump uint8 `db:"vm_bump"` + Omnibus string `db:"omnibus"` + OmnibusBump uint8 `db:"omnibus_bump"` + DaysLocked uint8 `db:"days_locked"` + + CreatedAt time.Time `db:"created_at"` +} + +func toModel(obj *metadata.Record) (*model, error) { + if err := obj.Validate(); err != nil { + return nil, err + } + + return &model{ + Mint: obj.Mint, + Authority: obj.Authority, + Vm: obj.Vm, + VmBump: obj.VmBump, + Omnibus: obj.Omnibus, + OmnibusBump: obj.OmnibusBump, + DaysLocked: obj.DaysLocked, + + CreatedAt: obj.CreatedAt, + }, nil +} + +func fromModel(obj *model) *metadata.Record { + return &metadata.Record{ + Id: uint64(obj.Id.Int64), + + Mint: obj.Mint, + Authority: obj.Authority, + Vm: obj.Vm, + VmBump: obj.VmBump, + Omnibus: obj.Omnibus, + OmnibusBump: obj.OmnibusBump, + DaysLocked: obj.DaysLocked, + + CreatedAt: obj.CreatedAt, + } +} + +func (m *model) dbPut(ctx context.Context, db *sqlx.DB) error { + query := `INSERT INTO ` + tableName + ` + (mint, authority, vm, vm_bump, omnibus, omnibus_bump, days_locked, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, mint, authority, vm, vm_bump, omnibus, omnibus_bump, days_locked, created_at + ` + + if m.CreatedAt.IsZero() { + m.CreatedAt = time.Now() + } + + err := db.QueryRowxContext( + ctx, + query, + m.Mint, + m.Authority, + m.Vm, + m.VmBump, + m.Omnibus, + m.OmnibusBump, + m.DaysLocked, + m.CreatedAt, + ).StructScan(m) + + return pgutil.CheckUniqueViolation(err, metadata.ErrAlreadyExists) +} + +func dbGetByMint(ctx context.Context, db *sqlx.DB, mint string) (*model, error) { + var res model + query := `SELECT id, mint, authority, vm, vm_bump, omnibus, omnibus_bump, days_locked, created_at FROM ` + tableName + ` + WHERE mint = $1 + ` + + err := db.GetContext(ctx, &res, query, mint) + if err != nil { + return nil, pgutil.CheckNoRows(err, metadata.ErrNotFound) + } + return &res, nil +} diff --git a/ocp/data/vm/metadata/postgres/store.go b/ocp/data/vm/metadata/postgres/store.go new file mode 100644 index 0000000..3596092 --- /dev/null +++ b/ocp/data/vm/metadata/postgres/store.go @@ -0,0 +1,47 @@ +package postgres + +import ( + "context" + "database/sql" + + "github.com/jmoiron/sqlx" + + "github.com/code-payments/ocp-server/ocp/data/vm/metadata" +) + +type store struct { + db *sqlx.DB +} + +// New returns a new postgres vm.metadata.Store +func New(db *sql.DB) metadata.Store { + return &store{ + db: sqlx.NewDb(db, "pgx"), + } +} + +// Put implements vm.metadata.Store.Put +func (s *store) Put(ctx context.Context, record *metadata.Record) error { + obj, err := toModel(record) + if err != nil { + return err + } + + err = obj.dbPut(ctx, s.db) + if err != nil { + return err + } + + fromModel(obj).CopyTo(record) + + return nil +} + +// GetByMint implements vm.metadata.Store.GetByMint +func (s *store) GetByMint(ctx context.Context, mint string) (*metadata.Record, error) { + obj, err := dbGetByMint(ctx, s.db, mint) + if err != nil { + return nil, err + } + return fromModel(obj), nil +} diff --git a/ocp/data/vm/metadata/postgres/store_test.go b/ocp/data/vm/metadata/postgres/store_test.go new file mode 100644 index 0000000..c0cc9fa --- /dev/null +++ b/ocp/data/vm/metadata/postgres/store_test.go @@ -0,0 +1,113 @@ +package postgres + +import ( + "database/sql" + "os" + "testing" + + "github.com/ory/dockertest/v3" + "go.uber.org/zap" + + "github.com/code-payments/ocp-server/ocp/data/vm/metadata" + "github.com/code-payments/ocp-server/ocp/data/vm/metadata/tests" + + postgrestest "github.com/code-payments/ocp-server/database/postgres/test" + + _ "github.com/jackc/pgx/v4/stdlib" +) + +var ( + testStore metadata.Store + teardown func() +) + +const ( + // Used for testing ONLY, the table and migrations are external to this repository + tableCreate = ` + CREATE TABLE ocp__core_vmmetadata ( + id SERIAL NOT NULL PRIMARY KEY, + + mint TEXT NOT NULL, + authority TEXT NOT NULL, + vm TEXT NOT NULL, + vm_bump INTEGER NOT NULL, + omnibus TEXT NOT NULL, + omnibus_bump INTEGER NOT NULL, + days_locked INTEGER NOT NULL, + + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + + CONSTRAINT ocp__core_vmmetadata__uniq__mint UNIQUE (mint) + ); + ` + + // Used for testing ONLY, the table and migrations are external to this repository + tableDestroy = ` + DROP TABLE ocp__core_vmmetadata; + ` +) + +func TestMain(m *testing.M) { + log := zap.Must(zap.NewDevelopment()) + + testPool, err := dockertest.NewPool("") + if err != nil { + log.With(zap.Error(err)).Error("Error creating docker pool") + os.Exit(1) + } + + var cleanUpFunc func() + db, cleanUpFunc, err := postgrestest.StartPostgresDB(testPool) + if err != nil { + log.With(zap.Error(err)).Error("Error starting postgres image") + os.Exit(1) + } + defer db.Close() + + if err := createTestTables(log, db); err != nil { + log.With(zap.Error(err)).Error("Error creating test tables") + cleanUpFunc() + os.Exit(1) + } + + testStore = New(db) + teardown = func() { + if pc := recover(); pc != nil { + cleanUpFunc() + panic(pc) + } + + if err := resetTestTables(log, db); err != nil { + log.With(zap.Error(err)).Error("Error resetting test tables") + cleanUpFunc() + os.Exit(1) + } + } + + code := m.Run() + cleanUpFunc() + os.Exit(code) +} + +func TestVmMetadataPostgresStore(t *testing.T) { + tests.RunTests(t, testStore, teardown) +} + +func createTestTables(log *zap.Logger, db *sql.DB) error { + _, err := db.Exec(tableCreate) + if err != nil { + log.With(zap.Error(err)).Error("could not create test tables") + return err + } + return nil +} + +func resetTestTables(log *zap.Logger, db *sql.DB) error { + _, err := db.Exec(tableDestroy) + if err != nil { + log.With(zap.Error(err)).Error("could not drop test tables") + return err + } + + return createTestTables(log, db) +} diff --git a/ocp/data/vm/metadata/store.go b/ocp/data/vm/metadata/store.go new file mode 100644 index 0000000..48575b5 --- /dev/null +++ b/ocp/data/vm/metadata/store.go @@ -0,0 +1,80 @@ +package metadata + +import ( + "context" + "errors" + "time" +) + +var ( + ErrAlreadyExists = errors.New("vm metadata already exists") + ErrNotFound = errors.New("vm metadata not found") +) + +type Record struct { + Id uint64 + + Mint string + Authority string + Vm string + VmBump uint8 + Omnibus string + OmnibusBump uint8 + DaysLocked uint8 + + CreatedAt time.Time +} + +type Store interface { + // Put inserts or updates a VM metadata record + Put(ctx context.Context, record *Record) error + + // GetByMint returns the VM metadata record for the given mint + GetByMint(ctx context.Context, mint string) (*Record, error) +} + +func (r *Record) Validate() error { + if len(r.Mint) == 0 { + return errors.New("mint is required") + } + + if len(r.Authority) == 0 { + return errors.New("authority is required") + } + + if len(r.Vm) == 0 { + return errors.New("vm is required") + } + + if len(r.Omnibus) == 0 { + return errors.New("omnibus is required") + } + + return nil +} + +func (r *Record) Clone() Record { + return Record{ + Id: r.Id, + Mint: r.Mint, + Authority: r.Authority, + Vm: r.Vm, + VmBump: r.VmBump, + Omnibus: r.Omnibus, + OmnibusBump: r.OmnibusBump, + DaysLocked: r.DaysLocked, + CreatedAt: r.CreatedAt, + } +} + +func (r *Record) CopyTo(dst *Record) { + dst.Id = r.Id + dst.Mint = r.Mint + dst.Authority = r.Authority + dst.Vm = r.Vm + dst.VmBump = r.VmBump + dst.Omnibus = r.Omnibus + dst.OmnibusBump = r.OmnibusBump + dst.DaysLocked = r.DaysLocked + dst.CreatedAt = r.CreatedAt +} diff --git a/ocp/data/vm/metadata/tests/tests.go b/ocp/data/vm/metadata/tests/tests.go new file mode 100644 index 0000000..9ab367b --- /dev/null +++ b/ocp/data/vm/metadata/tests/tests.go @@ -0,0 +1,71 @@ +package tests + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/code-payments/ocp-server/ocp/data/vm/metadata" +) + +func RunTests(t *testing.T, s metadata.Store, teardown func()) { + for _, tf := range []func(t *testing.T, s metadata.Store){ + testHappyPath, + } { + tf(t, s) + teardown() + } +} + +func testHappyPath(t *testing.T, s metadata.Store) { + t.Run("testHappyPath", func(t *testing.T) { + ctx := context.Background() + + record := &metadata.Record{ + Mint: "mint1", + Authority: "authority1", + Vm: "vm1", + VmBump: 255, + Omnibus: "omnibus1", + OmnibusBump: 254, + DaysLocked: 21, + } + + // Get on non-existent record returns ErrNotFound + _, err := s.GetByMint(ctx, "mint1") + assert.Equal(t, metadata.ErrNotFound, err) + + // Put and verify fields are populated + cloned := record.Clone() + start := time.Now() + require.NoError(t, s.Put(ctx, record)) + assert.True(t, record.Id > 0) + assert.True(t, record.CreatedAt.After(start) || record.CreatedAt.Equal(start)) + + // Get by mint and verify all fields + actual, err := s.GetByMint(ctx, "mint1") + require.NoError(t, err) + assertEquivalentRecords(t, &cloned, actual) + assert.Equal(t, record.Id, actual.Id) + + // Duplicate put returns ErrAlreadyExists + assert.Equal(t, metadata.ErrAlreadyExists, s.Put(ctx, record)) + + // Get on non-existent mint still returns ErrNotFound + _, err = s.GetByMint(ctx, "mint2") + assert.Equal(t, metadata.ErrNotFound, err) + }) +} + +func assertEquivalentRecords(t *testing.T, obj1, obj2 *metadata.Record) { + assert.Equal(t, obj1.Mint, obj2.Mint) + assert.Equal(t, obj1.Authority, obj2.Authority) + assert.Equal(t, obj1.Vm, obj2.Vm) + assert.Equal(t, obj1.VmBump, obj2.VmBump) + assert.Equal(t, obj1.Omnibus, obj2.Omnibus) + assert.Equal(t, obj1.OmnibusBump, obj2.OmnibusBump) + assert.Equal(t, obj1.DaysLocked, obj2.DaysLocked) +}