refactor(config): check the owner name in the app with the domain rules
The copy of the repository name rules in config is gone: internal/app checks ERMITE_OWNER with domain.ParseRepoName and joins the problem to the report of the other variables.
- commit
- 5b299eb72805cb1716d48a073d17a5522a40c6b0
- author
- brice · · 2026-10-12 19:17 UTC
- parent
- 1beb9db
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +81 -22
M internal/app/serve.go +28 -1
| @@ -4,14 +4,17 @@ package app | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/signal" | |
| 10 | + | "strings" | |
| 9 | 11 | "syscall" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/zerowrap" | |
| 12 | 14 | "github.com/spf13/cobra" | |
| 13 | 15 | ||
| 14 | 16 | "bnema.dev/ermite/internal/config" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | 18 | ) | |
| 16 | 19 | ||
| 17 | 20 | func newServeCmd() *cobra.Command { | |
| @@ -32,7 +35,7 @@ func newServeCmd() *cobra.Command { | |||
| 32 | 35 | ||
| 33 | 36 | // serve wires the adapters and runs the server until SIGINT or SIGTERM. | |
| 34 | 37 | func serve(ctx context.Context) error { | |
| 35 | - | cfg, err := config.Load(os.Getenv) | |
| 38 | + | cfg, err := loadConfig(os.Getenv) | |
| 36 | 39 | if err != nil { | |
| 37 | 40 | return err | |
| 38 | 41 | } | |
| @@ -67,3 +70,27 @@ func serve(ctx context.Context) error { | |||
| 67 | 70 | log.Info().Msg("stopped") | |
| 68 | 71 | return nil | |
| 69 | 72 | } | |
| 73 | + | ||
| 74 | + | // loadConfig reads the configuration and adds the checks that need the domain, | |
| 75 | + | // which config does not import: ERMITE_OWNER follows the rules of repository | |
| 76 | + | // names. Its problem joins the others in one report. | |
| 77 | + | func loadConfig(get func(string) string) (config.Config, error) { | |
| 78 | + | cfg, err := config.Load(get) | |
| 79 | + | var problems []string | |
| 80 | + | var ce *config.Error | |
| 81 | + | switch { | |
| 82 | + | case errors.As(err, &ce): | |
| 83 | + | problems = ce.Problems | |
| 84 | + | case err != nil: | |
| 85 | + | return config.Config{}, err | |
| 86 | + | } | |
| 87 | + | if owner := strings.TrimSpace(get(config.EnvOwner)); owner != "" { | |
| 88 | + | if _, perr := domain.ParseRepoName(owner); perr != nil { | |
| 89 | + | problems = append(problems, config.EnvOwner+": must follow the rules of repository names ("+perr.Error()+")") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | if len(problems) > 0 { | |
| 93 | + | return config.Config{}, &config.Error{Problems: problems} | |
| 94 | + | } | |
| 95 | + | return cfg, nil | |
| 96 | + | } | |
| @@ -4,14 +4,17 @@ package app | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "os/signal" | 9 | "os/signal" | ||
| 10 | + | "strings" | |||
| 9 | "syscall" | 11 | "syscall" | ||
| 10 | 12 | ||||
| 11 | "github.com/bnema/zerowrap" | 13 | "github.com/bnema/zerowrap" | ||
| 12 | "github.com/spf13/cobra" | 14 | "github.com/spf13/cobra" | ||
| 13 | 15 | ||||
| 14 | "bnema.dev/ermite/internal/config" | 16 | "bnema.dev/ermite/internal/config" | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | ) | 18 | ) | ||
| 16 | 19 | ||||
| 17 | func newServeCmd() *cobra.Command { | 20 | func newServeCmd() *cobra.Command { | ||
| @@ -32,7 +35,7 @@ func newServeCmd() *cobra.Command { | |||||
| 32 | 35 | ||||
| 33 | // serve wires the adapters and runs the server until SIGINT or SIGTERM. | 36 | // serve wires the adapters and runs the server until SIGINT or SIGTERM. | ||
| 34 | func serve(ctx context.Context) error { | 37 | func serve(ctx context.Context) error { | ||
| 35 | - | cfg, err := config.Load(os.Getenv) | 38 | + | cfg, err := loadConfig(os.Getenv) |
| 36 | if err != nil { | 39 | if err != nil { | ||
| 37 | return err | 40 | return err | ||
| 38 | } | 41 | } | ||
| @@ -67,3 +70,27 @@ func serve(ctx context.Context) error { | |||||
| 67 | log.Info().Msg("stopped") | 70 | log.Info().Msg("stopped") | ||
| 68 | return nil | 71 | return nil | ||
| 69 | } | 72 | } | ||
| 73 | + | ||||
| 74 | + | // loadConfig reads the configuration and adds the checks that need the domain, | |||
| 75 | + | // which config does not import: ERMITE_OWNER follows the rules of repository | |||
| 76 | + | // names. Its problem joins the others in one report. | |||
| 77 | + | func loadConfig(get func(string) string) (config.Config, error) { | |||
| 78 | + | cfg, err := config.Load(get) | |||
| 79 | + | var problems []string | |||
| 80 | + | var ce *config.Error | |||
| 81 | + | switch { | |||
| 82 | + | case errors.As(err, &ce): | |||
| 83 | + | problems = ce.Problems | |||
| 84 | + | case err != nil: | |||
| 85 | + | return config.Config{}, err | |||
| 86 | + | } | |||
| 87 | + | if owner := strings.TrimSpace(get(config.EnvOwner)); owner != "" { | |||
| 88 | + | if _, perr := domain.ParseRepoName(owner); perr != nil { | |||
| 89 | + | problems = append(problems, config.EnvOwner+": must follow the rules of repository names ("+perr.Error()+")") | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | if len(problems) > 0 { | |||
| 93 | + | return config.Config{}, &config.Error{Problems: problems} | |||
| 94 | + | } | |||
| 95 | + | return cfg, nil | |||
| 96 | + | } | |||
A internal/app/serve_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/config" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestLoadConfigChecksTheOwnerName(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | base := map[string]string{ | |
| 18 | + | config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org", | |
| 19 | + | config.EnvAdminToken: "0123456789abcdef0123456789abcdef", | |
| 20 | + | } | |
| 21 | + | load := func(extra map[string]string) (config.Config, error) { | |
| 22 | + | env := map[string]string{} | |
| 23 | + | for k, v := range base { | |
| 24 | + | env[k] = v | |
| 25 | + | } | |
| 26 | + | for k, v := range extra { | |
| 27 | + | env[k] = v | |
| 28 | + | } | |
| 29 | + | return loadConfig(func(k string) string { return env[k] }) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | cfg, err := load(map[string]string{config.EnvOwner: " bnema "}) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | assert.Equal(t, "bnema", cfg.Owner) | |
| 35 | + | cfg, err = load(nil) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | assert.Equal(t, config.DefaultOwner, cfg.Owner) | |
| 38 | + | ||
| 39 | + | for _, owner := range []string{"Bad Name", ".dot", "a/b", "x.git"} { | |
| 40 | + | _, err := load(map[string]string{config.EnvOwner: owner}) | |
| 41 | + | var ce *config.Error | |
| 42 | + | require.True(t, errors.As(err, &ce), owner) | |
| 43 | + | assert.Contains(t, ce.Problems[0], config.EnvOwner, owner) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // The problem is reported with the others, in one error. | |
| 47 | + | _, err = load(map[string]string{config.EnvOwner: "Bad", config.EnvCILogRetention: "soon"}) | |
| 48 | + | var ce *config.Error | |
| 49 | + | require.True(t, errors.As(err, &ce)) | |
| 50 | + | assert.Len(t, ce.Problems, 2) | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/config" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestLoadConfigChecksTheOwnerName(t *testing.T) { | |||
| 16 | + | t.Parallel() | |||
| 17 | + | base := map[string]string{ | |||
| 18 | + | config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org", | |||
| 19 | + | config.EnvAdminToken: "0123456789abcdef0123456789abcdef", | |||
| 20 | + | } | |||
| 21 | + | load := func(extra map[string]string) (config.Config, error) { | |||
| 22 | + | env := map[string]string{} | |||
| 23 | + | for k, v := range base { | |||
| 24 | + | env[k] = v | |||
| 25 | + | } | |||
| 26 | + | for k, v := range extra { | |||
| 27 | + | env[k] = v | |||
| 28 | + | } | |||
| 29 | + | return loadConfig(func(k string) string { return env[k] }) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | cfg, err := load(map[string]string{config.EnvOwner: " bnema "}) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | assert.Equal(t, "bnema", cfg.Owner) | |||
| 35 | + | cfg, err = load(nil) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | assert.Equal(t, config.DefaultOwner, cfg.Owner) | |||
| 38 | + | ||||
| 39 | + | for _, owner := range []string{"Bad Name", ".dot", "a/b", "x.git"} { | |||
| 40 | + | _, err := load(map[string]string{config.EnvOwner: owner}) | |||
| 41 | + | var ce *config.Error | |||
| 42 | + | require.True(t, errors.As(err, &ce), owner) | |||
| 43 | + | assert.Contains(t, ce.Problems[0], config.EnvOwner, owner) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // The problem is reported with the others, in one error. | |||
| 47 | + | _, err = load(map[string]string{config.EnvOwner: "Bad", config.EnvCILogRetention: "soon"}) | |||
| 48 | + | var ce *config.Error | |||
| 49 | + | require.True(t, errors.As(err, &ce)) | |||
| 50 | + | assert.Len(t, ce.Problems, 2) | |||
| 51 | + | } | |||
M internal/config/config.go +2 -20
| @@ -232,11 +232,11 @@ func Load(get func(string) string) (Config, error) { | |||
| 232 | 232 | c.CommitterEmail = "ermite@" + u.Hostname() | |
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | + | // The name rules of the owner are those of repository names (the domain); | |
| 236 | + | // config imports nothing, so internal/app checks them (see loadConfig). | |
| 235 | 237 | c.Owner = strings.TrimSpace(get(EnvOwner)) | |
| 236 | 238 | if c.Owner == "" { | |
| 237 | 239 | c.Owner = DefaultOwner | |
| 238 | - | } else if err := validateOwner(c.Owner); err != nil { | |
| 239 | - | bad(EnvOwner, "%v", err) | |
| 240 | 240 | } | |
| 241 | 241 | ||
| 242 | 242 | c.CIRetention = DefaultCIRetention | |
| @@ -255,24 +255,6 @@ func Load(get func(string) string) (Config, error) { | |||
| 255 | 255 | return c, nil | |
| 256 | 256 | } | |
| 257 | 257 | ||
| 258 | - | // validateOwner applies the rules of repository names (lowercase letters, | |
| 259 | - | // digits, '.', '_' and '-', at most 100 characters, no leading or trailing | |
| 260 | - | // '.'), which config cannot import from the domain. | |
| 261 | - | func validateOwner(s string) error { | |
| 262 | - | if len(s) > 100 { | |
| 263 | - | return fmt.Errorf("must be at most 100 characters") | |
| 264 | - | } | |
| 265 | - | for _, r := range s { | |
| 266 | - | if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '.' && r != '_' && r != '-' { | |
| 267 | - | return fmt.Errorf("must follow the rules of repository names: a-z, 0-9, '.', '_', '-' (got %q)", s) | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | if strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") || strings.Contains(s, "..") { | |
| 271 | - | return fmt.Errorf("must not start or end with '.' nor contain '..' (got %q)", s) | |
| 272 | - | } | |
| 273 | - | return nil | |
| 274 | - | } | |
| 275 | - | ||
| 276 | 258 | // parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"), | |
| 277 | 259 | // at least one hour. | |
| 278 | 260 | func parseRetention(raw string) (time.Duration, error) { | |
| @@ -232,11 +232,11 @@ func Load(get func(string) string) (Config, error) { | |||||
| 232 | c.CommitterEmail = "ermite@" + u.Hostname() | 232 | c.CommitterEmail = "ermite@" + u.Hostname() | ||
| 233 | } | 233 | } | ||
| 234 | 234 | ||||
| 235 | + | // The name rules of the owner are those of repository names (the domain); | |||
| 236 | + | // config imports nothing, so internal/app checks them (see loadConfig). | |||
| 235 | c.Owner = strings.TrimSpace(get(EnvOwner)) | 237 | c.Owner = strings.TrimSpace(get(EnvOwner)) | ||
| 236 | if c.Owner == "" { | 238 | if c.Owner == "" { | ||
| 237 | c.Owner = DefaultOwner | 239 | c.Owner = DefaultOwner | ||
| 238 | - | } else if err := validateOwner(c.Owner); err != nil { | |||
| 239 | - | bad(EnvOwner, "%v", err) | |||
| 240 | } | 240 | } | ||
| 241 | 241 | ||||
| 242 | c.CIRetention = DefaultCIRetention | 242 | c.CIRetention = DefaultCIRetention | ||
| @@ -255,24 +255,6 @@ func Load(get func(string) string) (Config, error) { | |||||
| 255 | return c, nil | 255 | return c, nil | ||
| 256 | } | 256 | } | ||
| 257 | 257 | ||||
| 258 | - | // validateOwner applies the rules of repository names (lowercase letters, | |||
| 259 | - | // digits, '.', '_' and '-', at most 100 characters, no leading or trailing | |||
| 260 | - | // '.'), which config cannot import from the domain. | |||
| 261 | - | func validateOwner(s string) error { | |||
| 262 | - | if len(s) > 100 { | |||
| 263 | - | return fmt.Errorf("must be at most 100 characters") | |||
| 264 | - | } | |||
| 265 | - | for _, r := range s { | |||
| 266 | - | if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '.' && r != '_' && r != '-' { | |||
| 267 | - | return fmt.Errorf("must follow the rules of repository names: a-z, 0-9, '.', '_', '-' (got %q)", s) | |||
| 268 | - | } | |||
| 269 | - | } | |||
| 270 | - | if strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") || strings.Contains(s, "..") { | |||
| 271 | - | return fmt.Errorf("must not start or end with '.' nor contain '..' (got %q)", s) | |||
| 272 | - | } | |||
| 273 | - | return nil | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | // parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"), | 258 | // parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"), | ||
| 277 | // at least one hour. | 259 | // at least one hour. | ||
| 278 | func parseRetention(raw string) (time.Duration, error) { | 260 | func parseRetention(raw string) (time.Duration, error) { | ||
M internal/config/config_test.go +0 -1
| @@ -246,7 +246,6 @@ func TestOwnerAndCIRetention(t *testing.T) { | |||
| 246 | 246 | } | |
| 247 | 247 | ||
| 248 | 248 | for _, extra := range []map[string]string{ | |
| 249 | - | {config.EnvOwner: "Bad Name"}, {config.EnvOwner: ".dot"}, | |
| 250 | 249 | {config.EnvCILogRetention: "soon"}, {config.EnvCILogRetention: "0d"}, {config.EnvCILogRetention: "10m"}, | |
| 251 | 250 | } { | |
| 252 | 251 | if _, err := load(extra); err == nil { | |
| @@ -246,7 +246,6 @@ func TestOwnerAndCIRetention(t *testing.T) { | |||||
| 246 | } | 246 | } | ||
| 247 | 247 | ||||
| 248 | for _, extra := range []map[string]string{ | 248 | for _, extra := range []map[string]string{ | ||
| 249 | - | {config.EnvOwner: "Bad Name"}, {config.EnvOwner: ".dot"}, | |||
| 250 | {config.EnvCILogRetention: "soon"}, {config.EnvCILogRetention: "0d"}, {config.EnvCILogRetention: "10m"}, | 249 | {config.EnvCILogRetention: "soon"}, {config.EnvCILogRetention: "0d"}, {config.EnvCILogRetention: "10m"}, | ||
| 251 | } { | 250 | } { | ||
| 252 | if _, err := load(extra); err == nil { | 251 | if _, err := load(extra); err == nil { | ||