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.
This commit is contained in:
@@ -250,7 +250,7 @@ docker-watcher/
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
### Phase 1: Foundation ✅
|
||||
|
||||
Core infrastructure — store, config import, Docker client, NPM client.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
<!-- This is the final UI phase — no handoff needed. -->
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this 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
|
||||
|
||||
Reference in New Issue
Block a user