mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-06-08 23:09:33 +00:00
c0061b99e3
- [x] Implement GORM V2 metadata store with SQLite, PostgreSQL, and MySQL support - [x] Add database migration system using gormigrate for schema versioning - [x] Create migration CLI tool with support for migrate, rollback, and status commands - [x] Add Docker support for migration container (Dockerfile.migrate) - [x] Implement automatic partition management for PostgreSQL time-series tables - [x] Add background aggregation worker for download statistics - [x] Support connection pooling configuration (max_open_conns, max_idle_conns, conn_max_lifetime) - [x] Add blocking mechanism based on vulnerability thresholds in stats and handlers - [x] Update Helm charts with migration init containers and multi-database configuration - [x] Replace deprecated SQLite store with optimized GORM implementation - [x] Add comprehensive integration tests for MySQL and PostgreSQL - [x] Update frontend to display blocked packages and storage utilization - [x] Add goreleaser configuration for migrate binary and container image - [x] Update configuration examples with database backend options and recommendations
229 lines
6.4 KiB
Go
229 lines
6.4 KiB
Go
package gormstore
|
|
|
|
import (
|
|
"github.com/go-gormigrate/gormigrate/v2"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// GetMigrations returns all database migrations for gormigrate
|
|
func GetMigrations() []*gormigrate.Migration {
|
|
return []*gormigrate.Migration{
|
|
{
|
|
ID: "202601030001",
|
|
Migrate: func(tx *gorm.DB) error {
|
|
// Migration: Create V2 schema
|
|
return migrateToV2(tx)
|
|
},
|
|
Rollback: func(tx *gorm.DB) error {
|
|
// Rollback: Drop V2 schema (careful!)
|
|
return rollbackFromV2(tx)
|
|
},
|
|
},
|
|
// Future migrations go here
|
|
// {
|
|
// ID: "202601040001",
|
|
// Migrate: func(tx *gorm.DB) error {
|
|
// // Add new column, index, etc.
|
|
// return tx.Exec("ALTER TABLE packages ADD COLUMN new_field VARCHAR(255)").Error
|
|
// },
|
|
// Rollback: func(tx *gorm.DB) error {
|
|
// return tx.Exec("ALTER TABLE packages DROP COLUMN new_field").Error
|
|
// },
|
|
// },
|
|
}
|
|
}
|
|
|
|
// migrateToV2 creates the complete V2 schema
|
|
func migrateToV2(tx *gorm.DB) error {
|
|
// Get dialect name for database-specific features
|
|
dialectName := tx.Dialector.Name()
|
|
|
|
// Step 1: Create all tables using GORM AutoMigrate
|
|
// This handles cross-database compatibility automatically
|
|
if err := tx.AutoMigrate(GetAllModels()...); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 2: Seed default registries
|
|
registries := []RegistryModel{
|
|
{Name: "npm", DisplayName: "NPM Registry", UpstreamURL: "https://registry.npmjs.org", Enabled: true, ScanByDefault: true},
|
|
{Name: "pypi", DisplayName: "PyPI", UpstreamURL: "https://pypi.org", Enabled: true, ScanByDefault: true},
|
|
{Name: "go", DisplayName: "Go Modules", UpstreamURL: "https://proxy.golang.org", Enabled: true, ScanByDefault: true},
|
|
}
|
|
|
|
for _, reg := range registries {
|
|
// Upsert: create if not exists
|
|
if err := tx.Where("name = ?", reg.Name).FirstOrCreate(®).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Step 3: Create database-specific optimizations
|
|
if dialectName == "postgres" {
|
|
if err := createPostgreSQLOptimizations(tx); err != nil {
|
|
return err
|
|
}
|
|
} else if dialectName == "mysql" {
|
|
if err := createMySQLOptimizations(tx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createPostgreSQLOptimizations adds PostgreSQL-specific features
|
|
func createPostgreSQLOptimizations(tx *gorm.DB) error {
|
|
optimizations := []string{
|
|
// Create GIN indexes for JSONB columns
|
|
`CREATE INDEX IF NOT EXISTS idx_package_metadata_keywords_gin
|
|
ON package_metadata USING GIN(keywords)`,
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_package_metadata_raw_gin
|
|
ON package_metadata USING GIN(raw_metadata)`,
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_references_gin
|
|
ON vulnerabilities USING GIN(references)`,
|
|
|
|
// Create partial indexes (only non-deleted records)
|
|
`CREATE INDEX IF NOT EXISTS idx_packages_active
|
|
ON packages(registry_id, name, version) WHERE deleted_at IS NULL`,
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_packages_vulnerable
|
|
ON packages(vulnerability_count, highest_severity)
|
|
WHERE vulnerability_count > 0 AND deleted_at IS NULL`,
|
|
|
|
// Create view for vulnerable packages
|
|
`CREATE OR REPLACE VIEW v_vulnerable_packages AS
|
|
SELECT
|
|
r.name AS registry,
|
|
p.name,
|
|
p.version,
|
|
p.vulnerability_count,
|
|
p.highest_severity,
|
|
p.last_scanned_at
|
|
FROM packages p
|
|
JOIN registries r ON p.registry_id = r.id
|
|
WHERE p.vulnerability_count > 0 AND p.deleted_at IS NULL
|
|
ORDER BY
|
|
CASE p.highest_severity
|
|
WHEN 'critical' THEN 1
|
|
WHEN 'high' THEN 2
|
|
WHEN 'medium' THEN 3
|
|
WHEN 'low' THEN 4
|
|
ELSE 5
|
|
END,
|
|
p.vulnerability_count DESC`,
|
|
|
|
// Create function for automatic partition creation
|
|
`CREATE OR REPLACE FUNCTION create_next_month_partitions()
|
|
RETURNS void AS $$
|
|
DECLARE
|
|
next_month DATE := date_trunc('month', NOW() + INTERVAL '2 months');
|
|
partition_name TEXT;
|
|
start_date TEXT;
|
|
end_date TEXT;
|
|
BEGIN
|
|
-- Download events partition
|
|
partition_name := 'download_events_' || to_char(next_month, 'YYYY_MM');
|
|
start_date := to_char(next_month, 'YYYY-MM-DD');
|
|
end_date := to_char(next_month + INTERVAL '1 month', 'YYYY-MM-DD');
|
|
|
|
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF download_events FOR VALUES FROM (%L) TO (%L)',
|
|
partition_name, start_date, end_date);
|
|
|
|
-- Audit log partition
|
|
partition_name := 'audit_log_' || to_char(next_month, 'YYYY_MM');
|
|
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_log FOR VALUES FROM (%L) TO (%L)',
|
|
partition_name, start_date, end_date);
|
|
|
|
RAISE NOTICE 'Created partitions for %', to_char(next_month, 'YYYY-MM');
|
|
END;
|
|
$$ LANGUAGE plpgsql`,
|
|
}
|
|
|
|
for _, sql := range optimizations {
|
|
if err := tx.Exec(sql).Error; err != nil {
|
|
// Log warning but don't fail migration
|
|
// Some optimizations might already exist
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createMySQLOptimizations adds MySQL-specific features
|
|
func createMySQLOptimizations(tx *gorm.DB) error {
|
|
optimizations := []string{
|
|
// Create view for vulnerable packages
|
|
`CREATE OR REPLACE VIEW v_vulnerable_packages AS
|
|
SELECT
|
|
r.name AS registry,
|
|
p.name,
|
|
p.version,
|
|
p.vulnerability_count,
|
|
p.highest_severity,
|
|
p.last_scanned_at
|
|
FROM packages p
|
|
JOIN registries r ON p.registry_id = r.id
|
|
WHERE p.vulnerability_count > 0 AND p.deleted_at IS NULL
|
|
ORDER BY
|
|
CASE p.highest_severity
|
|
WHEN 'critical' THEN 1
|
|
WHEN 'high' THEN 2
|
|
WHEN 'medium' THEN 3
|
|
WHEN 'low' THEN 4
|
|
ELSE 5
|
|
END,
|
|
p.vulnerability_count DESC`,
|
|
}
|
|
|
|
for _, sql := range optimizations {
|
|
if err := tx.Exec(sql).Error; err != nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// rollbackFromV2 drops all V2 tables (USE WITH CAUTION!)
|
|
func rollbackFromV2(tx *gorm.DB) error {
|
|
// Drop in reverse order to respect foreign keys
|
|
tables := []string{
|
|
"audit_log",
|
|
"download_stats_daily",
|
|
"download_stats_hourly",
|
|
"download_events",
|
|
"cve_bypasses",
|
|
"scan_results",
|
|
"package_vulnerabilities",
|
|
"vulnerabilities",
|
|
"package_metadata",
|
|
"packages",
|
|
"registries",
|
|
}
|
|
|
|
// Drop PostgreSQL-specific objects
|
|
if tx.Dialector.Name() == "postgres" {
|
|
tx.Exec("DROP VIEW IF EXISTS v_vulnerable_packages")
|
|
tx.Exec("DROP FUNCTION IF EXISTS create_next_month_partitions()")
|
|
}
|
|
|
|
// Drop MySQL-specific objects
|
|
if tx.Dialector.Name() == "mysql" {
|
|
tx.Exec("DROP VIEW IF EXISTS v_vulnerable_packages")
|
|
}
|
|
|
|
// Drop all tables
|
|
for _, table := range tables {
|
|
if err := tx.Migrator().DropTable(table); err != nil {
|
|
// Continue even if table doesn't exist
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|