[WIP] v0.7.0 fixes and changes to cache and remotefetcher

This commit is contained in:
Andrew Woodlee 2025-01-28 15:42:50 -06:00
parent 8c633fd4b2
commit 086835453b
23 changed files with 491 additions and 96 deletions

View File

@ -0,0 +1,3 @@
kind: Added
body: Cache functionality - still a WIP
time: 2025-01-28T15:35:24.512485671-06:00

View File

@ -0,0 +1,3 @@
kind: Changed
body: "name of `configfetcher` to `remotefetcher`"
time: 2025-01-28T15:42:04.282668058-06:00

View File

@ -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

4
.gitignore vendored
View File

@ -1,4 +1,4 @@
dist/ .codegpt
.codegpt *.log

View File

@ -1,7 +1,7 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Cmds", "Cmds",
"configfetcher", "remotefetcher",
"knadh", "knadh",
"koanf", "koanf",
"mattn", "mattn",

View File

@ -14,9 +14,20 @@ If you leave the config path blank, the following paths will be searched in orde
1. `./backy.yml` 1. `./backy.yml`
2. `./backy.yaml` 2. `./backy.yaml`
3. `~/.config/backy.yml` 3. The same two files above contained in a `backy` subdirectory under in what is returned by Go's `os` package function `UserConfigDir()`.
4. `~/.config/backy.yaml`
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" %}}

View File

@ -2,7 +2,7 @@
title: "Command Lists" title: "Command Lists"
weight: 2 weight: 2
description: > 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. 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 1. key 'cmd-lists.file' is found
2. hosts.yml or hosts.yaml is found in the same directory as the backy config file 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: test2:
name: test2 name: test2
order: 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. 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 %}} {{% 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 %}} {{% /notice %}}
```yaml ```yaml {lineNos="true" wrap="true" title="yaml"}
cmd-lists: cmd-lists:
  docker-container-backup: # this can be any name you want   docker-container-backup: # this can be any name you want
    # all commands have to be defined     # all commands have to be defined

View File

@ -1,5 +1,6 @@
--- ---
title: "Commands" title: "Commands"
description: Commands are just that, commands
weight: 1 weight: 1
--- ---

View File

@ -1,6 +1,7 @@
--- ---
title: "Packages" title: "Packages"
weight: 2 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`: 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`:

View File

