diff --git a/.changes/unreleased/Added-20250128-153524.yaml b/.changes/unreleased/Added-20250128-153524.yaml new file mode 100644 index 0000000..0ed2115 --- /dev/null +++ b/.changes/unreleased/Added-20250128-153524.yaml @@ -0,0 +1,3 @@ +kind: Added +body: Cache functionality - still a WIP +time: 2025-01-28T15:35:24.512485671-06:00 diff --git a/.changes/unreleased/Changed-20250128-154204.yaml b/.changes/unreleased/Changed-20250128-154204.yaml new file mode 100644 index 0000000..ff3546c --- /dev/null +++ b/.changes/unreleased/Changed-20250128-154204.yaml @@ -0,0 +1,3 @@ +kind: Changed +body: "name of `configfetcher` to `remotefetcher`" +time: 2025-01-28T15:42:04.282668058-06:00 diff --git a/.changes/unreleased/Fixed-20250128-153806.yaml b/.changes/unreleased/Fixed-20250128-153806.yaml new file mode 100644 index 0000000..e007212 --- /dev/null +++ b/.changes/unreleased/Fixed-20250128-153806.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: Parsing of remote URLs when determining list config file path +time: 2025-01-28T15:38:06.957506929-06:00 diff --git a/.gitignore b/.gitignore index eece169..fe1f808 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -dist/ +.codegpt -.codegpt \ No newline at end of file +*.log \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index dc52063..57806b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "cSpell.words": [ "Cmds", - "configfetcher", + "remotefetcher", "knadh", "koanf", "mattn", diff --git a/docs/content/config/_index.md b/docs/content/config/_index.md index 01750d4..bf69d54 100644 --- a/docs/content/config/_index.md +++ b/docs/content/config/_index.md @@ -14,9 +14,20 @@ If you leave the config path blank, the following paths will be searched in orde 1. `./backy.yml` 2. `./backy.yaml` -3. `~/.config/backy.yml` -4. `~/.config/backy.yaml` +3. The same two files above contained in a `backy` subdirectory under in what is returned by Go's `os` package function `UserConfigDir()`. -Create a file at `~/.config/backy.yml`. +{{% expand title="`UserConfigDir()` documentation:" %}} -See the rest of the documentation in this section to configure it. +Up-to date documentation for this function may be found on [GoDoc](https://pkg.go.dev/os#UserConfigDir). + +>UserConfigDir returns the default root directory to use for user-specific configuration data. Users should create their own application-specific subdirectory within this one and use that. + +>On Unix systems, it returns $XDG_CONFIG_HOME as specified by https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html if non-empty, else $HOME/.config. On Darwin, it returns $HOME/Library/Application Support. On Windows, it returns %AppData%. On Plan 9, it returns $home/lib. + +>If the location cannot be determined (for example, $HOME is not defined), then it will return an error. + +{{% /expand %}} + +See the rest of the documentation, titles included below, in this section to configure it. + +{{% children description="true" %}} \ No newline at end of file diff --git a/docs/content/config/command-lists.md b/docs/content/config/command-lists.md index 180324d..ec28de7 100644 --- a/docs/content/config/command-lists.md +++ b/docs/content/config/command-lists.md @@ -2,7 +2,7 @@ title: "Command Lists" weight: 2 description: > - This page tells you how to get started with Backy. + This page tells you how to get use command lists. --- Command lists are for executing commands in sequence and getting notifications from them. @@ -14,7 +14,7 @@ Lists can go in a separate file. Command lists should be in a separate file if: 1. key 'cmd-lists.file' is found 2. hosts.yml or hosts.yaml is found in the same directory as the backy config file -```yaml +```yaml {lineNos="true" wrap="true" title="yaml"} test2: name: test2 order: @@ -65,10 +65,10 @@ Backy also has a cron mode, so one can run `backy cron` and start a process that Adding `cron: 0 0 1 * * *` to a `cmd-lists` object will schedule the list at 1 in the morning. See [https://crontab.guru/](https://crontab.guru/) for reference. {{% notice tip %}} -Note: Backy uses the second field of cron, so add anything except * to the beginning of a regular cron expression. +Note: Backy uses the second field of cron, so add anything except `*` to the beginning of a regular cron expression. {{% /notice %}} -```yaml +```yaml {lineNos="true" wrap="true" title="yaml"} cmd-lists:   docker-container-backup: # this can be any name you want     # all commands have to be defined diff --git a/docs/content/config/commands.md b/docs/content/config/commands.md index 48fb1f8..63ca86b 100644 --- a/docs/content/config/commands.md +++ b/docs/content/config/commands.md @@ -1,5 +1,6 @@ --- title: "Commands" +description: Commands are just that, commands weight: 1 --- diff --git a/docs/content/config/packages.md b/docs/content/config/packages.md index 43ffffa..ab45c59 100644 --- a/docs/content/config/packages.md +++ b/docs/content/config/packages.md @@ -1,6 +1,7 @@ --- title: "Packages" weight: 2 +description: This is dedicated to package commands. --- This is dedicated to `package` commands. The command `type` field must be `package`. Package is a type that allows one to perform package operations. There are several additional options available when `type` is `package`: diff --git a/docs/content/config/vault.md b/docs/content/config/vault.md index 07c72ba..a5f97c2 100644 --- a/docs/content/config/vault.md +++ b/docs/content/config/vault.md @@ -1,6 +1,7 @@ --- title: "Vault" weight: 4 +description: Set up and configure vault. --- [Vault](https://www.vaultproject.io/) is a tool for storing secrets and other data securely. diff --git a/examples/backy.yaml b/examples/backy.yaml index 8ebfdab..17046dd 100644 --- a/examples/backy.yaml +++ b/examples/backy.yaml @@ -28,12 +28,23 @@ commands: cmd: hostname update-docker: type: package - # shell: zsh + shell: zsh # best to run package commands in a shell packageName: docker-ce Args: - docker-ce-cli packageManager: apt - packageOperation: upgrade + packageOperation: install + update-dockerApt: + # type: package + shell: zsh + cmd: apt + Args: + - update + - "&&" + - apt install -y docker-ce + - docker-ce-cli + packageManager: apt + packageOperation: install cmd-lists: cmds-to-run: # this can be any name you want diff --git a/pkg/backy/backy.go b/pkg/backy/backy.go index d274dce..3012449 100644 --- a/pkg/backy/backy.go +++ b/pkg/backy/backy.go @@ -98,8 +98,14 @@ func (command *Command) RunCmd(cmdCtxLogger zerolog.Logger, opts *ConfigOpts) ([ cmdCtxLogger.Info().Str("Command", fmt.Sprintf("Running command %s on local machine", command.Name)).Send() - localCMD = exec.Command(command.Cmd, command.Args...) - + // execute package commands in a shell + if command.Type == "package" { + cmdCtxLogger.Info().Str("package", command.PackageName).Msg("Executing package command") + ArgsStr = fmt.Sprintf("%s %s", command.Cmd, ArgsStr) + localCMD = exec.Command("/bin/sh", "-c", ArgsStr) + } else { + localCMD = exec.Command(command.Cmd, command.Args...) + } } if command.Dir != nil { diff --git a/pkg/backy/config.go b/pkg/backy/config.go index 68bb361..51a4410 100644 --- a/pkg/backy/config.go +++ b/pkg/backy/config.go @@ -2,15 +2,17 @@ package backy import ( "context" + "errors" "fmt" + "net/url" "os" "path" "runtime" "strings" - "git.andrewnw.xyz/CyberShell/backy/pkg/configfetcher" "git.andrewnw.xyz/CyberShell/backy/pkg/logging" "git.andrewnw.xyz/CyberShell/backy/pkg/pkgman" + "git.andrewnw.xyz/CyberShell/backy/pkg/remotefetcher" "git.andrewnw.xyz/CyberShell/backy/pkg/usermanager" vault "github.com/hashicorp/vault/api" "github.com/knadh/koanf/parsers/yaml" @@ -20,23 +22,23 @@ import ( "github.com/rs/zerolog" ) -var homeDir string -var homeDirErr error -var backyHomeConfDir string -var configFiles []string - const macroStart string = "%{" const macroEnd string = "}%" const envMacroStart string = "%{env:" const vaultMacroStart string = "%{vault:" func (opts *ConfigOpts) InitConfig() { - homeDir, err := os.UserHomeDir() + var err error + homeConfigDir, err := os.UserConfigDir() + if err != nil { + logging.ExitWithMSG(err.Error(), 1, nil) + } + homeCacheDir, err := os.UserCacheDir() if err != nil { logging.ExitWithMSG(err.Error(), 1, nil) } - backyHomeConfDir := path.Join(homeDir, ".config/backy/") + backyHomeConfDir := path.Join(homeConfigDir, "backy") configFiles := []string{ "./backy.yml", "./backy.yaml", path.Join(backyHomeConfDir, "backy.yml"), @@ -46,8 +48,36 @@ func (opts *ConfigOpts) InitConfig() { backyKoanf := koanf.New(".") opts.ConfigFilePath = strings.TrimSpace(opts.ConfigFilePath) + metadataFile := "hashMetadataSample.yml" + cacheDir := homeCacheDir + + // Load metadata from file + opts.CachedData, err = remotefetcher.LoadMetadataFromFile(metadataFile) + if err != nil { + fmt.Println("Error loading metadata:", err) + panic(err) + } + + // Initialize cache with loaded metadata + cache, err := remotefetcher.NewCache(metadataFile, cacheDir) + if err != nil { + fmt.Println("Error initializing cache:", err) + panic(err) + } + + // Populate cache with loaded metadata + for _, data := range opts.CachedData { + cache.AddDataToStore(data.Hash, *data) + } + + opts.Cache, err = remotefetcher.NewCache(path.Join(backyHomeConfDir, "cache.yml"), backyHomeConfDir) + if err != nil { + logging.ExitWithMSG(fmt.Sprintf("error initializing cache: %v", err), 1, nil) + } // Initialize the fetcher - fetcher, err := configfetcher.NewConfigFetcher(opts.ConfigFilePath) + println("Creating new fetcher for source", opts.ConfigFilePath) + fetcher, err := remotefetcher.NewConfigFetcher(opts.ConfigFilePath, opts.Cache) + println("Created new fetcher for source", opts.ConfigFilePath) if err != nil { logging.ExitWithMSG(fmt.Sprintf("error initializing config fetcher: %v", err), 1, nil) @@ -62,7 +92,7 @@ func (opts *ConfigOpts) InitConfig() { opts.koanf = backyKoanf } -func loadConfigFile(fetcher configfetcher.ConfigFetcher, filePath string, k *koanf.Koanf, opts *ConfigOpts) { +func loadConfigFile(fetcher remotefetcher.ConfigFetcher, filePath string, k *koanf.Koanf, opts *ConfigOpts) { data, err := fetcher.Fetch(filePath) if err != nil { logging.ExitWithMSG(fmt.Sprintf("Could not fetch config file %s: %v", filePath, err), 1, nil) @@ -73,7 +103,7 @@ func loadConfigFile(fetcher configfetcher.ConfigFetcher, filePath string, k *koa } } -func loadDefaultConfigFiles(fetcher configfetcher.ConfigFetcher, configFiles []string, k *koanf.Koanf, opts *ConfigOpts) { +func loadDefaultConfigFiles(fetcher remotefetcher.ConfigFetcher, configFiles []string, k *koanf.Koanf, opts *ConfigOpts) { cFileFailures := 0 for _, c := range configFiles { data, err := fetcher.Fetch(c) @@ -236,13 +266,23 @@ func resolveProxyHosts(host *Host, opts *ConfigOpts) { } func loadCommandLists(opts *ConfigOpts, backyKoanf *koanf.Koanf) { - backyConfigFileDir := path.Dir(opts.ConfigFilePath) - listsConfig := koanf.New(".") - listConfigFiles := []string{ - path.Join(backyConfigFileDir, "lists.yml"), - path.Join(backyConfigFileDir, "lists.yaml"), + var backyConfigFileDir string + var listConfigFiles []string + var u *url.URL + // if config file is remote, use the directory of the remote file + if isRemoteURL(opts.ConfigFilePath) { + _, u = getRemoteDir(opts.ConfigFilePath) + listConfigFiles = []string{u.JoinPath("lists.yml").String(), u.JoinPath("lists.yaml").String()} + } else { + backyConfigFileDir = path.Dir(opts.ConfigFilePath) + listConfigFiles = []string{ + path.Join(backyConfigFileDir, "lists.yml"), + path.Join(backyConfigFileDir, "lists.yaml"), + } } + listsConfig := koanf.New(".") + for _, l := range listConfigFiles { if loadListConfigFile(l, listsConfig, opts) { break @@ -257,9 +297,29 @@ func loadCommandLists(opts *ConfigOpts, backyKoanf *koanf.Koanf) { } } -func loadListConfigFile(filePath string, k *koanf.Koanf, opts *ConfigOpts) bool { - fetcher, err := configfetcher.NewConfigFetcher(filePath) +func isRemoteURL(filePath string) bool { + return strings.HasPrefix(filePath, "http://") || strings.HasPrefix(filePath, "https://") || strings.HasPrefix(filePath, "s3://") +} + +func getRemoteDir(filePath string) (string, *url.URL) { + u, err := url.Parse(filePath) if err != nil { + return "", nil + } + // u.Path is the path to the file, stripped of scheme and hostname + u.Path = path.Dir(u.Path) + + return u.String(), u +} + +func loadListConfigFile(filePath string, k *koanf.Koanf, opts *ConfigOpts) bool { + fetcher, err := remotefetcher.NewConfigFetcher(filePath, opts.Cache, remotefetcher.IgnoreFileNotFound()) + if err != nil { + // if file not found, ignore + if errors.Is(err, remotefetcher.ErrFileNotFound) { + return true + } + logging.ExitWithMSG(fmt.Sprintf("error initializing config fetcher: %v", err), 1, nil) } @@ -282,7 +342,8 @@ func loadCmdListsFile(backyKoanf *koanf.Koanf, listsConfig *koanf.Koanf, opts *C opts.CmdListFile = path.Join(path.Dir(opts.ConfigFilePath), opts.CmdListFile) } - fetcher, err := configfetcher.NewConfigFetcher(opts.CmdListFile) + fetcher, err := remotefetcher.NewConfigFetcher(opts.CmdListFile, opts.Cache) + if err != nil { logging.ExitWithMSG(fmt.Sprintf("error initializing config fetcher: %v", err), 1, nil) } diff --git a/pkg/backy/types.go b/pkg/backy/types.go index 97451ed..682dbbc 100644 --- a/pkg/backy/types.go +++ b/pkg/backy/types.go @@ -7,6 +7,7 @@ import ( "strings" "git.andrewnw.xyz/CyberShell/backy/pkg/pkgman" + "git.andrewnw.xyz/CyberShell/backy/pkg/remotefetcher" "git.andrewnw.xyz/CyberShell/backy/pkg/usermanager" vaultapi "github.com/hashicorp/vault/api" "github.com/kevinburke/ssh_config" @@ -217,6 +218,9 @@ type ( koanf *koanf.Koanf NotificationConf *Notifications `yaml:"notifications"` + + Cache *remotefetcher.Cache + CachedData []*remotefetcher.CacheData } outStruct struct { diff --git a/pkg/backy/utils.go b/pkg/backy/utils.go index 7629325..1349e67 100644 --- a/pkg/backy/utils.go +++ b/pkg/backy/utils.go @@ -275,7 +275,7 @@ func getCommandType(command *Command) *Command { case "modify": command.Cmd, command.Args = command.userMan.ModifyUser( command.Username, - homeDir, + command.UserHome, command.UserShell, command.UserGroups) case "checkIfExists": diff --git a/pkg/configfetcher/configfetcher.go b/pkg/configfetcher/configfetcher.go deleted file mode 100644 index 2eebfd2..0000000 --- a/pkg/configfetcher/configfetcher.go +++ /dev/null @@ -1,27 +0,0 @@ -package configfetcher - -import "strings" - -type ConfigFetcher interface { - // Fetch retrieves the configuration from the specified URL or source - // Returns the raw data as bytes or an error - Fetch(source string) ([]byte, error) - - // Parse decodes the raw data into a Go structure (e.g., Commands, CommandLists) - // Takes the raw data as input and populates the target interface - Parse(data []byte, target interface{}) error -} - -func NewConfigFetcher(source string, options ...Option) (ConfigFetcher, error) { - if strings.HasPrefix(source, "http") || strings.HasPrefix(source, "https") { - return NewHTTPFetcher(options...), nil - } else if strings.HasPrefix(source, "s3") { - fetcher, err := NewS3Fetcher(options...) - if err != nil { - return nil, err - } - return fetcher, nil - } else { - return &LocalFetcher{}, nil - } -} diff --git a/pkg/configfetcher/local.go b/pkg/configfetcher/local.go deleted file mode 100644 index c141fa5..0000000 --- a/pkg/configfetcher/local.go +++ /dev/null @@ -1,26 +0,0 @@ -package configfetcher - -import ( - "io" - "os" - - "gopkg.in/yaml.v3" -) - -type LocalFetcher struct{} - -// Fetch retrieves the configuration from the specified local file path -func (l *LocalFetcher) Fetch(source string) ([]byte, error) { - file, err := os.Open(source) - if err != nil { - return nil, err - } - defer file.Close() - - return io.ReadAll(file) -} - -// Parse decodes the raw data into the provided target structure -func (l *LocalFetcher) Parse(data []byte, target interface{}) error { - return yaml.Unmarshal(data, target) -} diff --git a/pkg/remotefetcher/cache.go b/pkg/remotefetcher/cache.go new file mode 100644 index 0000000..8aa8116 --- /dev/null +++ b/pkg/remotefetcher/cache.go @@ -0,0 +1,191 @@ +package remotefetcher + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "gopkg.in/yaml.v3" +) + +type CacheData struct { + Hash string `yaml:"hash"` + Path string `yaml:"path"` + Type string `yaml:"type"` +} + +type Cache struct { + mu sync.Mutex + store map[string]CacheData + file string + dir string +} + +func NewCache(file, dir string) (*Cache, error) { + cache := &Cache{ + store: make(map[string]CacheData), + file: file, + dir: dir, + } + err := cache.loadFromFile() + if err != nil { + return nil, err + } + return cache, nil +} + +func (c *Cache) loadFromFile() error { + c.mu.Lock() + defer c.mu.Unlock() + if _, err := os.Stat(c.file); os.IsNotExist(err) { + return nil + } + + data, err := os.ReadFile(c.file) + if err != nil { + return err + } + + var cacheData []CacheData + err = yaml.Unmarshal(data, &cacheData) + if err != nil { + return err + } + + for _, item := range cacheData { + c.store[item.Hash] = item + } + + return nil +} + +func (c *Cache) saveToFile() error { + // println("Saving cache to file:", c.file) + c.mu.Lock() + defer c.mu.Unlock() + + var cacheData []CacheData + for _, data := range c.store { + cacheData = append(cacheData, data) + } + + data, err := yaml.Marshal(cacheData) + if err != nil { + return err + } + + return os.WriteFile(c.file, data, 0644) +} + +func (c *Cache) Get(hash string) ([]byte, CacheData, bool) { + c.mu.Lock() + defer c.mu.Unlock() + println("Getting cache data for hash:", hash) + cacheData, exists := c.store[hash] + if !exists { + println("Cache data does not exist for hash:", hash) + return nil, CacheData{}, false + } + + data, err := os.ReadFile(cacheData.Path) + if err != nil { + return nil, CacheData{}, false + } + + return data, cacheData, true +} + +func (c *Cache) AddDataToStore(hash string, cacheData CacheData) { + c.mu.Lock() + defer c.mu.Unlock() + c.store[hash] = cacheData +} + +func (c *Cache) Set(source, hash string, data []byte, dataType string) (CacheData, error) { + c.mu.Lock() + defer c.mu.Unlock() + + fileName := filepath.Base(source) + + path := filepath.Join(c.dir, fmt.Sprintf("%s-%s", fileName, hash)) + + if _, err := os.Stat(path); os.IsNotExist(err) { + os.MkdirAll(c.dir, 0700) + } + + err := os.WriteFile(path, data, 0644) + if err != nil { + return CacheData{}, err + } + + cacheData := CacheData{ + Hash: hash, + Path: path, + Type: dataType, + } + + c.store[hash] = cacheData + + // Unlock before calling saveToFile to avoid double-locking + c.mu.Unlock() + err = c.saveToFile() + c.mu.Lock() + if err != nil { + return CacheData{}, err + } + + // fmt.Printf("Cache data: %v", cacheData) + return cacheData, nil +} + +type CachedFetcher struct { + data []byte + path string + dataType string +} + +func (cf *CachedFetcher) Fetch(source string) ([]byte, error) { + return cf.data, nil +} + +func (cf *CachedFetcher) Parse(data []byte, target interface{}) error { + if cf.dataType == "yaml" { + return yaml.Unmarshal(data, target) + } + return errors.New("parse not supported on cached fetcher for scripts") +} + +func (cf *CachedFetcher) Hash(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} + +// Function to read and parse the hashMetadataSample.yml file +func LoadMetadataFromFile(filePath string) ([]*CacheData, error) { + // fmt.Println("Loading metadata from file:", filePath) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + // Create the file if it does not exist + emptyData := []byte("[]") + err := os.WriteFile(filePath, emptyData, 0644) + if err != nil { + return nil, err + } + return nil, nil + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var cacheData []*CacheData + err = yaml.Unmarshal(data, &cacheData) + if err != nil { + return nil, err + } + + return cacheData, nil +} diff --git a/pkg/remotefetcher/configfetcher.go b/pkg/remotefetcher/configfetcher.go new file mode 100644 index 0000000..56cefd0 --- /dev/null +++ b/pkg/remotefetcher/configfetcher.go @@ -0,0 +1,75 @@ +package remotefetcher + +import ( + "errors" + "strings" +) + +type ConfigFetcher interface { + // Fetch retrieves the configuration from the specified URL or source + // Returns the raw data as bytes or an error + Fetch(source string) ([]byte, error) + + // Parse decodes the raw data into a Go structure (e.g., Commands, CommandLists) + // Takes the raw data as input and populates the target interface + Parse(data []byte, target interface{}) error + + // Hash returns the hash of the configuration data + Hash(data []byte) string +} + +// ErrFileNotFound is returned when the file is not found and should be ignored +var ErrFileNotFound = errors.New("remotefetcher: file not found") + +func NewConfigFetcher(source string, cache *Cache, options ...Option) (ConfigFetcher, error) { + var fetcher ConfigFetcher + var dataType string + + config := FetcherConfig{} + for _, option := range options { + option(&config) + } + if strings.HasPrefix(source, "http") || strings.HasPrefix(source, "https") { + fetcher = NewHTTPFetcher(options...) + dataType = "yaml" + } else if strings.HasPrefix(source, "s3") { + var err error + fetcher, err = NewS3Fetcher(options...) + if err != nil { + return nil, err + } + dataType = "yaml" + } else { + fetcher = &LocalFetcher{} + dataType = "yaml" + + return fetcher, nil + } + + //TODO: should local files be cached? + + data, err := fetcher.Fetch(source) + if err != nil { + if config.IgnoreFileNotFound && isFileNotFoundError(err) { + return nil, ErrFileNotFound + } + return nil, err + } + + hash := fetcher.Hash(data) + if cachedData, cacheMeta, exists := cache.Get(hash); exists { + return &CachedFetcher{data: cachedData, path: cacheMeta.Path, dataType: cacheMeta.Type}, nil + } + + cacheData, err := cache.Set(source, hash, data, dataType) + if err != nil { + return nil, err + } + return &CachedFetcher{data: data, path: cacheData.Path, dataType: cacheData.Type}, nil +} + +func isFileNotFoundError(err error) bool { + // Implement logic to check if the error is a "file not found" error + // This can be based on the error type or message + return strings.Contains(err.Error(), "file not found") +} diff --git a/pkg/configfetcher/http.go b/pkg/remotefetcher/http.go similarity index 68% rename from pkg/configfetcher/http.go rename to pkg/remotefetcher/http.go index 2564873..cf53dc3 100644 --- a/pkg/configfetcher/http.go +++ b/pkg/remotefetcher/http.go @@ -1,6 +1,8 @@ -package configfetcher +package remotefetcher import ( + "crypto/sha256" + "encoding/hex" "errors" "io" "net/http" @@ -10,6 +12,7 @@ import ( type HTTPFetcher struct { HTTPClient *http.Client + config FetcherConfig } // NewHTTPFetcher creates a new instance of HTTPFetcher with the provided options. @@ -24,10 +27,10 @@ func NewHTTPFetcher(options ...Option) *HTTPFetcher { cfg.HTTPClient = http.DefaultClient } - return &HTTPFetcher{HTTPClient: cfg.HTTPClient} + return &HTTPFetcher{HTTPClient: cfg.HTTPClient, config: *cfg} } -// Fetch retrieves the configuration from the specified URL +// Fetch retrieves the file from the specified source URL func (h *HTTPFetcher) Fetch(source string) ([]byte, error) { resp, err := http.Get(source) if err != nil { @@ -35,6 +38,10 @@ func (h *HTTPFetcher) Fetch(source string) ([]byte, error) { } defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound && h.config.IgnoreFileNotFound { + return nil, ErrFileNotFound + } + if resp.StatusCode != http.StatusOK { return nil, errors.New("failed to fetch remote config: " + resp.Status) } @@ -46,3 +53,8 @@ func (h *HTTPFetcher) Fetch(source string) ([]byte, error) { func (h *HTTPFetcher) Parse(data []byte, target interface{}) error { return yaml.Unmarshal(data, target) } + +func (h *HTTPFetcher) Hash(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} diff --git a/pkg/remotefetcher/local.go b/pkg/remotefetcher/local.go new file mode 100644 index 0000000..a4d81d3 --- /dev/null +++ b/pkg/remotefetcher/local.go @@ -0,0 +1,42 @@ +package remotefetcher + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "os" + + "gopkg.in/yaml.v3" +) + +type LocalFetcher struct { + config FetcherConfig +} + +// Fetch retrieves the file from the specified local file path +func (l *LocalFetcher) Fetch(source string) ([]byte, error) { + // Check if the file exists + if _, err := os.Stat(source); os.IsNotExist(err) { + if l.config.IgnoreFileNotFound { + return nil, ErrFileNotFound + } + return nil, nil + } + file, err := os.Open(source) + if err != nil { + return nil, err + } + defer file.Close() + + return io.ReadAll(file) +} + +// Parse decodes the raw data into the provided target structure +func (l *LocalFetcher) Parse(data []byte, target interface{}) error { + return yaml.Unmarshal(data, target) +} + +func (l *LocalFetcher) Hash(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} diff --git a/pkg/configfetcher/options.go b/pkg/remotefetcher/options.go similarity index 72% rename from pkg/configfetcher/options.go rename to pkg/remotefetcher/options.go index c1f5d3a..9d4f0ae 100644 --- a/pkg/configfetcher/options.go +++ b/pkg/remotefetcher/options.go @@ -1,4 +1,4 @@ -package configfetcher +package remotefetcher import ( "net/http" @@ -11,8 +11,9 @@ type Option func(*FetcherConfig) // FetcherConfig holds the configuration for a fetcher. type FetcherConfig struct { - S3Client *s3.Client - HTTPClient *http.Client + S3Client *s3.Client + HTTPClient *http.Client + IgnoreFileNotFound bool } // WithS3Client sets the S3 client for the fetcher. @@ -28,3 +29,9 @@ func WithHTTPClient(client *http.Client) Option { cfg.HTTPClient = client } } + +func IgnoreFileNotFound() Option { + return func(cfg *FetcherConfig) { + cfg.IgnoreFileNotFound = true + } +} diff --git a/pkg/configfetcher/s3.go b/pkg/remotefetcher/s3.go similarity index 77% rename from pkg/configfetcher/s3.go rename to pkg/remotefetcher/s3.go index 3a5d0a0..bce3f02 100644 --- a/pkg/configfetcher/s3.go +++ b/pkg/remotefetcher/s3.go @@ -1,18 +1,22 @@ -package configfetcher +package remotefetcher import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "strings" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" "gopkg.in/yaml.v3" ) type S3Fetcher struct { S3Client *s3.Client + config FetcherConfig } // NewS3Fetcher creates a new instance of S3Fetcher with the provided options. @@ -31,7 +35,7 @@ func NewS3Fetcher(options ...Option) (*S3Fetcher, error) { cfg.S3Client = s3.NewFromConfig(awsCfg) } - return &S3Fetcher{S3Client: cfg.S3Client}, nil + return &S3Fetcher{S3Client: cfg.S3Client, config: *cfg}, nil } // Fetch retrieves the configuration from an S3 bucket @@ -47,6 +51,13 @@ func (s *S3Fetcher) Fetch(source string) ([]byte, error) { Key: &key, }) if err != nil { + if err != nil { + var notFound *types.NoSuchKey + if errors.As(err, ¬Found) && s.config.IgnoreFileNotFound { + return nil, ErrFileNotFound + } + return nil, err + } return nil, err } defer resp.Body.Close() @@ -73,3 +84,8 @@ func parseS3Source(source string) (bucket, key string, err error) { } return parts[0], parts[1], nil } + +func (s *S3Fetcher) Hash(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +}