Skip to content
Open
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
59 changes: 55 additions & 4 deletions pkg/cmd/config/configutil/configutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,61 @@ func ReadConfigFile(file string) (api.ConfigFile, error) {
}
}

if err := markPresentUnsupportedSections(trimmed, &cfg); err != nil {
return api.ConfigFile{}, err
}

return cfg, nil
}

// markPresentUnsupportedSections does a key-presence pass over the raw input
// and flips unsupported-section slice fields from nil to an empty (but non-nil)
// slice when the key was declared. This lets the downstream `!= nil` checks in
// ValidateSupportedSections / ValidateConfigFile reject bare (`upstreams:`) and
// null (`upstreams: null`) forms, not just explicit `upstreams: []`.
func markPresentUnsupportedSections(trimmed []byte, cfg *api.ConfigFile) error {
if len(trimmed) == 0 {
return nil
}

keys := map[string]bool{}
switch trimmed[0] {
case '[':
// Top-level JSON/YAML array can't carry section keys.
return nil
case '{':
var raw map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &raw); err != nil {
return fmt.Errorf("failed to parse JSON file: %w", err)
}
for k := range raw {
keys[k] = true
}
default:
var raw map[string]yaml.Node
if err := yaml.Unmarshal(trimmed, &raw); err != nil {
return fmt.Errorf("failed to parse YAML file: %w", err)
}
for k := range raw {
keys[k] = true
}
}

if keys["upstreams"] && cfg.Upstreams == nil {
cfg.Upstreams = []api.Upstream{}
}
if keys["consumer_groups"] && cfg.ConsumerGroups == nil {
cfg.ConsumerGroups = []api.ConsumerGroup{}
}
if keys["plugin_configs"] && cfg.PluginConfigs == nil {
cfg.PluginConfigs = []interface{}{}
}
if keys["service_templates"] && cfg.ServiceTemplates == nil {
cfg.ServiceTemplates = []interface{}{}
}
return nil
}

