Skip to content

Add yaml schema verification #46

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"path"
"reflect"
"slices"
"time"

"github.com/prometheus/client_golang/prometheus"
Expand Down Expand Up @@ -32,6 +35,11 @@ var (
Name: "git_mirror_config_last_reload_success_timestamp_seconds",
Help: "Timestamp of the last successful configuration reload.",
})
allowedRepoPoolConfig = getAllowedKeys(mirror.RepoPoolConfig{})
allowedDefaults = getAllowedKeys(mirror.DefaultConfig{})
allowedAuthKeys = getAllowedKeys(mirror.Auth{})
allowedRepoKeys = getAllowedKeys(mirror.RepositoryConfig{})
allowedWorktreeKeys = getAllowedKeys(mirror.WorktreeConfig{})
)

// WatchConfig polls the config file every interval and reloads if modified
Expand Down Expand Up @@ -174,14 +182,132 @@ func parseConfigFile(path string) (*mirror.RepoPoolConfig, error) {
if err != nil {
return nil, err
}

err = validateConfigYaml([]byte(yamlFile))
if err != nil {
return nil, err
}

conf := &mirror.RepoPoolConfig{}
err = yaml.Unmarshal(yamlFile, conf)
if err != nil {
return nil, err
}

return conf, nil
}

func validateConfigYaml(yamlData []byte) error {
var raw map[string]interface{}
if err := yaml.Unmarshal(yamlData, &raw); err != nil {
return err
}

// check all root config sections for unexpected keys
if key := findUnexpectedKey(raw, allowedRepoPoolConfig); key != "" {
return fmt.Errorf("unexpected key: .%v", key)
}

// check ".defaults" if it's not empty
if raw["defaults"] != nil {
defaultsMap, ok := raw["defaults"].(map[string]interface{})
if !ok {
return fmt.Errorf(".defaults config is not valid")
}

if key := findUnexpectedKey(defaultsMap, allowedDefaults); key != "" {
return fmt.Errorf("unexpected key: .defaults.%v", key)
}

// check ".defaults.auth"
if authMap, ok := defaultsMap["auth"].(map[string]interface{}); ok {
if key := findUnexpectedKey(authMap, allowedAuthKeys); key != "" {
return fmt.Errorf("unexpected key: .defaults.auth.%v", key)
}
}
}

// skip further config checks if ".repositories" is empty
if raw["repositories"] == nil {
return nil
}

// check ".repositories"
reposInterface, ok := raw["repositories"].([]interface{})
if !ok {
return fmt.Errorf(".repositories config must be an array")
}

// check each repository in ".repositories"
for _, repoInterface := range reposInterface {
repoMap, ok := repoInterface.(map[string]interface{})
if !ok {
return fmt.Errorf(".repositories config is not valid")
}

if key := findUnexpectedKey(repoMap, allowedRepoKeys); key != "" {
return fmt.Errorf("unexpected key: .repositories[%v].%v", repoMap["remote"], key)
}

// skip further repository checks if "worktrees" is empty
if repoMap["worktrees"] == nil {
continue
}

// check "worktrees" in each repository
worktreesInterface, ok := repoMap["worktrees"].([]interface{})
if !ok {
return fmt.Errorf("worktrees config must be an array in .repositories[%v]", repoMap["remote"])
}

for i, worktreeInterface := range worktreesInterface {
worktreeMap, ok := worktreeInterface.(map[string]interface{})
if !ok {
return fmt.Errorf("worktrees config is not valid in .repositories[%v]", repoMap["remote"])
}

if key := findUnexpectedKey(worktreeMap, allowedWorktreeKeys); key != "" {
return fmt.Errorf("unexpected key: .repositories[%v].worktrees[%v].%v", repoMap["remote"], i, key)
}

// Check "pathspecs" in each worktree
if pathspecsInterface, exists := worktreeMap["pathspecs"]; exists {
if _, ok := pathspecsInterface.([]interface{}); !ok {
return fmt.Errorf("pathspecs config must be an array in .repositories[%v].worktrees[%v]", repoMap["remote"], i)
}
}
}
}

return nil
}

// getAllowedKeys retrieves a list of allowed keys from the specified struct
func getAllowedKeys(config interface{}) []string {
var allowedKeys []string
val := reflect.ValueOf(config)
typ := reflect.TypeOf(config)

for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
yamlTag := field.Tag.Get("yaml")
if yamlTag != "" {
allowedKeys = append(allowedKeys, yamlTag)
}
}
return allowedKeys
}

