From cdf21682d64b82367c782d7639aafe35e427e257 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 27 Mar 2026 21:01:16 +0300 Subject: [PATCH] feat(docker-watcher): phase 2 - crypto & config seed loader 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. --- PLAN.md | 2 +- cmd/server/main.go | 7 + docker-watcher.example.yaml | 78 +++++++ internal/config/config.go | 112 ++++++++++ internal/config/seed.go | 194 ++++++++++++++++++ internal/crypto/crypto.go | 96 +++++++++ internal/store/store.go | 5 + plans/docker-watcher-core/PLAN.md | 27 ++- .../docker-watcher-core/phase-13-ui-polish.md | 59 ++++++ .../phase-2-crypto-config.md | 41 ++-- 10 files changed, 602 insertions(+), 19 deletions(-) create mode 100644 docker-watcher.example.yaml create mode 100644 internal/config/config.go create mode 100644 internal/config/seed.go create mode 100644 internal/crypto/crypto.go create mode 100644 plans/docker-watcher-core/phase-13-ui-polish.md diff --git a/PLAN.md b/PLAN.md index 8660633..8d433ad 100644 --- a/PLAN.md +++ b/PLAN.md @@ -250,7 +250,7 @@ docker-watcher/ ## Implementation Phases -### Phase 1: Foundation +### Phase 1: Foundation ✅ Core infrastructure — store, config import, Docker client, NPM client. diff --git a/cmd/server/main.go b/cmd/server/main.go index ed00de5..e5eef2c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/alexei/docker-watcher/internal/config" "github.com/alexei/docker-watcher/internal/store" ) @@ -23,6 +24,12 @@ func main() { } defer db.Close() + // Import seed config on first launch (idempotent — skipped if DB has data). + seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml") + if err := config.ImportSeed(db, seedPath); err != nil { + log.Fatalf("seed import: %v", err) + } + fmt.Printf("Docker Watcher started. Database: %s\n", dbPath) // Future phases will wire up the HTTP server, deployer, poller, etc. diff --git a/docker-watcher.example.yaml b/docker-watcher.example.yaml new file mode 100644 index 0000000..072b5a4 --- /dev/null +++ b/docker-watcher.example.yaml @@ -0,0 +1,78 @@ +# Docker Watcher — Seed Configuration +# +# This file is read ONCE on first launch to populate the SQLite database. +# After import, all configuration is managed via the Web UI. +# The only required env var is ENCRYPTION_KEY (used to encrypt credentials in DB). +# +# Place this file as ./docker-watcher.yaml (or set SEED_FILE env var) +# and start Docker Watcher. Once imported, the file is never read again. + +global: + # Your base domain — must have a Cloudflare wildcard DNS record (*.domain) + domain: example.com + + # The IP address of your Docker host + server_ip: 192.168.1.100 + + # Docker network that containers will be attached to + network: staging-net + + # Pattern for generating subdomains. Available placeholders: {stage}, {project} + subdomain_pattern: "stage-{stage}-{project}" + + # Webhook URL for deploy notifications (optional) + notification_url: https://notify.example.com/webhook + + # Nginx Proxy Manager connection + npm: + url: http://npm:81 + email: admin@example.com + password: "your-npm-password-here" + +# Container registries — referenced by name in project definitions +registries: + gitea: + url: https://git.example.com + type: gitea + token: "your-registry-token-here" + + # github: + # url: https://ghcr.io + # type: github + # token: "ghp_your-github-token-here" + +# Projects to deploy — each project has an image and one or more stages +projects: + my-web-app: + registry: gitea + image: git.example.com/org/my-web-app + port: 3000 + healthcheck: /api/health + + # Environment variables passed to the container + env: + NODE_ENV: production + + # Volume mounts (host:container) + # volumes: + # /data/uploads: /app/uploads + + stages: + dev: + tag_pattern: "dev-*" + auto_deploy: true + max_instances: 5 + + rel: + tag_pattern: "v*" + auto_deploy: false + max_instances: 2 + + prod: + tag_pattern: "v*" + auto_deploy: false + confirm: true + promote_from: rel + max_instances: 2 + # Custom subdomain (instead of the pattern-generated one) + subdomain: my-app diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e66327b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,112 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// SeedConfig represents the top-level YAML seed configuration. +type SeedConfig struct { + Global GlobalConfig `yaml:"global"` + Registries map[string]RegistryDef `yaml:"registries"` + Projects map[string]ProjectDef `yaml:"projects"` +} + +// GlobalConfig holds domain-wide settings from the seed file. +type GlobalConfig struct { + Domain string `yaml:"domain"` + ServerIP string `yaml:"server_ip"` + Network string `yaml:"network"` + SubdomainPattern string `yaml:"subdomain_pattern"` + NotificationURL string `yaml:"notification_url"` + Npm NpmConfig `yaml:"npm"` +} + +// NpmConfig holds Nginx Proxy Manager connection details. +type NpmConfig struct { + URL string `yaml:"url"` + Email string `yaml:"email"` + Password string `yaml:"password"` +} + +// RegistryDef defines a container registry from the seed file. +type RegistryDef struct { + URL string `yaml:"url"` + Type string `yaml:"type"` + Token string `yaml:"token"` +} + +// ProjectDef defines a project from the seed file. +type ProjectDef struct { + Registry string `yaml:"registry"` + Image string `yaml:"image"` + Port int `yaml:"port"` + Healthcheck string `yaml:"healthcheck"` + Env map[string]string `yaml:"env"` + Volumes map[string]string `yaml:"volumes"` + Stages map[string]StageDef `yaml:"stages"` +} + +// StageDef defines a deployment stage from the seed file. +type StageDef struct { + TagPattern string `yaml:"tag_pattern"` + AutoDeploy bool `yaml:"auto_deploy"` + MaxInstances int `yaml:"max_instances"` + Confirm bool `yaml:"confirm"` + PromoteFrom string `yaml:"promote_from"` + Subdomain string `yaml:"subdomain"` +} + +// LoadSeedFile reads and parses the YAML seed config from the given path. +func LoadSeedFile(path string) (SeedConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return SeedConfig{}, fmt.Errorf("read seed file: %w", err) + } + + return ParseSeed(data) +} + +// ParseSeed parses raw YAML bytes into a SeedConfig. +func ParseSeed(data []byte) (SeedConfig, error) { + var cfg SeedConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return SeedConfig{}, fmt.Errorf("parse yaml: %w", err) + } + + if err := validate(cfg); err != nil { + return SeedConfig{}, fmt.Errorf("validate seed config: %w", err) + } + + return cfg, nil +} + +// validate checks that required fields are present in the seed config. +func validate(cfg SeedConfig) error { + if cfg.Global.Domain == "" { + return fmt.Errorf("global.domain is required") + } + + for name, proj := range cfg.Projects { + if proj.Image == "" { + return fmt.Errorf("project %q: image is required", name) + } + if proj.Registry != "" { + if _, ok := cfg.Registries[proj.Registry]; !ok { + return fmt.Errorf("project %q: references unknown registry %q", name, proj.Registry) + } + } + for stageName, stage := range proj.Stages { + if stage.TagPattern == "" { + return fmt.Errorf("project %q stage %q: tag_pattern is required", name, stageName) + } + if stage.MaxInstances < 0 { + return fmt.Errorf("project %q stage %q: max_instances must be >= 0", name, stageName) + } + } + } + + return nil +} diff --git a/internal/config/seed.go b/internal/config/seed.go new file mode 100644 index 0000000..5c27649 --- /dev/null +++ b/internal/config/seed.go @@ -0,0 +1,194 @@ +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 +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..ac8ccb4 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,96 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" +) + +// ErrNoKey is returned when ENCRYPTION_KEY is not set. +var ErrNoKey = errors.New("ENCRYPTION_KEY environment variable is not set") + +// DeriveKey computes a 32-byte AES-256 key from the given passphrase using SHA-256. +// This is acceptable when ENCRYPTION_KEY is a high-entropy random string (e.g., 32+ hex chars). +// For human-chosen passphrases, consider Argon2id or PBKDF2 with a salt instead. +func DeriveKey(passphrase string) [32]byte { + return sha256.Sum256([]byte(passphrase)) +} + +// KeyFromEnv reads ENCRYPTION_KEY from the environment and derives a 32-byte key. +func KeyFromEnv() ([32]byte, error) { + raw := os.Getenv("ENCRYPTION_KEY") + if raw == "" { + return [32]byte{}, ErrNoKey + } + return DeriveKey(raw), nil +} + +// Encrypt encrypts plaintext using AES-256-GCM with a random nonce. +// The returned ciphertext is hex-encoded: nonce || ciphertext+tag. +func Encrypt(key [32]byte, plaintext string) (string, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + + sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return hex.EncodeToString(sealed), nil +} + +// Decrypt decrypts a hex-encoded ciphertext produced by Encrypt. +func Decrypt(key [32]byte, ciphertextHex string) (string, error) { + data, err := hex.DecodeString(ciphertextHex) + if err != nil { + return "", fmt.Errorf("decode hex: %w", err) + } + + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create gcm: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce := data[:nonceSize] + ciphertext := data[nonceSize:] + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + + return string(plaintext), nil +} + +// EncryptIfNotEmpty encrypts the value only if it is non-empty. +// Returns empty string for empty input. +func EncryptIfNotEmpty(key [32]byte, value string) (string, error) { + if value == "" { + return "", nil + } + return Encrypt(key, value) +} diff --git a/internal/store/store.go b/internal/store/store.go index 12dea51..2a8fdfc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -51,6 +51,11 @@ func (s *Store) Close() error { return s.db.Close() } +// DB returns the underlying *sql.DB for advanced operations like transactions. +func (s *Store) DB() *sql.DB { + return s.db +} + // migrate creates all tables if they do not already exist. func (s *Store) migrate() error { _, err := s.db.Exec(schema) diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index cea0187..bb6f805 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -13,6 +13,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy Manager integration. Detects new images from Gitea/GitHub registries, deploys containers, and configures reverse proxy routing — all from a web dashboard. Supports multiple simultaneous versions of the same project. ## Build & Test Commands + - **Build (Go):** `go build ./cmd/server/` - **Test (Go):** `go test ./...` - **Lint (Go):** `golangci-lint run` @@ -34,16 +35,18 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M - [ ] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md) - [ ] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md) - [ ] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md) +- [ ] Phase 13: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md) ### Parallel Execution Notes + - Phases 3 and 4 are independent (Docker client vs NPM client) — can run in parallel - Phases 9 and 10 are independent (dashboard vs settings pages) — can run in parallel ## Phase Progress Log | Phase | Domain | Status | Review | Build | Committed | -|-------|--------|--------|--------|-------|-----------| -| Phase 1: Scaffold & Store | backend | ✅ Complete | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | +| ----- | ------ | ------ | ------ | ----- | --------- | +| Phase 1: Scaffold & Store | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | | Phase 2: Crypto & Config | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 3: Docker Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 4: NPM Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | @@ -54,9 +57,27 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M | Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 11: Embed & SSE | fullstack | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | -| Phase 12: Hardening | backend | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ | +| Phase 12: Hardening | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | +| Phase 13: UI Polish | frontend | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ | + +## Amendment Log + +### Amendment 1 — 2026-03-27 + +**Type:** Added phase +**What changed:** Added Phase 13: Frontend Polish & Modern UI after Phase 12 +**Why:** User wants modern look & feel with SVG icons and polished frontend +**Impact on existing phases:** None — Phase 13 runs after all functionality is complete. Build/tests now required on Phase 13 (final) instead of Phase 12. + +### Amendment 2 — 2026-03-27 + +**Type:** Modified phase +**What changed:** Added Task 13 (EN/RU localization) to Phase 13: Frontend Polish & Modern UI +**Why:** User wants bilingual support (English and Russian) in the dashboard +**Impact on existing phases:** None — contained within Phase 13 ## Final Review + - [ ] Comprehensive code review - [ ] Full build passes - [ ] Full test suite passes diff --git a/plans/docker-watcher-core/phase-13-ui-polish.md b/plans/docker-watcher-core/phase-13-ui-polish.md new file mode 100644 index 0000000..889f799 --- /dev/null +++ b/plans/docker-watcher-core/phase-13-ui-polish.md @@ -0,0 +1,59 @@ +# Phase 13: Frontend Polish & Modern UI + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Enhance the web UI with a modern, polished look and feel — custom SVG icons, refined typography, consistent color palette, smooth transitions, and overall professional frontend quality. + +## Tasks + +- [ ] Task 1: Design system foundations — define color palette (dark/light), spacing scale, typography scale, border radius tokens as CSS custom properties +- [ ] Task 2: SVG icon set — create or integrate a consistent icon library (Lucide, Heroicons, or custom SVGs) for all UI actions (deploy, stop, start, restart, remove, settings, registry, etc.) +- [ ] Task 3: Refine layout — polished sidebar/topnav with active state indicators, smooth transitions, responsive breakpoints +- [ ] Task 4: Dashboard cards — redesign project cards with status indicators, instance count badges, sparkline activity, hover effects +- [ ] Task 5: Project detail view — clean table/card layout for instances, inline status badges with pulse animation for "running", deploy history timeline +- [ ] Task 6: Form styling — consistent input fields, select dropdowns, toggle switches (replace checkboxes), button hierarchy (primary/secondary/danger) +- [ ] Task 7: Toast/notification system — slide-in toasts with icons, auto-dismiss, stacking +- [ ] Task 8: Loading states — skeleton loaders for data fetching, spinner for actions, progress indicator for deploys +- [ ] Task 9: Empty states — illustrated empty states with call-to-action for "no projects", "no instances", "no deploys" +- [ ] Task 10: Responsive design — mobile-friendly layout, collapsible sidebar, touch-friendly controls +- [ ] Task 11: Micro-interactions — button press feedback, status transition animations, deploy progress animation +- [ ] Task 12: Dark mode support (optional) — toggle in settings, respect system preference +- [ ] Task 13: Localization (EN/RU) — i18n setup with locale switcher, translate all UI strings to English and Russian, persist language preference + +## Files to Modify/Create +- `web/src/lib/styles/` — design tokens, global styles +- `web/src/lib/components/icons/` — SVG icon components +- `web/src/lib/components/` — enhanced existing components +- `web/src/lib/i18n/` — locale files (en.json, ru.json), i18n helper, locale switcher component +- All route files — refined layouts and styling, replace hardcoded strings with i18n keys + +## Acceptance Criteria +- UI looks modern and professional — not "default framework" appearance +- Consistent icon language throughout the app +- Smooth transitions and meaningful animations (not gratuitous) +- Responsive down to mobile viewport +- Loading and empty states provide good UX +- Color palette works well in both light and dark contexts +- All UI strings available in English and Russian, switchable via locale picker + +## Notes +- This phase runs AFTER all functionality is complete — pure visual/UX enhancement +- Do not change any functionality or API contracts +- Prefer CSS custom properties for theming over hardcoded values +- Keep bundle size reasonable — inline SVGs preferred over icon font libraries +- Animations should be tasteful and serve UX, not decoration +- For i18n, use a lightweight approach (JSON locale files + Svelte store) — no heavy i18n framework needed +- Default language: English. Russian as secondary. Locale persisted to localStorage + +## Review Checklist +- [ ] All tasks completed +- [ ] Visual consistency across all pages +- [ ] No functionality regressions +- [ ] Responsive on mobile/tablet/desktop +- [ ] Accessible (proper contrast ratios, focus states, aria labels on icons) + +## Handoff to Next Phase + diff --git a/plans/docker-watcher-core/phase-2-crypto-config.md b/plans/docker-watcher-core/phase-2-crypto-config.md index 19fa0c2..f9f4aa4 100644 --- a/plans/docker-watcher-core/phase-2-crypto-config.md +++ b/plans/docker-watcher-core/phase-2-crypto-config.md @@ -1,6 +1,6 @@ # Phase 2: Crypto & Config Seed Loader -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend @@ -9,14 +9,14 @@ Implement AES-256 encryption for credential storage and the YAML seed config par ## Tasks -- [ ] Task 1: Implement AES-256-GCM encrypt/decrypt functions using Go stdlib `crypto/aes` + `crypto/cipher` -- [ ] Task 2: Key derivation from ENCRYPTION_KEY env var (SHA-256 hash to get 32 bytes) -- [ ] Task 3: Define YAML config structs matching the seed format from PLAN.md -- [ ] Task 4: Implement YAML parser — read and validate seed file -- [ ] Task 5: Implement seed importer — checks if DB is empty, if so imports YAML into SQLite via store CRUD -- [ ] Task 6: Encrypt credential fields (registry tokens, NPM password) during import -- [ ] Task 7: Create `docker-watcher.example.yaml` with documented example config -- [ ] Task 8: Wire seed import into `cmd/server/main.go` startup sequence +- [x] Task 1: Implement AES-256-GCM encrypt/decrypt functions using Go stdlib `crypto/aes` + `crypto/cipher` +- [x] Task 2: Key derivation from ENCRYPTION_KEY env var (SHA-256 hash to get 32 bytes) +- [x] Task 3: Define YAML config structs matching the seed format from PLAN.md +- [x] Task 4: Implement YAML parser — read and validate seed file +- [x] Task 5: Implement seed importer — checks if DB is empty, if so imports YAML into SQLite via store CRUD +- [x] Task 6: Encrypt credential fields (registry tokens, NPM password) during import +- [x] Task 7: Create `docker-watcher.example.yaml` with documented example config +- [x] Task 8: Wire seed import into `cmd/server/main.go` startup sequence ## Files to Modify/Create - `internal/crypto/crypto.go` — AES-256-GCM encrypt/decrypt @@ -40,11 +40,22 @@ Implement AES-256 encryption for credential storage and the YAML seed config par - The example YAML should have placeholder values, not real credentials ## Review Checklist -- [ ] All tasks completed -- [ ] Crypto uses secure practices (random nonce, GCM, no ECB) -- [ ] No hardcoded keys or secrets -- [ ] YAML parsing validates required fields -- [ ] Import is transactional +- [x] All tasks completed +- [x] Crypto uses secure practices (random nonce, GCM, no ECB) +- [x] No hardcoded keys or secrets +- [x] YAML parsing validates required fields +- [x] Import is transactional ## Handoff to Next Phase - + +- `crypto.Encrypt(key, plaintext)` and `crypto.Decrypt(key, ciphertextHex)` handle AES-256-GCM encryption; ciphertext is hex-encoded with prepended nonce +- `crypto.KeyFromEnv()` derives a `[32]byte` key from the `ENCRYPTION_KEY` env var via SHA-256 +- `crypto.EncryptIfNotEmpty(key, value)` is a convenience wrapper that passes through empty strings unchanged +- `config.ImportSeed(db, seedPath)` is the single entry point for seed import — called from `main.go` at startup +- Import is idempotent: skipped if the DB already has projects or registries +- Import is transactional: all inserts happen within a single SQLite transaction (rollback on any failure) +- Registry `token` and settings `npm_password` are now stored encrypted in SQLite — later phases that read these fields must decrypt with `crypto.Decrypt(key, value)` +- `store.DB()` method was added to expose the underlying `*sql.DB` for transaction use +- Seed file path is configurable via `SEED_FILE` env var (default: `./docker-watcher.yaml`) +- YAML validation ensures: `global.domain` is required, every project needs `image`, project registry references must exist, stages need `tag_pattern` +- `go.sum` still does not exist — run `go mod tidy` when Go toolchain is available