this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add sync and additional cli commands

Alex Ottr 2f015382 eb483bcb

+399 -182
+127 -33
cmd/nox/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "context" 5 + "fmt" 4 6 "log" 5 7 "os" 6 - "context" 7 8 8 9 "github.com/aottr/nox/internal/config" 9 10 "github.com/aottr/nox/internal/constants" 10 - "github.com/aottr/nox/internal/process" 11 + "github.com/aottr/nox/internal/processor" 11 12 "github.com/urfave/cli/v3" 12 13 ) 13 14 14 15 func main() { 15 16 16 17 var configPath string 18 + var statePath string 19 + var identityPath string 20 + var appName string 21 + var dryRun bool 22 + var force bool 23 + var verbose bool 24 + var inputPath string 25 + var outputPath string 17 26 18 27 cmd := &cli.Command{ 19 28 Name: "nox", 20 29 Usage: "Manage and decrypt app secrets", 21 30 Flags: []cli.Flag{ 22 - &cli.StringFlag{ 23 - Name: "config", 24 - Value: constants.DefaultConfigPath, 25 - Usage: "path to config file", 26 - Destination: &configPath, 27 - }, 28 - }, 29 - Commands: []*cli.Command{ 30 - { 31 - Name: "run", 32 - Aliases: []string{"r"}, 33 - Usage: "Fetch, decrypt, and process app secrets", 34 - Action: func(ctx context.Context, cmd *cli.Command) error { 35 - cfg, err := config.Load(configPath) 31 + &cli.StringFlag{ 32 + Name: "config", 33 + Value: constants.DefaultConfigPath, 34 + Usage: "path to config file", 35 + Destination: &configPath, 36 + }, 37 + &cli.StringFlag{ 38 + Name: "state", 39 + Value: constants.DefaultStatePath, 40 + Usage: "path to state file", 41 + Destination: &statePath, 42 + }, 43 + &cli.StringFlag{ 44 + Name: "identity", 45 + Usage: "path to age identity file", 46 + Destination: &identityPath, 47 + }, 48 + &cli.BoolFlag{ 49 + Name: "verbose", 50 + Aliases: []string{"v"}, 51 + Value: false, 52 + Usage: "print verbose output", 53 + Destination: &verbose, 54 + }, 55 + }, 56 + Commands: []*cli.Command{ 57 + // { 58 + // Name: "run", 59 + // Aliases: []string{"r"}, 60 + // Usage: "Fetch, decrypt, and process app secrets", 61 + // Action: func(ctx context.Context, cmd *cli.Command) error { 62 + // cfg, err := config.Load(configPath) 63 + // if err != nil { 64 + // log.Fatalf("failed to load config: %v", err) 65 + // } 66 + // return processor.ProcessApps(cfg) 67 + // }, 68 + // }, 69 + { 70 + Name: "export", 71 + Aliases: []string{"e"}, 72 + Usage: "Export all secrets to a single file", 73 + Flags: []cli.Flag{ 74 + &cli.StringFlag{ 75 + Name: "app", 76 + Aliases: []string{"a"}, 77 + Usage: "app to export secrets for", 78 + Destination: &appName, 79 + }, 80 + &cli.BoolFlag{ 81 + Name: "dry-run", 82 + Aliases: []string{"d"}, 83 + Value: false, 84 + Usage: "only print what would be exported", 85 + Destination: &dryRun, 86 + }, 87 + &cli.BoolFlag{ 88 + Name: "force", 89 + Aliases: []string{"f"}, 90 + Value: false, 91 + Usage: "ignore state file", 92 + Destination: &force, 93 + }, 94 + }, 95 + Action: func(ctx context.Context, cmd *cli.Command) error { 96 + rtx, err := config.BuildRuntimeContext(config.RuntimeOptions{ 97 + ConfigPath: configPath, 98 + StatePath: statePath, 99 + IdentityPath: identityPath, 100 + DryRun: dryRun, 101 + Force: force, 102 + AppName: appName, 103 + Verbose: verbose, 104 + }) 36 105 if err != nil { 37 - log.Fatalf("failed to load config: %v", err) 106 + log.Fatalf("failed to build runtime context: %v", err) 38 107 } 39 - return process.ProcessApps(cfg) 40 - }, 41 - }, 42 - { 43 - Name: "validate", 44 - Aliases: []string{"v"}, 45 - Usage: "Validate configuration and secret integrity", 46 - Action: func(ctx context.Context, cmd *cli.Command) error { 108 + if appName != "" { 109 + return processor.SyncApp(rtx) 110 + } 111 + return processor.SyncApps(rtx) 112 + }, 113 + }, 114 + { 115 + Name: "encrypt", 116 + Aliases: []string{"enc"}, 117 + Usage: "Encrypt a file", 118 + Flags: []cli.Flag{ 119 + &cli.StringFlag{ 120 + Name: "input", 121 + Usage: "path to input file", 122 + Destination: &inputPath, 123 + }, 124 + &cli.StringFlag{ 125 + Name: "output", 126 + Usage: "path to output file", 127 + Destination: &outputPath, 128 + }, 129 + }, 130 + Action: func(ctx context.Context, cmd *cli.Command) error { 131 + fmt.Println("encrypting file", inputPath) 132 + fmt.Println("writing to", outputPath) 133 + return nil 134 + }, 135 + }, 136 + { 137 + Name: "validate", 138 + Aliases: []string{"v"}, 139 + Usage: "Validate configuration and secret integrity", 140 + Action: func(ctx context.Context, cmd *cli.Command) error { 47 141 cfg, err := config.Load(configPath) 48 142 if err != nil { 49 143 log.Fatalf("failed to load config: %v", err) 50 144 } 51 - return process.Validate(cfg) 52 - }, 53 - }, 54 - }, 55 - } 145 + return processor.Validate(cfg) 146 + }, 147 + }, 148 + }, 149 + } 56 150 57 - if err := cmd.Run(context.Background(), os.Args); err != nil { 58 - log.Fatal(err) 59 - } 151 + if err := cmd.Run(context.Background(), os.Args); err != nil { 152 + log.Fatal(err) 153 + } 60 154 }
+57
internal/cache/repocache.go
··· 1 + package cache 2 + 3 + import ( 4 + "sync" 5 + 6 + "github.com/aottr/nox/internal/gitrepo" 7 + "github.com/go-git/go-git/v5/plumbing/object" 8 + ) 9 + 10 + type RepoKey struct { 11 + Repo string 12 + Branch string 13 + } 14 + 15 + type RepoCache struct { 16 + mu sync.RWMutex 17 + repos map[RepoKey]*object.Tree 18 + } 19 + 20 + var ( 21 + GlobalCache = &RepoCache{ 22 + repos: make(map[RepoKey]*object.Tree), 23 + } 24 + ) 25 + 26 + func (c *RepoCache) Get(key RepoKey) (*object.Tree, bool) { 27 + c.mu.RLock() 28 + defer c.mu.RUnlock() 29 + tree, exists := c.repos[key] 30 + return tree, exists 31 + } 32 + 33 + func (c *RepoCache) Set(key RepoKey, tree *object.Tree) { 34 + c.mu.Lock() 35 + defer c.mu.Unlock() 36 + c.repos[key] = tree 37 + } 38 + 39 + func (c *RepoCache) FetchRepo(key RepoKey, token *string) (*object.Tree, error) { 40 + r, err := gitrepo.CloneRepoInMemory(gitrepo.GitFetchOptions{ 41 + RepoURL: key.Repo, 42 + Branch: key.Branch, 43 + Token: token, 44 + }) 45 + if err != nil { 46 + return nil, err 47 + } 48 + 49 + c.Set(key, r.Tree) 50 + return r.Tree, nil 51 + } 52 + 53 + func ClearRepoCache() { 54 + GlobalCache.mu.Lock() 55 + defer GlobalCache.mu.Unlock() 56 + GlobalCache.repos = make(map[RepoKey]*object.Tree) 57 + }
+85
internal/config/context.go
··· 1 + package config 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "log" 7 + "os" 8 + 9 + "filippo.io/age" 10 + "github.com/aottr/nox/internal/crypto" 11 + "github.com/aottr/nox/internal/state" 12 + ) 13 + 14 + type RuntimeOptions struct { 15 + ConfigPath string 16 + StatePath string 17 + IdentityPath string 18 + DryRun bool 19 + Force bool 20 + Verbose bool 21 + AppName string 22 + } 23 + 24 + type RuntimeContext struct { 25 + Config *Config 26 + State *state.State 27 + Identities []age.Identity 28 + App *string 29 + Logger *log.Logger 30 + DryRun bool 31 + Force bool 32 + Verbose bool 33 + } 34 + 35 + func BuildRuntimeContext(opts RuntimeOptions) (*RuntimeContext, error) { 36 + 37 + cfg, err := Load(opts.ConfigPath) 38 + if err != nil { 39 + return nil, err 40 + } 41 + 42 + if opts.StatePath != "" { 43 + state.SetPath(opts.StatePath) 44 + } 45 + st, err := state.Load() 46 + if err != nil { 47 + return nil, err 48 + } 49 + 50 + identityPath := opts.IdentityPath 51 + if identityPath == "" { 52 + identityPath = cfg.AgeKeyPath 53 + } 54 + ids, err := crypto.LoadAgeIdentities(identityPath) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + var app *string 60 + if opts.AppName != "" { 61 + if _, exists := cfg.Apps[opts.AppName]; exists { 62 + app = &opts.AppName 63 + } else { 64 + return nil, fmt.Errorf("app '%s' not found in configuration", opts.AppName) 65 + } 66 + } 67 + 68 + var logger *log.Logger 69 + if opts.Verbose { 70 + logger = log.New(os.Stdout, "", log.LstdFlags) 71 + } else { 72 + logger = log.New(io.Discard, "", 0) 73 + } 74 + 75 + return &RuntimeContext{ 76 + Config: cfg, 77 + State: st, 78 + Identities: ids, 79 + App: app, 80 + Logger: logger, 81 + DryRun: opts.DryRun, 82 + Force: opts.Force, 83 + Verbose: opts.Verbose, 84 + }, nil 85 + }
+2 -35
internal/crypto/decrypt.go
··· 9 9 "filippo.io/age" 10 10 ) 11 11 12 - func DecryptAgeFile(inputPath, outputPath, identityPath string) error { 13 - identityFile, err := os.ReadFile(identityPath) 14 - if err != nil { 15 - return fmt.Errorf("failed to read identity: %w", err) 16 - } 17 - 18 - identities, err := age.ParseIdentities(bytes.NewReader(identityFile)) 19 - if err != nil { 20 - return fmt.Errorf("failed to parse identities: %w", err) 21 - } 22 - 23 - in, err := os.Open(inputPath) 24 - if err != nil { 25 - return fmt.Errorf("failed to open encrypted file: %w", err) 26 - } 27 - defer in.Close() 28 - 29 - r, err := age.Decrypt(in, identities...) 30 - if err != nil { 31 - return fmt.Errorf("failed to decrypt: %w", err) 32 - } 33 - 34 - out, err := os.Create(outputPath) 35 - if err != nil { 36 - return fmt.Errorf("failed to create output: %w", err) 37 - } 38 - defer out.Close() 39 - 40 - if _, err := io.Copy(out, r); err != nil { 41 - return fmt.Errorf("failed to write: %w", err) 42 - } 43 - 44 - return nil 45 - } 46 - 12 + // DecryptFile decrypts the given file using the given identities 47 13 func DecryptFile(inputPath string, identities []age.Identity) ([]byte, error) { 48 14 data, err := os.ReadFile(inputPath) 49 15 if err != nil { ··· 52 18 return DecryptBytes(data, identities) 53 19 } 54 20 21 + // DecryptBytes decrypts the given bytes using the given identities 55 22 func DecryptBytes(encrypted []byte, identities []age.Identity) ([]byte, error) { 56 23 57 24 dec, err := age.Decrypt(bytes.NewReader(encrypted), identities...)
-113
internal/process/gitsync.go
··· 1 - package process 2 - 3 - import ( 4 - "fmt" 5 - "log" 6 - "os" 7 - 8 - "github.com/aottr/nox/internal/config" 9 - "github.com/aottr/nox/internal/crypto" 10 - "github.com/aottr/nox/internal/gitrepo" 11 - "github.com/aottr/nox/internal/state" 12 - "github.com/go-git/go-git/v5/plumbing/object" 13 - ) 14 - 15 - // ProcessApps clones and decrypts configured app secrets efficiently. 16 - func ProcessApps(cfg *config.Config) error { 17 - type repoKey struct { 18 - Repo string 19 - Branch string 20 - } 21 - 22 - clones := map[repoKey]*object.Tree{} 23 - 24 - identities, err := crypto.LoadAgeIdentities(cfg.AgeKeyPath) 25 - if err != nil { 26 - return fmt.Errorf("Failed to load age identities: %w", err) 27 - } 28 - 29 - st, err := state.Load() 30 - if err != nil { 31 - return fmt.Errorf("Failed to load state: %w", err) 32 - } 33 - 34 - for appName, app := range cfg.Apps { 35 - 36 - fmt.Printf("Processing app %s\n", appName) 37 - 38 - repoUrl := app.Repo 39 - if repoUrl == "" { 40 - repoUrl = cfg.DefaultRepo 41 - } 42 - key := repoKey{Repo: repoUrl, Branch: app.Branch} 43 - 44 - clone, ok := clones[key] 45 - if !ok { 46 - repo, err := gitrepo.CloneRepoInMemory(gitrepo.GitFetchOptions{ 47 - RepoURL: repoUrl, 48 - Branch: app.Branch, 49 - }) 50 - if err != nil { 51 - log.Printf("Clone failed for %s/%s: %v", repoUrl, app.Branch, err) 52 - continue 53 - } 54 - clone = repo.Tree 55 - clones[key] = clone 56 - } 57 - 58 - for _, file := range app.Files { 59 - content, err := gitrepo.GetFileContentFromTree(clone, file.Path) 60 - if err != nil { 61 - log.Printf("Failed to get file %s: %v", file, err) 62 - continue 63 - } 64 - 65 - hash := state.HashContent(content) 66 - cacheKey := state.GenerateKey(appName, file.Path) 67 - 68 - if prevHash, ok := st.Data[cacheKey]; ok && prevHash == hash { 69 - log.Printf("File %s is up to date", file.Path) 70 - continue 71 - } 72 - 73 - plaintext, err := crypto.DecryptBytes(content, identities) 74 - if err != nil { 75 - log.Printf("Failed to decrypt file %s: %v", file.Path, err) 76 - continue 77 - } 78 - 79 - outPath := file.Output 80 - // if outPath == "" { 81 - // // Default output filename if none specified, e.g. replace .age with .env 82 - // outPath = filepath.Base(fileCfg.Path) 83 - // if filepath.Ext(outPath) == ".age" { 84 - // outPath = outPath[:len(outPath)-4] + ".env" 85 - // } 86 - // } 87 - 88 - // if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { 89 - // log.Printf("Failed to create directories for %s: %v", outPath, err) 90 - // continue 91 - // } 92 - 93 - if err := os.WriteFile(outPath, plaintext, 0600); err != nil { 94 - log.Printf("Failed to write decrypted file to %s: %v", outPath, err) 95 - continue 96 - } 97 - 98 - log.Printf("decrypted %s for app %s (size: %d bytes)", file, appName, len(plaintext)) 99 - 100 - st.Data[cacheKey] = hash 101 - st.Touch() 102 - 103 - log.Printf("Decrypted file %s", file) 104 - } 105 - 106 - } 107 - 108 - if err := state.Save(st); err != nil { 109 - return fmt.Errorf("Failed to save state: %w", err) 110 - } 111 - 112 - return nil 113 - }
+1 -1
internal/process/validate.go internal/processor/validate.go
··· 1 - package process 1 + package processor 2 2 3 3 import ( 4 4 "fmt"
+35
internal/processor/file.go
··· 1 + package processor 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + 8 + "github.com/aottr/nox/internal/config" 9 + ) 10 + 11 + type FileProcessorOptions struct { 12 + CreateDir bool 13 + } 14 + 15 + func WriteToFile(data []byte, file config.FileConfig, opts *FileProcessorOptions) error { 16 + path := file.Output 17 + if path == "" { 18 + // Default output filename if none specified, e.g. replace .age with .env 19 + path = filepath.Base(file.Path) 20 + fmt.Println(path) 21 + if filepath.Ext(path) == ".age" { 22 + path = path[:len(path)-4] + ".env" 23 + } 24 + } 25 + if opts.CreateDir { 26 + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 27 + return fmt.Errorf("failed to create directories for %s: %w", path, err) 28 + } 29 + } 30 + 31 + if err := os.WriteFile(path, data, 0600); err != nil { 32 + return fmt.Errorf("failed to write decrypted file to %s: %w", path, err) 33 + } 34 + return nil 35 + }
+92
internal/processor/gitsync.go
··· 1 + package processor 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/aottr/nox/internal/cache" 7 + "github.com/aottr/nox/internal/config" 8 + "github.com/aottr/nox/internal/crypto" 9 + "github.com/aottr/nox/internal/gitrepo" 10 + "github.com/aottr/nox/internal/state" 11 + ) 12 + 13 + func SyncApp(ctx *config.RuntimeContext) error { 14 + 15 + cfg, appName, identities, st := ctx.Config, ctx.App, ctx.Identities, ctx.State 16 + 17 + if appName == nil { 18 + return fmt.Errorf("app name is required") 19 + } 20 + 21 + // retrieve app config and repository 22 + app := cfg.Apps[*appName] 23 + repoUrl := app.Repo 24 + if repoUrl == "" { 25 + repoUrl = cfg.DefaultRepo 26 + } 27 + key := cache.RepoKey{Repo: repoUrl, Branch: app.Branch} 28 + repo, exists := cache.GlobalCache.Get(key) 29 + if !exists { 30 + var err error 31 + repo, err = cache.GlobalCache.FetchRepo(key, nil) 32 + if err != nil { 33 + return fmt.Errorf("failed to fetch repo for app %s: %w", *appName, err) 34 + } 35 + } 36 + 37 + // iterate over files and decrypt 38 + for _, file := range app.Files { 39 + content, err := gitrepo.GetFileContentFromTree(repo, file.Path) 40 + if err != nil { 41 + return fmt.Errorf("failed to get file %s: %w", file, err) 42 + } 43 + 44 + hash := state.HashContent(content) 45 + cacheKey := state.GenerateKey(*appName, file.Path) 46 + 47 + // skip if file is up to date and force is not set 48 + if !ctx.Force { 49 + if prevHash, ok := st.Data[cacheKey]; ok && prevHash == hash { 50 + ctx.Logger.Printf("file %s is up to date", file.Path) 51 + continue 52 + } 53 + } 54 + 55 + // decrypt file 56 + plaintext, err := crypto.DecryptBytes(content, identities) 57 + if err != nil { 58 + ctx.Logger.Printf("failed to decrypt file %s: %v", file.Path, err) 59 + continue 60 + } 61 + 62 + // skip writing file if dry run is set 63 + if ctx.DryRun { 64 + ctx.Logger.Printf("❌ dry run, not writing file %s", file.Output) 65 + fmt.Println(string(plaintext)) 66 + continue 67 + } 68 + WriteToFile(plaintext, file, &FileProcessorOptions{CreateDir: true}) 69 + 70 + ctx.Logger.Printf("decrypted %s for app %s (size: %d bytes)", file, *appName, len(plaintext)) 71 + 72 + // update state 73 + st.Data[cacheKey] = hash 74 + st.Touch() 75 + } 76 + 77 + if err := state.Save(st); err != nil { 78 + return fmt.Errorf("failed to save state: %w", err) 79 + } 80 + return nil 81 + } 82 + 83 + func SyncApps(ctx *config.RuntimeContext) error { 84 + for appName := range ctx.Config.Apps { 85 + ctx.App = &appName 86 + ctx.Logger.Printf("Processing app: %s\n", appName) 87 + if err := SyncApp(ctx); err != nil { 88 + return err 89 + } 90 + } 91 + return nil 92 + }