func findUnexpectedKey(raw map[string]interface{}, allowedKeys []string) string {
for key := range raw {
if !slices.Contains(allowedKeys, key) {
return key
}
}

return ""
}

// diffRepositories will do the diff between current state and new config and
// return new repositories config and list of remote url which are not found in config
func diffRepositories(repoPool *mirror.RepoPool, newConfig *mirror.RepoPoolConfig) (
Expand Down
180 changes: 178 additions & 2 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
)

func Test_diffRepositories(t *testing.T) {

tests := []struct {
name string
initialConfig *mirror.RepoPoolConfig
Expand Down Expand Up @@ -199,7 +198,6 @@ func Test_diffWorktrees(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

if err := tt.initialRepoConf.PopulateEmptyLinkPaths(); err != nil {
t.Fatalf("failed to create repo error = %v", err)
}
Expand Down Expand Up @@ -234,3 +232,181 @@ func Test_diffWorktrees(t *testing.T) {
})
}
}

func Test_validateConfigYaml(t *testing.T) {
tests := []struct {
name string
yamlData []byte
wantError bool
}{
{
name: "valid - full config",
yamlData: []byte(`
defaults:
root: /tmp/git-mirror
link_root: /tmp/links
interval: 30s
mirror_timeout: 2m
git_gc: always
auth:
ssh_key_path: /etc/git-secret/ssh
ssh_known_hosts_path: /etc/git-secret/known_hosts

repositories:
- remote: https://github.com/utilitywarehouse/git-mirror
worktrees:
- link: aaa
ref: main
- link: bbb
ref: main
- remote: https://github.com/utilitywarehouse/another-repo
root: /some/other/location
link_root: /some/path
interval: 1m
mirror_timeout: 5m
git_gc: always
auth:
ssh_key_path: /some/other/location
ssh_known_hosts_path: /some/other/location
worktrees:
- link: alerts
ref: main
pathspecs:
- path
- path2/*.yaml
`),
wantError: false,
},
{
name: "valid - empty config",
yamlData: []byte(`
`),
wantError: false,
},
{
name: "valid - defaults config only",
yamlData: []byte(`
defaults:
`),
wantError: false,
},
{
name: "valid - repositories config only",
yamlData: []byte(`
repositories:
`),
wantError: false,
},
{
name: "invalid - unexpected key",
yamlData: []byte(`
not-valid:
test: test

defaults:
root: /tmp/git-mirror

repositories:
- remote: https://github.com/utilitywarehouse/git-mirror
`),
wantError: true,
},
{
name: "invalid - unexpected key in defaults",
yamlData: []byte(`
defaults:
root: /tmp/git-mirror
not_valid: test

repositories:
- remote: https://github.com/utilitywarehouse/git-mirror
`),
wantError: true,
},
{
name: "invalid - unexpected key in auth",
yamlData: []byte(`
defaults:
root: /tmp/git-mirror
auth:
not_valid: test

repositories:
- remote: https://github.com/utilitywarehouse/git-mirror
`),
wantError: true,
},
{
name: "invalid - unexpected key in repositories",
yamlData: []byte(`
defaults:
root: /tmp/git-mirror

repositories:
- remote: https://github.com/utilitywarehouse/git-mirror
not_valid: test
`),
wantError: true,
},
{
name: "invalid - unexpected key in repository worktrees",
yamlData: []byte(`
defaults:
root: /tmp/git-mirror

repositories:
- remote: https://github.com/utilitywarehouse/git-mirror
worktrees:
- link: aaa
not_valid: test
`),
wantError: true,
},
{
name: "invalid - repositories is not an array",
yamlData: []byte(`
defaults:
root: /tmp/git-mirror

repositories: https://github.com/utilitywarehouse/git-mirror
`),
wantError: true,
},
{
name: "invalid - worktrees is not an array",
yamlData: []byte(`
defaults:
root: /tmp/git-mirror

repositories:
- remote: https://github.com/utilitywarehouse/git-mirror
worktrees: test
`),
wantError: true,
},
{
name: "invalid - pathspecs is not an array",
yamlData: []byte(`
defaults:
root: /tmp/git-mirror

repositories:
- remote: https://github.com/utilitywarehouse/git-mirror
worktrees:
- link: aaa
not_valid: test
pathspecs: readme.md
`),
wantError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfigYaml(tt.yamlData)
if (err != nil) != tt.wantError {
t.Errorf("validateConfigYaml() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
Loading