update to support postgresql and add godocs
Some checks failed
continuous-integration/drone Build is passing
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / End-to-End (push) Has been cancelled
CI / Publish Docker (push) Has been cancelled

This commit is contained in:
2026-01-13 17:05:14 +11:00
parent afec4aacb0
commit ea1eeb5c21
37 changed files with 618 additions and 38 deletions

View File

@@ -114,6 +114,25 @@ Create a new up/down migration file with this command
goose -dir db/migrations sqlite3 ./db.sqlite3 create init sql goose -dir db/migrations sqlite3 ./db.sqlite3 create init sql
``` ```
#### Database Configuration
By default the app uses SQLite and creates/opens `db.sqlite3`. You can opt into PostgreSQL
by setting environment variables:
- `DB_DRIVER`: `sqlite` (default) or `postgres`
- `DB_URL`: SQLite file path/DSN or PostgreSQL DSN
Examples:
```shell
# SQLite (default)
DB_DRIVER=sqlite DB_URL=./db.sqlite3
# PostgreSQL
DB_DRIVER=postgres DB_URL=postgres://user:pass@localhost:5432/vctp?sslmode=disable
```
PostgreSQL migrations live in `db/migrations_postgres`, while SQLite migrations remain in
`db/migrations`.
### Dist ### Dist
This is where your assets live. Any Javascript, images, or styling needs to go in the This is where your assets live. Any Javascript, images, or styling needs to go in the

View File