// FetchRemoteConfig fetches all runtime resources from API7 EE
// for the given gateway group and assembles them into a ConfigFile.
func FetchRemoteConfig(client *api.Client, gatewayGroup string) (*api.ConfigFile, error) {
Expand Down Expand Up @@ -227,10 +279,9 @@ func ComputeDiff(local, remote api.ConfigFile) (*DiffResult, error) {

func ValidateSupportedSections(cfg api.ConfigFile) error {
var unsupported []string
// Reject explicitly-empty unsupported sections (e.g. `upstreams: []`)
// in addition to non-empty ones. Bare `upstreams:` or `upstreams: null`
// unmarshal as nil and slip past this check; that's a separate hardening
// (would require yaml.Node / two-pass parsing) tracked elsewhere.
// Bare (`upstreams:`) and null (`upstreams: null`) forms are normalized to
// non-nil empty slices in ReadConfigFile via markPresentUnsupportedSections,
// so this typed nil-check rejects all three shapes ([], bare, null).
if cfg.Upstreams != nil {
unsupported = append(unsupported, "upstreams")
}
Expand Down
34 changes: 5 additions & 29 deletions pkg/cmd/config/validate/validate.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
package validate

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"strings"

"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/api7/a7/internal/config"
"github.com/api7/a7/pkg/api"
cmd "github.com/api7/a7/pkg/cmd"
"github.com/api7/a7/pkg/cmd/config/configutil"
"github.com/api7/a7/pkg/cmdutil"
"github.com/api7/a7/pkg/iostreams"
)
Expand Down Expand Up @@ -54,23 +51,11 @@ func NewCmdValidate(f *cmd.Factory) *cobra.Command {
}

func validateRun(opts *Options) error {
data, err := readFile(opts.File)
cfg, err := configutil.ReadConfigFile(opts.File)
if err != nil {
return err
}

var cfg api.ConfigFile
trimmed := bytes.TrimSpace(data)
if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
if err := json.Unmarshal(trimmed, &cfg); err != nil {
return fmt.Errorf("failed to parse JSON file: %w", err)
}
} else {
if err := yaml.Unmarshal(trimmed, &cfg); err != nil {
return fmt.Errorf("failed to parse YAML file: %w", err)
}
}

errs := ValidateConfigFile(cfg)
if len(errs) > 0 {
return fmt.Errorf("config validation failed:\n- %s", strings.Join(errs, "\n- "))
Expand All @@ -80,14 +65,6 @@ func validateRun(opts *Options) error {
return nil
}

func readFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
return data, nil
}

func ValidateConfigFile(cfg api.ConfigFile) []string {
var errs []string

Expand All @@ -97,10 +74,9 @@ func ValidateConfigFile(cfg api.ConfigFile) []string {
errs = append(errs, "version must be \"1\"")
}

// Reject explicitly-empty unsupported sections (e.g. `upstreams: []`)
// in addition to non-empty ones. Bare `upstreams:` or `upstreams: null`
// unmarshal as nil and slip past this check; that's a separate hardening
// (would require yaml.Node / two-pass parsing) tracked elsewhere.
// Bare (`upstreams:`) and null (`upstreams: null`) forms are normalized to
// non-nil empty slices in configutil.ReadConfigFile, so this typed nil-check
// rejects all three shapes ([], bare, null).
if cfg.Upstreams != nil {
errs = append(errs, "upstreams are not supported as top-level API7 EE resources; define upstream inline on services instead")
}
Expand Down
65 changes: 58 additions & 7 deletions pkg/cmd/config/validate/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,35 +288,86 @@ func TestConfigValidate_MissingFileFlag(t *testing.T) {
}

// TestConfigValidate_EmptyUnsupportedSections asserts that declaring an
// unsupported top-level section (upstreams, consumer_groups, service_templates)
// is rejected even when the section is explicitly empty. Presence alone is
// enough — the user is asserting an unsupported resource type.
// unsupported top-level section (upstreams, consumer_groups, plugin_configs,
// service_templates) is rejected for every "empty" shape the user might write:
// explicit `[]`, bare (`upstreams:` with no value), and `null`. Presence of
// the key alone is enough — the user is asserting an unsupported resource type.
func TestConfigValidate_EmptyUnsupportedSections(t *testing.T) {
cases := []struct {
name string
body string
wantErr string
}{
{
name: "upstreams",
name: "upstreams_empty_list",
body: "version: \"1\"\nupstreams: []\n",
wantErr: "upstreams are not supported",
},
{
name: "consumer_groups",
name: "upstreams_bare",
body: "version: \"1\"\nupstreams:\n",
wantErr: "upstreams are not supported",
},
{
name: "upstreams_null",
body: "version: \"1\"\nupstreams: null\n",
wantErr: "upstreams are not supported",
},
{
name: "consumer_groups_empty_list",
body: "version: \"1\"\nconsumer_groups: []\n",
wantErr: "consumer_groups are not supported",
},
{
name: "plugin_configs",
name: "consumer_groups_bare",
body: "version: \"1\"\nconsumer_groups:\n",
wantErr: "consumer_groups are not supported",
},
{
name: "consumer_groups_null",
body: "version: \"1\"\nconsumer_groups: null\n",
wantErr: "consumer_groups are not supported",
},
{
name: "plugin_configs_empty_list",
body: "version: \"1\"\nplugin_configs: []\n",
wantErr: "plugin_configs are not supported",
},
{
name: "service_templates",
name: "plugin_configs_bare",
body: "version: \"1\"\nplugin_configs:\n",
wantErr: "plugin_configs are not supported",
},
{
name: "plugin_configs_null",
body: "version: \"1\"\nplugin_configs: null\n",
wantErr: "plugin_configs are not supported",
},
{
name: "service_templates_empty_list",
body: "version: \"1\"\nservice_templates: []\n",
wantErr: "service_templates are not supported",
},
{
name: "service_templates_bare",
body: "version: \"1\"\nservice_templates:\n",
wantErr: "service_templates are not supported",
},
{
name: "service_templates_null",
body: "version: \"1\"\nservice_templates: null\n",
wantErr: "service_templates are not supported",
},
{
name: "json_upstreams_null",
body: `{"version": "1", "upstreams": null}`,
wantErr: "upstreams are not supported",
},
{
name: "json_consumer_groups_null",
body: `{"version": "1", "consumer_groups": null}`,
wantErr: "consumer_groups are not supported",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
Loading