@ -1,6 +1,7 @@
--- ---
title: "Vault" title: "Vault"
weight: 4 weight: 4
description: Set up and configure vault.
--- ---
[Vault](https://www.vaultproject.io/) is a tool for storing secrets and other data securely. [Vault](https://www.vaultproject.io/) is a tool for storing secrets and other data securely.

View File

@ -28,12 +28,23 @@ commands:
cmd: hostname cmd: hostname
update-docker: update-docker:
type: package type: package
# shell: zsh shell: zsh # best to run package commands in a shell
packageName: docker-ce packageName: docker-ce
Args: Args:
- docker-ce-cli - docker-ce-cli
packageManager: apt 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: cmd-lists:
cmds-to-run: # this can be any name you want cmds-to-run: # this can be any name you want

View File

@ -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() 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 { if command.Dir != nil {

View File

@ -2,15 +2,17 @@ package backy
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/url"
"os" "os"
"path" "path"
"runtime" "runtime"
"strings" "strings"
"git.andrewnw.xyz/CyberShell/backy/pkg/configfetcher"
"git.andrewnw.xyz/CyberShell/backy/pkg/logging" "git.andrewnw.xyz/CyberShell/backy/pkg/logging"
"git.andrewnw.xyz/CyberShell/backy/pkg/pkgman" "git.andrewnw.xyz/CyberShell/backy/pkg/pkgman"
"git.andrewnw.xyz/CyberShell/backy/pkg/remotefetcher"
"git.andrewnw.xyz/CyberShell/backy/pkg/usermanager" "git.andrewnw.xyz/CyberShell/backy/pkg/usermanager"
vault "github.com/hashicorp/vault/api" vault "github.com/hashicorp/vault/api"
"github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/parsers/yaml"
@ -20,23 +22,23 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
var homeDir string
var homeDirErr error
var backyHomeConfDir string
var configFiles []string
const macroStart string = "%{" const macroStart string = "%{"
const macroEnd string = "}%" const macroEnd string = "}%"
const envMacroStart string = "%{env:" const envMacroStart string = "%{env:"
const vaultMacroStart string = "%{vault:" const vaultMacroStart string = "%{vault:"
func (opts *ConfigOpts) InitConfig() { 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 { if err != nil {
logging.ExitWithMSG(err.Error(), 1, nil) logging.ExitWithMSG(err.Error(), 1, nil)
} }
backyHomeConfDir := path.Join(homeDir, ".config/backy/") backyHomeConfDir := path.Join(homeConfigDir, "backy")
configFiles := []string{ configFiles := []string{
"./backy.yml", "./backy.yaml", "./backy.yml", "./backy.yaml",
path.Join(backyHomeConfDir, "backy.yml"), path.Join(backyHomeConfDir, "backy.yml"),
@ -46,8 +48,36 @@ func (opts *ConfigOpts) InitConfig() {
backyKoanf := koanf.New(".") backyKoanf := koanf.New(".")
opts.ConfigFilePath = strings.TrimSpace(opts.ConfigFilePath) 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 // 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 { if err != nil {
logging.ExitWithMSG(fmt.Sprintf("error initializing config fetcher: %v", err), 1, nil) logging.ExitWithMSG(fmt.Sprintf("error initializing config fetcher: %v", err), 1, nil)
@ -62,7 +92,7 @@ func (opts *ConfigOpts) InitConfig() {
opts.koanf = backyKoanf 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) data, err := fetcher.Fetch(filePath)
if err != nil { if err != nil {
logging.ExitWithMSG(fmt.Sprintf("Could not fetch config file %s: %v", filePath, err), 1, 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 cFileFailures := 0
for _, c := range configFiles { for _, c := range configFiles {
data, err := fetcher.Fetch(c) data, err := fetcher.Fetch(c)
@ -236,13 +266,23 @@ func resolveProxyHosts(host *Host, opts *ConfigOpts) {
} }
func loadCommandLists(opts *ConfigOpts, backyKoanf *koanf.Koanf) { func loadCommandLists(opts *ConfigOpts, backyKoanf *koanf.Koanf) {
backyConfigFileDir := path.Dir(opts.ConfigFilePath) var backyConfigFileDir string
listsConfig := koanf.New(".") var listConfigFiles []string
listConfigFiles := []string{ var u *url.URL
path.Join(backyConfigFileDir, "lists.yml"), // if config file is remote, use the directory of the remote file
path.Join(backyConfigFileDir, "lists.yaml"), 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 { for _, l := range listConfigFiles {
if loadListConfigFile(l, listsConfig, opts) { if loadListConfigFile(l, listsConfig, opts) {
break break
@ -257,9 +297,29 @@ func loadCommandLists(opts *ConfigOpts, backyKoanf *koanf.Koanf) {
} }
} }
func loadListConfigFile(filePath string, k *koanf.Koanf, opts *ConfigOpts) bool { func isRemoteURL(filePath string) bool {
fetcher, err := configfetcher.NewConfigFetcher(filePath) 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 { 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) 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) 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 { if err != nil {
logging.ExitWithMSG(fmt.Sprintf("error initializing config fetcher: %v", err), 1, nil) logging.ExitWithMSG(fmt.Sprintf("error initializing config fetcher: %v", err), 1, nil)
} }

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"git.andrewnw.xyz/CyberShell/backy/pkg/pkgman" "git.andrewnw.xyz/CyberShell/backy/pkg/pkgman"
"git.andrewnw.xyz/CyberShell/backy/pkg/remotefetcher"
"git.andrewnw.xyz/CyberShell/backy/pkg/usermanager" "git.andrewnw.xyz/CyberShell/backy/pkg/usermanager"
vaultapi "github.com/hashicorp/vault/api" vaultapi "github.com/hashicorp/vault/api"
"github.com/kevinburke/ssh_config" "github.com/kevinburke/ssh_config"
@ -217,6 +218,9 @@ type (
koanf *koanf.Koanf koanf *koanf.Koanf
NotificationConf *Notifications `yaml:"notifications"` NotificationConf *Notifications `yaml:"notifications"`
Cache *remotefetcher.Cache
CachedData []*remotefetcher.CacheData
} }
outStruct struct { outStruct struct {

View File

@ -275,7 +275,7 @@ func getCommandType(command *Command) *Command {
case "modify": case "modify":
command.Cmd, command.Args = command.userMan.ModifyUser( command.Cmd, command.Args = command.userMan.ModifyUser(
command.Username, command.Username,
homeDir, command.UserHome,
command.UserShell, command.UserShell,
command.UserGroups) command.UserGroups)
case "checkIfExists": case "checkIfExists":

View File

@ -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
}
}

View File

@ -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)
}

191
pkg/remotefetcher/cache.go Normal file
View File

@ -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
}

View File

@ -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")
}

View File

@ -1,6 +1,8 @@
package configfetcher package remotefetcher
import ( import (
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"io" "io"
"net/http" "net/http"
@ -10,6 +12,7 @@ import (
type HTTPFetcher struct { type HTTPFetcher struct {
HTTPClient *http.Client HTTPClient *http.Client
config FetcherConfig
} }
// NewHTTPFetcher creates a new instance of HTTPFetcher with the provided options. // NewHTTPFetcher creates a new instance of HTTPFetcher with the provided options.
@ -24,10 +27,10 @@ func NewHTTPFetcher(options ...Option) *HTTPFetcher {
cfg.HTTPClient = http.DefaultClient 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) { func (h *HTTPFetcher) Fetch(source string) ([]byte, error) {
resp, err := http.Get(source) resp, err := http.Get(source)
if err != nil { if err != nil {
@ -35,6 +38,10 @@ func (h *HTTPFetcher) Fetch(source string) ([]byte, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound && h.config.IgnoreFileNotFound {
return nil, ErrFileNotFound
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to fetch remote config: " + resp.Status) 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 { func (h *HTTPFetcher) Parse(data []byte, target interface{}) error {
return yaml.Unmarshal(data, target) return yaml.Unmarshal(data, target)
} }
func (h *HTTPFetcher) Hash(data []byte) string {
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}

View File

@ -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[:])
}

View File

@ -1,4 +1,4 @@
package configfetcher package remotefetcher
import ( import (
"net/http" "net/http"
@ -11,8 +11,9 @@ type Option func(*FetcherConfig)
// FetcherConfig holds the configuration for a fetcher. // FetcherConfig holds the configuration for a fetcher.
type FetcherConfig struct { type FetcherConfig struct {
S3Client *s3.Client S3Client *s3.Client
HTTPClient *http.Client HTTPClient *http.Client
IgnoreFileNotFound bool
} }
// WithS3Client sets the S3 client for the fetcher. // WithS3Client sets the S3 client for the fetcher.
@ -28,3 +29,9 @@ func WithHTTPClient(client *http.Client) Option {
cfg.HTTPClient = client cfg.HTTPClient = client
} }
} }
func IgnoreFileNotFound() Option {
return func(cfg *FetcherConfig) {
cfg.IgnoreFileNotFound = true
}
}

View File

@ -1,18 +1,22 @@
package configfetcher package remotefetcher
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"strings" "strings"
"github.com/aws/aws-sdk-go-v2/config" "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"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type S3Fetcher struct { type S3Fetcher struct {
S3Client *s3.Client S3Client *s3.Client
config FetcherConfig
} }
// NewS3Fetcher creates a new instance of S3Fetcher with the provided options. // 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) 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 // Fetch retrieves the configuration from an S3 bucket
@ -47,6 +51,13 @@ func (s *S3Fetcher) Fetch(source string) ([]byte, error) {
Key: &key, Key: &key,
}) })
if err != nil { if err != nil {
if err != nil {
var notFound *types.NoSuchKey
if errors.As(err, &notFound) && s.config.IgnoreFileNotFound {
return nil, ErrFileNotFound
}
return nil, err
}
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -73,3 +84,8 @@ func parseS3Source(source string) (bucket, key string, err error) {
} }
return parts[0], parts[1], nil return parts[0], parts[1], nil
} }
func (s *S3Fetcher) Hash(data []byte) string {
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}