diff --git a/cmd/skaffold/app/cmd/cmd.go b/cmd/skaffold/app/cmd/cmd.go index dad46b695ac..eeef1ed51de 100644 --- a/cmd/skaffold/app/cmd/cmd.go +++ b/cmd/skaffold/app/cmd/cmd.go @@ -84,6 +84,7 @@ func NewSkaffoldCommand(out, err io.Writer) *cobra.Command { rootCmd.AddCommand(NewCmdVersion(out)) rootCmd.AddCommand(NewCmdRun(out)) rootCmd.AddCommand(NewCmdDev(out)) + rootCmd.AddCommand(NewCmdDebug(out)) rootCmd.AddCommand(NewCmdBuild(out)) rootCmd.AddCommand(NewCmdDeploy(out)) rootCmd.AddCommand(NewCmdDelete(out)) @@ -158,6 +159,14 @@ func AddRunDevFlags(cmd *cobra.Command) { cmd.Flags().StringVarP(&opts.CacheFile, "cache-file", "", "", "Specify the location of the cache file (default $HOME/.skaffold/cache)") } +func AddDevDebugFlags(cmd *cobra.Command) { + cmd.Flags().BoolVar(&opts.TailDev, "tail", true, "Stream logs from deployed objects") + cmd.Flags().BoolVar(&opts.Cleanup, "cleanup", true, "Delete deployments after dev mode is interrupted") + cmd.Flags().BoolVar(&opts.PortForward, "port-forward", true, "Port-forward exposed container ports within pods") + cmd.Flags().StringArrayVarP(&opts.CustomLabels, "label", "l", nil, "Add custom labels to deployed objects. Set multiple times for multiple labels") + cmd.Flags().BoolVar(&opts.ExperimentalGUI, "experimental-gui", false, "Experimental Graphical User Interface") +} + func SetUpLogs(out io.Writer, level string) error { logrus.SetOutput(out) lvl, err := logrus.ParseLevel(v) diff --git a/cmd/skaffold/app/cmd/debug.go b/cmd/skaffold/app/cmd/debug.go new file mode 100644 index 00000000000..181a87b9e81 --- /dev/null +++ b/cmd/skaffold/app/cmd/debug.go @@ -0,0 +1,53 @@ +/* +Copyright 2019 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 ( + "io" + + debugging "github.com/GoogleContainerTools/skaffold/pkg/skaffold/debug" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/deploy" + "github.com/spf13/cobra" +) + +// NewCmdDebug describes the CLI command to run a pipeline in debug mode. +func NewCmdDebug(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "debug", + Short: "Runs a pipeline file in debug mode", + Long: "Similar to `dev`, but configures the pipeline for debugging.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return debug(out) + }, + } + AddRunDevFlags(cmd) + AddDevDebugFlags(cmd) + return cmd +} + +func debug(out io.Writer) error { + // HACK: disable watcher to prevent redeploying changed containers during debugging + // TODO: enable file-sync but avoid redeploys of artifacts being debugged + if len(opts.TargetImages) == 0 { + opts.TargetImages = []string{"none"} + } + + deploy.AddManifestTransform(debugging.ApplyDebuggingTransforms) + + return dev(out, opts.ExperimentalGUI) +} diff --git a/cmd/skaffold/app/cmd/dev.go b/cmd/skaffold/app/cmd/dev.go index 7f3d8f5a888..30c892dc6e4 100644 --- a/cmd/skaffold/app/cmd/dev.go +++ b/cmd/skaffold/app/cmd/dev.go @@ -44,15 +44,10 @@ func NewCmdDev(out io.Writer) *cobra.Command { }, } AddRunDevFlags(cmd) - cmd.Flags().BoolVar(&opts.TailDev, "tail", true, "Stream logs from deployed objects") + AddDevDebugFlags(cmd) cmd.Flags().StringVar(&opts.Trigger, "trigger", "polling", "How are changes detected? (polling, manual or notify)") - cmd.Flags().BoolVar(&opts.Cleanup, "cleanup", true, "Delete deployments after dev mode is interrupted") cmd.Flags().StringArrayVarP(&opts.TargetImages, "watch-image", "w", nil, "Choose which artifacts to watch. Artifacts with image names that contain the expression will be watched only. Default is to watch sources for all artifacts") cmd.Flags().IntVarP(&opts.WatchPollInterval, "watch-poll-interval", "i", 1000, "Interval (in ms) between two checks for file changes") - cmd.Flags().BoolVar(&opts.PortForward, "port-forward", true, "Port-forward exposed container ports within pods") - cmd.Flags().StringArrayVarP(&opts.CustomLabels, "label", "l", nil, "Add custom labels to deployed objects. Set multiple times for multiple labels") - cmd.Flags().BoolVar(&opts.ExperimentalGUI, "experimental-gui", false, "Experimental Graphical User Interface") - return cmd } diff --git a/docs/content/en/docs/how-tos/_index.md b/docs/content/en/docs/how-tos/_index.md index ee6d75c8841..99289d33614 100755 --- a/docs/content/en/docs/how-tos/_index.md +++ b/docs/content/en/docs/how-tos/_index.md @@ -14,3 +14,4 @@ weight: 30 | [Port forwarding](/docs/how-tos/portforward) | Port forwarding from pods | | [Profiles](/docs/how-tos/profiles) | Define configurations for different contexts | | [Templated fields](/docs/how-tos/templating) | Adjust configuration with environment variables | +| [Debugging (alpha)](/docs/how-tos/debug) | Enabling debugging of apps as deployed to a Kubernetes cluster | diff --git a/docs/content/en/docs/how-tos/debug/_index.md b/docs/content/en/docs/how-tos/debug/_index.md new file mode 100644 index 00000000000..1c44ef8ca63 --- /dev/null +++ b/docs/content/en/docs/how-tos/debug/_index.md @@ -0,0 +1,48 @@ +--- +title: "Debugging with Skaffold" +linkTitle: "Debugging" +weight: 100 +--- + +This page describes `skaffold debug`, a zero-configuration solution for +setting up containers for debugging on a Kubernetes cluster. + +{{< alert title="Note" >}} +This functionality is in an alpha state and may change without warning. +{{< /alert >}} + +## Debugging with Skaffold + +`skaffold debug` acts like `skaffold dev`, but it configures containers in pods + for debugging as required for each container's runtime technology. +The associated debugging ports are exposed and labelled and port-forwarded to the +local machine. Helper metadata is also added to allow IDEs to detect the debugging +configuration parameters. + +## How it works + +`skaffold debug` examines the built artifacts to determine the underlying runtime technology +(currently supported: Java and NodeJS). Any Kubernetes manifest that references these +artifacts are transformed to enable the runtime technology's debugging functions: + + - a JDWP agent is configured for Java applications, + - the Chrome DevTools inspector is configured for NodeJS applications. + +`skaffold debug` uses a set of heuristics to identify the runtime technology. +The Kubernetes manifests are transformed on-the-fly such that the on-disk +representations are untouched. + +## Limitations + +`skaffold debug` has some limitations: + + - Only the `kubectl` deployer is supported at the moment: the Helm and Kustomize + deployers are not yet available. + - Only JVM and NodeJS applications are supported: + - JVM applications are configured using the `JAVA_TOOL_OPTIONS` environment variable + which causes extra debugging output on launch. + - NodeJS applications must be launched using `node` or `nodemon` + - File watching is disabled for all artifacts, regardless of whether + the artifact could be configured for debugging. + + Support for additional language runtimes will be forthcoming. \ No newline at end of file diff --git a/docs/content/en/docs/references/cli/_index.md b/docs/content/en/docs/references/cli/_index.md index cf123ce01ba..47f600b4f57 100644 --- a/docs/content/en/docs/references/cli/_index.md +++ b/docs/content/en/docs/references/cli/_index.md @@ -214,6 +214,57 @@ Env vars: * `SKAFFOLD_GLOBAL` (same as `--global`) * `SKAFFOLD_KUBE_CONTEXT` (same as `--kube-context`) +### skaffold debug + +Runs a pipeline file in debug mode + +``` +Usage: + skaffold debug + +Flags: + --cache-artifacts Set to true to enable caching of artifacts. + --cache-file string Specify the location of the cache file (default $HOME/.skaffold/cache) + --cleanup Delete deployments after dev mode is interrupted (default true) + -d, --default-repo string Default repository value (overrides global config) + --enable-rpc skaffold dev Enable gRPC for exposing Skaffold events (true by default for skaffold dev) + --experimental-gui Experimental Graphical User Interface + -f, --filename string Filename or URL to the pipeline file (default "skaffold.yaml") + -l, --label stringArray Add custom labels to deployed objects. Set multiple times for multiple labels + -n, --namespace string Run deployments in the specified namespace + --port-forward Port-forward exposed container ports within pods (default true) + -p, --profile stringArray Activate profiles by name + --rpc-http-port int tcp port to expose event REST API over HTTP (default 50052) + --rpc-port int tcp port to expose event API (default 50051) + --skip-tests Whether to skip the tests after building + --tail Stream logs from deployed objects (default true) + --toot Emit a terminal beep after the deploy is complete + +Global Flags: + --color int Specify the default output color in ANSI escape codes (default 34) + -v, --verbosity string Log level (debug, info, warn, error, fatal, panic) (default "warning") + + +``` +Env vars: + +* `SKAFFOLD_CACHE_ARTIFACTS` (same as `--cache-artifacts`) +* `SKAFFOLD_CACHE_FILE` (same as `--cache-file`) +* `SKAFFOLD_CLEANUP` (same as `--cleanup`) +* `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`) +* `SKAFFOLD_ENABLE_RPC` (same as `--enable-rpc`) +* `SKAFFOLD_EXPERIMENTAL_GUI` (same as `--experimental-gui`) +* `SKAFFOLD_FILENAME` (same as `--filename`) +* `SKAFFOLD_LABEL` (same as `--label`) +* `SKAFFOLD_NAMESPACE` (same as `--namespace`) +* `SKAFFOLD_PORT_FORWARD` (same as `--port-forward`) +* `SKAFFOLD_PROFILE` (same as `--profile`) +* `SKAFFOLD_RPC_HTTP_PORT` (same as `--rpc-http-port`) +* `SKAFFOLD_RPC_PORT` (same as `--rpc-port`) +* `SKAFFOLD_SKIP_TESTS` (same as `--skip-tests`) +* `SKAFFOLD_TAIL` (same as `--tail`) +* `SKAFFOLD_TOOT` (same as `--toot`) + ### skaffold delete Delete the deployed resources diff --git a/pkg/skaffold/debug/debug.go b/pkg/skaffold/debug/debug.go new file mode 100644 index 00000000000..332c3d895bc --- /dev/null +++ b/pkg/skaffold/debug/debug.go @@ -0,0 +1,132 @@ +/* +Copyright 2019 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 debug + +import ( + "bufio" + "bytes" + "context" + "strings" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "k8s.io/apimachinery/pkg/runtime" + serializer "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/client-go/kubernetes/scheme" + + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/deploy/kubectl" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/docker" +) + +var ( + decodeFromYaml = scheme.Codecs.UniversalDeserializer().Decode + encodeAsYaml = func(o runtime.Object) ([]byte, error) { + s := serializer.NewYAMLSerializer(serializer.DefaultMetaFactory, scheme.Scheme, scheme.Scheme) + var b bytes.Buffer + w := bufio.NewWriter(&b) + if err := s.Encode(o, w); err != nil { + return nil, err + } + w.Flush() + return b.Bytes(), nil + } +) + +// ApplyDebuggingTransforms applies language-platform-specific transforms to a list of manifests. +func ApplyDebuggingTransforms(l kubectl.ManifestList, builds []build.Artifact) (kubectl.ManifestList, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + retriever := func(image string) (imageConfiguration, error) { + if artifact := findArtifact(image, builds); artifact != nil { + return retrieveImageConfiguration(ctx, artifact) + } + return imageConfiguration{}, errors.Errorf("no build artifact for [%q]", image) + } + return applyDebuggingTransforms(l, retriever) +} + +func applyDebuggingTransforms(l kubectl.ManifestList, retriever configurationRetriever) (kubectl.ManifestList, error) { + var updated kubectl.ManifestList + for _, manifest := range l { + obj, _, err := decodeFromYaml(manifest, nil, nil) + if err != nil { + return nil, errors.Wrap(err, "reading kubernetes YAML") + } + + if transformManifest(obj, retriever) { + manifest, err = encodeAsYaml(obj) + if err != nil { + return nil, errors.Wrap(err, "marshalling yaml") + } + if logrus.IsLevelEnabled(logrus.DebugLevel) { + logrus.Debugln("Applied debugging transform:\n", string(manifest)) + } + } + updated = append(updated, manifest) + } + + return updated, nil +} + +// findArtifact finds the corresponding artifact for the given image +func findArtifact(image string, builds []build.Artifact) *build.Artifact { + for _, artifact := range builds { + if image == artifact.ImageName || image == artifact.Tag { + logrus.Debugf("Found artifact for image [%s]", image) + return &artifact + } + } + return nil +} + +// retrieveImageConfiguration retrieves the image container configuration for +// the given build artifact +func retrieveImageConfiguration(ctx context.Context, artifact *build.Artifact) (imageConfiguration, error) { + apiClient, err := docker.NewAPIClient() + if err != nil { + return imageConfiguration{}, errors.Wrap(err, "could not connect to local docker daemon") + } + + // the apiClient will go to the remote registry if local docker daemon is not available + manifest, err := apiClient.ConfigFile(ctx, artifact.Tag) + if err != nil { + logrus.Debugf("Error retrieving image manifest for %v: %v", artifact.Tag, err) + return imageConfiguration{}, errors.Wrapf(err, "retrieving image config for %q", artifact.Tag) + } + + config := manifest.Config + logrus.Debugf("Retrieved local image configuration for %v: %v", artifact.Tag, config) + return imageConfiguration{ + env: envAsMap(config.Env), + entrypoint: config.Entrypoint, + arguments: config.Cmd, + labels: config.Labels, + }, nil +} + +// envAsMap turns an array of environment "NAME=value" strings into a map +func envAsMap(env []string) map[string]string { + result := make(map[string]string) + for _, pair := range env { + s := strings.SplitN(pair, "=", 2) + result[s[0]] = s[1] + } + return result +} diff --git a/pkg/skaffold/debug/debug_test.go b/pkg/skaffold/debug/debug_test.go new file mode 100644 index 00000000000..393e2955940 --- /dev/null +++ b/pkg/skaffold/debug/debug_test.go @@ -0,0 +1,491 @@ +/* +Copyright 2019 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 debug + +import ( + "testing" + + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/deploy/kubectl" + "github.com/GoogleContainerTools/skaffold/testutil" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestFindArtifact(t *testing.T) { + buildArtifacts := []build.Artifact{ + {ImageName: "image1", Tag: "tag1"}, + } + tests := []struct { + description string + source string + returnNil bool + }{ + { + description: "found", + source: "image1", + returnNil: false, + }, + { + description: "not found", + source: "image2", + returnNil: true, + }, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + result := findArtifact(test.source, buildArtifacts) + testutil.CheckDeepEqual(t, test.returnNil, result == nil) + }) + } +} + +func TestEnvAsMap(t *testing.T) { + tests := []struct { + description string + source []string + result map[string]string + }{ + {"nil", nil, map[string]string{}}, + {"empty", []string{}, map[string]string{}}, + {"single", []string{"a=b"}, map[string]string{"a": "b"}}, + {"multiple", []string{"a=b", "c=d"}, map[string]string{"c": "d", "a": "b"}}, + {"embedded equals", []string{"a=b=c", "c=d"}, map[string]string{"c": "d", "a": "b=c"}}, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + result := envAsMap(test.source) + testutil.CheckDeepEqual(t, test.result, result) + }) + } +} + +func TestPodEncodeDecode(t *testing.T) { + pod := &v1.Pod{ + TypeMeta: metav1.TypeMeta{APIVersion: v1.SchemeGroupVersion.Version, Kind: "Pod"}, + ObjectMeta: metav1.ObjectMeta{Name: "podname"}, + Spec: v1.PodSpec{Containers: []v1.Container{{Name: "name1", Image: "image1"}}}} + b, err := encodeAsYaml(pod) + if err != nil { + t.Errorf("encodeAsYaml() failed: %v", err) + return + } + o, _, err := decodeFromYaml(b, nil, nil) + if err != nil { + t.Errorf("decodeFromYaml() failed: %v", err) + return + } + switch o := o.(type) { + case *v1.Pod: + testutil.CheckDeepEqual(t, "podname", o.ObjectMeta.Name) + testutil.CheckDeepEqual(t, 1, len(o.Spec.Containers)) + testutil.CheckDeepEqual(t, "name1", o.Spec.Containers[0].Name) + testutil.CheckDeepEqual(t, "image1", o.Spec.Containers[0].Image) + default: + t.Errorf("decodeFromYaml() failed: expected *v1.Pod but got %T", o) + } +} + +// testTransformer is a simple transformer that applies to everything +type testTransformer struct{} + +func (t testTransformer) IsApplicable(config imageConfiguration) bool { + return true +} + +func (t testTransformer) Apply(container *v1.Container, config imageConfiguration, portAlloc portAllocator) map[string]interface{} { + port := portAlloc(9999) + container.Ports = append(container.Ports, v1.ContainerPort{Name: "test", ContainerPort: port}) + + testEnv := v1.EnvVar{Name: "KEY", Value: "value"} + container.Env = append(container.Env, testEnv) + + return map[string]interface{}{"key": "value"} +} + +func TestApplyDebuggingTransforms(t *testing.T) { + defer func(c []containerTransformer) { containerTransforms = c }(containerTransforms) + containerTransforms = append(containerTransforms, testTransformer{}) + + tests := []struct { + description string + shouldError bool + in string + out string + }{ + { + "Pod", false, + `apiVersion: v1 +kind: Pod +metadata: + name: pod +spec: + containers: + - image: gcr.io/k8s-debug/debug-example:latest + name: example +`, + `apiVersion: v1 +kind: Pod +metadata: + annotations: + debug.cloud.google.com/config: '{"example":{"key":"value"}}' + creationTimestamp: null + name: pod +spec: + containers: + - env: + - name: KEY + value: value + image: gcr.io/k8s-debug/debug-example:latest + name: example + ports: + - containerPort: 9999 + name: test + resources: {} +status: {}`, + }, + { + "Deployment", false, + `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + replicas: 10 + selector: + matchLabels: + app: debug-app + template: + metadata: + labels: + app: debug-app + name: debug-pod + spec: + containers: + - image: gcr.io/k8s-debug/debug-example:latest + name: example +`, + `apiVersion: apps/v1 +kind: Deployment +metadata: + creationTimestamp: null + name: my-app +spec: + replicas: 1 + selector: + matchLabels: + app: debug-app + strategy: {} + template: + metadata: + annotations: + debug.cloud.google.com/config: '{"example":{"key":"value"}}' + creationTimestamp: null + labels: + app: debug-app + name: debug-pod + spec: + containers: + - env: + - name: KEY + value: value + image: gcr.io/k8s-debug/debug-example:latest + name: example + ports: + - containerPort: 9999 + name: test + resources: {} +status: {}`, + }, + { + "ReplicaSet", false, + `apiVersion: apps/v1 +kind: ReplicaSet +metadata: + name: my-replicaset +spec: + replicas: 10 + selector: + matchLabels: + app: debug-app + template: + metadata: + labels: + app: debug-app + name: debug-pod + spec: + containers: + - image: gcr.io/k8s-debug/debug-example:latest + name: example +`, + `apiVersion: apps/v1 +kind: ReplicaSet +metadata: + creationTimestamp: null + name: my-replicaset +spec: + replicas: 1 + selector: + matchLabels: + app: debug-app + template: + metadata: + annotations: + debug.cloud.google.com/config: '{"example":{"key":"value"}}' + creationTimestamp: null + labels: + app: debug-app + name: debug-pod + spec: + containers: + - env: + - name: KEY + value: value + image: gcr.io/k8s-debug/debug-example:latest + name: example + ports: + - containerPort: 9999 + name: test + resources: {} +status: + replicas: 0`, + }, + { + "StatefulSet", false, + `apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: my-statefulset +spec: + replicas: 10 + selector: + matchLabels: + app: debug-app + serviceName: service + template: + metadata: + labels: + app: debug-app + name: debug-pod + spec: + containers: + - image: gcr.io/k8s-debug/debug-example:latest + name: example +`, + `apiVersion: apps/v1 +kind: StatefulSet +metadata: + creationTimestamp: null + name: my-statefulset +spec: + replicas: 1 + selector: + matchLabels: + app: debug-app + serviceName: service + template: + metadata: + annotations: + debug.cloud.google.com/config: '{"example":{"key":"value"}}' + creationTimestamp: null + labels: + app: debug-app + name: debug-pod + spec: + containers: + - env: + - name: KEY + value: value + image: gcr.io/k8s-debug/debug-example:latest + name: example + ports: + - containerPort: 9999 + name: test + resources: {} + updateStrategy: {} +status: + replicas: 0`, + }, + { + "DaemonSet", false, + `apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: my-daemonset +spec: + selector: + matchLabels: + app: debug-app + template: + metadata: + labels: + app: debug-app + name: debug-pod + spec: + containers: + - image: gcr.io/k8s-debug/debug-example:latest + name: example +`, + `apiVersion: apps/v1 +kind: DaemonSet +metadata: + creationTimestamp: null + name: my-daemonset +spec: + selector: + matchLabels: + app: debug-app + template: + metadata: + annotations: + debug.cloud.google.com/config: '{"example":{"key":"value"}}' + creationTimestamp: null + labels: + app: debug-app + name: debug-pod + spec: + containers: + - env: + - name: KEY + value: value + image: gcr.io/k8s-debug/debug-example:latest + name: example + ports: + - containerPort: 9999 + name: test + resources: {} + updateStrategy: {} +status: + currentNumberScheduled: 0 + desiredNumberScheduled: 0 + numberMisscheduled: 0 + numberReady: 0`, + }, + { + "Job", false, + `apiVersion: batch/v1 +kind: Job +metadata: + name: my-job +spec: + selector: + matchLabels: + app: debug-app + template: + metadata: + labels: + app: debug-app + name: debug-pod + spec: + containers: + - image: gcr.io/k8s-debug/debug-example:latest + name: example +`, + `apiVersion: batch/v1 +kind: Job +metadata: + creationTimestamp: null + name: my-job +spec: + selector: + matchLabels: + app: debug-app + template: + metadata: + annotations: + debug.cloud.google.com/config: '{"example":{"key":"value"}}' + creationTimestamp: null + labels: + app: debug-app + name: debug-pod + spec: + containers: + - env: + - name: KEY + value: value + image: gcr.io/k8s-debug/debug-example:latest + name: example + ports: + - containerPort: 9999 + name: test + resources: {} +status: {}`, + }, + { + "ReplicationController", false, + `apiVersion: v1 +kind: ReplicationController +metadata: + name: my-rc +spec: + replicas: 10 + selector: + app: debug-app + template: + metadata: + name: debug-pod + labels: + app: debug-app + spec: + containers: + - image: gcr.io/k8s-debug/debug-example:latest + name: example +`, + `apiVersion: v1 +kind: ReplicationController +metadata: + creationTimestamp: null + name: my-rc +spec: + replicas: 1 + selector: + app: debug-app + template: + metadata: + annotations: + debug.cloud.google.com/config: '{"example":{"key":"value"}}' + creationTimestamp: null + labels: + app: debug-app + name: debug-pod + spec: + containers: + - env: + - name: KEY + value: value + image: gcr.io/k8s-debug/debug-example:latest + name: example + ports: + - containerPort: 9999 + name: test + resources: {} +status: + replicas: 0`, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + retriever := func(image string) (imageConfiguration, error) { + return imageConfiguration{}, nil + } + result, error := applyDebuggingTransforms(kubectl.ManifestList{[]byte(test.in)}, retriever) + testutil.CheckErrorAndDeepEqual(t, test.shouldError, error, test.out, result.String()) + }) + } +} diff --git a/pkg/skaffold/debug/transform.go b/pkg/skaffold/debug/transform.go new file mode 100644 index 00000000000..2e9881b0aa4 --- /dev/null +++ b/pkg/skaffold/debug/transform.go @@ -0,0 +1,202 @@ +/* +Copyright 2019 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 debug + +import ( + "encoding/json" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// portAllocator is a function that takes a desired port and returns an available port +// Ports are normally uint16 but Kubernetes ContainerPort.containerPort is an integer +type portAllocator func(int32) int32 + +// configurationRetriever retrieves an container image configuration +type configurationRetriever func(string) (imageConfiguration, error) + +// imageConfiguration captures information from a docker/oci image configuration +type imageConfiguration struct { + labels map[string]string + env map[string]string + entrypoint []string + arguments []string +} + +// containerTransformer transforms a container definition +type containerTransformer interface { + // IsApplicable determines if this container is suitable to be transformed. + IsApplicable(config imageConfiguration) bool + + // Apply configures a container definition for debugging, returning a simple map describing the debug configuration details or `nil` if it could not be done + Apply(container *v1.Container, config imageConfiguration, portAlloc portAllocator) map[string]interface{} +} + +var containerTransforms []containerTransformer + +// transformManifest attempts to configure a manifest for debugging. +// Returns true if changed, false otherwise. +func transformManifest(obj runtime.Object, retrieveImageConfiguration configurationRetriever) bool { + one := int32(1) + switch o := obj.(type) { + case *v1.Pod: + return transformPodSpec(&o.ObjectMeta, &o.Spec, retrieveImageConfiguration) + case *v1.PodList: + changed := false + for i := range o.Items { + if transformPodSpec(&o.Items[i].ObjectMeta, &o.Items[i].Spec, retrieveImageConfiguration) { + changed = true + } + } + return changed + case *v1.ReplicationController: + if o.Spec.Replicas != nil { + o.Spec.Replicas = &one + } + return transformPodSpec(&o.Spec.Template.ObjectMeta, &o.Spec.Template.Spec, retrieveImageConfiguration) + case *appsv1.Deployment: + if o.Spec.Replicas != nil { + o.Spec.Replicas = &one + } + return transformPodSpec(&o.Spec.Template.ObjectMeta, &o.Spec.Template.Spec, retrieveImageConfiguration) + case *appsv1.DaemonSet: + return transformPodSpec(&o.Spec.Template.ObjectMeta, &o.Spec.Template.Spec, retrieveImageConfiguration) + case *appsv1.ReplicaSet: + if o.Spec.Replicas != nil { + o.Spec.Replicas = &one + } + return transformPodSpec(&o.Spec.Template.ObjectMeta, &o.Spec.Template.Spec, retrieveImageConfiguration) + case *appsv1.StatefulSet: + if o.Spec.Replicas != nil { + o.Spec.Replicas = &one + } + return transformPodSpec(&o.Spec.Template.ObjectMeta, &o.Spec.Template.Spec, retrieveImageConfiguration) + case *batchv1.Job: + return transformPodSpec(&o.Spec.Template.ObjectMeta, &o.Spec.Template.Spec, retrieveImageConfiguration) + + default: + logrus.Debugf("skipping unknown object: %T (%v)\n", obj.GetObjectKind(), obj) + return false + } +} + +// transformPodSpec attempts to configure a podspec for debugging. +// Returns true if changed, false otherwise. +func transformPodSpec(metadata *metav1.ObjectMeta, podSpec *v1.PodSpec, retrieveImageConfiguration configurationRetriever) bool { + portAlloc := func(desiredPort int32) int32 { + return allocatePort(podSpec, desiredPort) + } + // containers are required to have unique name within a pod + configurations := make(map[string]map[string]interface{}) + for i := range podSpec.Containers { + container := &podSpec.Containers[i] + // we only reconfigure build artifacts + if configuration, err := transformContainer(container, retrieveImageConfiguration, portAlloc); err == nil { + configurations[container.Name] = configuration + // todo: add this artifact to the watch list? + } else { + logrus.Infof("Image [%s] not configured for debugging: %v", container.Image, err) + } + } + if len(configurations) > 0 { + if metadata.Annotations == nil { + metadata.Annotations = make(map[string]string) + } + metadata.Annotations["debug.cloud.google.com/config"] = encodeConfigurations(configurations) + return true + } + return false +} + +// allocatePort walks the podSpec's containers looking for an available port that is close to desiredPort. +// We deal with wrapping and avoid allocating ports < 1024 +func allocatePort(podSpec *v1.PodSpec, desiredPort int32) int32 { + var maxPort int32 = 65535 // ports are normally [1-65535] + if desiredPort < 1024 || desiredPort > maxPort { + desiredPort = 1024 // skip reserved ports + } + // We assume ports are rather sparsely allocated, so even if desiredPort + // is allocated, desiredPort+1 or desiredPort+2 are likely to be free + for port := desiredPort; port < maxPort; port++ { + if isPortAvailable(podSpec, port) { + return port + } + } + for port := desiredPort; port > 1024; port-- { + if isPortAvailable(podSpec, port) { + return port + } + } + panic("cannot find available port") // exceedingly unlikely +} + +// isPortAvailable returns true if none of the pod's containers specify the given port. +func isPortAvailable(podSpec *v1.PodSpec, port int32) bool { + for _, container := range podSpec.Containers { + for _, portSpec := range container.Ports { + if portSpec.ContainerPort == port { + return false + } + } + } + return true +} + +// transformContainer rewrites the container definition to enable debugging. +// Returns a debugging configuration description or an error if the rewrite was unsuccessful. +func transformContainer(container *v1.Container, retrieveImageConfiguration configurationRetriever, portAlloc portAllocator) (map[string]interface{}, error) { + var config imageConfiguration + config, err := retrieveImageConfiguration(container.Image) + if err != nil { + return nil, err + } + + // update image configuration values with those set in the k8s manifest + for _, envVar := range container.Env { + // FIXME handle ValueFrom? + config.env[envVar.Name] = envVar.Value + } + + if len(container.Command) > 0 { + config.entrypoint = container.Command + } + if len(container.Args) > 0 { + config.arguments = container.Args + } + + for _, transform := range containerTransforms { + if transform.IsApplicable(config) { + return transform.Apply(container, config, portAlloc), nil + } + } + return nil, errors.Errorf("unable to determine runtime for [%s]", container.Name) +} + +func encodeConfigurations(configurations map[string]map[string]interface{}) string { + bytes, err := json.Marshal(configurations) + if err != nil { + return "" + } + return string(bytes) +} diff --git a/pkg/skaffold/debug/transform_jvm.go b/pkg/skaffold/debug/transform_jvm.go new file mode 100644 index 00000000000..ce45d19befd --- /dev/null +++ b/pkg/skaffold/debug/transform_jvm.go @@ -0,0 +1,204 @@ +/* +Copyright 2019 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 debug + +import ( + "fmt" + "strconv" + "strings" + + "github.com/sirupsen/logrus" + v1 "k8s.io/api/core/v1" +) + +type jdwpTransformer struct{} + +func init() { + containerTransforms = append(containerTransforms, jdwpTransformer{}) +} + +const ( + // no standard port for JDWP; most examples use 5005 or 8000 + defaultJdwpPort = 5005 +) + +func (t jdwpTransformer) IsApplicable(config imageConfiguration) bool { + if _, found := config.env["JAVA_TOOL_OPTIONS"]; found { + return true + } + if _, found := config.env["JAVA_VERSION"]; found { + return true + } + if len(config.entrypoint) > 0 { + return config.entrypoint[0] == "java" || strings.HasSuffix(config.entrypoint[0], "/java") + } + if len(config.arguments) > 0 { + return config.arguments[0] == "java" || strings.HasSuffix(config.arguments[0], "/java") + } + return false +} + +// captures the useful jdwp options (see `java -agentlib:jdwp=help`) +type jdwpSpec struct { + transport string + // `address` portion is split into host/port + host string + port uint16 + quiet bool + suspend bool + server bool +} + +// Apply configures a container definition for JVM debugging. +// Returns a simple map describing the debug configuration details. +func (t jdwpTransformer) Apply(container *v1.Container, config imageConfiguration, portAlloc portAllocator) map[string]interface{} { + logrus.Infof("Configuring [%s] for JVM debugging", container.Name) + // try to find existing JAVA_TOOL_OPTIONS or jdwp command argument + // todo: find existing containerPort "jdwp" and use port. But what if it conflicts with jdwp spec? + spec := retrieveJdwpSpec(config) + + var port int32 + if spec != nil { + port = int32(spec.port) + } else { + port = portAlloc(defaultJdwpPort) + + javaToolOptions := v1.EnvVar{ + Name: "JAVA_TOOL_OPTIONS", + Value: fmt.Sprintf("-agentlib:jdwp=transport=dt_socket,server=y,address=%d,suspend=n,quiet=y", port), + } + container.Env = append(container.Env, javaToolOptions) + } + + jdwpPort := v1.ContainerPort{ + Name: "jdwp", + ContainerPort: port, + } + container.Ports = append(container.Ports, jdwpPort) + + return map[string]interface{}{ + "runtime": "jvm", + "jdwp": port, + } +} + +func retrieveJdwpSpec(config imageConfiguration) *jdwpSpec { + for _, arg := range config.entrypoint { + if spec := extractJdwpArg(arg); spec != nil { + return spec + } + } + for _, arg := range config.arguments { + if spec := extractJdwpArg(arg); spec != nil { + return spec + } + } + // Nobody should be setting JDWP options via _JAVA_OPTIONS and IBM_JAVA_OPTIONS + for key, value := range config.env { + if key == "JAVA_TOOL_OPTIONS" { + for _, arg := range strings.Split(value, " ") { + if spec := extractJdwpArg(arg); spec != nil { + return spec + } + } + } + } + return nil +} + +func extractJdwpArg(spec string) *jdwpSpec { + if strings.Index(spec, "-agentlib:jdwp=") == 0 { + return parseJdwpSpec(spec[15:]) + } + if strings.Index(spec, "-Xrunjdwp:") == 0 { + return parseJdwpSpec(spec[10:]) + } + return nil +} + +func (spec jdwpSpec) String() string { + result := []string{"transport=" + spec.transport} + if spec.quiet { + result = append(result, "quiet=y") + } + if spec.server { + result = append(result, "server=y") + } + if !spec.suspend { + result = append(result, "suspend=n") + } + if spec.port > 0 { + if len(spec.host) > 0 { + result = append(result, "address="+spec.host+":"+strconv.FormatUint(uint64(spec.port), 10)) + } else { + result = append(result, "address="+strconv.FormatUint(uint64(spec.port), 10)) + } + } + return strings.Join(result, ",") +} + +// parseJdwpSpec parses a JDWP spec string as passed to `-agentlib:jdwp=` or `-Xrunjdwp:` +// like `transport=dt_socket,server=y,address=8000,quiet=y,suspend=n` +func parseJdwpSpec(specification string) *jdwpSpec { + parsed := make(map[string]string) + for _, component := range strings.Split(specification, ",") { + if len(component) > 0 { + keyValue := strings.SplitN(component, "=", 2) + if len(keyValue) == 2 { + parsed[keyValue[0]] = keyValue[1] + } + // else return error? + } + } + // use defaults as per https://docs.oracle.com/javase/7/docs/technotes/guides/jpda/conninv.html#jdwpoptions + spec := jdwpSpec{ + transport: "dt_socket", + quiet: false, + suspend: true, + server: false, + host: "", + port: 0, + } + if transport, found := parsed["transport"]; found { + spec.transport = transport + } + if quietYN, found := parsed["quiet"]; found { + spec.quiet = quietYN == "y" + } + if suspendYN, found := parsed["suspend"]; found { + spec.suspend = suspendYN == "y" + } + if serverYN, found := parsed["server"]; found { + spec.server = serverYN == "y" + } + if address, found := parsed["address"]; found { + split := strings.SplitN(address, ":", 2) + switch len(split) { + // port only + case 1: + p, _ := strconv.ParseUint(split[0], 10, 16) + spec.port = uint16(p) + + // host and port + case 2: + spec.host = split[0] + p, _ := strconv.ParseUint(split[1], 10, 16) + spec.port = uint16(p) + } + } + return &spec +} diff --git a/pkg/skaffold/debug/transform_jvm_test.go b/pkg/skaffold/debug/transform_jvm_test.go new file mode 100644 index 00000000000..12c0d08b7df --- /dev/null +++ b/pkg/skaffold/debug/transform_jvm_test.go @@ -0,0 +1,482 @@ +/* +Copyright 2019 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 debug + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/GoogleContainerTools/skaffold/testutil" + "github.com/google/go-cmp/cmp" +) + +func TestJdwpTransformer_IsApplicable(t *testing.T) { + tests := []struct { + description string + source imageConfiguration + result bool + }{ + { + description: "JAVA_TOOL_OPTIONS", + source: imageConfiguration{env: map[string]string{"JAVA_TOOL_OPTIONS": "-agent:jdwp"}}, + result: true, + }, + { + description: "JAVA_VERSION", + source: imageConfiguration{env: map[string]string{"JAVA_VERSION": "8"}}, + result: true, + }, + { + description: "entrypoint java", + source: imageConfiguration{entrypoint: []string{"java", "-jar", "foo.jar"}}, + result: true, + }, + { + description: "entrypoint /usr/bin/java", + source: imageConfiguration{entrypoint: []string{"/usr/bin/java", "-jar", "foo.jar"}}, + result: true, + }, + { + description: "no entrypoint, args java", + source: imageConfiguration{arguments: []string{"java", "-jar", "foo.jar"}}, + result: true, + }, + { + description: "no entrypoint, arguments /usr/bin/java", + source: imageConfiguration{arguments: []string{"/usr/bin/java", "-jar", "foo.jar"}}, + result: true, + }, + { + description: "entrypoint /bin/sh", + source: imageConfiguration{entrypoint: []string{"/bin/sh"}}, + result: false, + }, + { + description: "nothing", + source: imageConfiguration{}, + result: false, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + result := jdwpTransformer{}.IsApplicable(test.source) + testutil.CheckDeepEqual(t, test.result, result) + }) + } +} + +func TestJdwpTransformerApply(t *testing.T) { + tests := []struct { + description string + containerSpec v1.Container + configuration imageConfiguration + result v1.Container + }{ + { + description: "empty", + containerSpec: v1.Container{}, + configuration: imageConfiguration{}, + result: v1.Container{ + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }, + { + description: "existing port", + containerSpec: v1.Container{ + Ports: []v1.ContainerPort{{Name: "http-server", ContainerPort: 8080}}, + }, + configuration: imageConfiguration{}, + result: v1.Container{ + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "http-server", ContainerPort: 8080}, {Name: "jdwp", ContainerPort: 5005}}, + }, + }, + { + description: "existing jdwp spec", + containerSpec: v1.Container{ + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=8000,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{ContainerPort: 5005}}, + }, + configuration: imageConfiguration{env: map[string]string{"JAVA_TOOL_OPTIONS": "-agentlib:jdwp=transport=dt_socket,server=y,address=8000,suspend=n,quiet=y"}}, + result: v1.Container{ + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=8000,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{ContainerPort: 5005}, {Name: "jdwp", ContainerPort: 8000}}, + }, + }, + } + var identity portAllocator = func(port int32) int32 { + return port + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + jdwpTransformer{}.Apply(&test.containerSpec, test.configuration, identity) + testutil.CheckDeepEqual(t, test.result, test.containerSpec) + }) + } +} + +func TestParseJdwpSpec(t *testing.T) { + tests := []struct { + in string + result jdwpSpec + }{ + {"", jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 0}}, + {"transport=foo", jdwpSpec{transport: "foo", quiet: false, suspend: true, server: false, host: "", port: 0}}, + {"quiet=n", jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 0}}, + {"quiet=y", jdwpSpec{transport: "dt_socket", quiet: true, suspend: true, server: false, host: "", port: 0}}, + {"server=n", jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 0}}, + {"server=y", jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: true, host: "", port: 0}}, + {"suspend=y", jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 0}}, + {"suspend=n", jdwpSpec{transport: "dt_socket", quiet: false, suspend: false, server: false, host: "", port: 0}}, + {"address=5005", jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 5005}}, + {"address=:5005", jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 5005}}, + {"address=localhost:5005", jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "localhost", port: 5005}}, + {"address=localhost:5005,quiet=y,server=y,suspend=n", jdwpSpec{transport: "dt_socket", quiet: true, suspend: false, server: true, host: "localhost", port: 5005}}, + } + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + testutil.CheckDeepEqualWithOptions(t, cmp.Options{cmp.AllowUnexported(jdwpSpec{})}, test.result, *parseJdwpSpec(test.in)) + testutil.CheckDeepEqualWithOptions(t, cmp.Options{cmp.AllowUnexported(jdwpSpec{})}, test.result, *extractJdwpArg("-agentlib:jdwp=" + test.in)) + testutil.CheckDeepEqualWithOptions(t, cmp.Options{cmp.AllowUnexported(jdwpSpec{})}, test.result, *extractJdwpArg("-Xrunjdwp:" + test.in)) + }) + } +} + +func TestJdwpSpecString(t *testing.T) { + tests := []struct { + in jdwpSpec + result string + }{ + {jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 0}, "transport=dt_socket"}, + {jdwpSpec{transport: "foo", quiet: false, suspend: true, server: false, host: "", port: 0}, "transport=foo"}, + {jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 0}, "transport=dt_socket"}, + {jdwpSpec{transport: "dt_socket", quiet: true, suspend: true, server: false, host: "", port: 0}, "transport=dt_socket,quiet=y"}, + {jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 0}, "transport=dt_socket"}, + {jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: true, host: "", port: 0}, "transport=dt_socket,server=y"}, + {jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 0}, "transport=dt_socket"}, + {jdwpSpec{transport: "dt_socket", quiet: false, suspend: false, server: false, host: "", port: 0}, "transport=dt_socket,suspend=n"}, + {jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "", port: 5005}, "transport=dt_socket,address=5005"}, + {jdwpSpec{transport: "dt_socket", quiet: false, suspend: true, server: false, host: "localhost", port: 5005}, "transport=dt_socket,address=localhost:5005"}, + } + for _, test := range tests { + t.Run(test.result, func(t *testing.T) { + testutil.CheckDeepEqual(t, test.result, test.in.String()) + }) + } +} + +func TestTransformManifestJVM(t *testing.T) { + int32p := func(x int32) *int32 { return &x } + tests := []struct { + description string + in runtime.Object + transformed bool + out runtime.Object + }{ + { + "Pod with no transformable container", + &v1.Pod{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"echo", "Hello World"}, + }, + }}}, + false, + &v1.Pod{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"echo", "Hello World"}, + }, + }}}, + }, + { + "Pod with Java container", + &v1.Pod{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + }, + }}}, + true, + &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"jdwp":5005,"runtime":"jvm"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }}}, + }, + { + "Deployment with Java container", + &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: int32p(2), + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + }, + }}}}}, + true, + &appsv1.Deployment{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: appsv1.DeploymentSpec{ + Replicas: int32p(1), + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"jdwp":5005,"runtime":"jvm"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }}}}}, + }, + { + "ReplicaSet with Java container", + &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: int32p(2), + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + }, + }}}}}, + true, + &appsv1.ReplicaSet{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: int32p(1), + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"jdwp":5005,"runtime":"jvm"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }}}}}, + }, + { + "StatefulSet with Java container", + &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: int32p(2), + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + }, + }}}}}, + true, + &appsv1.StatefulSet{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: appsv1.StatefulSetSpec{ + Replicas: int32p(1), + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"jdwp":5005,"runtime":"jvm"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }}}}}, + }, + { + "DaemonSet with Java container", + &appsv1.DaemonSet{ + Spec: appsv1.DaemonSetSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + }, + }}}}}, + true, + &appsv1.DaemonSet{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: appsv1.DaemonSetSpec{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"jdwp":5005,"runtime":"jvm"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }}}}}, + }, + { + "Job with Java container", + &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + }, + }}}}}, + true, + &batchv1.Job{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: batchv1.JobSpec{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"jdwp":5005,"runtime":"jvm"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }}}}}, + }, + { + "ReplicationController with Java container", + &v1.ReplicationController{ + Spec: v1.ReplicationControllerSpec{ + Replicas: int32p(2), + Template: &v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + }, + }}}}}, + true, + &v1.ReplicationController{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: v1.ReplicationControllerSpec{ + Replicas: int32p(1), + Template: &v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"jdwp":5005,"runtime":"jvm"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }}}}}, + }, + { + "PodList with Java and non-Java container", + &v1.PodList{ + Items: []v1.Pod{ + { + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "echo", + Command: []string{"echo", "Hello World"}, + }, + }}}, + { + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + }, + }}}, + }}, + true, + &v1.PodList{ + Items: []v1.Pod{ + { + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "echo", + Command: []string{"echo", "Hello World"}, + }, + }}}, + { + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"jdwp":5005,"runtime":"jvm"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"java", "-jar", "foo.jar"}, + Env: []v1.EnvVar{{Name: "JAVA_TOOL_OPTIONS", Value: "-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y"}}, + Ports: []v1.ContainerPort{{Name: "jdwp", ContainerPort: 5005}}, + }, + }}}, + }}, + }, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + value := test.in.DeepCopyObject() + + retriever := func(image string) (imageConfiguration, error) { + return imageConfiguration{}, nil + } + result := transformManifest(value, retriever) + testutil.CheckDeepEqual(t, test.transformed, result) + testutil.CheckDeepEqual(t, test.out, value) + }) + } +} diff --git a/pkg/skaffold/debug/transform_nodejs.go b/pkg/skaffold/debug/transform_nodejs.go new file mode 100644 index 00000000000..84a38d792e8 --- /dev/null +++ b/pkg/skaffold/debug/transform_nodejs.go @@ -0,0 +1,170 @@ +/* +Copyright 2019 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 debug + +import ( + "strconv" + "strings" + + "github.com/sirupsen/logrus" + v1 "k8s.io/api/core/v1" +) + +type nodeTransformer struct{} + +func init() { + containerTransforms = append(containerTransforms, nodeTransformer{}) +} + +const ( + // most examples use 9229 + defaultDevtoolsPort = 9229 +) + +// inspectSpec captures the useful nodejs devtools options +type inspectSpec struct { + host string + port int32 + brk bool +} + +// isLaunchingNode determines if the arguments seems to be invoking node +func isLaunchingNode(args []string) bool { + return args[0] == "node" || strings.HasSuffix(args[0], "/node") || + args[0] == "nodemon" || strings.HasSuffix(args[0], "/nodemon") +} + +func (t nodeTransformer) IsApplicable(config imageConfiguration) bool { + if _, found := config.env["NODE_VERSION"]; found { + return true + } + if len(config.entrypoint) > 0 { + return isLaunchingNode(config.entrypoint) + } else if len(config.arguments) > 0 { + return isLaunchingNode(config.arguments) + } + return false +} + +// configureNodeJsDebugging configures a container definition for NodeJS Chrome V8 Inspector. +// Returns a simple map describing the debug configuration details. +func (t nodeTransformer) Apply(container *v1.Container, config imageConfiguration, portAlloc portAllocator) map[string]interface{} { + logrus.Infof("Configuring [%s] for node.js debugging", container.Name) + + // try to find existing `--inspect` command + spec := retrieveNodeInspectSpec(config) + // todo: find existing containerPort "devtools" and use port. But what if it conflicts with command-line spec? + + if spec == nil { + spec = &inspectSpec{port: portAlloc(defaultDevtoolsPort)} + switch { + case len(config.entrypoint) > 0 && isLaunchingNode(config.entrypoint): + container.Command = config.entrypoint + container.Command = append(container.Command, "") + copy(container.Command[2:], container.Command[1:]) + container.Command[1] = spec.String() + + case len(config.entrypoint) == 0 && len(config.arguments) > 0 && isLaunchingNode(config.arguments): + container.Args = config.arguments + container.Args = append(container.Args, "") + copy(container.Args[2:], container.Args[1:]) + container.Args[1] = spec.String() + + default: + logrus.Warnf("Skipping [%s] as does not appear to invoke node", container.Name) + return nil + } + } + + inspectPort := v1.ContainerPort{ + Name: "devtools", + ContainerPort: spec.port, + } + container.Ports = append(container.Ports, inspectPort) + + return map[string]interface{}{ + "runtime": "nodejs", + "devtools": spec.port, + } +} + +func retrieveNodeInspectSpec(config imageConfiguration) *inspectSpec { + for _, arg := range config.entrypoint { + if spec := extractInspectArg(arg); spec != nil { + return spec + } + } + for _, arg := range config.arguments { + if spec := extractInspectArg(arg); spec != nil { + return spec + } + } + return nil +} + +func extractInspectArg(arg string) *inspectSpec { + spec := inspectSpec{port: 9229} + address := "" + switch { + case strings.Index(arg, "--inspect=") == 0: + address = arg[10:] + fallthrough + case arg == "--inspect": + spec.brk = false + + case strings.Index(arg, "--inspect-brk=") == 0: + address = arg[14:] + fallthrough + case arg == "--inspect-brk": + spec.brk = true + + default: + return nil + } + if len(address) > 0 { + if split := strings.SplitN(address, ":", 2); len(split) == 1 { + port, err := strconv.ParseInt(split[0], 10, 32) + if err != nil { + logrus.Errorf("Invalid NodeJS inspect port \"%s\": %s\n", address, err) + return nil + } + spec.port = int32(port) + } else { + spec.host = split[0] + port, err := strconv.ParseInt(split[1], 10, 32) + if err != nil { + logrus.Errorf("Invalid NodeJS inspect port \"%s\": %s\n", address, err) + return nil + } + spec.port = int32(port) + } + } + return &spec +} + +func (spec inspectSpec) String() string { + s := "--inspect" + if spec.brk { + s = "--inspect-brk" + } + if len(spec.host) > 0 { + s += "=" + spec.host + ":" + strconv.FormatInt(int64(spec.port), 10) + } else if spec.port > 0 { + s += "=" + strconv.FormatInt(int64(spec.port), 10) + } + return s +} diff --git a/pkg/skaffold/debug/transform_nodejs_test.go b/pkg/skaffold/debug/transform_nodejs_test.go new file mode 100644 index 00000000000..64a5f4b8fb9 --- /dev/null +++ b/pkg/skaffold/debug/transform_nodejs_test.go @@ -0,0 +1,472 @@ +/* +Copyright 2019 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 debug + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/GoogleContainerTools/skaffold/testutil" + "github.com/google/go-cmp/cmp" +) + +func TestExtractInspectArg(t *testing.T) { + tests := []struct { + in string + result *inspectSpec + }{ + {"", nil}, + {"foo", nil}, + {"--foo", nil}, + {"-inspect", nil}, + {"-inspect=9329", nil}, + {"--inspect", &inspectSpec{port: 9229, brk: false}}, + {"--inspect=9329", &inspectSpec{port: 9329, brk: false}}, + {"--inspect=:9329", &inspectSpec{port: 9329, brk: false}}, + {"--inspect=foo:9329", &inspectSpec{host: "foo", port: 9329, brk: false}}, + {"--inspect-brk", &inspectSpec{port: 9229, brk: true}}, + {"--inspect-brk=9329", &inspectSpec{port: 9329, brk: true}}, + {"--inspect-brk=:9329", &inspectSpec{port: 9329, brk: true}}, + {"--inspect-brk=foo:9329", &inspectSpec{host: "foo", port: 9329, brk: true}}, + } + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + if test.result == nil { + testutil.CheckDeepEqualWithOptions(t, nil, test.result, extractInspectArg(test.in)) + } else { + testutil.CheckDeepEqualWithOptions(t, cmp.Options{cmp.AllowUnexported(inspectSpec{})}, *test.result, *extractInspectArg(test.in)) + } + }) + } +} + +func TestNodeTransformer_IsApplicable(t *testing.T) { + tests := []struct { + description string + source imageConfiguration + result bool + }{ + { + description: "NODE_VERSION", + source: imageConfiguration{env: map[string]string{"NODE_VERSION": "10"}}, + result: true, + }, + { + description: "entrypoint node", + source: imageConfiguration{entrypoint: []string{"node", "init.js"}}, + result: true, + }, + { + description: "entrypoint /usr/bin/node", + source: imageConfiguration{entrypoint: []string{"/usr/bin/node", "init.js"}}, + result: true, + }, + { + description: "no entrypoint, args node", + source: imageConfiguration{arguments: []string{"node", "init.js"}}, + result: true, + }, + { + description: "no entrypoint, arguments /usr/bin/node", + source: imageConfiguration{arguments: []string{"/usr/bin/node", "init.js"}}, + result: true, + }, + { + description: "entrypoint nodemon", + source: imageConfiguration{entrypoint: []string{"nodemon", "init.js"}}, + result: true, + }, + { + description: "entrypoint /usr/bin/nodemon", + source: imageConfiguration{entrypoint: []string{"/usr/bin/nodemon", "init.js"}}, + result: true, + }, + { + description: "no entrypoint, args nodemon", + source: imageConfiguration{arguments: []string{"nodemon", "init.js"}}, + result: true, + }, + { + description: "no entrypoint, arguments /usr/bin/nodemon", + source: imageConfiguration{arguments: []string{"/usr/bin/nodemon", "init.js"}}, + result: true, + }, + { + description: "entrypoint /bin/sh", + source: imageConfiguration{entrypoint: []string{"/bin/sh"}}, + result: false, + }, + { + description: "nothing", + source: imageConfiguration{}, + result: false, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + result := nodeTransformer{}.IsApplicable(test.source) + testutil.CheckDeepEqual(t, test.result, result) + }) + } +} + +func TestNodeTransformerApply(t *testing.T) { + tests := []struct { + description string + containerSpec v1.Container + configuration imageConfiguration + result v1.Container + }{ + { + description: "empty", + containerSpec: v1.Container{}, + configuration: imageConfiguration{}, + result: v1.Container{}, + }, + { + description: "basic", + containerSpec: v1.Container{}, + configuration: imageConfiguration{entrypoint: []string{"node"}}, + result: v1.Container{ + Command: []string{"node", "--inspect=9229"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }, + { + description: "existing port", + containerSpec: v1.Container{ + Ports: []v1.ContainerPort{{Name: "http-server", ContainerPort: 8080}}, + }, + configuration: imageConfiguration{entrypoint: []string{"node"}}, + result: v1.Container{ + Command: []string{"node", "--inspect=9229"}, + Ports: []v1.ContainerPort{{Name: "http-server", ContainerPort: 8080}, {Name: "devtools", ContainerPort: 9229}}, + }, + }, + { + description: "command not entrypoint", + containerSpec: v1.Container{}, + configuration: imageConfiguration{arguments: []string{"node"}}, + result: v1.Container{ + Args: []string{"node", "--inspect=9229"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }, + } + var identity portAllocator = func(port int32) int32 { + return port + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + nodeTransformer{}.Apply(&test.containerSpec, test.configuration, identity) + testutil.CheckDeepEqual(t, test.result, test.containerSpec) + }) + } +} + +func TestTransformManifestNodeJS(t *testing.T) { + int32p := func(x int32) *int32 { return &x } + tests := []struct { + description string + in runtime.Object + transformed bool + out runtime.Object + }{ + { + "Pod with no transformable container", + &v1.Pod{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"echo", "Hello World"}, + }, + }}}, + false, + &v1.Pod{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"echo", "Hello World"}, + }, + }}}, + }, + { + "Pod with NodeJS container", + &v1.Pod{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "foo.js"}, + }, + }}}, + true, + &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"devtools":9229,"runtime":"nodejs"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "--inspect=9229", "foo.js"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }}}, + }, + { + "Deployment with NodeJS container", + &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: int32p(2), + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "foo.js"}, + }, + }}}}}, + true, + &appsv1.Deployment{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: appsv1.DeploymentSpec{ + Replicas: int32p(1), + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"devtools":9229,"runtime":"nodejs"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "--inspect=9229", "foo.js"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }}}}}, + }, + { + "ReplicaSet with NodeJS container", + &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: int32p(2), + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "foo.js"}, + }, + }}}}}, + true, + &appsv1.ReplicaSet{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: int32p(1), + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"devtools":9229,"runtime":"nodejs"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "--inspect=9229", "foo.js"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }}}}}, + }, + { + "StatefulSet with NodeJS container", + &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: int32p(2), + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "foo.js"}, + }, + }}}}}, + true, + &appsv1.StatefulSet{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: appsv1.StatefulSetSpec{ + Replicas: int32p(1), + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"devtools":9229,"runtime":"nodejs"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "--inspect=9229", "foo.js"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }}}}}, + }, + { + "DaemonSet with NodeJS container", + &appsv1.DaemonSet{ + Spec: appsv1.DaemonSetSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "foo.js"}, + }, + }}}}}, + true, + &appsv1.DaemonSet{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: appsv1.DaemonSetSpec{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"devtools":9229,"runtime":"nodejs"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "--inspect=9229", "foo.js"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }}}}}, + }, + { + "Job with NodeJS container", + &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "foo.js"}, + }, + }}}}}, + true, + &batchv1.Job{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: batchv1.JobSpec{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"devtools":9229,"runtime":"nodejs"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "--inspect=9229", "foo.js"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }}}}}, + }, + { + "ReplicationController with NodeJS container", + &v1.ReplicationController{ + Spec: v1.ReplicationControllerSpec{ + Replicas: int32p(2), + Template: &v1.PodTemplateSpec{ + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "foo.js"}, + }, + }}}}}, + true, + &v1.ReplicationController{ + //ObjectMeta: metav1.ObjectMeta{ + // Labels: map[string]string{"debug.cloud.google.com/enabled": `yes`}, + //}, + Spec: v1.ReplicationControllerSpec{ + Replicas: int32p(1), + Template: &v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"devtools":9229,"runtime":"nodejs"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "--inspect=9229", "foo.js"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }}}}}, + }, + { + "PodList with Java and non-Java container", + &v1.PodList{ + Items: []v1.Pod{ + { + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "echo", + Command: []string{"echo", "Hello World"}, + }, + }}}, + { + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "foo.js"}, + }, + }}}, + }}, + true, + &v1.PodList{ + Items: []v1.Pod{ + { + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "echo", + Command: []string{"echo", "Hello World"}, + }, + }}}, + { + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"debug.cloud.google.com/config": `{"test":{"devtools":9229,"runtime":"nodejs"}}`}, + }, + Spec: v1.PodSpec{Containers: []v1.Container{ + { + Name: "test", + Command: []string{"node", "--inspect=9229", "foo.js"}, + Ports: []v1.ContainerPort{{Name: "devtools", ContainerPort: 9229}}, + }, + }}}, + }}, + }, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + value := test.in.DeepCopyObject() + + retriever := func(image string) (imageConfiguration, error) { + return imageConfiguration{}, nil + } + result := transformManifest(value, retriever) + testutil.CheckDeepEqual(t, test.transformed, result) + testutil.CheckDeepEqual(t, test.out, value) + }) + } +} diff --git a/pkg/skaffold/debug/transform_test.go b/pkg/skaffold/debug/transform_test.go new file mode 100644 index 00000000000..51b81656fbd --- /dev/null +++ b/pkg/skaffold/debug/transform_test.go @@ -0,0 +1,103 @@ +/* +Copyright 2019 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 debug + +import ( + "testing" + + "github.com/GoogleContainerTools/skaffold/testutil" + v1 "k8s.io/api/core/v1" +) + +func TestAllocatePort(t *testing.T) { + // helper function to create a container + containerWithPorts := func(ports ...int32) v1.Container { + var created []v1.ContainerPort + for _, port := range ports { + created = append(created, v1.ContainerPort{ContainerPort: port}) + } + return v1.Container{Ports: created} + } + + tests := []struct { + description string + pod v1.PodSpec + desiredPort int32 + result int32 + }{ + { + description: "simple", + pod: v1.PodSpec{}, + desiredPort: 5005, + result: 5005, + }, + { + description: "finds next available port", + pod: v1.PodSpec{Containers: []v1.Container{ + containerWithPorts(5005, 5007), + containerWithPorts(5008, 5006), + }}, + desiredPort: 5005, + result: 5009, + }, + { + description: "skips reserved", + pod: v1.PodSpec{}, + desiredPort: 1, + result: 1024, + }, + { + description: "skips 0", + pod: v1.PodSpec{}, + desiredPort: 0, + result: 1024, + }, + { + description: "skips negative", + pod: v1.PodSpec{}, + desiredPort: -1, + result: 1024, + }, + { + description: "wraps at maxPort", + pod: v1.PodSpec{}, + desiredPort: 65536, + result: 1024, + }, + { + description: "wraps beyond maxPort", + pod: v1.PodSpec{}, + desiredPort: 65537, + result: 1024, + }, + { + description: "looks backwards at 65535", + pod: v1.PodSpec{Containers: []v1.Container{ + containerWithPorts(65535), + }}, + desiredPort: 65535, + result: 65534, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + result := allocatePort(&test.pod, test.desiredPort) + testutil.CheckDeepEqual(t, test.result, result) + }) + } +} diff --git a/pkg/skaffold/deploy/kubectl.go b/pkg/skaffold/deploy/kubectl.go index 115f7bc530a..309662e5c8a 100644 --- a/pkg/skaffold/deploy/kubectl.go +++ b/pkg/skaffold/deploy/kubectl.go @@ -61,6 +61,14 @@ func (k *KubectlDeployer) Labels() map[string]string { } } +// Transforms are applied to manifests +var manifestTransforms []func(kubectl.ManifestList, []build.Artifact) (kubectl.ManifestList, error) + +// AddManifestTransform adds a transform to be applied when deploying. +func AddManifestTransform(newTransform func(kubectl.ManifestList, []build.Artifact) (kubectl.ManifestList, error)) { + manifestTransforms = append(manifestTransforms, newTransform) +} + // Deploy templates the provided manifests with a simple `find and replace` and // runs `kubectl apply` on those manifests func (k *KubectlDeployer) Deploy(ctx context.Context, out io.Writer, builds []build.Artifact, labellers []Labeller) error { @@ -93,6 +101,13 @@ func (k *KubectlDeployer) Deploy(ctx context.Context, out io.Writer, builds []bu return errors.Wrap(err, "setting labels in manifests") } + for _, transform := range manifestTransforms { + manifests, err = transform(manifests, builds) + if err != nil { + return errors.Wrap(err, "debug transform of manifests") + } + } + err = k.kubectl.Apply(ctx, out, manifests) if err != nil { event.DeployFailed(err) diff --git a/pkg/skaffold/event/proto/skaffold.pb.go b/pkg/skaffold/event/proto/skaffold.pb.go index 19fc5b3cd9a..853063b033e 100644 --- a/pkg/skaffold/event/proto/skaffold.pb.go +++ b/pkg/skaffold/event/proto/skaffold.pb.go @@ -6,6 +6,8 @@ package proto import ( context "context" fmt "fmt" + math "math" + proto "github.com/golang/protobuf/proto" empty "github.com/golang/protobuf/ptypes/empty" timestamp "github.com/golang/protobuf/ptypes/timestamp" @@ -13,7 +15,6 @@ import ( grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" - math "math" ) // Reference imports to suppress errors if they are not otherwise used. diff --git a/testutil/util.go b/testutil/util.go index 915683979e3..51d77f9ec3b 100644 --- a/testutil/util.go +++ b/testutil/util.go @@ -46,6 +46,14 @@ func CheckDeepEqual(t *testing.T, expected, actual interface{}) { } } +func CheckDeepEqualWithOptions(t *testing.T, options cmp.Options, expected, actual interface{}) { + t.Helper() + if diff := cmp.Diff(actual, expected, options); diff != "" { + t.Errorf("%T differ (-got, +want): %s", expected, diff) + return + } +} + func CheckErrorAndDeepEqual(t *testing.T, shouldErr bool, err error, expected, actual interface{}) { t.Helper() if err := checkErr(shouldErr, err); err != nil {