feat: initial open-source release of Pipely
OTA update server designed for high-concurrency, low-bandwidth deployments. GORM-backed PostgreSQL, JWT auth, device management, artefact versioning, deployment rollout with gated rate-limited downloads, and a React admin panel.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
)
|
||||
|
||||
// Connect opens a PostgreSQL connection via GORM. If the target database does
|
||||
// not exist it is created automatically. Tables are then migrated.
|
||||
func Connect(databaseURL string) (*gorm.DB, error) {
|
||||
dbName, err := ensureDatabase(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
|
||||
log.Printf("[database] Connected to PostgreSQL database: %s", dbName)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// AutoMigrate creates or updates tables to match the model definitions.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&model.AdminUser{},
|
||||
&model.Device{},
|
||||
&model.Artefact{},
|
||||
&model.Deployment{},
|
||||
&model.DeploymentDevice{},
|
||||
)
|
||||
}
|
||||
|
||||
// ensureDatabase creates the target database if it does not exist.
|
||||
func ensureDatabase(databaseURL string) (string, error) {
|
||||
u, err := url.Parse(databaseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid database URL: %w", err)
|
||||
}
|
||||
dbName := strings.TrimPrefix(u.Path, "/")
|
||||
|
||||
adminURL := *u
|
||||
adminURL.Path = "/postgres"
|
||||
|
||||
db, err := gorm.Open(postgres.Open(adminURL.String()), &gorm.Config{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to connect to admin database: %w", err)
|
||||
}
|
||||
|
||||
var exists bool
|
||||
db.Raw("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = ?)", dbName).Scan(&exists)
|
||||
if !exists {
|
||||
if err := db.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, dbName)).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to create database %s: %w", dbName, err)
|
||||
}
|
||||
log.Printf("[database] Created database: %s", dbName)
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
return dbName, nil
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
)
|
||||
|
||||
// -- Admin Users --
|
||||
|
||||
func CreateAdminUser(db *gorm.DB, u *model.AdminUser) error {
|
||||
return db.Create(u).Error
|
||||
}
|
||||
|
||||
func GetAdminUserByUsername(db *gorm.DB, username string) (*model.AdminUser, error) {
|
||||
var u model.AdminUser
|
||||
err := db.Where("username = ?", username).First(&u).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func ListAdminUsers(db *gorm.DB) ([]model.AdminUser, error) {
|
||||
var users []model.AdminUser
|
||||
err := db.Order("created_at").Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func DeleteAdminUser(db *gorm.DB, id int64) error {
|
||||
return db.Delete(&model.AdminUser{}, id).Error
|
||||
}
|
||||
|
||||
func UpdateAdminPassword(db *gorm.DB, id int64, passwordHash string) error {
|
||||
return db.Model(&model.AdminUser{}).Where("id = ?", id).
|
||||
Update("password_hash", passwordHash).Error
|
||||
}
|
||||
|
||||
// -- Devices --
|
||||
|
||||
func UpsertDevice(db *gorm.DB, d *model.Device) error {
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "device_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"ip_address", "hostname", "os", "current_version", "status", "group_name", "last_heartbeat", "updated_at"}),
|
||||
}).Create(d).Error
|
||||
}
|
||||
|
||||
func UpdateDeviceHeartbeat(db *gorm.DB, deviceID, version, status string) error {
|
||||
return db.Model(&model.Device{}).Where("device_id = ?", deviceID).
|
||||
Updates(map[string]interface{}{
|
||||
"current_version": version,
|
||||
"status": status,
|
||||
"last_heartbeat": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func GetDeviceByID(db *gorm.DB, deviceID string) (*model.Device, error) {
|
||||
var d model.Device
|
||||
err := db.Where("device_id = ?", deviceID).First(&d).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func ListDevices(db *gorm.DB, status, group string, page, pageSize int) ([]model.Device, int64, error) {
|
||||
var devices []model.Device
|
||||
var total int64
|
||||
|
||||
q := db.Model(&model.Device{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if group != "" {
|
||||
q = q.Where("group_name = ?", group)
|
||||
}
|
||||
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := q.Order("last_heartbeat DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&devices).Error
|
||||
return devices, total, err
|
||||
}
|
||||
|
||||
func GetOfflineDevices(db *gorm.DB, threshold time.Duration) ([]model.Device, error) {
|
||||
var devices []model.Device
|
||||
err := db.Where("last_heartbeat < ? AND status = ?", time.Now().Add(-threshold), model.DeviceStatusOnline).
|
||||
Find(&devices).Error
|
||||
return devices, err
|
||||
}
|
||||
|
||||
func MarkDevicesOffline(db *gorm.DB, deviceIDs []string) error {
|
||||
return db.Model(&model.Device{}).Where("device_id IN ?", deviceIDs).
|
||||
Update("status", model.DeviceStatusOffline).Error
|
||||
}
|
||||
|
||||
// -- Artefacts --
|
||||
|
||||
func CreateArtefact(db *gorm.DB, a *model.Artefact) error {
|
||||
return db.Create(a).Error
|
||||
}
|
||||
|
||||
func GetArtefactByID(db *gorm.DB, id int64) (*model.Artefact, error) {
|
||||
var a model.Artefact
|
||||
err := db.First(&a, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func GetArtefactByVersion(db *gorm.DB, version string) (*model.Artefact, error) {
|
||||
var a model.Artefact
|
||||
err := db.Where("version = ?", version).Order("created_at DESC").First(&a).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func ListArtefacts(db *gorm.DB, page, pageSize int) ([]model.Artefact, int64, error) {
|
||||
var artefacts []model.Artefact
|
||||
var total int64
|
||||
|
||||
db.Model(&model.Artefact{}).Count(&total)
|
||||
err := db.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&artefacts).Error
|
||||
return artefacts, total, err
|
||||
}
|
||||
|
||||
// -- Deployments --
|
||||
|
||||
func CreateDeployment(db *gorm.DB, d *model.Deployment) error {
|
||||
return db.Create(d).Error
|
||||
}
|
||||
|
||||
func GetDeploymentByID(db *gorm.DB, id int64) (*model.Deployment, error) {
|
||||
var d model.Deployment
|
||||
err := db.Preload("Artefact").First(&d, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func ListDeployments(db *gorm.DB, status string, page, pageSize int) ([]model.Deployment, int64, error) {
|
||||
var deployments []model.Deployment
|
||||
var total int64
|
||||
|
||||
q := db.Model(&model.Deployment{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := q.Preload("Artefact").Order("created_at DESC").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).Find(&deployments).Error
|
||||
return deployments, total, err
|
||||
}
|
||||
|
||||
func UpdateDeploymentStatus(db *gorm.DB, id int64, status string) error {
|
||||
return db.Model(&model.Deployment{}).Where("id = ?", id).Update("status", status).Error
|
||||
}
|
||||
|
||||
// -- Deployment Devices --
|
||||
|
||||
func CreateDeploymentDevices(db *gorm.DB, deploymentID int64, deviceIDs []string) error {
|
||||
devices := make([]model.DeploymentDevice, len(deviceIDs))
|
||||
for i, did := range deviceIDs {
|
||||
devices[i] = model.DeploymentDevice{DeploymentID: deploymentID, DeviceID: did, Status: model.DeployDeviceStatusPending}
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&devices).Error
|
||||
}
|
||||
|
||||
func GetActiveDeploymentForDevice(db *gorm.DB, deviceID string) (*model.Deployment, *model.Artefact, error) {
|
||||
var dd model.DeploymentDevice
|
||||
err := db.Where("device_id = ? AND status = ?", deviceID, model.DeployDeviceStatusPending).
|
||||
Preload("Deployment.Artefact").First(&dd).Error
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var d model.Deployment
|
||||
db.Preload("Artefact").First(&d, dd.DeploymentID)
|
||||
if d.Status != model.DeploymentStatusActive {
|
||||
return nil, nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &d, d.Artefact, nil
|
||||
}
|
||||
|
||||
func UpdateDeploymentDeviceStatus(db *gorm.DB, deploymentID int64, deviceID, status, errMsg string) error {
|
||||
updates := map[string]interface{}{"status": status, "error": errMsg}
|
||||
now := time.Now()
|
||||
if status == model.DeployDeviceStatusDownloaded {
|
||||
updates["downloaded_at"] = &now
|
||||
}
|
||||
if status == model.DeployDeviceStatusCompleted || status == model.DeployDeviceStatusFailed {
|
||||
updates["completed_at"] = &now
|
||||
}
|
||||
return db.Model(&model.DeploymentDevice{}).
|
||||
Where("deployment_id = ? AND device_id = ?", deploymentID, deviceID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func GetDeploymentDeviceStats(db *gorm.DB, deploymentID int64) (map[string]int64, error) {
|
||||
type row struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
var rows []row
|
||||
if err := db.Model(&model.DeploymentDevice{}).
|
||||
Select("status, COUNT(*) as count").
|
||||
Where("deployment_id = ?", deploymentID).
|
||||
Group("status").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
stats[r.Status] = r.Count
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func GetDeploymentDevices(db *gorm.DB, deploymentID int64, status string, page, pageSize int) ([]model.DeploymentDevice, int64, error) {
|
||||
var devices []model.DeploymentDevice
|
||||
var total int64
|
||||
|
||||
q := db.Model(&model.DeploymentDevice{}).Where("deployment_id = ?", deploymentID)
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
q.Count(&total)
|
||||
err := q.Order("created_at").Offset((page - 1) * pageSize).Limit(pageSize).Find(&devices).Error
|
||||
return devices, total, err
|
||||
}
|
||||
Reference in New Issue
Block a user