diff --git a/cmd/skaffold/app/cmd/inspect.go b/cmd/skaffold/app/cmd/inspect.go index 35e253c675c..fa3767f8abd 100644 --- a/cmd/skaffold/app/cmd/inspect.go +++ b/cmd/skaffold/app/cmd/inspect.go @@ -43,7 +43,7 @@ func NewCmdInspect() *cobra.Command { WithDescription("Helper commands for Cloud Code IDEs to interact with and modify skaffold configuration files."). WithPersistentFlagAdder(cmdInspectFlags). Hidden(). - WithCommands(cmdModules(), cmdProfiles(), cmdBuildEnv(), cmdTests()) + WithCommands(cmdModules(), cmdProfiles(), cmdBuildEnv(), cmdTests(), cmdNamespaces()) } func cmdInspectFlags(f *pflag.FlagSet) { diff --git a/cmd/skaffold/app/cmd/inspect_namespaces.go b/cmd/skaffold/app/cmd/inspect_namespaces.go new file mode 100644 index 00000000000..b4352385e1d --- /dev/null +++ b/cmd/skaffold/app/cmd/inspect_namespaces.go @@ -0,0 +1,71 @@ +/* +Copyright 2021 The Skaffold Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "context" + "errors" + "io" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/inspect" + namespaces "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/inspect/namespaces" +) + +func cmdNamespaces() *cobra.Command { + return NewCmd("namespaces"). + WithDescription("View skaffold test information"). + WithCommands(cmdNamespacesList()) +} + +func cmdNamespacesList() *cobra.Command { + return NewCmd("list"). + WithExample("Get list of namespaces", "inspect namespaces list --format json"). + WithExample("Get list of namespaces targeting a specific configuration", "inspect namespaces list --profile local --format json"). + WithDescription("Print the list of namespaces that would be run for a given configuration (default skaffold configuration, specific module, specific profile, etc)."). + WithFlagAdder(cmdNamespacesListFlags). + WithArgs(func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("`inspect namespaces list` requires exactly one manifest file path argument") + } + return nil + }, listNamespaces) +} + +// NOTE: +// - currently kubecontext namespaces are not handled as they were not expected for the +// initial use cases involving this command +// - also this code currently does not account for the possibility of the -n flag passed +// additionally to a skaffold command (eg: skaffold apply -n foo) +func listNamespaces(ctx context.Context, out io.Writer, args []string) error { + return namespaces.PrintNamespacesList(ctx, out, args[0], inspect.Options{ + Filename: inspectFlags.filename, + RepoCacheDir: inspectFlags.repoCacheDir, + OutFormat: inspectFlags.outFormat, + Modules: inspectFlags.modules, + Profiles: inspectFlags.profiles, + PropagateProfiles: inspectFlags.propagateProfiles, + }) +} + +func cmdNamespacesListFlags(f *pflag.FlagSet) { + f.StringSliceVarP(&inspectFlags.profiles, "profile", "p", nil, `Profile names to activate`) + f.BoolVar(&inspectFlags.propagateProfiles, "propagate-profiles", true, `Setting '--propagate-profiles=false' disables propagating profiles set by the '--profile' flag across config dependencies. This mean that only profiles defined directly in the target 'skaffold.yaml' file are activated.`) + f.StringSliceVarP(&inspectFlags.modules, "module", "m", nil, "Names of modules to filter target action by.") +} diff --git a/pkg/skaffold/inspect/namespaces/list.go b/pkg/skaffold/inspect/namespaces/list.go new file mode 100644 index 00000000000..bcd6bff2fb4 --- /dev/null +++ b/pkg/skaffold/inspect/namespaces/list.go @@ -0,0 +1,152 @@ +/* +Copyright 2021 The Skaffold Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package inspect + +import ( + "context" + "io" + "io/ioutil" + "log" + "strings" + + appsv1 "k8s.io/api/apps/v1" + "k8s.io/kubectl/pkg/scheme" + + "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/config" + "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/inspect" + "github.com/GoogleContainerTools/skaffold/v2/pkg/webhook/constants" +) + +type resourceToInfoContainer struct { + ResourceToInfoMap map[string][]resourceInfo `json:"resourceToInfoMap"` +} + +type resourceInfo struct { + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +func PrintNamespacesList(ctx context.Context, out io.Writer, manifestFile string, opts inspect.Options) error { + // do some additional processing here + b, err := ioutil.ReadFile(manifestFile) + if err != nil { + log.Fatalf("error reading file: %v", err) + // TODO(aaron-prindle) better error message + return err + } + + // Create a runtime.Decoder from the Codecs field within + // k8s.io/client-go that's pre-loaded with the schemas for all + // the standard Kubernetes resource types. + decoder := scheme.Codecs.UniversalDeserializer() + + resourceToInfoMap := map[string][]resourceInfo{} + for _, resourceYAML := range strings.Split(string(b), "---") { + // skip empty documents, `Decode` will fail on them + if len(resourceYAML) == 0 { + continue + } + // - obj is the API object (e.g., Deployment) + // - groupVersionKind is a generic object that allows + // detecting the API type we are dealing with, for + // accurate type casting later. + obj, groupVersionKind, err := decoder.Decode( + []byte(resourceYAML), + nil, + nil) + if err != nil { + log.Print(err) + continue + } + // Only process Deployments for now + if groupVersionKind.Group == "apps" && groupVersionKind.Version == "v1" && groupVersionKind.Kind == "Deployment" { + deployment := obj.(*appsv1.Deployment) + + if _, ok := resourceToInfoMap[groupVersionKind.String()]; !ok { + resourceToInfoMap[groupVersionKind.String()] = []resourceInfo{} + } + resourceToInfoMap[groupVersionKind.String()] = append(resourceToInfoMap[groupVersionKind.String()], resourceInfo{ + Name: deployment.ObjectMeta.Name, + Namespace: deployment.ObjectMeta.Namespace, + }) + } + } + + formatter := inspect.OutputFormatter(out, opts.OutFormat) + cfgs, err := inspect.GetConfigSet(ctx, config.SkaffoldOptions{ + ConfigurationFile: opts.Filename, + ConfigurationFilter: opts.Modules, + RepoCacheDir: opts.RepoCacheDir, + Profiles: opts.Profiles, + PropagateProfiles: opts.PropagateProfiles, + }) + if err != nil { + formatter.WriteErr(err) + return err + } + + defaultNamespace := constants.Namespace + flagNamespace := "" + for _, c := range cfgs { + if c.Deploy.KubectlDeploy != nil { + if c.Deploy.KubectlDeploy.DefaultNamespace != nil && *c.Deploy.KubectlDeploy.DefaultNamespace != "" { + defaultNamespace = *c.Deploy.KubectlDeploy.DefaultNamespace + } + if namespaceVal := parseNamespaceFromFlags(c.Deploy.KubectlDeploy.Flags.Global); namespaceVal != "" { + flagNamespace = namespaceVal + } + if namespaceVal := parseNamespaceFromFlags(c.Deploy.KubectlDeploy.Flags.Apply); namespaceVal != "" { + flagNamespace = namespaceVal + } + // NOTE: Cloud Deploy uses `skaffold apply` which always uses kubectl deployer. As such other + // namespace config should be ignored - eg: .Deploy.LegacyHelmDeploy.Releases[i].Namespace + } + } + + for gvk, ris := range resourceToInfoMap { + for i := range ris { + if ris[i].Namespace == "" { + if flagNamespace != "" { + resourceToInfoMap[gvk][i].Namespace = flagNamespace + continue + } + resourceToInfoMap[gvk][i].Namespace = defaultNamespace + } + } + } + l := &resourceToInfoContainer{ResourceToInfoMap: resourceToInfoMap} + + return formatter.Write(l) +} + +func parseNamespaceFromFlags(flgs []string) string { + for i, s := range flgs { + if s == "-n" && i < len(flgs)-1 { + return flgs[i+1] + } + if strings.HasPrefix(s, "-n=") && len(strings.Split(s, "=")) == 2 { + return strings.Split(s, "=")[1] + } + if s == "--namespace" && i < len(flgs)-1 { + return flgs[i+1] + } + if strings.HasPrefix(s, "--namespace=") && len(strings.Split(s, "=")) == 2 { + return strings.Split(s, "=")[1] + } + } + return "" +} diff --git a/pkg/skaffold/inspect/namespaces/list_test.go b/pkg/skaffold/inspect/namespaces/list_test.go new file mode 100644 index 00000000000..fc64ee43bb1 --- /dev/null +++ b/pkg/skaffold/inspect/namespaces/list_test.go @@ -0,0 +1,219 @@ +/* +Copyright 2021 The Skaffold Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package inspect + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/config" + "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/inspect" + "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/parser" + sErrors "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/schema/errors" + "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/schema/latest" + "github.com/GoogleContainerTools/skaffold/v2/pkg/skaffold/util/stringslice" + "github.com/GoogleContainerTools/skaffold/v2/testutil" +) + +var manifest = `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: leeroy-app + name: leeroy-app +spec: + replicas: 1 + selector: + matchLabels: + app: leeroy-app + template: + metadata: + labels: + app: leeroy-app + spec: + containers: + - image: leeroy-app:1d38c165eada98acbbf9f8869b92bf32f4f9c4e80bdea23d20c7020db3ace2da + name: leeroy-app + ports: + - containerPort: 50051 + name: http +` + +var manifestWithNamespace = `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: leeroy-app + name: leeroy-app + namespace: manifest-namespace +spec: + replicas: 1 + selector: + matchLabels: + app: leeroy-app + template: + metadata: + labels: + app: leeroy-app + spec: + containers: + - image: leeroy-app:1d38c165eada98acbbf9f8869b92bf32f4f9c4e80bdea23d20c7020db3ace2da + name: leeroy-app + ports: + - containerPort: 50051 + name: http +` + +func TestPrintTestsList(t *testing.T) { + tests := []struct { + description string + manifest string + profiles []string + module []string + err error + expected string + }{ + { + description: "print all deployment namespaces where no namespace is set in manifest(s) or deploy config", + manifest: manifest, + expected: `{"resourceToInfoMap":{"apps/v1, Kind=Deployment":[{"name":"leeroy-app","namespace":"default"}]}}` + "\n", + // expected: `` + "\n", + module: []string{"cfg-without-default-namespace"}, + }, + { + description: "print all deployment namespaces where a namespace is set via the kubectl flag deploy config", + manifest: manifest, + expected: `{"resourceToInfoMap":{"apps/v1, Kind=Deployment":[{"name":"leeroy-app","namespace":"foo-flag-ns"}]}}` + "\n", + profiles: []string{"foo-flag-ns"}, + module: []string{"cfg-without-default-namespace"}, + }, + { + description: "print all deployment namespaces where a default namespace is set via the kubectl defaultNamespace deploy config", + manifest: manifest, + expected: `{"resourceToInfoMap":{"apps/v1, Kind=Deployment":[{"name":"leeroy-app","namespace":"bar"}]}}` + "\n", + module: []string{"cfg-with-default-namespace"}, + }, + { + description: "print all deployment namespaces where a default namespace and namespace is set via the kubectl deploy config", + manifest: manifest, + expected: `{"resourceToInfoMap":{"apps/v1, Kind=Deployment":[{"name":"leeroy-app","namespace":"baz-flag-ns"}]}}` + "\n", + profiles: []string{"baz-flag-ns"}, + module: []string{"cfg-with-default-namespace"}, + }, + { + description: "print all deployment namespaces where the manifest has a namespace set but it is also set via the kubectl flag deploy config", + manifest: manifestWithNamespace, + expected: `{"resourceToInfoMap":{"apps/v1, Kind=Deployment":[{"name":"leeroy-app","namespace":"manifest-namespace"}]}}` + "\n", + profiles: []string{"baz-flag-ns"}, + module: []string{"cfg-with-default-namespace"}, + }, + { + description: "actionable error", + manifest: manifest, + err: sErrors.MainConfigFileNotFoundErr("path/to/skaffold.yaml", fmt.Errorf("failed to read file : %q", "skaffold.yaml")), + expected: `{"errorCode":"CONFIG_FILE_NOT_FOUND_ERR","errorMessage":"unable to find configuration file \"path/to/skaffold.yaml\": failed to read file : \"skaffold.yaml\". Check that the specified configuration file exists at \"path/to/skaffold.yaml\"."}` + "\n", + }, + { + description: "generic error", + manifest: manifest, + err: errors.New("some error occurred"), + expected: `{"errorCode":"INSPECT_UNKNOWN_ERR","errorMessage":"some error occurred"}` + "\n", + }, + } + + for _, test := range tests { + testutil.Run(t, test.description, func(t *testutil.T) { + manifestPath := t.TempFile("", []byte(test.manifest)) + barStr := "bar" + + configSet := parser.SkaffoldConfigSet{ + &parser.SkaffoldConfigEntry{SkaffoldConfig: &latest.SkaffoldConfig{ + Metadata: latest.Metadata{Name: "cfg-without-default-namespace"}, + Pipeline: latest.Pipeline{Deploy: latest.DeployConfig{ + DeployType: latest.DeployType{ + KubectlDeploy: &latest.KubectlDeploy{ + Flags: latest.KubectlFlags{ + Global: []string{}, + }, + }, + }, + }}, + Profiles: []latest.Profile{ + {Name: "foo-flag-ns", + Pipeline: latest.Pipeline{Deploy: latest.DeployConfig{ + DeployType: latest.DeployType{ + KubectlDeploy: &latest.KubectlDeploy{ + Flags: latest.KubectlFlags{ + Global: []string{"-n", "foo-flag-ns"}, + }, + }, + }, + }}}}, + }, SourceFile: "path/to/cfg-without-default-namespace"}, + + &parser.SkaffoldConfigEntry{SkaffoldConfig: &latest.SkaffoldConfig{ + Metadata: latest.Metadata{Name: "cfg-with-default-namespace"}, + Pipeline: latest.Pipeline{Deploy: latest.DeployConfig{ + DeployType: latest.DeployType{ + KubectlDeploy: &latest.KubectlDeploy{ + DefaultNamespace: &barStr, + }, + }, + }}, + Profiles: []latest.Profile{ + {Name: "baz-flag-ns", + Pipeline: latest.Pipeline{Deploy: latest.DeployConfig{ + DeployType: latest.DeployType{ + KubectlDeploy: &latest.KubectlDeploy{ + Flags: latest.KubectlFlags{ + Apply: []string{"-n", "baz-flag-ns"}, + }, + }, + }, + }}}}, + }, SourceFile: "path/to/cfg-with-default-namespace"}, + } + t.Override(&inspect.GetConfigSet, func(_ context.Context, opts config.SkaffoldOptions) (parser.SkaffoldConfigSet, error) { + // mock profile activation + var set parser.SkaffoldConfigSet + for _, c := range configSet { + if len(opts.ConfigurationFilter) > 0 && !stringslice.Contains(opts.ConfigurationFilter, c.Metadata.Name) { + continue + } + for _, pName := range opts.Profiles { + for _, profile := range c.Profiles { + if profile.Name != pName { + continue + } + c.Deploy.KubectlDeploy = profile.Deploy.KubectlDeploy + } + } + set = append(set, c) + } + return set, test.err + }) + var buf bytes.Buffer + err := PrintNamespacesList(context.Background(), &buf, manifestPath, inspect.Options{ + OutFormat: "json", Modules: test.module, Profiles: test.profiles}) + t.CheckError(test.err != nil, err) + t.CheckDeepEqual(test.expected, buf.String()) + }) + } +}