diff --git a/cmd/nox/main.go b/cmd/nox/main.go index ed301aa..efc9fa4 100644 --- a/cmd/nox/main.go +++ b/cmd/nox/main.go @@ -1,60 +1,154 @@ package main import ( + "context" + "fmt" "log" "os" - "context" "github.com/aottr/nox/internal/config" "github.com/aottr/nox/internal/constants" - "github.com/aottr/nox/internal/process" + "github.com/aottr/nox/internal/processor" "github.com/urfave/cli/v3" ) func main() { var configPath string + var statePath string + var identityPath string + var appName string + var dryRun bool + var force bool + var verbose bool + var inputPath string + var outputPath string cmd := &cli.Command{ Name: "nox", Usage: "Manage and decrypt app secrets", Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "config", - Value: constants.DefaultConfigPath, - Usage: "path to config file", - Destination: &configPath, - }, - }, - 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) + &cli.StringFlag{ + Name: "config", + Value: constants.DefaultConfigPath, + Usage: "path to config file", + Destination: &configPath, + }, + &cli.StringFlag{ + Name: "state", + Value: constants.DefaultStatePath, + Usage: "path to state file", + Destination: &statePath, + }, + &cli.StringFlag{ + Name: "identity", + Usage: "path to age identity file", + Destination: &identityPath, + }, + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Value: false, + Usage: "print verbose output", + Destination: &verbose, + }, + }, + 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: "export", + Aliases: []string{"e"}, + Usage: "Export all secrets to a single file", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "app", + Aliases: []string{"a"}, + Usage: "app to export secrets for", + Destination: &appName, + }, + &cli.BoolFlag{ + Name: "dry-run", + Aliases: []string{"d"}, + Value: false, + Usage: "only print what would be exported", + Destination: &dryRun, + }, + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Value: false, + Usage: "ignore state file", + Destination: &force, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + rtx, err := config.BuildRuntimeContext(config.RuntimeOptions{ + ConfigPath: configPath, + StatePath: statePath, + IdentityPath: identityPath, + DryRun: dryRun, + Force: force, + AppName: appName, + Verbose: verbose, + }) if err != nil { - log.Fatalf("failed to load config: %v", err) + log.Fatalf("failed to build runtime context: %v", err) + } + if appName != "" { + return processor.SyncApp(rtx) } - return process.ProcessApps(cfg) - }, - }, - { - Name: "validate", - Aliases: []string{"v"}, - Usage: "Validate configuration and secret integrity", - Action: func(ctx context.Context, cmd *cli.Command) error { + return processor.SyncApps(rtx) + }, + }, + { + Name: "encrypt", + Aliases: []string{"enc"}, + Usage: "Encrypt a file", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "input", + Usage: "path to input file", + Destination: &inputPath, + }, + &cli.StringFlag{ + Name: "output", + Usage: "path to output file", + Destination: &outputPath, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println("encrypting file", inputPath) + fmt.Println("writing to", outputPath) + return nil + }, + }, + { + Name: "validate", + Aliases: []string{"v"}, + Usage: "Validate configuration and secret integrity", + 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 process.Validate(cfg) - }, - }, - }, - } + return processor.Validate(cfg) + }, + }, + }, + } - if err := cmd.Run(context.Background(), os.Args); err != nil { - log.Fatal(err) - } + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } } diff --git a/internal/cache/repocache.go b/internal/cache/repocache.go new file mode 100644 index 0000000..42373ab --- /dev/null +++ b/internal/cache/repocache.go @@ -0,0 +1,57 @@ +package cache + +import ( + "sync" + + "github.com/aottr/nox/internal/gitrepo" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type RepoKey struct { + Repo string + Branch string +} + +type RepoCache struct { + mu sync.RWMutex + repos map[RepoKey]*object.Tree +} + +var ( + GlobalCache = &RepoCache{ + repos: make(map[RepoKey]*object.Tree), + } +) + +func (c *RepoCache) Get(key RepoKey) (*object.Tree, 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) { + c.mu.Lock() + defer c.mu.Unlock() + c.repos[key] = tree +} + +func (c *RepoCache) FetchRepo(key RepoKey, token *string) (*object.Tree, error) { + r, err := gitrepo.CloneRepoInMemory(gitrepo.GitFetchOptions{ + RepoURL: key.Repo, + Branch: key.Branch, + Token: token, + }) + if err != nil { + return nil, err + } + + c.Set(key, r.Tree) + return r.Tree, nil +} + +func ClearRepoCache() { + GlobalCache.mu.Lock() + defer GlobalCache.mu.Unlock() + GlobalCache.repos = make(map[RepoKey]*object.Tree) +} diff --git a/internal/config/context.go b/internal/config/context.go new file mode 100644 index 0000000..72e07bf --- /dev/null +++ b/internal/config/context.go @@ -0,0 +1,85 @@ +package config + +import ( + "fmt" + "io" + "log" + "os" + + "filippo.io/age" + "github.com/aottr/nox/internal/crypto" + "github.com/aottr/nox/internal/state" +) + +type RuntimeOptions struct { + ConfigPath string + StatePath string + IdentityPath string + DryRun bool + Force bool + Verbose bool + AppName string +} + +type RuntimeContext struct { + Config *Config + State *state.State + Identities []age.Identity + App *string + Logger *log.Logger + DryRun bool + Force bool + Verbose bool +} + +func BuildRuntimeContext(opts RuntimeOptions) (*RuntimeContext, error) { + + cfg, err := Load(opts.ConfigPath) + if err != nil { + return nil, err + } + + if opts.StatePath != "" { + state.SetPath(opts.StatePath) + } + st, err := state.Load() + if err != nil { + return nil, err + } + + identityPath := opts.IdentityPath + if identityPath == "" { + identityPath = cfg.AgeKeyPath + } + ids, err := crypto.LoadAgeIdentities(identityPath) + if err != nil { + return nil, err + } + + var app *string + if opts.AppName != "" { + if _, exists := cfg.Apps[opts.AppName]; exists { + 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/crypto/decrypt.go b/internal/crypto/decrypt.go index ca7971a..3fdb89d 100644 --- a/internal/crypto/decrypt.go +++ b/internal/crypto/decrypt.go @@ -9,41 +9,7 @@ import ( "filippo.io/age" ) -func DecryptAgeFile(inputPath, outputPath, identityPath string) error { - identityFile, err := os.ReadFile(identityPath) - if err != nil { - return fmt.Errorf("failed to read identity: %w", err) - } - - identities, err := age.ParseIdentities(bytes.NewReader(identityFile)) - if err != nil { - return fmt.Errorf("failed to parse identities: %w", err) - } - - in, err := os.Open(inputPath) - if err != nil { - return fmt.Errorf("failed to open encrypted file: %w", err) - } - defer in.Close() - - r, err := age.Decrypt(in, identities...) - if err != nil { - return fmt.Errorf("failed to decrypt: %w", err) - } - - out, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("failed to create output: %w", err) - } - defer out.Close() - - if _, err := io.Copy(out, r); err != nil { - return fmt.Errorf("failed to write: %w", err) - } - - return nil -} - +// DecryptFile decrypts the given file using the given identities func DecryptFile(inputPath string, identities []age.Identity) ([]byte, error) { data, err := os.ReadFile(inputPath) if err != nil { @@ -52,6 +18,7 @@ func DecryptFile(inputPath string, identities []age.Identity) ([]byte, error) { return DecryptBytes(data, identities) } +// DecryptBytes decrypts the given bytes using the given identities func DecryptBytes(encrypted []byte, identities []age.Identity) ([]byte, error) { dec, err := age.Decrypt(bytes.NewReader(encrypted), identities...) diff --git a/internal/process/gitsync.go b/internal/process/gitsync.go deleted file mode 100644 index 013c2b2..0000000 --- a/internal/process/gitsync.go +++ /dev/null @@ -1,113 +0,0 @@ -package process - -import ( - "fmt" - "log" - "os" - - "github.com/aottr/nox/internal/config" - "github.com/aottr/nox/internal/crypto" - "github.com/aottr/nox/internal/gitrepo" - "github.com/aottr/nox/internal/state" - "github.com/go-git/go-git/v5/plumbing/object" -) - -// ProcessApps clones and decrypts configured app secrets efficiently. -func ProcessApps(cfg *config.Config) error { - type repoKey struct { - Repo string - Branch string - } - - clones := map[repoKey]*object.Tree{} - - identities, err := crypto.LoadAgeIdentities(cfg.AgeKeyPath) - if err != nil { - return fmt.Errorf("Failed to load age identities: %w", err) - } - - st, err := state.Load() - if err != nil { - return fmt.Errorf("Failed to load state: %w", err) - } - - for appName, app := range cfg.Apps { - - fmt.Printf("Processing app %s\n", appName) - - repoUrl := app.Repo - if repoUrl == "" { - repoUrl = cfg.DefaultRepo - } - key := repoKey{Repo: repoUrl, Branch: app.Branch} - - clone, ok := clones[key] - if !ok { - repo, err := gitrepo.CloneRepoInMemory(gitrepo.GitFetchOptions{ - RepoURL: repoUrl, - Branch: app.Branch, - }) - if err != nil { - log.Printf("Clone failed for %s/%s: %v", repoUrl, app.Branch, err) - continue - } - clone = repo.Tree - clones[key] = clone - } - - for _, file := range app.Files { - content, err := gitrepo.GetFileContentFromTree(clone, file.Path) - if err != nil { - log.Printf("Failed to get file %s: %v", file, err) - continue - } - - hash := state.HashContent(content) - cacheKey := state.GenerateKey(appName, file.Path) - - if prevHash, ok := st.Data[cacheKey]; ok && prevHash == hash { - log.Printf("File %s is up to date", file.Path) - continue - } - - plaintext, err := crypto.DecryptBytes(content, identities) - if err != nil { - log.Printf("Failed to decrypt file %s: %v", file.Path, err) - continue - } - - outPath := file.Output - // if outPath == "" { - // // Default output filename if none specified, e.g. replace .age with .env - // outPath = filepath.Base(fileCfg.Path) - // if filepath.Ext(outPath) == ".age" { - // outPath = outPath[:len(outPath)-4] + ".env" - // } - // } - - // if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { - // log.Printf("Failed to create directories for %s: %v", outPath, err) - // continue - // } - - if err := os.WriteFile(outPath, plaintext, 0600); err != nil { - log.Printf("Failed to write decrypted file to %s: %v", outPath, err) - continue - } - - log.Printf("decrypted %s for app %s (size: %d bytes)", file, appName, len(plaintext)) - - st.Data[cacheKey] = hash - st.Touch() - - log.Printf("Decrypted file %s", file) - } - - } - - if err := state.Save(st); err != nil { - return fmt.Errorf("Failed to save state: %w", err) - } - - return nil -} diff --git a/internal/processor/file.go b/internal/processor/file.go new file mode 100644 index 0000000..1be792a --- /dev/null +++ b/internal/processor/file.go @@ -0,0 +1,35 @@ +package processor + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/aottr/nox/internal/config" +) + +type FileProcessorOptions struct { + CreateDir bool +} + +func WriteToFile(data []byte, file config.FileConfig, opts *FileProcessorOptions) error { + path := file.Output + if path == "" { + // Default output filename if none specified, e.g. replace .age with .env + path = filepath.Base(file.Path) + fmt.Println(path) + if filepath.Ext(path) == ".age" { + 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.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write decrypted file to %s: %w", path, err) + } + return nil +} diff --git a/internal/processor/gitsync.go b/internal/processor/gitsync.go new file mode 100644 index 0000000..ac7050f --- /dev/null +++ b/internal/processor/gitsync.go @@ -0,0 +1,92 @@ +package processor + +import ( + "fmt" + + "github.com/aottr/nox/internal/cache" + "github.com/aottr/nox/internal/config" + "github.com/aottr/nox/internal/crypto" + "github.com/aottr/nox/internal/gitrepo" + "github.com/aottr/nox/internal/state" +) + +func SyncApp(ctx *config.RuntimeContext) error { + + cfg, appName, identities, st := ctx.Config, ctx.App, ctx.Identities, ctx.State + + if appName == nil { + return fmt.Errorf("app name is required") + } + + // retrieve app config and repository + app := cfg.Apps[*appName] + repoUrl := app.Repo + if repoUrl == "" { + repoUrl = cfg.DefaultRepo + } + key := cache.RepoKey{Repo: repoUrl, Branch: app.Branch} + repo, exists := cache.GlobalCache.Get(key) + if !exists { + var err error + repo, err = cache.GlobalCache.FetchRepo(key, nil) + 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 := gitrepo.GetFileContentFromTree(repo, 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) + + // skip if file is up to date and force is not set + if !ctx.Force { + if prevHash, ok := st.Data[cacheKey]; ok && prevHash == hash { + ctx.Logger.Printf("file %s is up to date", file.Path) + continue + } + } + + // decrypt file + plaintext, err := crypto.DecryptBytes(content, identities) + if err != nil { + ctx.Logger.Printf("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) + fmt.Println(string(plaintext)) + continue + } + WriteToFile(plaintext, file, &FileProcessorOptions{CreateDir: true}) + + ctx.Logger.Printf("decrypted %s for app %s (size: %d bytes)", file, *appName, len(plaintext)) + + // update state + st.Data[cacheKey] = hash + st.Touch() + } + + if err := state.Save(st); err != nil { + return fmt.Errorf("failed to save state: %w", err) + } + return nil +} + +func SyncApps(ctx *config.RuntimeContext) error { + for appName := range ctx.Config.Apps { + ctx.App = &appName + ctx.Logger.Printf("Processing app: %s\n", appName) + if err := SyncApp(ctx); err != nil { + return err + } + } + return nil +} diff --git a/internal/process/validate.go b/internal/processor/validate.go similarity index 98% rename from internal/process/validate.go rename to internal/processor/validate.go index 35f2a6d..7facf21 100644 --- a/internal/process/validate.go +++ b/internal/processor/validate.go @@ -1,4 +1,4 @@ -package process +package processor import ( "fmt"