cdf21682d6
AES-256-GCM encryption for credential storage, YAML seed config parser with validation, and transactional import into SQLite. Credentials (registry tokens, NPM password) encrypted before storage.
195 lines
5.4 KiB
Go
195 lines
5.4 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/alexei/docker-watcher/internal/crypto"
|
|
"github.com/alexei/docker-watcher/internal/store"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ImportSeed loads the seed YAML file and imports its contents into the store.
|
|
// Import is idempotent: it is skipped if any projects or registries already exist.
|
|
// Credential fields (registry tokens, NPM password) are encrypted before storage.
|
|
func ImportSeed(db *store.Store, seedPath string) error {
|
|
if _, err := os.Stat(seedPath); os.IsNotExist(err) {
|
|
log.Printf("No seed file at %s, skipping import", seedPath)
|
|
return nil
|
|
}
|
|
|
|
populated, err := isPopulated(db)
|
|
if err != nil {
|
|
return fmt.Errorf("check if db is populated: %w", err)
|
|
}
|
|
if populated {
|
|
log.Println("Database already has data, skipping seed import")
|
|
return nil
|
|
}
|
|
|
|
cfg, err := LoadSeedFile(seedPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load seed file: %w", err)
|
|
}
|
|
|
|
encKey, err := crypto.KeyFromEnv()
|
|
if err != nil {
|
|
return fmt.Errorf("encryption key: %w", err)
|
|
}
|
|
|
|
if err := importAll(db, cfg, encKey); err != nil {
|
|
return fmt.Errorf("import seed: %w", err)
|
|
}
|
|
|
|
log.Printf("Seed config imported from %s", seedPath)
|
|
return nil
|
|
}
|
|
|
|
// isPopulated returns true if the store already contains projects or registries.
|
|
func isPopulated(db *store.Store) (bool, error) {
|
|
projects, err := db.GetAllProjects()
|
|
if err != nil {
|
|
return false, fmt.Errorf("get projects: %w", err)
|
|
}
|
|
if len(projects) > 0 {
|
|
return true, nil
|
|
}
|
|
|
|
registries, err := db.GetAllRegistries()
|
|
if err != nil {
|
|
return false, fmt.Errorf("get registries: %w", err)
|
|
}
|
|
return len(registries) > 0, nil
|
|
}
|
|
|
|
// now returns the current time formatted for SQLite storage.
|
|
func now() string {
|
|
return time.Now().UTC().Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
// importAll runs the full seed import inside a database transaction.
|
|
// Uses raw SQL within the transaction so all inserts are atomic.
|
|
func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
|
|
tx, err := db.DB().Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // rollback after commit is a no-op
|
|
|
|
timestamp := now()
|
|
|
|
// Import registries first — projects reference them by name.
|
|
for name, regDef := range cfg.Registries {
|
|
encToken, err := crypto.EncryptIfNotEmpty(encKey, regDef.Token)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt registry %q token: %w", name, err)
|
|
}
|
|
|
|
id := uuid.New().String()
|
|
_, err = tx.Exec(
|
|
`INSERT INTO registries (id, name, url, type, token, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
id, name, regDef.URL, regDef.Type, encToken, timestamp, timestamp,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert registry %q: %w", name, err)
|
|
}
|
|
}
|
|
|
|
// Import projects and their stages.
|
|
for name, projDef := range cfg.Projects {
|
|
envJSON, err := mapToJSON(projDef.Env)
|
|
if err != nil {
|
|
return fmt.Errorf("encode env for project %q: %w", name, err)
|
|
}
|
|
volJSON, err := mapToJSON(projDef.Volumes)
|
|
if err != nil {
|
|
return fmt.Errorf("encode volumes for project %q: %w", name, err)
|
|
}
|
|
|
|
projectID := uuid.New().String()
|
|
_, err = tx.Exec(
|
|
`INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
projectID, name, projDef.Registry, projDef.Image, projDef.Port,
|
|
projDef.Healthcheck, envJSON, volJSON, timestamp, timestamp,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert project %q: %w", name, err)
|
|
}
|
|
|
|
for stageName, stageDef := range projDef.Stages {
|
|
maxInstances := stageDef.MaxInstances
|
|
if maxInstances == 0 {
|
|
maxInstances = 1
|
|
}
|
|
|
|
stageID := uuid.New().String()
|
|
_, err = tx.Exec(
|
|
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
stageID, projectID, stageName, stageDef.TagPattern,
|
|
boolToInt(stageDef.AutoDeploy), maxInstances,
|
|
boolToInt(stageDef.Confirm), stageDef.PromoteFrom,
|
|
stageDef.Subdomain, timestamp, timestamp,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert stage %q for project %q: %w", stageName, name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Import global settings — encrypt NPM password.
|
|
encNpmPassword, err := crypto.EncryptIfNotEmpty(encKey, cfg.Global.Npm.Password)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt npm password: %w", err)
|
|
}
|
|
|
|
subdomainPattern := cfg.Global.SubdomainPattern
|
|
if subdomainPattern == "" {
|
|
subdomainPattern = "stage-{stage}-{project}"
|
|
}
|
|
|
|
_, err = tx.Exec(
|
|
`UPDATE settings SET
|
|
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
|
npm_url=?, npm_email=?, npm_password=?, updated_at=?
|
|
WHERE id = 1`,
|
|
cfg.Global.Domain, cfg.Global.ServerIP, cfg.Global.Network,
|
|
subdomainPattern, cfg.Global.NotificationURL,
|
|
cfg.Global.Npm.URL, cfg.Global.Npm.Email, encNpmPassword, timestamp,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update settings: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit transaction: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// boolToInt converts a bool to an integer for SQLite storage.
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// mapToJSON encodes a string map to JSON. Returns "{}" for nil maps.
|
|
func mapToJSON(m map[string]string) (string, error) {
|
|
if m == nil {
|
|
return "{}", nil
|
|
}
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|