this repo has no description

feat: add simple watcher, refactor repo/cache and logging

Signed-off-by: A. Ottr <alex@otter.foo>

+324 -166
+10 -13
cmd/nox/main.go
··· 10 10 "github.com/aottr/nox/internal/crypto" 11 11 "github.com/aottr/nox/internal/logging" 12 12 "github.com/aottr/nox/internal/processor" 13 + "github.com/aottr/nox/internal/watcher" 13 14 "github.com/urfave/cli/v3" 14 15 ) 15 16 16 17 func main() { 17 18 18 - logging.Init() 19 + logging.InitTextLogger() 19 20 logging.SetLevel("info") 20 21 log := logging.Get() 21 22 ··· 61 62 }, 62 63 }, 63 64 Commands: []*cli.Command{ 64 - // { 65 - // Name: "run", 66 - // Aliases: []string{"r"}, 67 - // Usage: "Fetch, decrypt, and process app secrets", 68 - // Action: func(ctx context.Context, cmd *cli.Command) error { 69 - // cfg, err := config.Load(configPath) 70 - // if err != nil { 71 - // log.Fatalf("failed to load config: %v", err) 72 - // } 73 - // return processor.ProcessApps(cfg) 74 - // }, 75 - // }, 76 65 { 77 66 Name: "encrypt", 78 67 Aliases: []string{"enc"}, ··· 232 221 Name: "init", 233 222 Action: func(ctx context.Context, cmd *cli.Command) error { 234 223 return config.InitConfig(configPath) 224 + }, 225 + }, 226 + { 227 + Name: "watch", 228 + Action: func(ctx context.Context, cmd *cli.Command) error { 229 + cfg, _ := config.Load(configPath) 230 + watcher.Start(cfg) 231 + return nil 235 232 }, 236 233 }, 237 234 },
+42 -10
internal/cache/repocache.go
··· 5 5 6 6 "github.com/aottr/nox/internal/config" 7 7 "github.com/aottr/nox/internal/git" 8 - "github.com/go-git/go-git/v5/plumbing/object" 9 8 ) 10 9 11 10 type RepoKey struct { ··· 15 14 16 15 type RepoCache struct { 17 16 mu sync.RWMutex 18 - repos map[RepoKey]*object.Tree 17 + repos map[RepoKey]*git.ClonedRepo 18 + // sf singleflight.Group TODO 19 19 } 20 20 21 21 var ( 22 22 GlobalCache = &RepoCache{ 23 - repos: make(map[RepoKey]*object.Tree), 23 + repos: make(map[RepoKey]*git.ClonedRepo), 24 24 } 25 25 ) 26 26 27 - func (c *RepoCache) Get(key RepoKey) (*object.Tree, bool) { 27 + func (c *RepoCache) Get(key RepoKey) (*git.ClonedRepo, bool) { 28 28 c.mu.RLock() 29 29 defer c.mu.RUnlock() 30 30 tree, exists := c.repos[key] 31 31 return tree, exists 32 32 } 33 33 34 - func (c *RepoCache) Set(key RepoKey, tree *object.Tree) { 34 + func (c *RepoCache) Set(key RepoKey, repo *git.ClonedRepo) { 35 35 c.mu.Lock() 36 36 defer c.mu.Unlock() 37 - c.repos[key] = tree 37 + c.repos[key] = repo 38 38 } 39 39 40 - func (c *RepoCache) FetchRepo(key RepoKey) (*object.Tree, error) { 40 + func (c *RepoCache) FetchRepo(key RepoKey) (*git.ClonedRepo, error) { 41 41 r, err := git.CloneRepo(config.GitConfig{ 42 42 Repo: key.Repo, 43 43 Branch: key.Branch, ··· 46 46 return nil, err 47 47 } 48 48 49 - c.Set(key, r.Tree) 50 - return r.Tree, nil 49 + c.Set(key, r) 50 + return r, nil 51 + } 52 + 53 + func (c *RepoCache) GetOrFetch(key RepoKey) (*git.ClonedRepo, error) { 54 + tree, exists := c.Get(key) 55 + if exists { 56 + return tree, nil 57 + } else { 58 + return c.FetchRepo(key) 59 + } 60 + } 61 + 62 + func (c *RepoCache) RefreshCache() error { 63 + c.mu.RLock() 64 + repos := make([]*git.ClonedRepo, 0, len(c.repos)) 65 + for _, r := range c.repos { 66 + repos = append(repos, r) 67 + } 68 + c.mu.RUnlock() 69 + 70 + for _, repo := range repos { 71 + if err := repo.Refresh(); err != nil { 72 + return err 73 + } 74 + } 75 + return nil 76 + } 77 + 78 + func (c *RepoCache) Has(key RepoKey) bool { 79 + c.mu.RLock() 80 + defer c.mu.RUnlock() 81 + _, exists := c.repos[key] 82 + return exists 51 83 } 52 84 53 85 func ClearRepoCache() { 54 86 GlobalCache.mu.Lock() 55 87 defer GlobalCache.mu.Unlock() 56 - GlobalCache.repos = make(map[RepoKey]*object.Tree) 88 + GlobalCache.repos = make(map[RepoKey]*git.ClonedRepo) 57 89 }
+14 -7
internal/config/config.go
··· 3 3 import ( 4 4 "fmt" 5 5 "os" 6 + "time" 6 7 7 8 "gopkg.in/yaml.v3" 8 9 ) ··· 38 39 } 39 40 40 41 type Config struct { 41 - Interval string `yaml:"interval"` 42 - Age AgeConfig `yaml:"age"` 43 - StatePath string `yaml:"statePath"` 44 - GitConfig GitConfig `yaml:"git"` 45 - Apps map[string]AppConfig `yaml:"apps"` 42 + Interval time.Duration `yaml:"-"` 43 + IntervalString string `yaml:"interval"` 44 + Age AgeConfig `yaml:"age"` 45 + StatePath string `yaml:"statePath"` 46 + GitConfig GitConfig `yaml:"git"` 47 + Apps map[string]AppConfig `yaml:"apps"` 46 48 } 47 49 48 50 func Load(path string) (*Config, error) { ··· 71 73 return nil, fmt.Errorf("no git configuration found: set either top-level git or app-specific git") 72 74 } 73 75 76 + // validate interval 77 + cfg.Interval, err = time.ParseDuration(cfg.IntervalString) 78 + if err != nil { 79 + return nil, fmt.Errorf("invalid interval: %w", err) 80 + } 74 81 return &cfg, nil 75 82 } 76 83 ··· 82 89 } 83 90 84 91 cfg := Config{ 85 - Interval: "10m", 86 - StatePath: ".nox-state.json", 92 + IntervalString: "10m", 93 + StatePath: ".nox-state.json", 87 94 GitConfig: GitConfig{ 88 95 Repo: "", 89 96 Branch: "main",
+36 -17
internal/config/context.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "io" 6 - "log" 7 - "os" 8 5 9 6 "filippo.io/age" 10 7 "github.com/aottr/nox/internal/crypto" ··· 25 22 Config *Config 26 23 State *state.State 27 24 Identities []age.Identity 28 - App *string 29 - Logger *log.Logger 25 + App string 30 26 DryRun bool 31 27 Force bool 32 - Verbose bool 28 + } 29 + 30 + func BuildRuntimeCtxFromConfig(config *Config) (*RuntimeContext, error) { 31 + 32 + if config.StatePath != "" { 33 + state.SetPath(config.StatePath) 34 + } 35 + st, err := state.Load() 36 + if err != nil { 37 + return nil, err 38 + } 39 + 40 + var identityPaths []string 41 + // try single identity file first 42 + if config.Age.Identity != "" { 43 + identityPaths = []string{config.Age.Identity} 44 + } else if len(config.Age.Identities) > 0 { 45 + identityPaths = config.Age.Identities 46 + } else { 47 + return nil, fmt.Errorf("no age identites found") 48 + } 49 + ids, err := crypto.LoadAgeIdentitiesFromPaths(identityPaths) 50 + if err != nil { 51 + return nil, err 52 + } 53 + 54 + return &RuntimeContext{ 55 + Config: config, 56 + State: st, 57 + Identities: ids, 58 + DryRun: false, 59 + Force: false, 60 + }, nil 33 61 } 34 62 35 63 func BuildRuntimeContext(opts RuntimeOptions) (*RuntimeContext, error) { ··· 63 91 return nil, err 64 92 } 65 93 66 - var app *string 94 + var app string 67 95 if opts.AppName != "" { 68 96 if _, exists := cfg.Apps[opts.AppName]; exists { 69 - app = &opts.AppName 97 + app = opts.AppName 70 98 } else { 71 99 return nil, fmt.Errorf("app '%s' not found in configuration", opts.AppName) 72 100 } 73 101 } 74 102 75 - var logger *log.Logger 76 - if opts.Verbose { 77 - logger = log.New(os.Stdout, "", log.LstdFlags) 78 - } else { 79 - logger = log.New(io.Discard, "", 0) 80 - } 81 - 82 103 return &RuntimeContext{ 83 104 Config: cfg, 84 105 State: st, 85 106 Identities: ids, 86 107 App: app, 87 - Logger: logger, 88 108 DryRun: opts.DryRun, 89 109 Force: opts.Force, 90 - Verbose: opts.Verbose, 91 110 }, nil 92 111 }
+30
internal/git/repo.go
··· 13 13 14 14 type ClonedRepo struct { 15 15 Repo *git.Repository 16 + Branch string 16 17 Tree *object.Tree 17 18 Ref *plumbing.Reference 18 19 Commit *object.Commit ··· 53 54 } 54 55 return &ClonedRepo{ 55 56 Repo: repo, 57 + Branch: c.Branch, 56 58 Ref: ref, 57 59 Commit: commit, 58 60 Tree: tree, ··· 77 79 } 78 80 79 81 return content, nil 82 + } 83 + 84 + func (r *ClonedRepo) Refresh() error { 85 + if err := r.Repo.Fetch(&git.FetchOptions{ 86 + RemoteName: "origin", 87 + Force: true, 88 + Prune: true, 89 + }); err != nil && err != git.NoErrAlreadyUpToDate { 90 + return fmt.Errorf("fetch: %w", err) 91 + } 92 + remoteRef := plumbing.NewRemoteReferenceName("origin", r.Branch) 93 + ref, err := r.Repo.Reference(remoteRef, true) 94 + if err != nil { 95 + return err 96 + } 97 + r.Ref = ref 98 + 99 + commit, err := r.Repo.CommitObject(r.Ref.Hash()) 100 + if err != nil { 101 + return err 102 + } 103 + r.Commit = commit 104 + tree, err := commit.Tree() 105 + if err != nil { 106 + return err 107 + } 108 + r.Tree = tree 109 + return nil 80 110 } 81 111 82 112 func FileExistsInTree(tree *object.Tree, path string) bool {
+48
internal/logging/cli_handler.go
··· 1 + package logging 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + ) 9 + 10 + type CliHandler struct { 11 + w io.Writer 12 + level slog.Leveler 13 + } 14 + 15 + func (h *CliHandler) Enabled(_ context.Context, level slog.Level) bool { 16 + minLevel := slog.LevelInfo 17 + if h.level != nil { 18 + minLevel = h.level.Level() 19 + } 20 + return level >= minLevel 21 + } 22 + 23 + func NewCliHandler(w io.Writer, level slog.Leveler) slog.Handler { 24 + h := &CliHandler{w: w, level: level} 25 + return h 26 + } 27 + 28 + func (h *CliHandler) Handle(_ context.Context, r slog.Record) error { 29 + 30 + attrs := "" 31 + r.Attrs(func(a slog.Attr) bool { 32 + if a.Key == "error" { 33 + attrs += fmt.Sprintf("%v ", a.Value) 34 + return true 35 + } 36 + return false 37 + }) 38 + 39 + if attrs != "" { 40 + fmt.Fprintf(h.w, "%s: %s\n", r.Message, attrs[:len(attrs)-1]) 41 + } else { 42 + fmt.Fprintln(h.w, r.Message) 43 + } 44 + return nil 45 + } 46 + 47 + func (h *CliHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h } 48 + func (h *CliHandler) WithGroup(name string) slog.Handler { return h }
+9 -4
internal/logging/logger.go
··· 20 20 return logger 21 21 } 22 22 23 + func InitTextLogger() { 24 + once.Do(func() { 25 + logLevel.Set(slog.LevelInfo) 26 + h := NewCliHandler(os.Stdout, logLevel) 27 + logger = slog.New(h) 28 + }) 29 + } 30 + 23 31 func Init() { 24 32 once.Do(func() { 25 33 logLevel.Set(slog.LevelInfo) 26 - 27 - h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 28 - Level: logLevel, 29 - }) 34 + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}) 30 35 logger = slog.New(h) 31 36 }) 32 37 }
+3 -9
internal/processor/fsutil.go
··· 10 10 "github.com/aottr/nox/internal/constants" 11 11 ) 12 12 13 - type FileProcessorOptions struct { 14 - CreateDir bool 15 - } 16 - 17 - func WriteToFile(data []byte, file config.FileConfig, opts *FileProcessorOptions) error { 13 + func WriteToFile(data []byte, file config.FileConfig) error { 18 14 path := file.Output 19 15 if path == "" { 20 16 // Default output filename if none specified, e.g. replace .age with .env ··· 24 20 path = path[:len(path)-4] + ".env" 25 21 } 26 22 } 27 - if opts.CreateDir { 28 - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 29 - return fmt.Errorf("failed to create directories for %s: %w", path, err) 30 - } 23 + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 24 + return fmt.Errorf("failed to create directories for %s: %w", path, err) 31 25 } 32 26 33 27 if err := os.WriteFile(path, data, 0600); err != nil {
-98
internal/processor/gitsync.go
··· 1 - package processor 2 - 3 - import ( 4 - "fmt" 5 - "os" 6 - 7 - "github.com/aottr/nox/internal/cache" 8 - "github.com/aottr/nox/internal/config" 9 - "github.com/aottr/nox/internal/crypto" 10 - "github.com/aottr/nox/internal/git" 11 - "github.com/aottr/nox/internal/state" 12 - ) 13 - 14 - func SyncApp(ctx *config.RuntimeContext) error { 15 - 16 - cfg, appName, identities, st := ctx.Config, ctx.App, ctx.Identities, ctx.State 17 - 18 - if appName == nil { 19 - return fmt.Errorf("app name is required") 20 - } 21 - 22 - // retrieve app config and repository 23 - app := cfg.Apps[*appName] 24 - repoUrl := app.GitConfig.Repo 25 - if repoUrl == "" { 26 - repoUrl = cfg.GitConfig.Repo 27 - } 28 - branchName := app.GitConfig.Branch 29 - if branchName == "" { 30 - branchName = cfg.GitConfig.Branch 31 - } 32 - 33 - key := cache.RepoKey{Repo: repoUrl, Branch: branchName} 34 - repo, exists := cache.GlobalCache.Get(key) 35 - if !exists { 36 - var err error 37 - repo, err = cache.GlobalCache.FetchRepo(key) 38 - if err != nil { 39 - return fmt.Errorf("failed to fetch repo for app %s: %w", *appName, err) 40 - } 41 - } 42 - 43 - // iterate over files and decrypt 44 - for _, file := range app.Files { 45 - content, err := git.GetFileContentFromTree(repo, file.Path) 46 - if err != nil { 47 - return fmt.Errorf("failed to get file %s: %w", file, err) 48 - } 49 - 50 - hash := state.HashContent(content) 51 - cacheKey := state.GenerateKey(*appName, file.Path) 52 - 53 - // skip if file is up to date and force is not set 54 - if !ctx.Force && !ctx.DryRun { 55 - if prevHash, ok := st.Data[cacheKey]; ok && prevHash == hash { 56 - ctx.Logger.Printf("file %s is up to date", file.Path) 57 - continue 58 - } 59 - } 60 - 61 - // decrypt file 62 - plaintext, err := crypto.DecryptBytes(content, identities) 63 - if err != nil { 64 - ctx.Logger.Printf("failed to decrypt file %s: %v", file.Path, err) 65 - continue 66 - } 67 - 68 - // skip writing file if dry run is set 69 - if ctx.DryRun { 70 - ctx.Logger.Printf("dry run, not writing file %s", file.Output) 71 - os.Stdout.Write(plaintext) 72 - continue 73 - } 74 - WriteToFile(plaintext, file, &FileProcessorOptions{CreateDir: true}) 75 - 76 - ctx.Logger.Printf("decrypted %s for app %s (size: %d bytes)", file, *appName, len(plaintext)) 77 - 78 - // update state 79 - st.Data[cacheKey] = hash 80 - st.Touch() 81 - } 82 - 83 - if err := state.Save(st); err != nil { 84 - return fmt.Errorf("failed to save state: %w", err) 85 - } 86 - return nil 87 - } 88 - 89 - func SyncApps(ctx *config.RuntimeContext) error { 90 - for appName := range ctx.Config.Apps { 91 - ctx.App = &appName 92 - ctx.Logger.Printf("Processing app: %s\n", appName) 93 - if err := SyncApp(ctx); err != nil { 94 - return err 95 - } 96 - } 97 - return nil 98 - }
+96
internal/processor/sync.go
··· 1 + package processor 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/aottr/nox/internal/cache" 8 + "github.com/aottr/nox/internal/config" 9 + "github.com/aottr/nox/internal/crypto" 10 + "github.com/aottr/nox/internal/git" 11 + "github.com/aottr/nox/internal/logging" 12 + "github.com/aottr/nox/internal/state" 13 + ) 14 + 15 + func SyncApp(ctx *config.RuntimeContext) error { 16 + 17 + log := logging.Get() 18 + var err error 19 + cfg, appName, identities, st := ctx.Config, ctx.App, ctx.Identities, ctx.State 20 + 21 + if appName == "" { 22 + return fmt.Errorf("app name is required") 23 + } 24 + 25 + // retrieve app config and repository 26 + app := cfg.Apps[appName] 27 + gitConf := app.GitConfig 28 + if !gitConf.IsValid() { 29 + gitConf = cfg.GitConfig 30 + } 31 + 32 + key := cache.RepoKey{Repo: gitConf.Repo, Branch: gitConf.Branch} 33 + repo, err := cache.GlobalCache.GetOrFetch(key) 34 + if err != nil { 35 + return fmt.Errorf("failed to fetch repo for app %s: %w", appName, err) 36 + } 37 + 38 + // iterate over files and decrypt 39 + for _, file := range app.Files { 40 + content, err := git.GetFileContentFromTree(repo.Tree, file.Path) 41 + if err != nil { 42 + return fmt.Errorf("failed to get file %s: %w", file, err) 43 + } 44 + 45 + hash := state.HashContent(content) 46 + cacheKey := state.GenerateKey(appName, file.Path) 47 + 48 + // skip if file is up to date and force is not set 49 + if !ctx.Force && !ctx.DryRun { 50 + if prevHash, ok := st.Data[cacheKey]; ok && prevHash == hash { 51 + log.Debug(fmt.Sprintf("file %s is up to date", file.Path)) 52 + continue 53 + } 54 + } 55 + 56 + // decrypt file 57 + plaintext, err := crypto.DecryptBytes(content, identities) 58 + if err != nil { 59 + log.Warn("failed to decrypt file %s: %v", file.Path, err) 60 + continue 61 + } 62 + 63 + // skip writing file if dry run is set 64 + if ctx.DryRun { 65 + log.Debug(fmt.Sprintf("dry run, not writing file %s", file.Output)) 66 + os.Stdout.Write(plaintext) 67 + continue 68 + } 69 + if err := WriteToFile(plaintext, file); err != nil { 70 + log.Error("failed to write file %s: %v", file.Output, err) 71 + continue 72 + } 73 + 74 + log.Debug(fmt.Sprintf("decrypted %s for app %s (size: %d bytes)", file, appName, len(plaintext))) 75 + 76 + // update state 77 + st.Data[cacheKey] = hash 78 + st.Touch() 79 + } 80 + 81 + if err := state.Save(st); err != nil { 82 + return fmt.Errorf("failed to save state: %w", err) 83 + } 84 + return nil 85 + } 86 + 87 + func SyncApps(ctx *config.RuntimeContext) error { 88 + for appName := range ctx.Config.Apps { 89 + ctx.App = appName 90 + logging.Get().Debug(fmt.Sprintf("Processing app: %s", appName)) 91 + if err := SyncApp(ctx); err != nil { 92 + return err 93 + } 94 + } 95 + return nil 96 + }
-3
internal/processor/validate.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - // "os" 6 - // "path/filepath" 7 - // "strings" 8 5 9 6 "github.com/aottr/nox/internal/config" 10 7 "github.com/aottr/nox/internal/git"
+1 -5
internal/state/state.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 - "log" 6 5 "os" 7 6 "time" 8 7 ) ··· 14 13 Data map[string]string 15 14 } 16 15 17 - var defaultPath = ".nox-state.json" // fallback default 16 + var defaultPath = ".nox-state.json" 18 17 19 18 // SetPath updates the default file path used for saving and loading state 20 19 func SetPath(path string) { ··· 27 26 } 28 27 29 28 // Load reads the state from the state file 30 - // Returns an error if the file cannot be read or unmarshaled. 31 29 func Load() (*State, error) { 32 30 return loadFromFile(defaultPath) 33 31 } ··· 42 40 func loadFromFile(path string) (*State, error) { 43 41 data, err := os.ReadFile(path) 44 42 if err != nil { 45 - log.Printf("⚠️ No previous state found, starting fresh: %v", err) 46 43 return &State{Data: make(map[string]string)}, nil 47 44 } 48 - 49 45 var state State 50 46 if err := json.Unmarshal(data, &state); err != nil { 51 47 return nil, err
+35
internal/watcher/watcher.go
··· 1 + package watcher 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/aottr/nox/internal/cache" 8 + "github.com/aottr/nox/internal/config" 9 + "github.com/aottr/nox/internal/logging" 10 + "github.com/aottr/nox/internal/processor" 11 + ) 12 + 13 + func Start(cfg *config.Config) { 14 + log := logging.Get() 15 + logging.SetLevel("debug") 16 + ticker := time.NewTicker(cfg.Interval) 17 + defer ticker.Stop() 18 + 19 + ctx, err := config.BuildRuntimeCtxFromConfig(cfg) 20 + if err != nil { 21 + log.Error("error building runtime context", "error", err.Error()) 22 + return 23 + } 24 + log.Info(fmt.Sprintf("Starting watcher (interval: %s)\n", cfg.Interval)) 25 + 26 + for { 27 + if err := cache.GlobalCache.RefreshCache(); err != nil { 28 + log.Error("error pre-fetching secrets", "error", err.Error()) 29 + } 30 + if err := processor.SyncApps(ctx); err != nil { 31 + log.Error("error syncing secrets", "error", err.Error()) 32 + } 33 + <-ticker.C 34 + } 35 + }