@@ -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
1720const 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+
55147type 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
62153func 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}
0 commit comments