From 2df221bcf71ec597fb429b85eff4b397e78695d1 Mon Sep 17 00:00:00 2001 From: "A. Ottr" Date: Sat, 16 Aug 2025 03:17:53 +0200 Subject: [PATCH] feat: add simple watcher, refactor repo/cache and logging Signed-off-by: A. Ottr --- cmd/nox/main.go | 23 ++++------ internal/cache/repocache.go | 52 +++++++++++++++++---- internal/config/config.go | 21 ++++++--- internal/config/context.go | 53 +++++++++++++++------- internal/git/repo.go | 30 ++++++++++++ internal/logging/cli_handler.go | 48 ++++++++++++++++++++ internal/logging/logger.go | 13 ++++-- internal/processor/fsutil.go | 12 ++--- internal/processor/{gitsync.go => sync.go} | 50 ++++++++++---------- internal/processor/validate.go | 3 -- internal/state/state.go | 6 +-- internal/watcher/watcher.go | 35 ++++++++++++++ 12 files changed, 252 insertions(+), 94 deletions(-) create mode 100644 internal/logging/cli_handler.go rename internal/processor/{gitsync.go => sync.go} (57%) create mode 100644 internal/watcher/watcher.go diff --git a/cmd/nox/main.go b/cmd/nox/main.go index fe6d341..062efcf 100644 --- a/cmd/nox/main.go +++ b/cmd/nox/main.go @@ -10,12 +10,13 @@ import ( "github.com/aottr/nox/internal/crypto" "github.com/aottr/nox/internal/logging" "github.com/aottr/nox/internal/processor" + "github.com/aottr/nox/internal/watcher" "github.com/urfave/cli/v3" ) func main() { - logging.Init() + logging.InitTextLogger() logging.SetLevel("info") log := logging.Get() @@ -61,18 +62,6 @@ func main() { }, }, Commands: []*cli.Command{ - // { - // Name: "run", - // Aliases: []string{"r"}, - // Usage: "Fetch, decrypt, and process app secrets", - // Action: func(ctx context.Context, cmd *cli.Command) error { - // cfg, err := config.Load(configPath) - // if err != nil { - // log.Fatalf("failed to load config: %v", err) - // } - // return processor.ProcessApps(cfg) - // }, - // }, { Name: "encrypt", Aliases: []string{"enc"}, @@ -234,6 +223,14 @@ func main() { return config.InitConfig(configPath) }, }, + { + Name: "watch", + Action: func(ctx context.Context, cmd *cli.Command) error { + cfg, _ := config.Load(configPath) + watcher.Start(cfg) + return nil + }, + }, }, } diff --git a/internal/cache/repocache.go b/internal/cache/repocache.go index 5544ac1..5339c35 100644 --- a/internal/cache/repocache.go +++ b/internal/cache/repocache.go @@ -5,7 +5,6 @@ import ( "github.com/aottr/nox/internal/config" "github.com/aottr/nox/internal/git" - "github.com/go-git/go-git/v5/plumbing/object" ) type RepoKey struct { @@ -15,29 +14,30 @@ type RepoKey struct { type RepoCache struct { mu sync.RWMutex - repos map[RepoKey]*object.Tree + repos map[RepoKey]*git.ClonedRepo + // sf singleflight.Group TODO } var ( GlobalCache = &RepoCache{ - repos: make(map[RepoKey]*object.Tree), + repos: make(map[RepoKey]*git.ClonedRepo), } ) -func (c *RepoCache) Get(key RepoKey) (*object.Tree, bool) { +func (c *RepoCache) Get(key RepoKey) (*git.ClonedRepo, bool) { c.mu.RLock() defer c.mu.RUnlock() tree, exists := c.repos[key] return tree, exists } -func (c *RepoCache) Set(key RepoKey, tree *object.Tree) { +func (c *RepoCache) Set(key RepoKey, repo *git.ClonedRepo) { c.mu.Lock() defer c.mu.Unlock() - c.repos[key] = tree + c.repos[key] = repo } -func (c *RepoCache) FetchRepo(key RepoKey) (*object.Tree, error) { +func (c *RepoCache) FetchRepo(key RepoKey) (*git.ClonedRepo, error) { r, err := git.CloneRepo(config.GitConfig{ Repo: key.Repo, Branch: key.Branch, @@ -46,12 +46,44 @@ func (c *RepoCache) FetchRepo(key RepoKey) (*object.Tree, error) { return nil, err } - c.Set(key, r.Tree) - return r.Tree, nil + c.Set(key, r) + return r, nil +} + +func (c *RepoCache) GetOrFetch(key RepoKey) (*git.ClonedRepo, error) { + tree, exists := c.Get(key) + if exists { + return tree, nil + } else { + return c.FetchRepo(key) + } +} + +func (c *RepoCache) RefreshCache() error { + c.mu.RLock() + repos := make([]*git.ClonedRepo, 0, len(c.repos)) + for _, r := range c.repos { + repos = append(repos, r) + } + c.mu.RUnlock() + + for _, repo := range repos { + if err := repo.Refresh(); err != nil { + return err + } + } + return nil +} + +func (c *RepoCache) Has(key RepoKey) bool { + c.mu.RLock() + defer c.mu.RUnlock() + _, exists := c.repos[key] + return exists } func ClearRepoCache() { GlobalCache.mu.Lock() defer GlobalCache.mu.Unlock() - GlobalCache.repos = make(map[RepoKey]*object.Tree) + GlobalCache.repos = make(map[RepoKey]*git.ClonedRepo) } diff --git a/internal/config/config.go b/internal/config/config.go index ccc1e3f..6b918c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "time" "gopkg.in/yaml.v3" ) @@ -38,11 +39,12 @@ type AgeConfig struct { } type Config struct { - Interval string `yaml:"interval"` - Age AgeConfig `yaml:"age"` - StatePath string `yaml:"statePath"` - GitConfig GitConfig `yaml:"git"` - Apps map[string]AppConfig `yaml:"apps"` + Interval time.Duration `yaml:"-"` + IntervalString string `yaml:"interval"` + Age AgeConfig `yaml:"age"` + StatePath string `yaml:"statePath"` + GitConfig GitConfig `yaml:"git"` + Apps map[string]AppConfig `yaml:"apps"` } func Load(path string) (*Config, error) { @@ -71,6 +73,11 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("no git configuration found: set either top-level git or app-specific git") } + // validate interval + cfg.Interval, err = time.ParseDuration(cfg.IntervalString) + if err != nil { + return nil, fmt.Errorf("invalid interval: %w", err) + } return &cfg, nil } @@ -82,8 +89,8 @@ func InitConfig(path string) error { } cfg := Config{ - Interval: "10m", - StatePath: ".nox-state.json", + IntervalString: "10m", + StatePath: ".nox-state.json", GitConfig: GitConfig{ Repo: "", Branch: "main", diff --git a/internal/config/context.go b/internal/config/context.go index 2311316..09eed87 100644 --- a/internal/config/context.go +++ b/internal/config/context.go @@ -2,9 +2,6 @@ package config import ( "fmt" - "io" - "log" - "os" "filippo.io/age" "github.com/aottr/nox/internal/crypto" @@ -25,11 +22,42 @@ type RuntimeContext struct { Config *Config State *state.State Identities []age.Identity - App *string - Logger *log.Logger + App string DryRun bool Force bool - Verbose bool +} + +func BuildRuntimeCtxFromConfig(config *Config) (*RuntimeContext, error) { + + if config.StatePath != "" { + state.SetPath(config.StatePath) + } + st, err := state.Load() + if err != nil { + return nil, err + } + + var identityPaths []string + // try single identity file first + if config.Age.Identity != "" { + identityPaths = []string{config.Age.Identity} + } else if len(config.Age.Identities) > 0 { + identityPaths = config.Age.Identities + } else { + return nil, fmt.Errorf("no age identites found") + } + ids, err := crypto.LoadAgeIdentitiesFromPaths(identityPaths) + if err != nil { + return nil, err + } + + return &RuntimeContext{ + Config: config, + State: st, + Identities: ids, + DryRun: false, + Force: false, + }, nil } func BuildRuntimeContext(opts RuntimeOptions) (*RuntimeContext, error) { @@ -63,30 +91,21 @@ func BuildRuntimeContext(opts RuntimeOptions) (*RuntimeContext, error) { return nil, err } - var app *string + var app string if opts.AppName != "" { if _, exists := cfg.Apps[opts.AppName]; exists { - app = &opts.AppName + app = opts.AppName } else { return nil, fmt.Errorf("app '%s' not found in configuration", opts.AppName) } } - var logger *log.Logger - if opts.Verbose { - logger = log.New(os.Stdout, "", log.LstdFlags) - } else { - logger = log.New(io.Discard, "", 0) - } - return &RuntimeContext{ Config: cfg, State: st, Identities: ids, App: app, - Logger: logger, DryRun: opts.DryRun, Force: opts.Force, - Verbose: opts.Verbose, }, nil } diff --git a/internal/git/repo.go b/internal/git/repo.go index d6ed8e8..80ecdbd 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -13,6 +13,7 @@ import ( type ClonedRepo struct { Repo *git.Repository + Branch string Tree *object.Tree Ref *plumbing.Reference Commit *object.Commit @@ -53,6 +54,7 @@ func CloneRepo(c config.GitConfig) (*ClonedRepo, error) { } return &ClonedRepo{ Repo: repo, + Branch: c.Branch, Ref: ref, Commit: commit, Tree: tree, @@ -79,6 +81,34 @@ func GetFileContentFromTree(tree *object.Tree, path string) ([]byte, error) { return content, nil } +func (r *ClonedRepo) Refresh() error { + if err := r.Repo.Fetch(&git.FetchOptions{ + RemoteName: "origin", + Force: true, + Prune: true, + }); err != nil && err != git.NoErrAlreadyUpToDate { + return fmt.Errorf("fetch: %w", err) + } + remoteRef := plumbing.NewRemoteReferenceName("origin", r.Branch) + ref, err := r.Repo.Reference(remoteRef, true) + if err != nil { + return err + } + r.Ref = ref + + commit, err := r.Repo.CommitObject(r.Ref.Hash()) + if err != nil { + return err + } + r.Commit = commit + tree, err := commit.Tree() + if err != nil { + return err + } + r.Tree = tree + return nil +} + func FileExistsInTree(tree *object.Tree, path string) bool { _, err := tree.File(path) return err == nil diff --git a/internal/logging/cli_handler.go b/internal/logging/cli_handler.go new file mode 100644 index 0000000..8ce3e8e --- /dev/null +++ b/internal/logging/cli_handler.go @@ -0,0 +1,48 @@ +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" +) + +type CliHandler struct { + w io.Writer + level slog.Leveler +} + +func (h *CliHandler) Enabled(_ context.Context, level slog.Level) bool { + minLevel := slog.LevelInfo + if h.level != nil { + minLevel = h.level.Level() + } + return level >= minLevel +} + +func NewCliHandler(w io.Writer, level slog.Leveler) slog.Handler { + h := &CliHandler{w: w, level: level} + return h +} + +func (h *CliHandler) Handle(_ context.Context, r slog.Record) error { + + attrs := "" + r.Attrs(func(a slog.Attr) bool { + if a.Key == "error" { + attrs += fmt.Sprintf("%v ", a.Value) + return true + } + return false + }) + + if attrs != "" { + fmt.Fprintf(h.w, "%s: %s\n", r.Message, attrs[:len(attrs)-1]) + } else { + fmt.Fprintln(h.w, r.Message) + } + return nil +} + +func (h *CliHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h } +func (h *CliHandler) WithGroup(name string) slog.Handler { return h } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 75bb804..6709442 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -20,13 +20,18 @@ func Get() Logger { return logger } -func Init() { +func InitTextLogger() { once.Do(func() { logLevel.Set(slog.LevelInfo) + h := NewCliHandler(os.Stdout, logLevel) + logger = slog.New(h) + }) +} - h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: logLevel, - }) +func Init() { + once.Do(func() { + logLevel.Set(slog.LevelInfo) + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}) logger = slog.New(h) }) } diff --git a/internal/processor/fsutil.go b/internal/processor/fsutil.go index 7690e61..19dfab0 100644 --- a/internal/processor/fsutil.go +++ b/internal/processor/fsutil.go @@ -10,11 +10,7 @@ import ( "github.com/aottr/nox/internal/constants" ) -type FileProcessorOptions struct { - CreateDir bool -} - -func WriteToFile(data []byte, file config.FileConfig, opts *FileProcessorOptions) error { +func WriteToFile(data []byte, file config.FileConfig) error { path := file.Output if path == "" { // Default output filename if none specified, e.g. replace .age with .env @@ -24,10 +20,8 @@ func WriteToFile(data []byte, file config.FileConfig, opts *FileProcessorOptions path = path[:len(path)-4] + ".env" } } - if opts.CreateDir { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("failed to create directories for %s: %w", path, err) - } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create directories for %s: %w", path, err) } if err := os.WriteFile(path, data, 0600); err != nil { diff --git a/internal/processor/gitsync.go b/internal/processor/sync.go similarity index 57% rename from internal/processor/gitsync.go rename to internal/processor/sync.go index 08b4ef0..0c301e7 100644 --- a/internal/processor/gitsync.go +++ b/internal/processor/sync.go @@ -8,52 +8,47 @@ import ( "github.com/aottr/nox/internal/config" "github.com/aottr/nox/internal/crypto" "github.com/aottr/nox/internal/git" + "github.com/aottr/nox/internal/logging" "github.com/aottr/nox/internal/state" ) func SyncApp(ctx *config.RuntimeContext) error { + log := logging.Get() + var err error cfg, appName, identities, st := ctx.Config, ctx.App, ctx.Identities, ctx.State - if appName == nil { + if appName == "" { return fmt.Errorf("app name is required") } // retrieve app config and repository - app := cfg.Apps[*appName] - repoUrl := app.GitConfig.Repo - if repoUrl == "" { - repoUrl = cfg.GitConfig.Repo - } - branchName := app.GitConfig.Branch - if branchName == "" { - branchName = cfg.GitConfig.Branch + app := cfg.Apps[appName] + gitConf := app.GitConfig + if !gitConf.IsValid() { + gitConf = cfg.GitConfig } - key := cache.RepoKey{Repo: repoUrl, Branch: branchName} - repo, exists := cache.GlobalCache.Get(key) - if !exists { - var err error - repo, err = cache.GlobalCache.FetchRepo(key) - if err != nil { - return fmt.Errorf("failed to fetch repo for app %s: %w", *appName, err) - } + key := cache.RepoKey{Repo: gitConf.Repo, Branch: gitConf.Branch} + repo, err := cache.GlobalCache.GetOrFetch(key) + if err != nil { + return fmt.Errorf("failed to fetch repo for app %s: %w", appName, err) } // iterate over files and decrypt for _, file := range app.Files { - content, err := git.GetFileContentFromTree(repo, file.Path) + content, err := git.GetFileContentFromTree(repo.Tree, file.Path) if err != nil { return fmt.Errorf("failed to get file %s: %w", file, err) } hash := state.HashContent(content) - cacheKey := state.GenerateKey(*appName, file.Path) + cacheKey := state.GenerateKey(appName, file.Path) // skip if file is up to date and force is not set if !ctx.Force && !ctx.DryRun { if prevHash, ok := st.Data[cacheKey]; ok && prevHash == hash { - ctx.Logger.Printf("file %s is up to date", file.Path) + log.Debug(fmt.Sprintf("file %s is up to date", file.Path)) continue } } @@ -61,19 +56,22 @@ func SyncApp(ctx *config.RuntimeContext) error { // decrypt file plaintext, err := crypto.DecryptBytes(content, identities) if err != nil { - ctx.Logger.Printf("failed to decrypt file %s: %v", file.Path, err) + log.Warn("failed to decrypt file %s: %v", file.Path, err) continue } // skip writing file if dry run is set if ctx.DryRun { - ctx.Logger.Printf("dry run, not writing file %s", file.Output) + log.Debug(fmt.Sprintf("dry run, not writing file %s", file.Output)) os.Stdout.Write(plaintext) continue } - WriteToFile(plaintext, file, &FileProcessorOptions{CreateDir: true}) + if err := WriteToFile(plaintext, file); err != nil { + log.Error("failed to write file %s: %v", file.Output, err) + continue + } - ctx.Logger.Printf("decrypted %s for app %s (size: %d bytes)", file, *appName, len(plaintext)) + log.Debug(fmt.Sprintf("decrypted %s for app %s (size: %d bytes)", file, appName, len(plaintext))) // update state st.Data[cacheKey] = hash @@ -88,8 +86,8 @@ func SyncApp(ctx *config.RuntimeContext) error { func SyncApps(ctx *config.RuntimeContext) error { for appName := range ctx.Config.Apps { - ctx.App = &appName - ctx.Logger.Printf("Processing app: %s\n", appName) + ctx.App = appName + logging.Get().Debug(fmt.Sprintf("Processing app: %s", appName)) if err := SyncApp(ctx); err != nil { return err } diff --git a/internal/processor/validate.go b/internal/processor/validate.go index cc1c370..716b283 100644 --- a/internal/processor/validate.go +++ b/internal/processor/validate.go @@ -2,9 +2,6 @@ package processor import ( "fmt" - // "os" - // "path/filepath" - // "strings" "github.com/aottr/nox/internal/config" "github.com/aottr/nox/internal/git" diff --git a/internal/state/state.go b/internal/state/state.go index d7ffd16..ca9e912 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -2,7 +2,6 @@ package state import ( "encoding/json" - "log" "os" "time" ) @@ -14,7 +13,7 @@ type State struct { Data map[string]string } -var defaultPath = ".nox-state.json" // fallback default +var defaultPath = ".nox-state.json" // SetPath updates the default file path used for saving and loading state func SetPath(path string) { @@ -27,7 +26,6 @@ func (s *State) Touch() { } // Load reads the state from the state file -// Returns an error if the file cannot be read or unmarshaled. func Load() (*State, error) { return loadFromFile(defaultPath) } @@ -42,10 +40,8 @@ func Save(state *State) error { func loadFromFile(path string) (*State, error) { data, err := os.ReadFile(path) if err != nil { - log.Printf("⚠️ No previous state found, starting fresh: %v", err) return &State{Data: make(map[string]string)}, nil } - var state State if err := json.Unmarshal(data, &state); err != nil { return nil, err diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 0000000..42f3e0b --- /dev/null +++ b/internal/watcher/watcher.go @@ -0,0 +1,35 @@ +package watcher + +import ( + "fmt" + "time" + + "github.com/aottr/nox/internal/cache" + "github.com/aottr/nox/internal/config" + "github.com/aottr/nox/internal/logging" + "github.com/aottr/nox/internal/processor" +) + +func Start(cfg *config.Config) { + log := logging.Get() + logging.SetLevel("debug") + ticker := time.NewTicker(cfg.Interval) + defer ticker.Stop() + + ctx, err := config.BuildRuntimeCtxFromConfig(cfg) + if err != nil { + log.Error("error building runtime context", "error", err.Error()) + return + } + log.Info(fmt.Sprintf("Starting watcher (interval: %s)\n", cfg.Interval)) + + for { + if err := cache.GlobalCache.RefreshCache(); err != nil { + log.Error("error pre-fetching secrets", "error", err.Error()) + } + if err := processor.SyncApps(ctx); err != nil { + log.Error("error syncing secrets", "error", err.Error()) + } + <-ticker.C + } +} -- 2.51.2