@@ -3,46 +3,79 @@ package db
import ( import (
"database/sql" "database/sql"
"embed" "embed"
"fmt"
"log/slog" "log/slog"
"reflect" "reflect"
"vctp/db/queries" "strings"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
) )
//go:embed migrations/*.sql //go:embed migrations migrations_postgres
var migrations embed.FS var migrations embed.FS
type Database interface { type Database interface {
DB() *sqlx.DB DB() *sqlx.DB
Queries() *queries.Queries Queries() Querier
Logger() *slog.Logger Logger() *slog.Logger
Close() error Close() error
} }
func New(logger *slog.Logger, url string) (Database, error) { type Config struct {
db, err := newLocalDB(logger, url) Driver string
if err != nil { DSN string
return nil, err
}
if err = db.db.Ping(); err != nil {
return nil, err
}
return db, nil
} }
// Migrate runs the migrations on the database. Assumes the database is SQLite. func New(logger *slog.Logger, cfg Config) (Database, error) {
func Migrate(db Database) error { driver := normalizeDriver(cfg.Driver)
switch driver {
case "sqlite":
db, err := newLocalDB(logger, cfg.DSN)
if err != nil {
return nil, err
}
if err = db.db.Ping(); err != nil {
return nil, err
}
return db, nil
case "postgres":
db, err := newPostgresDB(logger, cfg.DSN)
if err != nil {
return nil, err
}
if err = db.db.Ping(); err != nil {
return nil, err
}
return db, nil
default:
return nil, fmt.Errorf("unsupported database driver: %s", cfg.Driver)
}
}
// Migrate runs the migrations on the database.
func Migrate(db Database, driver string) error {
driver = normalizeDriver(driver)
goose.SetBaseFS(migrations) goose.SetBaseFS(migrations)
if err := goose.SetDialect("sqlite3"); err != nil { switch driver {
panic(err) case "sqlite":
} if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("failed to set sqlite dialect: %w", err)
if err := goose.Up(db.DB().DB, "migrations"); err != nil { }
panic(err) if err := goose.Up(db.DB().DB, "migrations"); err != nil {
return fmt.Errorf("failed to run sqlite migrations: %w", err)
}
case "postgres":
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("failed to set postgres dialect: %w", err)
}
if err := goose.Up(db.DB().DB, "migrations_postgres"); err != nil {
return fmt.Errorf("failed to run postgres migrations: %w", err)
}
default:
return fmt.Errorf("unsupported database driver: %s", driver)
} }
// TODO - replace with goose // TODO - replace with goose
@@ -69,6 +102,18 @@ func Migrate(db Database) error {
return nil return nil
} }
func normalizeDriver(driver string) string {
normalized := strings.ToLower(strings.TrimSpace(driver))
switch normalized {
case "", "sqlite3":
return "sqlite"
case "postgresql":
return "postgres"
default:
return normalized
}
}
// ConvertToSQLParams is a utility function that generically converts a struct to a corresponding sqlc-generated struct // ConvertToSQLParams is a utility function that generically converts a struct to a corresponding sqlc-generated struct
func ConvertToSQLParams(input interface{}, output interface{}) { func ConvertToSQLParams(input interface{}, output interface{}) {
inputVal := reflect.ValueOf(input).Elem() inputVal := reflect.ValueOf(input).Elem()

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"vctp/db/queries" "vctp/db/queries"
//_ "github.com/tursodatabase/libsql-client-go/libsql" //_ "github.com/tursodatabase/libsql-client-go/libsql"
@@ -28,7 +29,7 @@ func (d *LocalDB) DB() *sqlx.DB {
return d.db return d.db
} }
func (d *LocalDB) Queries() *queries.Queries { func (d *LocalDB) Queries() Querier {
return d.queries return d.queries
} }
@@ -42,7 +43,7 @@ func (d *LocalDB) Close() error {
return d.db.Close() return d.db.Close()
} }
func newLocalDB(logger *slog.Logger, path string) (*LocalDB, error) { func newLocalDB(logger *slog.Logger, dsn string) (*LocalDB, error) {
// TODO - work out if https://kerkour.com/sqlite-for-servers is possible without using sqlx // TODO - work out if https://kerkour.com/sqlite-for-servers is possible without using sqlx
/* /*
@@ -62,8 +63,9 @@ func newLocalDB(logger *slog.Logger, path string) (*LocalDB, error) {
readDB.SetMaxOpenConns(max(4, runtime.NumCPU())) readDB.SetMaxOpenConns(max(4, runtime.NumCPU()))
*/ */
//db, err := sql.Open("libsql", "file:"+path) normalizedDSN := normalizeSqliteDSN(dsn)
db, err := sqlx.Open("sqlite", "file:"+path) //db, err := sql.Open("libsql", normalizedDSN)
db, err := sqlx.Open("sqlite", normalizedDSN)
if err != nil { if err != nil {
logger.Error("can't open database connection", "error", err) logger.Error("can't open database connection", "error", err)
return nil, err return nil, err
@@ -92,3 +94,15 @@ func newLocalDB(logger *slog.Logger, path string) (*LocalDB, error) {
return &LocalDB{logger: logger, db: db, queries: queries.New(db)}, nil return &LocalDB{logger: logger, db: db, queries: queries.New(db)}, nil
} }
func normalizeSqliteDSN(dsn string) string {
trimmed := strings.TrimSpace(dsn)
if trimmed == "" {
return "file:db.sqlite3"
}
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "file:") || strings.HasPrefix(lower, "file::memory:") || trimmed == ":memory:" {
return trimmed
}
return "file:" + trimmed
}

View File

@@ -0,0 +1,38 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS "Inventory" (
"Iid" BIGSERIAL PRIMARY KEY,
"Name" TEXT NOT NULL,
"Vcenter" TEXT NOT NULL,
"VmId" TEXT,
"EventKey" TEXT,
"EventId" TEXT,
"CreationTime" BIGINT,
"DeletionTime" BIGINT,
"ResourcePool" TEXT,
"VmType" TEXT,
"Datacenter" TEXT,
"Cluster" TEXT,
"Folder" TEXT,
"ProvisionedDisk" REAL,
"InitialVcpus" INTEGER,
"InitialRam" INTEGER,
"SrmPlaceholder" INTEGER
);
CREATE TABLE IF NOT EXISTS "Updates" (
"Uid" BIGSERIAL PRIMARY KEY,
"InventoryId" INTEGER,
"UpdateTime" BIGINT,
"UpdateType" TEXT NOT NULL,
"NewVcpus" INTEGER,
"NewRam" INTEGER,
"NewResourcePool" TEXT
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE "Inventory";
DROP TABLE "Updates";
-- +goose StatementEnd

View File

@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Updates" ADD COLUMN "EventKey" TEXT;
ALTER TABLE "Updates" ADD COLUMN "EventId" TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Updates" DROP COLUMN "EventKey";
ALTER TABLE "Updates" DROP COLUMN "EventId";
-- +goose StatementEnd

View File

@@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS "Events" (
"Eid" BIGSERIAL PRIMARY KEY,
"CloudId" TEXT NOT NULL,
"Source" TEXT NOT NULL,
"EventTime" BIGINT,
"ChainId" TEXT NOT NULL,
"VmId" TEXT,
"EventKey" TEXT,
"Datacenter" TEXT,
"ComputeResource" TEXT,
"UserName" TEXT,
"Processed" INTEGER NOT NULL DEFAULT 0
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE "Events";
-- +goose StatementEnd

View File

@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Events" RENAME COLUMN "Datacenter" TO "DatacenterName";
ALTER TABLE "Events" RENAME COLUMN "ComputeResource" TO "ComputeResourceName";
ALTER TABLE "Events" ADD COLUMN "DatacenterId" TEXT;
ALTER TABLE "Events" ADD COLUMN "ComputeResourceId" TEXT;
ALTER TABLE "Events" ADD COLUMN "VmName" TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Events" DROP COLUMN "VmName";
ALTER TABLE "Events" DROP COLUMN "ComputeResourceId";
ALTER TABLE "Events" DROP COLUMN "DatacenterId";
ALTER TABLE "Events" RENAME COLUMN "ComputeResourceName" TO "ComputeResource";
ALTER TABLE "Events" RENAME COLUMN "DatacenterName" TO "Datacenter";
-- +goose StatementEnd

View File

@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Events" ADD COLUMN "EventType" TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Updates" DROP COLUMN "EventType";
-- +goose StatementEnd

View File

@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Inventory" ADD COLUMN "IsTemplate" INTEGER;
ALTER TABLE "Inventory" ADD COLUMN "PowerState" INTEGER;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Inventory" DROP COLUMN "PowerState";
ALTER TABLE "Inventory" DROP COLUMN "IsTemplate";
-- +goose StatementEnd

View File

@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Inventory" RENAME COLUMN "EventId" TO "CloudId";
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Inventory" RENAME COLUMN "CloudId" TO "EventId";
-- +goose StatementEnd

View File

@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Updates" ADD COLUMN "NewProvisionedDisk" REAL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Updates" DROP COLUMN "NewProvisionedDisk";
-- +goose StatementEnd

View File

@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Updates" ADD COLUMN "UserName" TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Updates" DROP COLUMN "UserName";
-- +goose StatementEnd

View File

@@ -0,0 +1,55 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Inventory" RENAME COLUMN "IsTemplate" TO "IsTemplate_old";
ALTER TABLE "Inventory" RENAME COLUMN "PowerState" TO "PowerState_old";
ALTER TABLE "Inventory" RENAME COLUMN "SrmPlaceholder" TO "SrmPlaceholder_old";
ALTER TABLE "Inventory" ADD COLUMN "IsTemplate" TEXT NOT NULL DEFAULT 'FALSE';
ALTER TABLE "Inventory" ADD COLUMN "PoweredOn" TEXT NOT NULL DEFAULT 'FALSE';
ALTER TABLE "Inventory" ADD COLUMN "SrmPlaceholder" TEXT NOT NULL DEFAULT 'FALSE';
UPDATE "Inventory"
SET "IsTemplate" = CASE
WHEN "IsTemplate_old" = 1 THEN 'TRUE'
ELSE 'FALSE'
END;
UPDATE "Inventory"
SET "PoweredOn" = CASE
WHEN "PowerState_old" = 1 THEN 'TRUE'
ELSE 'FALSE'
END;
UPDATE "Inventory"
SET "SrmPlaceholder" = CASE
WHEN "SrmPlaceholder_old" = 1 THEN 'TRUE'
ELSE 'FALSE'
END;
ALTER TABLE "Inventory" DROP COLUMN "IsTemplate_old";
ALTER TABLE "Inventory" DROP COLUMN "PowerState_old";
ALTER TABLE "Inventory" DROP COLUMN "SrmPlaceholder_old";
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Inventory" RENAME COLUMN "IsTemplate" TO "IsTemplate_old";
ALTER TABLE "Inventory" RENAME COLUMN "PoweredOn" TO "PoweredOn_old";
ALTER TABLE "Inventory" RENAME COLUMN "SrmPlaceholder" TO "SrmPlaceholder_old";
ALTER TABLE "Inventory" ADD COLUMN "IsTemplate" INTEGER;
ALTER TABLE "Inventory" ADD COLUMN "PowerState" INTEGER;
ALTER TABLE "Inventory" ADD COLUMN "SrmPlaceholder" INTEGER;
UPDATE "Inventory"
SET "IsTemplate" = CASE
WHEN "IsTemplate_old" = 'TRUE' THEN 1
ELSE 0
END;
UPDATE "Inventory"
SET "PowerState" = CASE
WHEN "PoweredOn_old" = 'TRUE' THEN 1
ELSE 0
END;
UPDATE "Inventory"
SET "SrmPlaceholder" = CASE
WHEN "SrmPlaceholder_old" = 'TRUE' THEN 1
ELSE 0
END;
ALTER TABLE "Inventory" DROP COLUMN "IsTemplate_old";
ALTER TABLE "Inventory" DROP COLUMN "PoweredOn_old";
ALTER TABLE "Inventory" DROP COLUMN "SrmPlaceholder_old";
-- +goose StatementEnd

View File

@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Inventory" ADD COLUMN "VmUuid" TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Inventory" DROP COLUMN "VmUuid";
-- +goose StatementEnd

View File

@@ -0,0 +1,18 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS "InventoryHistory" (
"Hid" BIGSERIAL PRIMARY KEY,
"InventoryId" INTEGER,
"ReportDate" BIGINT,
"UpdateTime" BIGINT,
"PreviousVcpus" INTEGER,
"PreviousRam" INTEGER,
"PreviousResourcePool" TEXT,
"PreviousProvisionedDisk" REAL
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE "InventoryHistory";
-- +goose StatementEnd

View File

@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Updates" ADD COLUMN "PlaceholderChange" TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Updates" DROP COLUMN "PlaceholderChange";
-- +goose StatementEnd

View File

@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Updates" ADD COLUMN "Name" TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Updates" DROP COLUMN "Name";
-- +goose StatementEnd

View File

@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE "Updates" ADD COLUMN "RawChangeString" BYTEA;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE "Updates" DROP COLUMN "RawChangeString";
-- +goose StatementEnd

79
db/postgres.go Normal file
View File

@@ -0,0 +1,79 @@
package db
import (
"context"
"database/sql"
"fmt"
"log/slog"
"regexp"
"strings"
"vctp/db/queries"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
)
type PostgresDB struct {
logger *slog.Logger
db *sqlx.DB
queries *queries.Queries
}
var _ Database = (*PostgresDB)(nil)
func (d *PostgresDB) DB() *sqlx.DB {
return d.db
}
func (d *PostgresDB) Queries() Querier {
return d.queries
}
func (d *PostgresDB) Logger() *slog.Logger {
return d.logger
}
func (d *PostgresDB) Close() error {
return d.db.Close()
}
func newPostgresDB(logger *slog.Logger, dsn string) (*PostgresDB, error) {
if strings.TrimSpace(dsn) == "" {
return nil, fmt.Errorf("postgres DSN is required")
}
db, err := sqlx.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
rebindDB := rebindDBTX{db: db}
return &PostgresDB{logger: logger, db: db, queries: queries.New(rebindDB)}, nil
}
type rebindDBTX struct {
db *sqlx.DB
}
func (r rebindDBTX) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return r.db.ExecContext(ctx, rebindQuery(query), args...)
}
func (r rebindDBTX) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
return r.db.PrepareContext(ctx, rebindQuery(query))
}
func (r rebindDBTX) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
return r.db.QueryContext(ctx, rebindQuery(query), args...)
}
func (r rebindDBTX) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
return r.db.QueryRowContext(ctx, rebindQuery(query), args...)
}
var numberedPlaceholderRe = regexp.MustCompile(`\?\d+`)
func rebindQuery(query string) string {
unindexed := numberedPlaceholderRe.ReplaceAllString(query, "?")
return sqlx.Rebind(sqlx.DOLLAR, unindexed)
}

35
db/querier.go Normal file
View File

@@ -0,0 +1,35 @@
package db
import (
"context"
"database/sql"
"vctp/db/queries"
)
// Querier abstracts sqlc-generated queries so multiple database backends can share call sites.
type Querier interface {
CleanupUpdates(ctx context.Context, arg queries.CleanupUpdatesParams) error
CleanupUpdatesNullVm(ctx context.Context) error
CreateEvent(ctx context.Context, arg queries.CreateEventParams) (queries.Events, error)
CreateInventory(ctx context.Context, arg queries.CreateInventoryParams) (queries.Inventory, error)
CreateInventoryHistory(ctx context.Context, arg queries.CreateInventoryHistoryParams) (queries.InventoryHistory, error)
CreateUpdate(ctx context.Context, arg queries.CreateUpdateParams) (queries.Updates, error)
GetInventoryByName(ctx context.Context, name string) ([]queries.Inventory, error)
GetInventoryByVcenter(ctx context.Context, vcenter string) ([]queries.Inventory, error)
GetInventoryEventId(ctx context.Context, cloudid sql.NullString) (queries.Inventory, error)
GetInventoryVcUrl(ctx context.Context, vc string) ([]queries.Inventory, error)
GetInventoryVmId(ctx context.Context, arg queries.GetInventoryVmIdParams) (queries.Inventory, error)
GetInventoryVmUuid(ctx context.Context, arg queries.GetInventoryVmUuidParams) (queries.Inventory, error)
GetReportInventory(ctx context.Context) ([]queries.Inventory, error)
GetReportUpdates(ctx context.Context) ([]queries.Updates, error)
GetVmUpdates(ctx context.Context, arg queries.GetVmUpdatesParams) ([]queries.Updates, error)
InventoryCleanup(ctx context.Context, arg queries.InventoryCleanupParams) error
InventoryCleanupTemplates(ctx context.Context) error
InventoryCleanupVcenter(ctx context.Context, vc string) error
InventoryMarkDeleted(ctx context.Context, arg queries.InventoryMarkDeletedParams) error
InventoryUpdate(ctx context.Context, arg queries.InventoryUpdateParams) error
ListEvents(ctx context.Context) ([]queries.Events, error)
ListInventory(ctx context.Context) ([]queries.Inventory, error)
ListUnprocessedEvents(ctx context.Context, eventtime sql.NullInt64) ([]queries.Events, error)
UpdateEventsProcessed(ctx context.Context, eid int64) error
}

View File

@@ -60,7 +60,7 @@ RETURNING *;
-- name: InventoryCleanupTemplates :exec -- name: InventoryCleanupTemplates :exec
DELETE FROM "Inventory" DELETE FROM "Inventory"
WHERE "IsTemplate" = "TRUE" WHERE "IsTemplate" = 'TRUE'
RETURNING *; RETURNING *;
-- name: CreateUpdate :one -- name: CreateUpdate :one

View File

@@ -694,7 +694,7 @@ func (q *Queries) InventoryCleanup(ctx context.Context, arg InventoryCleanupPara
const inventoryCleanupTemplates = `-- name: InventoryCleanupTemplates :exec const inventoryCleanupTemplates = `-- name: InventoryCleanupTemplates :exec
DELETE FROM "Inventory" DELETE FROM "Inventory"
WHERE "IsTemplate" = "TRUE" WHERE "IsTemplate" = 'TRUE'
RETURNING Iid, Name, Vcenter, VmId, EventKey, CloudId, CreationTime, DeletionTime, ResourcePool, VmType, Datacenter, Cluster, Folder, ProvisionedDisk, InitialVcpus, InitialRam, IsTemplate, PoweredOn, SrmPlaceholder, VmUuid RETURNING Iid, Name, Vcenter, VmId, EventKey, CloudId, CreationTime, DeletionTime, ResourcePool, VmType, Datacenter, Cluster, Folder, ProvisionedDisk, InitialVcpus, InitialRam, IsTemplate, PoweredOn, SrmPlaceholder, VmUuid
` `

4
go.mod
View File

@@ -5,6 +5,7 @@ go 1.25.0
require ( require (
github.com/a-h/templ v0.3.977 github.com/a-h/templ v0.3.977
github.com/go-co-op/gocron/v2 v2.19.0 github.com/go-co-op/gocron/v2 v2.19.0
github.com/jackc/pgx/v5 v5.8.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.26.0 github.com/pressly/goose/v3 v3.26.0
@@ -17,6 +18,9 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect

13
go.sum
View File

@@ -2,6 +2,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -19,6 +20,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -55,6 +64,9 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
@@ -95,6 +107,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

18
main.go
View File

@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"runtime" "runtime"
"strings"
"time" "time"
"vctp/db" "vctp/db"
"vctp/internal/secrets" "vctp/internal/secrets"
@@ -46,7 +47,20 @@ func main() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
// Configure database // Configure database
database, err := db.New(logger, utils.GetFilePath("db.sqlite3")) dbDriver := os.Getenv("DB_DRIVER")
if dbDriver == "" {
dbDriver = "sqlite"
}
normalizedDriver := strings.ToLower(strings.TrimSpace(dbDriver))
if normalizedDriver == "" || normalizedDriver == "sqlite3" {
normalizedDriver = "sqlite"
}
dbURL := os.Getenv("DB_URL")
if dbURL == "" && normalizedDriver == "sqlite" {
dbURL = utils.GetFilePath("db.sqlite3")
}
database, err := db.New(logger, db.Config{Driver: dbDriver, DSN: dbURL})
if err != nil { if err != nil {
logger.Error("Failed to create database", "error", err) logger.Error("Failed to create database", "error", err)
os.Exit(1) os.Exit(1)
@@ -54,7 +68,7 @@ func main() {
defer database.Close() defer database.Close()
//defer database.DB().Close() //defer database.DB().Close()
if err = db.Migrate(database); err != nil { if err = db.Migrate(database, dbDriver); err != nil {
logger.Error("failed to migrate database", "error", err) logger.Error("failed to migrate database", "error", err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -7,6 +7,16 @@ import (
"net/http" "net/http"
) )
// EncryptData encrypts a plaintext value and returns the ciphertext.
// @Summary Encrypt data
// @Description Encrypts a plaintext value and returns the ciphertext.
// @Tags crypto
// @Accept json
// @Produce json
// @Param payload body map[string]string true "Plaintext payload"
// @Success 200 {object} map[string]string "Ciphertext response"
// @Failure 500 {object} map[string]string "Server error"
// @Router /api/encrypt [post]
func (h *Handler) EncryptData(w http.ResponseWriter, r *http.Request) { func (h *Handler) EncryptData(w http.ResponseWriter, r *http.Request) {
//ctx := context.Background() //ctx := context.Background()
var cipherText string var cipherText string

View File

@@ -5,7 +5,14 @@ import (
"vctp/components/views" "vctp/components/views"
) )
// Home handles the home page. // Home renders the web UI home page.
// @Summary Home page
// @Description Renders the main UI page.
// @Tags ui
// @Produce text/html
// @Success 200 {string} string "HTML page"
// @Failure 500 {string} string "Render failed"
// @Router / [get]
func (h *Handler) Home(w http.ResponseWriter, r *http.Request) { func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
//h.html(r.Context(), w, http.StatusOK, core.HTML("Example Site", home.Home())) //h.html(r.Context(), w, http.StatusOK, core.HTML("Example Site", home.Home()))

View File

@@ -8,6 +8,14 @@ import (
"vctp/internal/report" "vctp/internal/report"
) )
// InventoryReportDownload returns the inventory report as an XLSX download.
// @Summary Download inventory report
// @Description Generates an inventory XLSX report and returns it as a file download.
// @Tags reports
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// @Success 200 {file} file "Inventory XLSX report"
// @Failure 500 {object} map[string]string "Report generation failed"
// @Router /api/report/inventory [get]
func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request) { func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() ctx := context.Background()
@@ -34,6 +42,14 @@ func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request
w.Write(reportData) w.Write(reportData)
} }
// UpdateReportDownload returns the updates report as an XLSX download.
// @Summary Download updates report
// @Description Generates an updates XLSX report and returns it as a file download.
// @Tags reports
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// @Success 200 {file} file "Updates XLSX report"
// @Failure 500 {object} map[string]string "Report generation failed"
// @Router /api/report/updates [get]
func (h *Handler) UpdateReportDownload(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateReportDownload(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() ctx := context.Background()

View File

@@ -6,7 +6,14 @@ import (
"net/http" "net/http"
) )
// VmUpdate receives the CloudEvent for a VM modification or move // UpdateCleanup removes orphaned update records.
// @Summary Cleanup updates
// @Description Removes update records that are no longer associated with a VM.
// @Tags maintenance
// @Produce text/plain
// @Success 200 {string} string "Cleanup completed"
// @Failure 500 {string} string "Server error"
// @Router /api/cleanup/updates [delete]
func (h *Handler) UpdateCleanup(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateCleanup(w http.ResponseWriter, r *http.Request) {
/* /*
// Get the current time // Get the current time

View File

@@ -9,7 +9,15 @@ import (
"net/http" "net/http"
) )
// Remove a specified VM from the inventory // VcCleanup removes inventory entries for a vCenter instance.
// @Summary Cleanup vCenter inventory
// @Description Removes all inventory entries associated with a vCenter URL.
// @Tags maintenance
// @Produce json
// @Param vc_url query string true "vCenter URL"
// @Success 200 {object} map[string]string "Cleanup completed"
// @Failure 400 {object} map[string]string "Invalid request"
// @Router /api/cleanup/vcenter [delete]
func (h *Handler) VcCleanup(w http.ResponseWriter, r *http.Request) { func (h *Handler) VcCleanup(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() ctx := context.Background()

View File

@@ -10,7 +10,16 @@ import (
"vctp/db/queries" "vctp/db/queries"
) )
// Remove a specified VM from the inventory // VmCleanup removes a VM from inventory by ID and datacenter.
// @Summary Cleanup VM inventory entry
// @Description Removes a VM inventory entry by VM ID and datacenter name.
// @Tags inventory
// @Produce json
// @Param vm_id query string true "VM ID"
// @Param datacenter_name query string true "Datacenter name"
// @Success 200 {object} map[string]string "Cleanup completed"
// @Failure 400 {object} map[string]string "Invalid request"
// @Router /api/inventory/vm/delete [delete]
func (h *Handler) VmCleanup(w http.ResponseWriter, r *http.Request) { func (h *Handler) VmCleanup(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() ctx := context.Background()

View File

@@ -14,7 +14,17 @@ import (
models "vctp/server/models" models "vctp/server/models"
) )
// VmCreateEvent receives the CloudEvent for a VM creation // VmCreateEvent records a VM creation CloudEvent.
// @Summary Record VM create event
// @Description Parses a VM create CloudEvent and stores the event data.
// @Tags events
// @Accept json
// @Produce text/plain
// @Param event body models.CloudEventReceived true "CloudEvent payload"
// @Success 200 {string} string "Create event processed"
// @Failure 400 {string} string "Invalid request"
// @Failure 500 {string} string "Server error"
// @Router /api/event/vm/create [post]
func (h *Handler) VmCreateEvent(w http.ResponseWriter, r *http.Request) { func (h *Handler) VmCreateEvent(w http.ResponseWriter, r *http.Request) {
var ( var (
unixTimestamp int64 unixTimestamp int64

View File

@@ -12,7 +12,17 @@ import (
models "vctp/server/models" models "vctp/server/models"
) )
// VmUpdate receives the CloudEvent for a VM modification or move // VmDeleteEvent records a VM deletion CloudEvent in the inventory.
// @Summary Record VM delete event
// @Description Parses a VM delete CloudEvent and marks the VM as deleted in inventory.
// @Tags events
// @Accept json
// @Produce text/plain
// @Param event body models.CloudEventReceived true "CloudEvent payload"
// @Success 200 {string} string "Delete event processed"
// @Failure 400 {string} string "Invalid request"
// @Failure 500 {string} string "Server error"
// @Router /api/event/vm/delete [post]
func (h *Handler) VmDeleteEvent(w http.ResponseWriter, r *http.Request) { func (h *Handler) VmDeleteEvent(w http.ResponseWriter, r *http.Request) {
var ( var (
deletedTimestamp int64 deletedTimestamp int64

View File

@@ -13,7 +13,16 @@ import (
models "vctp/server/models" models "vctp/server/models"
) )
// VmImport is used for bulk import of existing VMs // VmImport ingests a bulk VM import payload.
// @Summary Import VMs
// @Description Imports existing VM inventory data in bulk.
// @Tags inventory
// @Accept json
// @Produce json
// @Param import body models.ImportReceived true "Bulk import payload"
// @Success 200 {object} map[string]string "Import processed"
// @Failure 500 {object} map[string]string "Server error"
// @Router /api/import/vm [post]
func (h *Handler) VmImport(w http.ResponseWriter, r *http.Request) { func (h *Handler) VmImport(w http.ResponseWriter, r *http.Request) {
// Read request body // Read request body
reqBody, err := io.ReadAll(r.Body) reqBody, err := io.ReadAll(r.Body)

View File

@@ -19,7 +19,17 @@ import (
"github.com/vmware/govmomi/vim25/types" "github.com/vmware/govmomi/vim25/types"
) )
// VmModifyEvent receives the CloudEvent for a VM modification or move // VmModifyEvent records a VM modification CloudEvent.
// @Summary Record VM modify event
// @Description Parses a VM modify CloudEvent and creates an update record when relevant changes are detected.
// @Tags events
// @Accept json
// @Produce json
// @Param event body models.CloudEventReceived true "CloudEvent payload"
// @Success 200 {object} map[string]string "Modify event processed"
// @Success 202 {object} map[string]string "No relevant changes"
// @Failure 500 {object} map[string]string "Server error"
// @Router /api/event/vm/modify [post]
func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) { func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) {
var configChanges []map[string]string var configChanges []map[string]string
params := queries.CreateUpdateParams{} params := queries.CreateUpdateParams{}

View File

@@ -14,6 +14,17 @@ import (
models "vctp/server/models" models "vctp/server/models"
) )
// VmMoveEvent records a VM move CloudEvent as an update.
// @Summary Record VM move event
// @Description Parses a VM move CloudEvent and creates an update record.
// @Tags events
// @Accept json
// @Produce json
// @Param event body models.CloudEventReceived true "CloudEvent payload"
// @Success 200 {object} map[string]string "Move event processed"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 500 {object} map[string]string "Server error"
// @Router /api/event/vm/move [post]
func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) { func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) {
params := queries.CreateUpdateParams{} params := queries.CreateUpdateParams{}
var unixTimestamp int64 var unixTimestamp int64

View File

@@ -9,7 +9,14 @@ import (
"vctp/internal/vcenter" "vctp/internal/vcenter"
) )
// VmUpdate receives the CloudEvent for a VM modification or move // VmUpdateDetails refreshes inventory metadata from vCenter.
// @Summary Refresh VM details
// @Description Queries vCenter and updates inventory records with missing details.
// @Tags inventory
// @Produce text/plain
// @Success 200 {string} string "Update completed"
// @Failure 500 {string} string "Server error"
// @Router /api/inventory/vm/update [post]
func (h *Handler) VmUpdateDetails(w http.ResponseWriter, r *http.Request) { func (h *Handler) VmUpdateDetails(w http.ResponseWriter, r *http.Request) {
var matchFound bool var matchFound bool
var inventoryId int64 var inventoryId int64