Skip to content

Commit d7e7a29

Browse files
committed
feat(api,cli): add PostgreSQL store infrastructure and scaffolding
Implement base structure for PostgreSQL store using bun ORM as an alternative to MongoDB. Store method signatures defined but not yet implemented. Both API server and CLI now support database selection via environment variable. - Add PostgreSQL connection and configuration in api/server.go and cli/main.go - Create pg store package with method signatures for all store operations - Implement database connection, migration system, and logging options - Add testcontainers-based test infrastructure for PostgreSQL - Define entity models and store interface implementation skeleton - Configure bun ORM with pgx driver for PostgreSQL operations
1 parent f44589c commit d7e7a29

24 files changed

+600
-10
lines changed

.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ SHELLHUB_AUTO_SSL=false
3636

3737
SHELLHUB_DATABASE=mongo
3838

39+
SHELLHUB_POSTGRES_HOST=postgres
40+
SHELLHUB_POSTGRES_PORT=5432
3941
SHELLHUB_POSTGRES_USERNAME=admin
4042
SHELLHUB_POSTGRES_PASSWORD=admin
4143
SHELLHUB_POSTGRES_DATABASE=main

api/server.go

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ package main
22

33
import (
44
"context"
5+
"errors"
56
"os"
67

78
"github.com/getsentry/sentry-go"
89
"github.com/labstack/echo/v4"
910
"github.com/shellhub-io/shellhub/api/routes"
1011
"github.com/shellhub-io/shellhub/api/services"
12+
"github.com/shellhub-io/shellhub/api/store"
1113
"github.com/shellhub-io/shellhub/api/store/mongo"
1214
"github.com/shellhub-io/shellhub/api/store/mongo/options"
15+
"github.com/shellhub-io/shellhub/api/store/pg"
1316
"github.com/shellhub-io/shellhub/pkg/api/internalclient"
1417
"github.com/shellhub-io/shellhub/pkg/cache"
1518
"github.com/shellhub-io/shellhub/pkg/geoip/geolite2"
@@ -19,9 +22,22 @@ import (
1922
)
2023

2124
type env struct {
25+
Database string `env:"DATABASE,default=mongo"`
26+
2227
// MongoURI specifies the connection string for MongoDB.
2328
MongoURI string `env:"MONGO_URI,default=mongodb://mongo:27017/main"`
2429

30+
// PostgresHost specifies the host for PostgreSQL.
31+
PostgresHost string `env:"POSTGRES_HOST,default=postgres"`
32+
// PostgresPort specifies the port for PostgreSQL.
33+
PostgresPort string `env:"POSTGRES_PORT,default=5432"`
34+
// PostgresUsername specifies the username for authenticate PostgreSQL.
35+
PostgresUsername string `env:"POSTGRES_USERNAME,default=admin"`
36+
// PostgresUser specifies the password for authenticate PostgreSQL.
37+
PostgresPassword string `env:"POSTGRES_PASSWORD,default=admin"`
38+
// PostgresDatabase especifica o nome do banco de dados PostgreSQL a ser utilizado.
39+
PostgresDatabase string `env:"POSTGRES_DATABASE,default=main"`
40+
2541
// RedisURI specifies the connection string for Redis.
2642
RedisURI string `env:"REDIS_URI,default=redis://redis:6379"`
2743
// RedisCachePoolSize defines the maximum number of concurrent connections to Redis cache.
@@ -75,14 +91,22 @@ func (s *Server) Setup(ctx context.Context) error {
7591

7692
log.Debug("Redis cache initialized successfully")
7793

78-
store, err := mongo.NewStore(ctx, s.env.MongoURI, cache, options.RunMigatrions)
79-
if err != nil {
80-
log.
81-
WithError(err).
82-
Fatal("failed to create the store")
94+
var store store.Store
95+
switch s.env.Database {
96+
case "mongodb":
97+
store, err = mongo.NewStore(ctx, s.env.MongoURI, cache, options.RunMigatrions)
98+
case "postgres":
99+
uri := pg.URI(s.env.PostgresHost, s.env.PostgresPort, s.env.PostgresUsername, s.env.PostgresPassword, s.env.PostgresDatabase)
100+
store, err = pg.New(ctx, uri)
101+
default:
102+
log.WithField("database", s.env.Database).Error("invalid database")
103+
return errors.New("invalid database")
83104
}
84105

85-
log.Debug("MongoDB store connected successfully")
106+
if err != nil {
107+
log.WithError(err).Error("failed to create the store")
108+
return err
109+
}
86110

87111
apiClient, err := internalclient.NewClient(nil, internalclient.WithAsynqWorker(s.env.RedisURI))
88112
if err != nil {

api/store/pg/api-key.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package pg
2+
3+
import (
4+
"context"
5+
6+
"github.com/shellhub-io/shellhub/api/store"
7+
"github.com/shellhub-io/shellhub/pkg/models"
8+
)
9+
10+
func (pg *Pg) APIKeyCreate(ctx context.Context, APIKey *models.APIKey) (insertedID string, err error)
11+
12+
func (pg *Pg) APIKeyResolve(ctx context.Context, resolver store.APIKeyResolver, value string, opts ...store.QueryOption) (*models.APIKey, error)
13+
14+
func (pg *Pg) APIKeyConflicts(ctx context.Context, tenantID string, target *models.APIKeyConflicts) (conflicts []string, has bool, err error)
15+
16+
func (pg *Pg) APIKeyList(ctx context.Context, opts ...store.QueryOption) (apiKeys []models.APIKey, count int, err error)
17+
18+
func (pg *Pg) APIKeySave(ctx context.Context, apiKey *models.APIKey) (err error)
19+
20+
func (pg *Pg) APIKeyDelete(ctx context.Context, apiKey *models.APIKey) (err error)

api/store/pg/dbtest/dbtest.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package dbtest
2+
3+
import (
4+
"context"
5+
"io"
6+
"log"
7+
"time"
8+
9+
"github.com/testcontainers/testcontainers-go"
10+
"github.com/testcontainers/testcontainers-go/modules/postgres"
11+
"github.com/testcontainers/testcontainers-go/wait"
12+
)
13+
14+
// Server represents a Postgres test server instance.
15+
type Server struct {
16+
container *postgres.PostgresContainer
17+
}
18+
19+
// Up starts a new Postgres container. Use [Server.ConnectionString] to access the connection string.
20+
func (srv *Server) Up(ctx context.Context, verbose bool) error {
21+
if !verbose {
22+
testcontainers.Logger = log.New(io.Discard, "", 0)
23+
}
24+
25+
opts := []testcontainers.ContainerCustomizer{
26+
postgres.WithDatabase("test"),
27+
postgres.WithUsername("admin"),
28+
postgres.WithPassword("admin"),
29+
testcontainers.WithWaitStrategy(wait.ForLog("database system is ready to accept connections").WithOccurrence(2).WithStartupTimeout(5 * time.Second)),
30+
}
31+
32+
container, err := postgres.Run(ctx, "postgres:18.0", opts...)
33+
if err != nil {
34+
return err
35+
}
36+
37+
srv.container = container
38+
39+
return nil
40+
}
41+
42+
// Down gracefully terminates the Postgres container.
43+
func (srv *Server) Down(ctx context.Context) error {
44+
return srv.container.Terminate(ctx)
45+
}
46+
47+
func (srv *Server) ConnectionString(ctx context.Context) (string, error) {
48+
cIP, err := srv.container.ContainerIP(ctx)
49+
if err != nil {
50+
return "", err
51+
}
52+
53+
return "postgres://admin:admin@" + cIP + ":5432/test", nil
54+
}

api/store/pg/dbtest/fixtures.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package dbtest
2+
3+
import (
4+
"path/filepath"
5+
"runtime"
6+
)
7+
8+
func FixturesPath() string {
9+
_, file, _, _ := runtime.Caller(0)
10+
11+
return filepath.Join(filepath.Dir(file), "fixtures")
12+
}

api/store/pg/device.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package pg
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/shellhub-io/shellhub/api/store"
8+
"github.com/shellhub-io/shellhub/pkg/models"
9+
)
10+
11+
func (pg *Pg) DeviceCreate(ctx context.Context, device *models.Device) (insertedUID string, err error)
12+
13+
func (pg *Pg) DeviceList(ctx context.Context, acceptable store.DeviceAcceptable, opts ...store.QueryOption) ([]models.Device, int, error)
14+
15+
func (pg *Pg) DeviceResolve(ctx context.Context, resolver store.DeviceResolver, value string, opts ...store.QueryOption) (*models.Device, error)
16+
17+
func (pg *Pg) DeviceConflicts(ctx context.Context, target *models.DeviceConflicts) (conflicts []string, has bool, err error)
18+
19+
func (pg *Pg) DeviceUpdate(ctx context.Context, device *models.Device) error
20+
21+
func (pg *Pg) DeviceHeartbeat(ctx context.Context, uids []string, lastSeen time.Time) (modifiedCount int64, err error)
22+
23+
func (pg *Pg) DeviceDelete(ctx context.Context, device *models.Device) error
24+
25+
func (pg *Pg) DeviceDeleteMany(ctx context.Context, uids []string) (deletedCount int64, err error)

api/store/pg/member.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package pg
2+
3+
import (
4+
"context"
5+
6+
"github.com/shellhub-io/shellhub/pkg/models"
7+
)
8+
9+
func (pg *Pg) NamespaceAddMember(ctx context.Context, tenantID string, member *models.Member) error
10+
11+
func (pg *Pg) NamespaceUpdateMember(ctx context.Context, tenantID string, memberID string, changes *models.MemberChanges) error
12+
13+
func (pg *Pg) NamespaceRemoveMember(ctx context.Context, tenantID string, memberID string) error

api/store/pg/namespace.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package pg
2+
3+
import (
4+
"context"
5+
6+
"github.com/shellhub-io/shellhub/api/store"
7+
"github.com/shellhub-io/shellhub/pkg/models"
8+
)
9+
10+
func (pg *Pg) NamespaceList(ctx context.Context, opts ...store.QueryOption) ([]models.Namespace, int, error)
11+
12+
func (pg *Pg) NamespaceResolve(ctx context.Context, resolver store.NamespaceResolver, value string) (*models.Namespace, error)
13+
14+
func (pg *Pg) NamespaceGetPreferred(ctx context.Context, userID string) (*models.Namespace, error)
15+
16+
func (pg *Pg) NamespaceCreate(ctx context.Context, namespace *models.Namespace) (*models.Namespace, error)
17+
18+
func (pg *Pg) NamespaceConflicts(ctx context.Context, target *models.NamespaceConflicts) (conflicts []string, has bool, err error)
19+
20+
func (pg *Pg) NamespaceUpdate(ctx context.Context, namespace *models.Namespace) error
21+
22+
func (pg *Pg) NamespaceIncrementDeviceCount(ctx context.Context, tenantID string, status models.DeviceStatus, count int64) error
23+
24+
func (pg *Pg) NamespaceDelete(ctx context.Context, namespace *models.Namespace) error
25+
26+
func (pg *Pg) NamespaceDeleteMany(ctx context.Context, tenantIDs []string) (int64, error)

api/store/pg/options/log.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package options
2+
3+
import (
4+
"context"
5+
"os"
6+
7+
"github.com/oiime/logrusbun"
8+
"github.com/sirupsen/logrus"
9+
"github.com/uptrace/bun"
10+
)
11+
12+
func Log(level string, verbose bool) Option {
13+
return func(ctx context.Context, db *bun.DB) error {
14+
level, err := logrus.ParseLevel(level)
15+
if err != nil {
16+
return err
17+
}
18+
19+
logger := &logrus.Logger{
20+
Out: os.Stderr,
21+
Formatter: new(logrus.TextFormatter),
22+
Hooks: make(logrus.LevelHooks),
23+
Level: level,
24+
}
25+
26+
db.AddQueryHook(logrusbun.NewQueryHook(
27+
logrusbun.WithEnabled(true),
28+
logrusbun.WithVerbose(verbose),
29+
logrusbun.WithQueryHookOptions(logrusbun.QueryHookOptions{
30+
Logger: logger,
31+
QueryLevel: logrus.DebugLevel,
32+
ErrorLevel: logrus.ErrorLevel,
33+
SlowLevel: logrus.WarnLevel,
34+
}),
35+
))
36+
37+
return nil
38+
}
39+
}

api/store/pg/options/migrate.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package options
2+
3+
import (
4+
"context"
5+
6+
"github.com/shellhub-io/shellhub/api/store/pg/migrations"
7+
log "github.com/sirupsen/logrus"
8+
"github.com/uptrace/bun"
9+
"github.com/uptrace/bun/migrate"
10+
)
11+
12+
func Migrate() Option {
13+
return func(ctx context.Context, db *bun.DB) error {
14+
log.Info("starting database migration")
15+
16+
migrations, err := migrations.FetchMigrations()
17+
if err != nil {
18+
log.WithError(err).Error("failed to fetch migrations")
19+
20+
return err
21+
}
22+
23+
migrator := migrate.NewMigrator(db, migrations)
24+
if err := migrator.Init(context.Background()); err != nil {
25+
log.WithError(err).Error("failed to start migrations tables")
26+
27+
return err
28+
}
29+
30+
if err := migrator.Lock(ctx); err != nil {
31+
log.WithError(err).Error("failed to acquire migration lock")
32+
33+
return err
34+
}
35+
36+
defer func() {
37+
if err := migrator.Unlock(ctx); err != nil {
38+
log.WithError(err).Error("failed to release migration lock")
39+
} else {
40+
log.Debug("migration lock released successfully")
41+
}
42+
}()
43+
44+
group, err := migrator.Migrate(ctx)
45+
if err != nil {
46+
log.WithError(err).Error("migration failed")
47+
48+
return err
49+
}
50+
51+
if group.IsZero() {
52+
log.Info("no new migrations to run (database is up to date)")
53+
54+
return nil
55+
}
56+
57+
log.Info("migration completed successfully")
58+
59+
return nil
60+
}
61+
}

0 commit comments

Comments
 (0)