feat: generate config field helpers
This commit is contained in:
parent
a79f73825f
commit
17b1b99686
6 changed files with 445 additions and 0 deletions
|
|
@ -20,6 +20,7 @@ mcpgen/
|
||||||
metadata.go
|
metadata.go
|
||||||
update.go
|
update.go
|
||||||
secretstore.go
|
secretstore.go
|
||||||
|
config.go # si [[config.fields]] existe
|
||||||
```
|
```
|
||||||
|
|
||||||
Le package généré expose le loader de manifeste :
|
Le package généré expose le loader de manifeste :
|
||||||
|
|
@ -74,6 +75,24 @@ func PreflightSecretStore(options SecretStoreOptions) (secretstore.PreflightRepo
|
||||||
`BitwardenDebug`, `Shell`, `ExecutableResolver`). Si `ServiceName` est vide,
|
`BitwardenDebug`, `Shell`, `ExecutableResolver`). Si `ServiceName` est vide,
|
||||||
le nom du binaire déclaré dans le manifeste est utilisé.
|
le nom du binaire déclaré dans le manifeste est utilisé.
|
||||||
|
|
||||||
|
Si le manifest déclare `[[config.fields]]`, le package généré expose aussi :
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ConfigFlags struct { /* champs internes */ }
|
||||||
|
|
||||||
|
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags
|
||||||
|
func ConfigFlagValues(flags ConfigFlags) map[string]string
|
||||||
|
func ResolveFieldSpecs(profile string) []cli.FieldSpec
|
||||||
|
func SetupFields(existing map[string]string) []cli.SetupField
|
||||||
|
```
|
||||||
|
|
||||||
|
`AddConfigFlags` branche les flags déclarés sur le `FlagSet` du projet.
|
||||||
|
`ConfigFlagValues` retourne uniquement les valeurs de flags non vides.
|
||||||
|
`ResolveFieldSpecs` génère les specs à passer à `cli.ResolveFields`, en
|
||||||
|
remplaçant `{profile}` dans les templates de secrets. `SetupFields` génère les
|
||||||
|
champs attendus par `cli.RunSetup`; le paramètre `existing` permet de fournir
|
||||||
|
les secrets déjà stockés par nom de champ.
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`.
|
- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`.
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,26 @@ known = ["dev", "staging", "prod"]
|
||||||
|
|
||||||
[bootstrap]
|
[bootstrap]
|
||||||
description = "Client MCP interne"
|
description = "Client MCP interne"
|
||||||
|
|
||||||
|
[[config.fields]]
|
||||||
|
name = "base_url"
|
||||||
|
flag = "base-url"
|
||||||
|
env = "MY_MCP_URL"
|
||||||
|
config_key = "base_url"
|
||||||
|
type = "url"
|
||||||
|
label = "Base URL"
|
||||||
|
required = true
|
||||||
|
sources = ["flag", "env", "config"]
|
||||||
|
|
||||||
|
[[config.fields]]
|
||||||
|
name = "api_token"
|
||||||
|
flag = "api-token"
|
||||||
|
env = "MY_MCP_TOKEN"
|
||||||
|
secret_key_template = "profile/{profile}/api-token"
|
||||||
|
type = "secret"
|
||||||
|
label = "API token"
|
||||||
|
required = true
|
||||||
|
sources = ["flag", "env", "secret"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Champs supportés :
|
Champs supportés :
|
||||||
|
|
@ -63,6 +83,21 @@ Champs supportés :
|
||||||
- `[profiles].default` : profil recommandé par défaut.
|
- `[profiles].default` : profil recommandé par défaut.
|
||||||
- `[profiles].known` : profils connus du projet.
|
- `[profiles].known` : profils connus du projet.
|
||||||
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
|
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
|
||||||
|
- `[[config.fields]]` : champs de configuration déclaratifs consommés par
|
||||||
|
`mcp-framework generate`.
|
||||||
|
- `name` : identifiant stable du champ.
|
||||||
|
- `flag` : nom du flag CLI, sans `--`.
|
||||||
|
- `env` : variable d'environnement associée.
|
||||||
|
- `config_key` : clé dans la config fichier du projet.
|
||||||
|
- `secret_key_template` : clé de secret, avec `{profile}` remplacé par le
|
||||||
|
profil courant dans le code généré.
|
||||||
|
- `type` : type de setup (`string`, `url`, `secret`, `bool`, `list`).
|
||||||
|
- `label` : libellé humain utilisé pendant le setup.
|
||||||
|
- `default` : valeur par défaut optionnelle.
|
||||||
|
- `required` : si `true`, la résolution échoue quand aucune source ne fournit
|
||||||
|
de valeur.
|
||||||
|
- `sources` : ordre de résolution spécifique au champ (`flag`, `env`,
|
||||||
|
`config`, `secret`).
|
||||||
|
|
||||||
Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles.
|
Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,10 @@ func Generate(options Options) (Result, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
config, err := renderConfig(normalized.PackageName, manifestFile.Config.Fields)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
files := []generatedFile{
|
files := []generatedFile{
|
||||||
{
|
{
|
||||||
|
|
@ -85,6 +89,13 @@ func Generate(options Options) (Result, error) {
|
||||||
Mode: 0o644,
|
Mode: 0o644,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(config) != "" {
|
||||||
|
files = append(files, generatedFile{
|
||||||
|
Path: filepath.Join(normalized.PackageDir, "config.go"),
|
||||||
|
Content: config,
|
||||||
|
Mode: 0o644,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
written := make([]string, 0, len(files))
|
written := make([]string, 0, len(files))
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|
@ -411,6 +422,164 @@ func secretStoreServiceName(options SecretStoreOptions) string {
|
||||||
return formatGenerated("secretstore", source)
|
return formatGenerated("secretstore", source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderConfig(packageName string, fields []manifest.ConfigField) (string, error) {
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var flagsBuilder strings.Builder
|
||||||
|
var specsBuilder strings.Builder
|
||||||
|
var setupBuilder strings.Builder
|
||||||
|
for _, field := range fields {
|
||||||
|
name := strings.TrimSpace(field.Name)
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("generate config field: name must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
flagName := strings.TrimSpace(field.Flag)
|
||||||
|
if flagName != "" {
|
||||||
|
fmt.Fprintf(
|
||||||
|
&flagsBuilder,
|
||||||
|
"\tflags.values[%s] = fs.String(%s, \"\", %s)\n",
|
||||||
|
strconv.Quote(name),
|
||||||
|
strconv.Quote(flagName),
|
||||||
|
strconv.Quote(configFieldLabel(field)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(
|
||||||
|
&specsBuilder,
|
||||||
|
"\t\t{Name: %s, Required: %t, DefaultValue: %s, Sources: []fwcli.ValueSource{%s}, FlagKey: %s, EnvKey: %s, ConfigKey: %s, SecretKey: replaceProfile(%s, profile)},\n",
|
||||||
|
strconv.Quote(name),
|
||||||
|
field.Required,
|
||||||
|
strconv.Quote(field.Default),
|
||||||
|
configSourceList(field.Sources),
|
||||||
|
strconv.Quote(flagName),
|
||||||
|
strconv.Quote(field.Env),
|
||||||
|
strconv.Quote(field.ConfigKey),
|
||||||
|
strconv.Quote(field.SecretKeyTemplate),
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Fprintf(
|
||||||
|
&setupBuilder,
|
||||||
|
"\t\t{Name: %s, Label: %s, Type: %s, Required: %t, Default: %s, ExistingSecret: existing[%s]},\n",
|
||||||
|
strconv.Quote(name),
|
||||||
|
strconv.Quote(configFieldLabel(field)),
|
||||||
|
configSetupFieldType(field.Type),
|
||||||
|
field.Required,
|
||||||
|
strconv.Quote(field.Default),
|
||||||
|
strconv.Quote(name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||||
|
|
||||||
|
package %s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigFlags struct {
|
||||||
|
values map[string]*string
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {
|
||||||
|
if fs == nil {
|
||||||
|
fs = flag.CommandLine
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := ConfigFlags{
|
||||||
|
values: make(map[string]*string),
|
||||||
|
}
|
||||||
|
%s
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigFlagValues(flags ConfigFlags) map[string]string {
|
||||||
|
values := make(map[string]string)
|
||||||
|
for name, value := range flags.values {
|
||||||
|
if value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed := strings.TrimSpace(*value); trimmed != "" {
|
||||||
|
values[name] = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {
|
||||||
|
return []fwcli.FieldSpec{
|
||||||
|
%s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupFields(existing map[string]string) []fwcli.SetupField {
|
||||||
|
if existing == nil {
|
||||||
|
existing = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []fwcli.SetupField{
|
||||||
|
%s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceProfile(value, profile string) string {
|
||||||
|
return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile))
|
||||||
|
}
|
||||||
|
`, packageName, flagsBuilder.String(), specsBuilder.String(), setupBuilder.String())
|
||||||
|
|
||||||
|
return formatGenerated("config", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFieldLabel(field manifest.ConfigField) string {
|
||||||
|
if label := strings.TrimSpace(field.Label); label != "" {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(field.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configSourceList(sources []string) string {
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(sources))
|
||||||
|
for _, source := range sources {
|
||||||
|
switch strings.TrimSpace(source) {
|
||||||
|
case "flag":
|
||||||
|
parts = append(parts, "fwcli.SourceFlag")
|
||||||
|
case "env":
|
||||||
|
parts = append(parts, "fwcli.SourceEnv")
|
||||||
|
case "config":
|
||||||
|
parts = append(parts, "fwcli.SourceConfig")
|
||||||
|
case "secret":
|
||||||
|
parts = append(parts, "fwcli.SourceSecret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configSetupFieldType(fieldType string) string {
|
||||||
|
switch strings.TrimSpace(fieldType) {
|
||||||
|
case "url":
|
||||||
|
return "fwcli.SetupFieldURL"
|
||||||
|
case "secret":
|
||||||
|
return "fwcli.SetupFieldSecret"
|
||||||
|
case "bool":
|
||||||
|
return "fwcli.SetupFieldBool"
|
||||||
|
case "list":
|
||||||
|
return "fwcli.SetupFieldList"
|
||||||
|
default:
|
||||||
|
return "fwcli.SetupFieldString"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func formatGenerated(name, source string) (string, error) {
|
func formatGenerated(name, source string) (string, error) {
|
||||||
formatted, err := format.Source([]byte(source))
|
formatted, err := format.Source([]byte(source))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,62 @@ description = "Demo MCP"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateCreatesConfigHelpersFromManifestFields(t *testing.T) {
|
||||||
|
projectDir := newProject(t, `
|
||||||
|
binary_name = "demo-mcp"
|
||||||
|
|
||||||
|
[[config.fields]]
|
||||||
|
name = "base_url"
|
||||||
|
flag = "base-url"
|
||||||
|
env = "BASE_URL"
|
||||||
|
config_key = "base_url"
|
||||||
|
type = "url"
|
||||||
|
label = "Graylog URL"
|
||||||
|
required = true
|
||||||
|
sources = ["flag", "env", "config"]
|
||||||
|
|
||||||
|
[[config.fields]]
|
||||||
|
name = "api_token"
|
||||||
|
flag = "api-token"
|
||||||
|
env = "API_TOKEN"
|
||||||
|
secret_key_template = "profile/{profile}/api-token"
|
||||||
|
type = "secret"
|
||||||
|
label = "API token"
|
||||||
|
required = true
|
||||||
|
sources = ["flag", "env", "secret"]
|
||||||
|
`)
|
||||||
|
|
||||||
|
result, err := Generate(Options{ProjectDir: projectDir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantFiles := generatedFilesWithConfig("mcpgen")
|
||||||
|
if !slices.Equal(result.Files, wantFiles) {
|
||||||
|
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "config.go"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile config.go: %v", err)
|
||||||
|
}
|
||||||
|
for _, snippet := range []string{
|
||||||
|
"type ConfigFlags struct {",
|
||||||
|
"func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {",
|
||||||
|
"func ConfigFlagValues(flags ConfigFlags) map[string]string {",
|
||||||
|
"func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {",
|
||||||
|
"func SetupFields(existing map[string]string) []fwcli.SetupField {",
|
||||||
|
`fs.String("base-url", "", "Graylog URL")`,
|
||||||
|
`SecretKey: replaceProfile("profile/{profile}/api-token", profile)`,
|
||||||
|
"fwcli.SetupFieldURL",
|
||||||
|
"fwcli.SetupFieldSecret",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(config), snippet) {
|
||||||
|
t.Fatalf("config.go missing snippet %q:\n%s", snippet, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) {
|
func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) {
|
||||||
projectDir := newProject(t, `binary_name = "demo-mcp"`)
|
projectDir := newProject(t, `binary_name = "demo-mcp"`)
|
||||||
|
|
||||||
|
|
@ -227,6 +283,26 @@ backend_policy = "env-only"
|
||||||
|
|
||||||
[bootstrap]
|
[bootstrap]
|
||||||
description = "Embedded Demo"
|
description = "Embedded Demo"
|
||||||
|
|
||||||
|
[[config.fields]]
|
||||||
|
name = "base_url"
|
||||||
|
flag = "base-url"
|
||||||
|
env = "BASE_URL"
|
||||||
|
config_key = "base_url"
|
||||||
|
type = "url"
|
||||||
|
label = "Base URL"
|
||||||
|
required = true
|
||||||
|
sources = ["flag", "env", "config"]
|
||||||
|
|
||||||
|
[[config.fields]]
|
||||||
|
name = "api_token"
|
||||||
|
flag = "api-token"
|
||||||
|
env = "API_TOKEN"
|
||||||
|
secret_key_template = "profile/{profile}/api-token"
|
||||||
|
type = "secret"
|
||||||
|
label = "API token"
|
||||||
|
required = true
|
||||||
|
sources = ["flag", "env", "secret"]
|
||||||
`)
|
`)
|
||||||
writeModule(t, projectDir)
|
writeModule(t, projectDir)
|
||||||
|
|
||||||
|
|
@ -265,6 +341,16 @@ func defaultGeneratedFiles(packageDir string) []string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generatedFilesWithConfig(packageDir string) []string {
|
||||||
|
return []string{
|
||||||
|
filepath.Join(packageDir, "config.go"),
|
||||||
|
filepath.Join(packageDir, "manifest.go"),
|
||||||
|
filepath.Join(packageDir, "metadata.go"),
|
||||||
|
filepath.Join(packageDir, "secretstore.go"),
|
||||||
|
filepath.Join(packageDir, "update.go"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeModule(t *testing.T, projectDir string) {
|
func writeModule(t *testing.T, projectDir string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|
@ -289,9 +375,11 @@ func writeModule(t *testing.T, projectDir string) {
|
||||||
testFile := `package main
|
testFile := `package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||||
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
"example.com/generated-demo/mcpgen"
|
"example.com/generated-demo/mcpgen"
|
||||||
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
|
|
@ -352,6 +440,30 @@ func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {
|
||||||
if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly {
|
if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly {
|
||||||
t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store))
|
t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flags := mcpgen.AddConfigFlags(flag.NewFlagSet("test", flag.ContinueOnError))
|
||||||
|
if len(mcpgen.ConfigFlagValues(flags)) != 0 {
|
||||||
|
t.Fatalf("empty flags should not return values")
|
||||||
|
}
|
||||||
|
|
||||||
|
specs := mcpgen.ResolveFieldSpecs("default")
|
||||||
|
if len(specs) != 2 {
|
||||||
|
t.Fatalf("field specs = %d, want 2", len(specs))
|
||||||
|
}
|
||||||
|
if specs[1].SecretKey != "profile/default/api-token" {
|
||||||
|
t.Fatalf("secret key = %q", specs[1].SecretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFields := mcpgen.SetupFields(map[string]string{"api_token": "stored"})
|
||||||
|
if len(setupFields) != 2 {
|
||||||
|
t.Fatalf("setup fields = %d, want 2", len(setupFields))
|
||||||
|
}
|
||||||
|
if setupFields[0].Type != fwcli.SetupFieldURL {
|
||||||
|
t.Fatalf("first setup field type = %q", setupFields[0].Type)
|
||||||
|
}
|
||||||
|
if setupFields[1].ExistingSecret != "stored" {
|
||||||
|
t.Fatalf("existing secret = %q", setupFields[1].ExistingSecret)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {
|
if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ type File struct {
|
||||||
SecretStore SecretStore `toml:"secret_store"`
|
SecretStore SecretStore `toml:"secret_store"`
|
||||||
Profiles Profiles `toml:"profiles"`
|
Profiles Profiles `toml:"profiles"`
|
||||||
Bootstrap Bootstrap `toml:"bootstrap"`
|
Bootstrap Bootstrap `toml:"bootstrap"`
|
||||||
|
Config Config `toml:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Update struct {
|
type Update struct {
|
||||||
|
|
@ -60,6 +61,23 @@ type Bootstrap struct {
|
||||||
Description string `toml:"description"`
|
Description string `toml:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Fields []ConfigField `toml:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigField struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Flag string `toml:"flag"`
|
||||||
|
Env string `toml:"env"`
|
||||||
|
ConfigKey string `toml:"config_key"`
|
||||||
|
SecretKeyTemplate string `toml:"secret_key_template"`
|
||||||
|
Type string `toml:"type"`
|
||||||
|
Label string `toml:"label"`
|
||||||
|
Default string `toml:"default"`
|
||||||
|
Required bool `toml:"required"`
|
||||||
|
Sources []string `toml:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
type BootstrapMetadata struct {
|
type BootstrapMetadata struct {
|
||||||
BinaryName string
|
BinaryName string
|
||||||
Description string
|
Description string
|
||||||
|
|
@ -180,6 +198,7 @@ func (f *File) normalize() {
|
||||||
f.SecretStore.normalize()
|
f.SecretStore.normalize()
|
||||||
f.Profiles.normalize()
|
f.Profiles.normalize()
|
||||||
f.Bootstrap.normalize()
|
f.Bootstrap.normalize()
|
||||||
|
f.Config.normalize()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Update) normalize() {
|
func (u *Update) normalize() {
|
||||||
|
|
@ -215,6 +234,24 @@ func (b *Bootstrap) normalize() {
|
||||||
b.Description = strings.TrimSpace(b.Description)
|
b.Description = strings.TrimSpace(b.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) normalize() {
|
||||||
|
for i := range c.Fields {
|
||||||
|
c.Fields[i].normalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *ConfigField) normalize() {
|
||||||
|
f.Name = strings.TrimSpace(f.Name)
|
||||||
|
f.Flag = strings.TrimSpace(f.Flag)
|
||||||
|
f.Env = strings.TrimSpace(f.Env)
|
||||||
|
f.ConfigKey = strings.TrimSpace(f.ConfigKey)
|
||||||
|
f.SecretKeyTemplate = strings.TrimSpace(f.SecretKeyTemplate)
|
||||||
|
f.Type = strings.ToLower(strings.TrimSpace(f.Type))
|
||||||
|
f.Label = strings.TrimSpace(f.Label)
|
||||||
|
f.Default = strings.TrimSpace(f.Default)
|
||||||
|
f.Sources = normalizeStringList(f.Sources)
|
||||||
|
}
|
||||||
|
|
||||||
func (u Update) ReleaseSource() update.ReleaseSource {
|
func (u Update) ReleaseSource() update.ReleaseSource {
|
||||||
u.normalize()
|
u.normalize()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,79 @@ description = " Client MCP interne "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadParsesConfigFields(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, DefaultFile)
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
[[config.fields]]
|
||||||
|
name = " base_url "
|
||||||
|
flag = "base-url"
|
||||||
|
env = "BASE_URL"
|
||||||
|
config_key = "base_url"
|
||||||
|
type = " url "
|
||||||
|
label = " Graylog URL "
|
||||||
|
required = true
|
||||||
|
sources = [" flag ", "env", "config"]
|
||||||
|
|
||||||
|
[[config.fields]]
|
||||||
|
name = "api_token"
|
||||||
|
flag = "api-token"
|
||||||
|
env = "API_TOKEN"
|
||||||
|
secret_key_template = "profile/{profile}/api-token"
|
||||||
|
type = "secret"
|
||||||
|
required = true
|
||||||
|
sources = ["flag", "env", "secret"]
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(file.Config.Fields) != 2 {
|
||||||
|
t.Fatalf("config fields = %d, want 2", len(file.Config.Fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := file.Config.Fields[0]
|
||||||
|
if baseURL.Name != "base_url" {
|
||||||
|
t.Fatalf("base URL name = %q", baseURL.Name)
|
||||||
|
}
|
||||||
|
if baseURL.Flag != "base-url" {
|
||||||
|
t.Fatalf("base URL flag = %q", baseURL.Flag)
|
||||||
|
}
|
||||||
|
if baseURL.Env != "BASE_URL" {
|
||||||
|
t.Fatalf("base URL env = %q", baseURL.Env)
|
||||||
|
}
|
||||||
|
if baseURL.ConfigKey != "base_url" {
|
||||||
|
t.Fatalf("base URL config key = %q", baseURL.ConfigKey)
|
||||||
|
}
|
||||||
|
if baseURL.Type != "url" {
|
||||||
|
t.Fatalf("base URL type = %q", baseURL.Type)
|
||||||
|
}
|
||||||
|
if baseURL.Label != "Graylog URL" {
|
||||||
|
t.Fatalf("base URL label = %q", baseURL.Label)
|
||||||
|
}
|
||||||
|
if !baseURL.Required {
|
||||||
|
t.Fatal("base URL should be required")
|
||||||
|
}
|
||||||
|
if !slices.Equal(baseURL.Sources, []string{"flag", "env", "config"}) {
|
||||||
|
t.Fatalf("base URL sources = %v", baseURL.Sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := file.Config.Fields[1]
|
||||||
|
if token.SecretKeyTemplate != "profile/{profile}/api-token" {
|
||||||
|
t.Fatalf("token secret key template = %q", token.SecretKeyTemplate)
|
||||||
|
}
|
||||||
|
if !slices.Equal(token.Sources, []string{"flag", "env", "secret"}) {
|
||||||
|
t.Fatalf("token sources = %v", token.Sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadEmbeddedParsesContent(t *testing.T) {
|
func TestLoadEmbeddedParsesContent(t *testing.T) {
|
||||||
file, source, err := LoadEmbedded(`
|
file, source, err := LoadEmbedded(`
|
||||||
[update]
|
[update]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue