Skip to content

Commit 7fb3f77

Browse files
add support for reporting multiple k8s envs with one reporter command (#688)
1 parent 9329254 commit 7fb3f77

10 files changed

Lines changed: 311 additions & 24 deletions

cmd/kosli/snapshotK8S.go

Lines changed: 158 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import (
66
"net/http"
77
"os"
88
"path/filepath"
9+
"regexp"
10+
"strings"
911

1012
"github.com/kosli-dev/cli/internal/filters"
1113
"github.com/kosli-dev/cli/internal/kube"
1214
"github.com/kosli-dev/cli/internal/requests"
1315
homedir "github.com/mitchellh/go-homedir"
1416
"github.com/spf13/cobra"
17+
"gopkg.in/yaml.v3"
1518
)
1619

1720
const snapshotK8SShortDesc = `Report a snapshot of running pods in a K8S cluster or namespace(s) to Kosli. `
@@ -52,11 +55,99 @@ kosli snapshot k8s yourEnvironmentName \
5255
--org yourOrgName
5356
`
5457

58+
const k8sConfigFileFlag = "[optional] The path to a YAML config file that maps multiple Kosli environments to namespace selectors. Cannot be used with a positional environment name argument or namespace flags."
59+
60+
type k8sSnapshotConfig struct {
61+
Environments []k8sEnvironmentConfig `yaml:"environments"`
62+
}
63+
64+
type k8sEnvironmentConfig struct {
65+
Name string `yaml:"name"`
66+
Namespaces []string `yaml:"namespaces"`
67+
NamespacesRegex []string `yaml:"namespacesRegex"`
68+
ExcludeNamespaces []string `yaml:"excludeNamespaces"`
69+
ExcludeNamespacesRegex []string `yaml:"excludeNamespacesRegex"`
70+
}
71+
72+
func (e *k8sEnvironmentConfig) toFilter() *filters.ResourceFilterOptions {
73+
return &filters.ResourceFilterOptions{
74+
IncludeNames: e.Namespaces,
75+
IncludeNamesRegex: e.NamespacesRegex,
76+
ExcludeNames: e.ExcludeNamespaces,
77+
ExcludeNamesRegex: e.ExcludeNamespacesRegex,
78+
}
79+
}
80+
81+
func parseK8SSnapshotConfig(path string) (*k8sSnapshotConfig, error) {
82+
data, err := os.ReadFile(path)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to read config file '%s': %w", path, err)
85+
}
86+
87+
var config k8sSnapshotConfig
88+
if err := yaml.Unmarshal(data, &config); err != nil {
89+
return nil, fmt.Errorf("failed to parse config file: %w", err)
90+
}
91+
92+
if err := validateK8SSnapshotConfig(&config); err != nil {
93+
return nil, err
94+
}
95+
96+
return &config, nil
97+
}
98+
99+
func validateK8SSnapshotConfig(config *k8sSnapshotConfig) error {
100+
if len(config.Environments) == 0 {
101+
return fmt.Errorf("invalid config: 'environments' list must contain at least one entry")
102+
}
103+
104+
seen := make(map[string]bool)
105+
for i, env := range config.Environments {
106+
if env.Name == "" {
107+
return fmt.Errorf("invalid config: environment entry %d is missing required field 'name'", i+1)
108+
}
109+
110+
if seen[env.Name] {
111+
return fmt.Errorf("invalid config: duplicate environment name '%s'", env.Name)
112+
}
113+
seen[env.Name] = true
114+
115+
hasInclude := len(env.Namespaces) > 0 || len(env.NamespacesRegex) > 0
116+
hasExclude := len(env.ExcludeNamespaces) > 0 || len(env.ExcludeNamespacesRegex) > 0
117+
if hasInclude && hasExclude {
118+
includeType := "namespaces"
119+
if len(env.Namespaces) == 0 {
120+
includeType = "namespacesRegex"
121+
}
122+
excludeType := "excludeNamespaces"
123+
if len(env.ExcludeNamespaces) == 0 {
124+
excludeType = "excludeNamespacesRegex"
125+
}
126+
return fmt.Errorf("invalid config for environment '%s': cannot combine '%s' with '%s'",
127+
env.Name, includeType, excludeType)
128+
}
129+
130+
for _, pattern := range env.NamespacesRegex {
131+
if _, err := regexp.Compile(pattern); err != nil {
132+
return fmt.Errorf("invalid config for environment '%s': invalid regex '%s': %v",
133+
env.Name, pattern, err)
134+
}
135+
}
136+
for _, pattern := range env.ExcludeNamespacesRegex {
137+
if _, err := regexp.Compile(pattern); err != nil {
138+
return fmt.Errorf("invalid config for environment '%s': invalid regex '%s': %v",
139+
env.Name, pattern, err)
140+
}
141+
}
142+
}
143+
144+
return nil
145+
}
146+
55147
type snapshotK8SOptions struct {
56-
kubeconfig string
57-
// namespaces []string
58-
// excludeNamespaces []string
59-
filter *filters.ResourceFilterOptions
148+
kubeconfig string
149+
configFilePath string
150+
filter *filters.ResourceFilterOptions
60151
}
61152

62153
func newSnapshotK8SCmd(out io.Writer) *cobra.Command {
@@ -68,20 +159,56 @@ func newSnapshotK8SCmd(out io.Writer) *cobra.Command {
68159
Short: snapshotK8SShortDesc,
69160
Long: snapshotK8SLongDesc,
70161
Example: snapshotK8SExample,
71-
Args: cobra.ExactArgs(1),
162+
Args: func(cmd *cobra.Command, args []string) error {
163+
configFileFlag := cmd.Flags().Lookup("config-file")
164+
hasConfigFile := configFileFlag != nil && configFileFlag.Changed
165+
if hasConfigFile && len(args) > 0 {
166+
return fmt.Errorf("cannot use '--config-file' together with a positional environment name argument")
167+
}
168+
if !hasConfigFile && len(args) == 0 {
169+
return fmt.Errorf("requires either a positional environment name argument or --config-file")
170+
}
171+
if !hasConfigFile && len(args) > 1 {
172+
return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args))
173+
}
174+
return nil
175+
},
72176
PreRunE: func(cmd *cobra.Command, args []string) error {
73177
err := RequireGlobalFlags(global, []string{"Org", "ApiToken"})
74178
if err != nil {
75179
return ErrorBeforePrintingUsage(cmd, err.Error())
76180
}
181+
182+
configFileFlag := cmd.Flags().Lookup("config-file")
183+
hasConfigFile := configFileFlag != nil && configFileFlag.Changed
184+
if hasConfigFile {
185+
namespaceFlagNames := []string{"namespaces", "exclude-namespaces", "namespaces-regex", "exclude-namespaces-regex"}
186+
for _, flagName := range namespaceFlagNames {
187+
if f := cmd.Flags().Lookup(flagName); f != nil && f.Changed {
188+
return fmt.Errorf("cannot use '--config-file' together with '--%s'", flagName)
189+
}
190+
}
191+
return nil
192+
}
193+
77194
return MuXRequiredFlags(cmd, []string{"namespaces", "exclude-namespaces"}, false)
78195
},
79196
RunE: func(cmd *cobra.Command, args []string) error {
80-
return o.run(args)
197+
clientset, err := kube.NewK8sClientSet(o.kubeconfig)
198+
if err != nil {
199+
return err
200+
}
201+
if o.configFilePath != "" {
202+
return o.runMultiEnv(clientset)
203+
}
204+
return o.run(clientset, args)
81205
},
82206
}
83207

84208
cmd.Flags().StringVarP(&o.kubeconfig, "kubeconfig", "k", defaultKubeConfigPath(), kubeconfigFlag)
209+
// Shadows the global --config-file persistent flag intentionally.
210+
// The global Kosli config can still be set via KOSLI_CONFIG_FILE env var.
211+
cmd.Flags().StringVar(&o.configFilePath, "config-file", "", k8sConfigFileFlag)
85212
cmd.Flags().StringSliceVarP(&o.filter.IncludeNames, "namespaces", "n", []string{}, namespacesFlag)
86213
cmd.Flags().StringSliceVar(&o.filter.IncludeNamesRegex, "namespaces-regex", []string{}, namespacesRegexFlag)
87214
cmd.Flags().StringSliceVarP(&o.filter.ExcludeNames, "exclude-namespaces", "x", []string{}, excludeNamespacesFlag)
@@ -90,32 +217,45 @@ func newSnapshotK8SCmd(out io.Writer) *cobra.Command {
90217
return cmd
91218
}
92219

93-
func (o *snapshotK8SOptions) run(args []string) error {
94-
envName := args[0]
95-
url := fmt.Sprintf("%s/api/v2/environments/%s/%s/report/K8S", global.Host, global.Org, envName)
96-
clientset, err := kube.NewK8sClientSet(o.kubeconfig)
220+
func (o *snapshotK8SOptions) run(clientset *kube.K8SConnection, args []string) error {
221+
return o.reportEnvironment(clientset, args[0], o.filter)
222+
}
223+
224+
func (o *snapshotK8SOptions) runMultiEnv(clientset *kube.K8SConnection) error {
225+
config, err := parseK8SSnapshotConfig(o.configFilePath)
97226
if err != nil {
98227
return err
99228
}
100-
podsData, err := clientset.GetPodsData(o.filter, logger)
101-
if err != nil {
102-
return err
229+
230+
var errs []string
231+
for _, env := range config.Environments {
232+
if err := o.reportEnvironment(clientset, env.Name, env.toFilter()); err != nil {
233+
errs = append(errs, fmt.Sprintf("environment '%s': %v", env.Name, err))
234+
}
103235
}
104236

105-
payload := &kube.K8sEnvRequest{
106-
Artifacts: podsData,
237+
if len(errs) > 0 {
238+
return fmt.Errorf("%s", strings.Join(errs, "\n"))
239+
}
240+
return nil
241+
}
242+
243+
func (o *snapshotK8SOptions) reportEnvironment(clientset *kube.K8SConnection, envName string, filter *filters.ResourceFilterOptions) error {
244+
podsData, err := clientset.GetPodsData(filter, logger)
245+
if err != nil {
246+
return err
107247
}
108248

109249
reqParams := &requests.RequestParams{
110250
Method: http.MethodPut,
111-
URL: url,
112-
Payload: payload,
251+
URL: fmt.Sprintf("%s/api/v2/environments/%s/%s/report/K8S", global.Host, global.Org, envName),
252+
Payload: &kube.K8sEnvRequest{Artifacts: podsData},
113253
DryRun: global.DryRun,
114254
Token: global.ApiToken,
115255
}
116256
_, err = kosliClient.Do(reqParams)
117257
if err == nil && !global.DryRun {
118-
logger.Info("[%d] pods were reported to environment %s", len(payload.Artifacts), envName)
258+
logger.Info("[%d] pods were reported to environment %s", len(podsData), envName)
119259
}
120260
return err
121261
}

cmd/kosli/snapshotK8S_test.go

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"fmt"
55
"testing"
66

7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
79
"github.com/stretchr/testify/suite"
810
)
911

@@ -38,21 +40,138 @@ func (suite *SnapshotK8STestSuite) TestSnapshotK8SCmd() {
3840
},
3941
{
4042
wantError: true,
41-
name: "snapshot K8S fails if 2 args are provided",
42-
cmd: fmt.Sprintf(`snapshot k8s %s xxx %s`, suite.envName, suite.defaultKosliArguments),
43-
golden: "Error: accepts 1 arg(s), received 2\n",
43+
name: "snapshot K8S fails if no args and no --config-file",
44+
cmd: fmt.Sprintf(`snapshot k8s %s`, suite.defaultKosliArguments),
45+
golden: "Error: requires either a positional environment name argument or --config-file\n",
4446
},
4547
{
4648
wantError: true,
47-
name: "snapshot K8S fails if no args are set",
48-
cmd: fmt.Sprintf(`snapshot k8s %s`, suite.defaultKosliArguments),
49-
golden: "Error: accepts 1 arg(s), received 0\n",
49+
name: "snapshot K8S fails if --config-file and positional arg are both provided",
50+
cmd: fmt.Sprintf(`snapshot k8s %s --config-file testdata/k8s-config/valid-single.yaml %s`, suite.envName, suite.defaultKosliArguments),
51+
golden: "Error: cannot use '--config-file' together with a positional environment name argument\n",
52+
},
53+
{
54+
wantError: true,
55+
name: "snapshot K8S fails if --config-file and --namespaces are both provided",
56+
cmd: fmt.Sprintf(`snapshot k8s --config-file testdata/k8s-config/valid-single.yaml --namespaces default %s`, suite.defaultKosliArguments),
57+
golden: "Error: cannot use '--config-file' together with '--namespaces'\n",
58+
},
59+
{
60+
wantError: true,
61+
name: "snapshot K8S fails if --config-file and --exclude-namespaces are both provided",
62+
cmd: fmt.Sprintf(`snapshot k8s --config-file testdata/k8s-config/valid-single.yaml --exclude-namespaces default %s`, suite.defaultKosliArguments),
63+
golden: "Error: cannot use '--config-file' together with '--exclude-namespaces'\n",
64+
},
65+
{
66+
wantError: true,
67+
name: "snapshot K8S fails if config file not found",
68+
cmd: fmt.Sprintf(`snapshot k8s --config-file /nonexistent/path.yaml %s`, suite.defaultKosliArguments),
69+
golden: "Error: failed to read config file '/nonexistent/path.yaml': open /nonexistent/path.yaml: no such file or directory\n",
70+
},
71+
{
72+
wantError: true,
73+
name: "snapshot K8S fails if config file has invalid YAML",
74+
cmd: fmt.Sprintf(`snapshot k8s --config-file testdata/k8s-config/invalid-yaml.yaml %s`, suite.defaultKosliArguments),
75+
goldenRegex: "Error: failed to parse config file.*",
76+
},
77+
{
78+
wantError: true,
79+
name: "snapshot K8S fails if config file has empty environments list",
80+
cmd: fmt.Sprintf(`snapshot k8s --config-file testdata/k8s-config/empty-environments.yaml %s`, suite.defaultKosliArguments),
81+
golden: "Error: invalid config: 'environments' list must contain at least one entry\n",
82+
},
83+
{
84+
wantError: true,
85+
name: "snapshot K8S fails if config file has entry missing name",
86+
cmd: fmt.Sprintf(`snapshot k8s --config-file testdata/k8s-config/missing-name.yaml %s`, suite.defaultKosliArguments),
87+
golden: "Error: invalid config: environment entry 1 is missing required field 'name'\n",
88+
},
89+
{
90+
wantError: true,
91+
name: "snapshot K8S fails if config file has duplicate environment names",
92+
cmd: fmt.Sprintf(`snapshot k8s --config-file testdata/k8s-config/duplicate-names.yaml %s`, suite.defaultKosliArguments),
93+
golden: "Error: invalid config: duplicate environment name 'prod-env'\n",
94+
},
95+
{
96+
wantError: true,
97+
name: "snapshot K8S fails if config file has conflicting filters",
98+
cmd: fmt.Sprintf(`snapshot k8s --config-file testdata/k8s-config/conflicting-filters.yaml %s`, suite.defaultKosliArguments),
99+
golden: "Error: invalid config for environment 'bad-env': cannot combine 'namespaces' with 'excludeNamespaces'\n",
100+
},
101+
{
102+
wantError: true,
103+
name: "snapshot K8S fails if config file has invalid regex",
104+
cmd: fmt.Sprintf(`snapshot k8s --config-file testdata/k8s-config/invalid-regex.yaml %s`, suite.defaultKosliArguments),
105+
goldenRegex: `Error: invalid config for environment 'bad-regex-env': invalid regex '\[invalid'.*`,
50106
},
51107
}
52108

53109
runTestCmd(suite.T(), tests)
54110
}
55111

112+
func TestParseK8SSnapshotConfig(t *testing.T) {
113+
t.Run("valid single environment config", func(t *testing.T) {
114+
config, err := parseK8SSnapshotConfig("testdata/k8s-config/valid-single.yaml")
115+
require.NoError(t, err)
116+
require.Len(t, config.Environments, 1)
117+
assert.Equal(t, "prod-env", config.Environments[0].Name)
118+
assert.Equal(t, []string{"prod-ns1", "prod-ns2"}, config.Environments[0].Namespaces)
119+
})
120+
121+
t.Run("valid multi-environment config", func(t *testing.T) {
122+
config, err := parseK8SSnapshotConfig("testdata/k8s-config/valid-multi.yaml")
123+
require.NoError(t, err)
124+
require.Len(t, config.Environments, 3)
125+
assert.Equal(t, "prod-env", config.Environments[0].Name)
126+
assert.Equal(t, "staging-env", config.Environments[1].Name)
127+
assert.Equal(t, "infra-env", config.Environments[2].Name)
128+
assert.Equal(t, []string{"^staging-.*"}, config.Environments[1].NamespacesRegex)
129+
assert.Equal(t, []string{"prod-ns1", "prod-ns2", "default"}, config.Environments[2].ExcludeNamespaces)
130+
})
131+
132+
t.Run("file not found", func(t *testing.T) {
133+
_, err := parseK8SSnapshotConfig("/nonexistent/path.yaml")
134+
require.Error(t, err)
135+
assert.Contains(t, err.Error(), "failed to read config file")
136+
})
137+
138+
t.Run("invalid YAML", func(t *testing.T) {
139+
_, err := parseK8SSnapshotConfig("testdata/k8s-config/invalid-yaml.yaml")
140+
require.Error(t, err)
141+
assert.Contains(t, err.Error(), "failed to parse config file")
142+
})
143+
144+
t.Run("empty environments list", func(t *testing.T) {
145+
_, err := parseK8SSnapshotConfig("testdata/k8s-config/empty-environments.yaml")
146+
require.Error(t, err)
147+
assert.Contains(t, err.Error(), "'environments' list must contain at least one entry")
148+
})
149+
150+
t.Run("missing environment name", func(t *testing.T) {
151+
_, err := parseK8SSnapshotConfig("testdata/k8s-config/missing-name.yaml")
152+
require.Error(t, err)
153+
assert.Contains(t, err.Error(), "environment entry 1 is missing required field 'name'")
154+
})
155+
156+
t.Run("duplicate environment names", func(t *testing.T) {
157+
_, err := parseK8SSnapshotConfig("testdata/k8s-config/duplicate-names.yaml")
158+
require.Error(t, err)
159+
assert.Contains(t, err.Error(), "duplicate environment name 'prod-env'")
160+
})
161+
162+
t.Run("conflicting filters", func(t *testing.T) {
163+
_, err := parseK8SSnapshotConfig("testdata/k8s-config/conflicting-filters.yaml")
164+
require.Error(t, err)
165+
assert.Contains(t, err.Error(), "cannot combine 'namespaces' with 'excludeNamespaces'")
166+
})
167+
168+
t.Run("invalid regex", func(t *testing.T) {
169+
_, err := parseK8SSnapshotConfig("testdata/k8s-config/invalid-regex.yaml")
170+
require.Error(t, err)
171+
assert.Contains(t, err.Error(), "invalid regex")
172+
})
173+
}
174+
56175
// In order for 'go test' to run this suite, we need to create
57176
// a normal test function and pass our suite to suite.Run
58177
func TestSnapshotK8STestSuite(t *testing.T) {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
environments:
2+
- name: bad-env
3+
namespaces: [prod-ns]
4+
excludeNamespaces: [staging-ns]

0 commit comments

Comments
 (0)