From 391ee2df46b281a6a43021cffc7ea8a01b20b00d Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Sun, 16 Feb 2020 20:43:01 -0800 Subject: [PATCH 01/12] add kn export (#653) --- pkg/kn/commands/service/export.go | 241 ++++++++++++++++++++ pkg/kn/commands/service/export_test.go | 302 +++++++++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 pkg/kn/commands/service/export.go create mode 100644 pkg/kn/commands/service/export_test.go diff --git a/pkg/kn/commands/service/export.go b/pkg/kn/commands/service/export.go new file mode 100644 index 0000000000..d9e2478313 --- /dev/null +++ b/pkg/kn/commands/service/export.go @@ -0,0 +1,241 @@ +// Copyright © 2020 The Knative 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 service + +import ( + "errors" + + "sort" + "strconv" + + "github.com/spf13/cobra" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + + "knative.dev/client/pkg/kn/commands" + clientservingv1alpha "knative.dev/client/pkg/serving/v1" + "knative.dev/serving/pkg/apis/serving" + servingv1 "knative.dev/serving/pkg/apis/serving/v1" +) + +// NewServiceExportCommand returns a new command for exporting a service. +func NewServiceExportCommand(p *commands.KnParams) *cobra.Command { + + // For machine readable output + machineReadablePrintFlags := genericclioptions.NewPrintFlags("") + + command := &cobra.Command{ + Use: "export NAME", + Short: "export a service", + Example: ` + # Export a service in yaml format + kn service export foo -n bar -o yaml + # Export a service in json format + kn service export foo -n bar -o yaml`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("'kn service export' requires name of the service as single argument") + } + if !machineReadablePrintFlags.OutputFlagSpecified() { + return errors.New("'kn service export' requires output format") + } + serviceName := args[0] + + namespace, err := p.GetNamespace(cmd) + if err != nil { + return err + } + + client, err := p.NewServingClient(namespace) + if err != nil { + return err + } + + service, err := client.GetService(serviceName) + if err != nil { + return err + } + + history, err := cmd.Flags().GetBool("history") + if err != nil { + return err + } + + // Print out machine readable output if requested + if machineReadablePrintFlags.OutputFlagSpecified() { + printer, err := machineReadablePrintFlags.ToPrinter() + if err != nil { + return err + } + if history { + svcList, err := exportServicewithActiveRevisions(service, client) + if err != nil { + return err + } + return printer.PrintObj(svcList, cmd.OutOrStdout()) + } else { + return printer.PrintObj(exportService(service), cmd.OutOrStdout()) + } + } + + return nil + }, + } + flags := command.Flags() + commands.AddNamespaceFlags(flags, false) + flags.BoolP("history", "r", false, "Export all active revisions") + machineReadablePrintFlags.AddFlags(command) + return command +} + +func exportService(latestService *servingv1.Service) *servingv1.Service { + return constructServiceTemplate(latestService) +} + +func constructServiceTemplate(latestSvc *servingv1.Service) *servingv1.Service { + + exportedSvc := servingv1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: latestSvc.ObjectMeta.Name, + Labels: latestSvc.ObjectMeta.Labels, + }, + TypeMeta: latestSvc.TypeMeta, + } + + exportedSvc.Spec.Template = servingv1.RevisionTemplateSpec{ + Spec: latestSvc.Spec.ConfigurationSpec.Template.Spec, + ObjectMeta: latestSvc.Spec.ConfigurationSpec.Template.ObjectMeta, + } + + return &exportedSvc +} + +func constructServicefromRevision(latestSvc *servingv1.Service, revision servingv1.Revision) servingv1.Service { + + exportedSvc := servingv1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: latestSvc.ObjectMeta.Name, + Labels: latestSvc.ObjectMeta.Labels, + }, + TypeMeta: latestSvc.TypeMeta, + } + + exportedSvc.Spec.Template = servingv1.RevisionTemplateSpec{ + Spec: revision.Spec, + ObjectMeta: latestSvc.Spec.ConfigurationSpec.Template.ObjectMeta, + } + + exportedSvc.Spec.ConfigurationSpec.Template.ObjectMeta.Name = revision.ObjectMeta.Name + + return exportedSvc +} + +func exportServicewithActiveRevisions(latestSvc *servingv1.Service, client clientservingv1alpha.KnServingClient) (*servingv1.ServiceList, error) { + var exportedSvcItems []servingv1.Service + + //get revisions to export from traffic + revsMap := getRevisionstoExport(latestSvc) + + var params []clientservingv1alpha.ListConfig + params = append(params, clientservingv1alpha.WithService(latestSvc.ObjectMeta.Name)) + + // Query for list with filters + revisionList, err := client.ListRevisions(params...) + if err != nil { + return nil, err + } + + sortRevisions(revisionList) + + for _, revision := range revisionList.Items { + //construct service only for active revisions + if revsMap[revision.ObjectMeta.Name] != nil { + exportedSvcItems = append(exportedSvcItems, constructServicefromRevision(latestSvc, revision)) + } + } + + //set traffic in the latest revision + exportedSvcItems[len(exportedSvcItems)-1] = setTrafficSplit(revsMap, exportedSvcItems[len(exportedSvcItems)-1]) + + typeMeta := metav1.TypeMeta{ + APIVersion: "v1", + Kind: "List", + } + exportedSvcList := &servingv1.ServiceList{ + TypeMeta: typeMeta, + Items: exportedSvcItems, + } + + return exportedSvcList, nil +} + +func setTrafficSplit(revMap map[string]*int64, svc servingv1.Service) servingv1.Service { + var trafficList []servingv1.TrafficTarget + for k, v := range revMap { + traffic := servingv1.TrafficTarget{ + RevisionName: k, + Percent: v, + } + trafficList = append(trafficList, traffic) + } + svc.Spec.RouteSpec = servingv1.RouteSpec{Traffic: trafficList} + + return svc +} + +func getRevisionstoExport(latestSvc *servingv1.Service) map[string]*int64 { + trafficList := latestSvc.Spec.RouteSpec.Traffic + revsMap := make(map[string]*int64) + + for _, traffic := range trafficList { + if traffic.RevisionName == "" { + revsMap[latestSvc.Spec.ConfigurationSpec.Template.ObjectMeta.Name] = traffic.Percent + } else { + revsMap[traffic.RevisionName] = traffic.Percent + } + } + return revsMap +} + +// sortRevisions sorts revisions by generation and name (in this order) +func sortRevisions(revisionList *servingv1.RevisionList) { + // sort revisionList by configuration generation key + sort.SliceStable(revisionList.Items, revisionListSortFunc(revisionList)) +} + +// revisionListSortFunc sorts by generation and name +func revisionListSortFunc(revisionList *servingv1.RevisionList) func(i int, j int) bool { + return func(i, j int) bool { + a := revisionList.Items[i] + b := revisionList.Items[j] + + // By Generation + // Convert configuration generation key from string to int for avoiding string comparison. + agen, err := strconv.Atoi(a.Labels[serving.ConfigurationGenerationLabelKey]) + if err != nil { + return a.Name > b.Name + } + bgen, err := strconv.Atoi(b.Labels[serving.ConfigurationGenerationLabelKey]) + if err != nil { + return a.Name > b.Name + } + + if agen != bgen { + return agen < bgen + } + return a.Name > b.Name + } +} diff --git a/pkg/kn/commands/service/export_test.go b/pkg/kn/commands/service/export_test.go new file mode 100644 index 0000000000..2ebc4f2fcb --- /dev/null +++ b/pkg/kn/commands/service/export_test.go @@ -0,0 +1,302 @@ +// Copyright © 2020 The Knative 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 service + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "gotest.tools/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + servinglib "knative.dev/client/pkg/serving" + knclient "knative.dev/client/pkg/serving/v1" + "knative.dev/client/pkg/util/mock" + "knative.dev/pkg/ptr" + apiserving "knative.dev/serving/pkg/apis/serving" + servingv1 "knative.dev/serving/pkg/apis/serving/v1" +) + +func TestServiceExport(t *testing.T) { + + var svcs []*servingv1.Service + typeMeta := metav1.TypeMeta{ + Kind: "service", + APIVersion: "serving.knative.dev/v1", + } + + // case 1 - plain svc + plainService := getService("foo") + svcs = append(svcs, plainService) + + // case 2 - svc with env variables + envSvc := getService("foo") + envVars := []v1.EnvVar{ + {Name: "a", Value: "mouse"}, + {Name: "b", Value: "cookie"}, + {Name: "empty", Value: ""}, + } + template := &envSvc.Spec.Template + template.Spec.Containers[0].Env = envVars + template.Spec.Containers[0].Image = "gcr.io/foo/bar:baz" + template.Annotations = map[string]string{servinglib.UserImageAnnotationKey: "gcr.io/foo/bar:baz"} + svcs = append(svcs, envSvc) + + //case 3 - svc with labels + labelService := getService("foo") + expected := map[string]string{ + "a": "mouse", + "b": "cookie", + "empty": "", + } + labelService.Labels = expected + labelService.Spec.Template.Annotations = map[string]string{ + servinglib.UserImageAnnotationKey: "gcr.io/foo/bar:baz", + } + template = &labelService.Spec.Template + template.ObjectMeta.Labels = expected + template.Spec.Containers[0].Image = "gcr.io/foo/bar:baz" + svcs = append(svcs, labelService) + + //case 4 - config map + CMservice := getService("foo") + template = &CMservice.Spec.Template + template.Spec.Containers[0].EnvFrom = []v1.EnvFromSource{ + { + ConfigMapRef: &v1.ConfigMapEnvSource{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "config-map-name", + }, + }, + }, + } + template.Spec.Containers[0].Image = "gcr.io/foo/bar:baz" + template.Annotations = map[string]string{servinglib.UserImageAnnotationKey: "gcr.io/foo/bar:baz"} + svcs = append(svcs, CMservice) + + //case 5 - volume mount and secrets + Volservice := getService("foo") + template = &Volservice.Spec.Template + template.Spec.Volumes = []v1.Volume{ + { + Name: "volume-name", + VolumeSource: v1.VolumeSource{ + Secret: &v1.SecretVolumeSource{ + SecretName: "secret-name", + }, + }, + }, + } + + template.Spec.Containers[0].VolumeMounts = []v1.VolumeMount{ + { + Name: "volume-name", + MountPath: "/mount/path", + ReadOnly: true, + }, + } + svcs = append(svcs, Volservice) + + template.Spec.Containers[0].Image = "gcr.io/foo/bar:baz" + template.Annotations = map[string]string{servinglib.UserImageAnnotationKey: "gcr.io/foo/bar:baz"} + + for _, svc := range svcs { + svc.TypeMeta = typeMeta + callServiceExportTest(t, svc) + } + +} + +func callServiceExportTest(t *testing.T, expectedService *servingv1.Service) { + // New mock client + client := knclient.NewMockKnServiceClient(t) + + // Recording: + r := client.Recorder() + + r.GetService(expectedService.ObjectMeta.Name, expectedService, nil) + + output, err := executeServiceCommand(client, "export", expectedService.ObjectMeta.Name, "-o", "json") + assert.NilError(t, err) + + expectedService.ObjectMeta.Namespace = "" + + expectedService.Spec.Template.Spec.Containers[0].Resources = v1.ResourceRequirements{} + + actSvc := servingv1.Service{} + + json.Unmarshal([]byte(output), &actSvc) + + if diff := cmp.Diff(expectedService, &actSvc); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + + // Validate that all recorded API methods have been called + r.Validate() +} + +func TestServiceExportwithHistory(t *testing.T) { + // New mock client + client := knclient.NewMockKnServiceClient(t) + + // Recording: + r := client.Recorder() + + expectedService := createServiceWithTraffic("foo") + + r.GetService(expectedService.ObjectMeta.Name, expectedService, nil) + + revs := createTestRevisionList("rev", "foo") + + r.ListRevisions(mock.Any(), revs, nil) + + output, err := executeServiceCommand(client, "export", "foo", "-r", "-o", "json") + assert.NilError(t, err) + actSvcList := servingv1.ServiceList{} + + json.Unmarshal([]byte(output), &actSvcList) + + for i, actSvc := range actSvcList.Items { + var checkTraffic bool + if i == (len(actSvcList.Items) - 1) { + checkTraffic = true + } + validateServiceWithRevisionHistory(t, expectedService, revs, actSvc, checkTraffic) + } + + // Validate that all recorded API methods have been called + r.Validate() +} + +func validateServiceWithRevisionHistory(t *testing.T, expectedsvc *servingv1.Service, expectedRevList *servingv1.RevisionList, actualSvc servingv1.Service, checkTraffic bool) { + var expectedRev servingv1.Revision + var routeSpec servingv1.RouteSpec + for _, rev := range expectedRevList.Items { + if actualSvc.Spec.ConfigurationSpec.Template.ObjectMeta.Name == rev.ObjectMeta.Name { + expectedRev = rev + break + } + } + expectedsvc.Spec.ConfigurationSpec.Template.ObjectMeta.Name = expectedRev.ObjectMeta.Name + expectedsvc.Spec.Template.Spec = expectedRev.Spec + expectedsvc.ObjectMeta.Namespace = "" + expectedsvc.Spec.Template.Spec.Containers[0].Resources = v1.ResourceRequirements{} + expectedsvc.Status = servingv1.ServiceStatus{} + expectedsvc.ObjectMeta.Annotations = nil + expectedsvc.ObjectMeta.CreationTimestamp = metav1.Time{} + if !checkTraffic { + routeSpec = expectedsvc.Spec.RouteSpec + expectedsvc.Spec.RouteSpec = servingv1.RouteSpec{} + } + if diff := cmp.Diff(expectedsvc, &actualSvc); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + expectedsvc.Spec.RouteSpec = routeSpec +} + +func TestServiceExportError(t *testing.T) { + // New mock client + client := knclient.NewMockKnServiceClient(t) + + expectedService := getService("foo") + + _, err := executeServiceCommand(client, "export", expectedService.ObjectMeta.Name) + + assert.Error(t, err, "'kn service export' requires output format") +} + +func createTestRevisionList(revision string, service string) *servingv1.RevisionList { + labels1 := make(map[string]string) + labels1[apiserving.ConfigurationGenerationLabelKey] = "1" + labels1[apiserving.ServiceLabelKey] = service + + labels2 := make(map[string]string) + labels2[apiserving.ConfigurationGenerationLabelKey] = "2" + labels2[apiserving.ServiceLabelKey] = service + + rev1 := servingv1.Revision{ + TypeMeta: metav1.TypeMeta{ + Kind: "Revision", + APIVersion: "serving.knative.dev/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-%d", service, revision, 1), + Namespace: "default", + Generation: int64(1), + Labels: labels1, + }, + Spec: servingv1.RevisionSpec{ + PodSpec: v1.PodSpec{ + Containers: []v1.Container{ + { + Image: "gcr.io/test/image:v1", + Env: []v1.EnvVar{ + {Name: "env1", Value: "eval1"}, + {Name: "env2", Value: "eval2"}, + }, + EnvFrom: []v1.EnvFromSource{ + {ConfigMapRef: &v1.ConfigMapEnvSource{LocalObjectReference: v1.LocalObjectReference{Name: "test1"}}}, + {ConfigMapRef: &v1.ConfigMapEnvSource{LocalObjectReference: v1.LocalObjectReference{Name: "test2"}}}, + }, + Ports: []v1.ContainerPort{ + {ContainerPort: 8080}, + }, + }, + }, + }, + }, + } + + rev2 := rev1 + + rev2.Spec.PodSpec.Containers[0].Image = "gcr.io/test/image:v2" + rev2.ObjectMeta.Labels = labels2 + rev2.ObjectMeta.Generation = int64(2) + rev2.ObjectMeta.Name = fmt.Sprintf("%s-%s-%d", service, revision, 2) + + typeMeta := metav1.TypeMeta{ + APIVersion: "v1", + Kind: "List", + } + + return &servingv1.RevisionList{ + TypeMeta: typeMeta, + Items: []servingv1.Revision{rev1, rev2}, + } +} + +func createServiceWithTraffic(svc string) *servingv1.Service { + expectedService := createTestService(svc, []string{svc + "-rev-1", svc + "-rev-2"}, goodConditions()) + expectedService.Status.Traffic[0].LatestRevision = ptr.Bool(true) + expectedService.Status.Traffic[0].Tag = "latest" + expectedService.Status.Traffic[1].Tag = "current" + + var trafficList []servingv1.TrafficTarget + traffic1 := servingv1.TrafficTarget{ + RevisionName: "foo-rev-1", + Percent: ptr.Int64(int64(50)), + } + traffic2 := servingv1.TrafficTarget{ + RevisionName: "foo-rev-2", + Percent: ptr.Int64(int64(50)), + } + trafficList = append(trafficList, traffic1) + trafficList = append(trafficList, traffic2) + expectedService.Spec.RouteSpec = servingv1.RouteSpec{Traffic: trafficList} + + return &expectedService +} From 86f865afd9173db685132753242436817070e0c9 Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Sun, 16 Feb 2020 20:46:41 -0800 Subject: [PATCH 02/12] add kn export (#653) --- pkg/kn/commands/service/export.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/kn/commands/service/export.go b/pkg/kn/commands/service/export.go index d9e2478313..fb20faa9e0 100644 --- a/pkg/kn/commands/service/export.go +++ b/pkg/kn/commands/service/export.go @@ -86,9 +86,8 @@ func NewServiceExportCommand(p *commands.KnParams) *cobra.Command { return err } return printer.PrintObj(svcList, cmd.OutOrStdout()) - } else { - return printer.PrintObj(exportService(service), cmd.OutOrStdout()) } + return printer.PrintObj(exportService(service), cmd.OutOrStdout()) } return nil From 0cd315b9a4f462d426a2b1248766caaf90086ff2 Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Mon, 17 Feb 2020 10:33:01 -0800 Subject: [PATCH 03/12] review comments for #669 --- pkg/kn/commands/service/export.go | 34 +++++----- pkg/kn/commands/service/export_test.go | 86 ++++++++++++++++---------- 2 files changed, 70 insertions(+), 50 deletions(-) diff --git a/pkg/kn/commands/service/export.go b/pkg/kn/commands/service/export.go index fb20faa9e0..677633668d 100644 --- a/pkg/kn/commands/service/export.go +++ b/pkg/kn/commands/service/export.go @@ -26,7 +26,7 @@ import ( "k8s.io/cli-runtime/pkg/genericclioptions" "knative.dev/client/pkg/kn/commands" - clientservingv1alpha "knative.dev/client/pkg/serving/v1" + clientservingv1 "knative.dev/client/pkg/serving/v1" "knative.dev/serving/pkg/apis/serving" servingv1 "knative.dev/serving/pkg/apis/serving/v1" ) @@ -44,7 +44,7 @@ func NewServiceExportCommand(p *commands.KnParams) *cobra.Command { # Export a service in yaml format kn service export foo -n bar -o yaml # Export a service in json format - kn service export foo -n bar -o yaml`, + kn service export foo -n bar -o json`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return errors.New("'kn service export' requires name of the service as single argument") @@ -142,14 +142,14 @@ func constructServicefromRevision(latestSvc *servingv1.Service, revision serving return exportedSvc } -func exportServicewithActiveRevisions(latestSvc *servingv1.Service, client clientservingv1alpha.KnServingClient) (*servingv1.ServiceList, error) { +func exportServicewithActiveRevisions(latestSvc *servingv1.Service, client clientservingv1.KnServingClient) (*servingv1.ServiceList, error) { var exportedSvcItems []servingv1.Service //get revisions to export from traffic revsMap := getRevisionstoExport(latestSvc) - var params []clientservingv1alpha.ListConfig - params = append(params, clientservingv1alpha.WithService(latestSvc.ObjectMeta.Name)) + var params []clientservingv1.ListConfig + params = append(params, clientservingv1.WithService(latestSvc.ObjectMeta.Name)) // Query for list with filters revisionList, err := client.ListRevisions(params...) @@ -167,7 +167,7 @@ func exportServicewithActiveRevisions(latestSvc *servingv1.Service, client clien } //set traffic in the latest revision - exportedSvcItems[len(exportedSvcItems)-1] = setTrafficSplit(revsMap, exportedSvcItems[len(exportedSvcItems)-1]) + exportedSvcItems[len(exportedSvcItems)-1] = setTrafficSplit(latestSvc, revsMap, exportedSvcItems[len(exportedSvcItems)-1]) typeMeta := metav1.TypeMeta{ APIVersion: "v1", @@ -181,18 +181,18 @@ func exportServicewithActiveRevisions(latestSvc *servingv1.Service, client clien return exportedSvcList, nil } -func setTrafficSplit(revMap map[string]*int64, svc servingv1.Service) servingv1.Service { - var trafficList []servingv1.TrafficTarget - for k, v := range revMap { - traffic := servingv1.TrafficTarget{ - RevisionName: k, - Percent: v, - } - trafficList = append(trafficList, traffic) - } - svc.Spec.RouteSpec = servingv1.RouteSpec{Traffic: trafficList} +func setTrafficSplit(latestSvc *servingv1.Service, revMap map[string]*int64, exportedSvc servingv1.Service) servingv1.Service { + // var trafficList []servingv1.TrafficTarget + // for k, v := range revMap { + // traffic := servingv1.TrafficTarget{ + // RevisionName: k, + // Percent: v, + // } + // trafficList = append(trafficList, traffic) + // } + exportedSvc.Spec.RouteSpec = latestSvc.Spec.RouteSpec - return svc + return exportedSvc } func getRevisionstoExport(latestSvc *servingv1.Service) map[string]*int64 { diff --git a/pkg/kn/commands/service/export_test.go b/pkg/kn/commands/service/export_test.go index 2ebc4f2fcb..c6cd0d8204 100644 --- a/pkg/kn/commands/service/export_test.go +++ b/pkg/kn/commands/service/export_test.go @@ -19,7 +19,6 @@ import ( "fmt" "testing" - "github.com/google/go-cmp/cmp" "gotest.tools/assert" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -131,41 +130,51 @@ func callServiceExportTest(t *testing.T, expectedService *servingv1.Service) { r.GetService(expectedService.ObjectMeta.Name, expectedService, nil) output, err := executeServiceCommand(client, "export", expectedService.ObjectMeta.Name, "-o", "json") - assert.NilError(t, err) - expectedService.ObjectMeta.Namespace = "" + assert.NilError(t, err) - expectedService.Spec.Template.Spec.Containers[0].Resources = v1.ResourceRequirements{} + stripExpectedSvcVariables(expectedService) actSvc := servingv1.Service{} json.Unmarshal([]byte(output), &actSvc) - if diff := cmp.Diff(expectedService, &actSvc); diff != "" { - t.Errorf("mismatch (-want +got):\n%s", diff) - } + assert.DeepEqual(t, expectedService, &actSvc) // Validate that all recorded API methods have been called r.Validate() } -func TestServiceExportwithHistory(t *testing.T) { +func TestServiceExportwithMultipleRevisions(t *testing.T) { + + //case 1 = 2 revisions with traffic split + trafficSplitService := createServiceTwoRevsionsWithTraffic("foo", true) + + multiRevs := createTestRevisionList("rev", "foo") + + callServiceExportHistoryTest(t, trafficSplitService, multiRevs) + + //case 2 - same revsions no traffic split + noTrafficSplitService := createServiceTwoRevsionsWithTraffic("foo", false) + + callServiceExportHistoryTest(t, noTrafficSplitService, multiRevs) +} + +func callServiceExportHistoryTest(t *testing.T, expectedService *servingv1.Service, revs *servingv1.RevisionList) { // New mock client client := knclient.NewMockKnServiceClient(t) // Recording: r := client.Recorder() - expectedService := createServiceWithTraffic("foo") - r.GetService(expectedService.ObjectMeta.Name, expectedService, nil) - revs := createTestRevisionList("rev", "foo") - r.ListRevisions(mock.Any(), revs, nil) - output, err := executeServiceCommand(client, "export", "foo", "-r", "-o", "json") + output, err := executeServiceCommand(client, "export", expectedService.ObjectMeta.Name, "-r", "-o", "json") + assert.NilError(t, err) + actSvcList := servingv1.ServiceList{} json.Unmarshal([]byte(output), &actSvcList) @@ -193,18 +202,15 @@ func validateServiceWithRevisionHistory(t *testing.T, expectedsvc *servingv1.Ser } expectedsvc.Spec.ConfigurationSpec.Template.ObjectMeta.Name = expectedRev.ObjectMeta.Name expectedsvc.Spec.Template.Spec = expectedRev.Spec - expectedsvc.ObjectMeta.Namespace = "" - expectedsvc.Spec.Template.Spec.Containers[0].Resources = v1.ResourceRequirements{} - expectedsvc.Status = servingv1.ServiceStatus{} - expectedsvc.ObjectMeta.Annotations = nil - expectedsvc.ObjectMeta.CreationTimestamp = metav1.Time{} + + stripExpectedSvcVariables(expectedsvc) + if !checkTraffic { routeSpec = expectedsvc.Spec.RouteSpec expectedsvc.Spec.RouteSpec = servingv1.RouteSpec{} } - if diff := cmp.Diff(expectedsvc, &actualSvc); diff != "" { - t.Errorf("mismatch (-want +got):\n%s", diff) - } + assert.DeepEqual(t, expectedsvc, &actualSvc) + expectedsvc.Spec.RouteSpec = routeSpec } @@ -279,24 +285,38 @@ func createTestRevisionList(revision string, service string) *servingv1.Revision } } -func createServiceWithTraffic(svc string) *servingv1.Service { +func createServiceTwoRevsionsWithTraffic(svc string, trafficSplit bool) *servingv1.Service { expectedService := createTestService(svc, []string{svc + "-rev-1", svc + "-rev-2"}, goodConditions()) expectedService.Status.Traffic[0].LatestRevision = ptr.Bool(true) expectedService.Status.Traffic[0].Tag = "latest" expectedService.Status.Traffic[1].Tag = "current" - var trafficList []servingv1.TrafficTarget - traffic1 := servingv1.TrafficTarget{ - RevisionName: "foo-rev-1", - Percent: ptr.Int64(int64(50)), - } - traffic2 := servingv1.TrafficTarget{ - RevisionName: "foo-rev-2", - Percent: ptr.Int64(int64(50)), + if trafficSplit { + trafficList := []servingv1.TrafficTarget{ + servingv1.TrafficTarget{ + RevisionName: "foo-rev-1", + Percent: ptr.Int64(int64(50)), + }, servingv1.TrafficTarget{ + RevisionName: "foo-rev-2", + Percent: ptr.Int64(int64(50)), + }} + expectedService.Spec.RouteSpec = servingv1.RouteSpec{Traffic: trafficList} + } else { + trafficList := []servingv1.TrafficTarget{ + servingv1.TrafficTarget{ + RevisionName: "foo-rev-2", + Percent: ptr.Int64(int64(50)), + }} + expectedService.Spec.RouteSpec = servingv1.RouteSpec{Traffic: trafficList} } - trafficList = append(trafficList, traffic1) - trafficList = append(trafficList, traffic2) - expectedService.Spec.RouteSpec = servingv1.RouteSpec{Traffic: trafficList} return &expectedService } + +func stripExpectedSvcVariables(expectedsvc *servingv1.Service) { + expectedsvc.ObjectMeta.Namespace = "" + expectedsvc.Spec.Template.Spec.Containers[0].Resources = v1.ResourceRequirements{} + expectedsvc.Status = servingv1.ServiceStatus{} + expectedsvc.ObjectMeta.Annotations = nil + expectedsvc.ObjectMeta.CreationTimestamp = metav1.Time{} +} From 768ac45203513852c23b82371dafd900143cfcd7 Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Mon, 17 Feb 2020 10:39:47 -0800 Subject: [PATCH 04/12] add kn export command --- pkg/kn/commands/service/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/kn/commands/service/service.go b/pkg/kn/commands/service/service.go index a9eb58734c..b6f357e646 100644 --- a/pkg/kn/commands/service/service.go +++ b/pkg/kn/commands/service/service.go @@ -41,6 +41,7 @@ func NewServiceCommand(p *commands.KnParams) *cobra.Command { serviceCmd.AddCommand(NewServiceCreateCommand(p)) serviceCmd.AddCommand(NewServiceDeleteCommand(p)) serviceCmd.AddCommand(NewServiceUpdateCommand(p)) + serviceCmd.AddCommand(NewServiceExportCommand(p)) return serviceCmd } From 8082f9816f8ca8a2e0e711652be1387bfe1e26bd Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Mon, 17 Feb 2020 10:52:09 -0800 Subject: [PATCH 05/12] add kn export command --- pkg/kn/commands/service/export_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/kn/commands/service/export_test.go b/pkg/kn/commands/service/export_test.go index c6cd0d8204..fd1837ade7 100644 --- a/pkg/kn/commands/service/export_test.go +++ b/pkg/kn/commands/service/export_test.go @@ -293,17 +293,17 @@ func createServiceTwoRevsionsWithTraffic(svc string, trafficSplit bool) *serving if trafficSplit { trafficList := []servingv1.TrafficTarget{ - servingv1.TrafficTarget{ + { RevisionName: "foo-rev-1", Percent: ptr.Int64(int64(50)), - }, servingv1.TrafficTarget{ + }, { RevisionName: "foo-rev-2", Percent: ptr.Int64(int64(50)), }} expectedService.Spec.RouteSpec = servingv1.RouteSpec{Traffic: trafficList} } else { trafficList := []servingv1.TrafficTarget{ - servingv1.TrafficTarget{ + { RevisionName: "foo-rev-2", Percent: ptr.Int64(int64(50)), }} From edb7cf053b71564724c9c004907c3d181a7d53ce Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Mon, 17 Feb 2020 11:03:22 -0800 Subject: [PATCH 06/12] add kn export command --- docs/cmd/kn_service.md | 1 + docs/cmd/kn_service_export.md | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/cmd/kn_service_export.md diff --git a/docs/cmd/kn_service.md b/docs/cmd/kn_service.md index 9ba670df39..61e51155cf 100644 --- a/docs/cmd/kn_service.md +++ b/docs/cmd/kn_service.md @@ -30,6 +30,7 @@ kn service [flags] * [kn service create](kn_service_create.md) - Create a service. * [kn service delete](kn_service_delete.md) - Delete a service. * [kn service describe](kn_service_describe.md) - Show details of a service +* [kn service export](kn_service_export.md) - export a service * [kn service list](kn_service_list.md) - List available services. * [kn service update](kn_service_update.md) - Update a service. diff --git a/docs/cmd/kn_service_export.md b/docs/cmd/kn_service_export.md new file mode 100644 index 0000000000..02b53c0569 --- /dev/null +++ b/docs/cmd/kn_service_export.md @@ -0,0 +1,45 @@ +## kn service export + +export a service + +### Synopsis + +export a service + +``` +kn service export NAME [flags] +``` + +### Examples + +``` + + # Export a service in yaml format + kn service export foo -n bar -o yaml + # Export a service in json format + kn service export foo -n bar -o json +``` + +### Options + +``` + --allow-missing-template-keys If true, ignore any errors in templates when a field or map key is missing in the template. Only applies to golang and jsonpath output formats. (default true) + -h, --help help for export + -r, --history Export all active revisions + -n, --namespace string Specify the namespace to operate in. + -o, --output string Output format. One of: json|yaml|name|go-template|go-template-file|template|templatefile|jsonpath|jsonpath-file. + --template string Template string or path to template file to use when -o=go-template, -o=go-template-file. The template format is golang templates [http://golang.org/pkg/text/template/#pkg-overview]. +``` + +### Options inherited from parent commands + +``` + --config string kn config file (default is $HOME/.kn/config.yaml) + --kubeconfig string kubectl config file (default is $HOME/.kube/config) + --log-http log http traffic +``` + +### SEE ALSO + +* [kn service](kn_service.md) - Service command group + From 43baea4b3f2f69d36340aca2fea76283985f243c Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Tue, 18 Feb 2020 13:51:39 -0800 Subject: [PATCH 07/12] add kn export command --- pkg/kn/commands/service/export.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/pkg/kn/commands/service/export.go b/pkg/kn/commands/service/export.go index 677633668d..61e30065d1 100644 --- a/pkg/kn/commands/service/export.go +++ b/pkg/kn/commands/service/export.go @@ -167,7 +167,7 @@ func exportServicewithActiveRevisions(latestSvc *servingv1.Service, client clien } //set traffic in the latest revision - exportedSvcItems[len(exportedSvcItems)-1] = setTrafficSplit(latestSvc, revsMap, exportedSvcItems[len(exportedSvcItems)-1]) + exportedSvcItems[len(exportedSvcItems)-1] = setTrafficSplit(latestSvc, exportedSvcItems[len(exportedSvcItems)-1]) typeMeta := metav1.TypeMeta{ APIVersion: "v1", @@ -181,15 +181,8 @@ func exportServicewithActiveRevisions(latestSvc *servingv1.Service, client clien return exportedSvcList, nil } -func setTrafficSplit(latestSvc *servingv1.Service, revMap map[string]*int64, exportedSvc servingv1.Service) servingv1.Service { - // var trafficList []servingv1.TrafficTarget - // for k, v := range revMap { - // traffic := servingv1.TrafficTarget{ - // RevisionName: k, - // Percent: v, - // } - // trafficList = append(trafficList, traffic) - // } +func setTrafficSplit(latestSvc *servingv1.Service, exportedSvc servingv1.Service) servingv1.Service { + exportedSvc.Spec.RouteSpec = latestSvc.Spec.RouteSpec return exportedSvc From 3da751c62003d140a2a9472f82a2cd2ab168e87f Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Tue, 18 Feb 2020 16:59:42 -0800 Subject: [PATCH 08/12] add changelog for pr 679 --- CHANGELOG.adoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index c7d3bf4cdb..bc29f12352 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -52,6 +52,10 @@ | 🐛 | Fix `--image` flag to only allow single occurence | https://github.com/knative/client/pull/647[#647] + +| 🎁 +| add kn service export command for exporting a service +| https://github.com/knative/client/pull/669[#669] |=== ## v0.12.0 (2020-01-29) From 4abb2be4114381380afd5598ed617016d5301eed Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Wed, 19 Feb 2020 05:54:01 -0800 Subject: [PATCH 09/12] add changelog --- CHANGELOG.adoc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index bc29f12352..060aaef200 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -45,8 +45,7 @@ | https://github.com/knative/client/pull/639[#639] | 🧽 -| Refactor service `create_test.go` and `update_test.go` to -| remove unecessary `sync` parameter in setup call +| Refactor service `create_test.go` and `update_test.go` to remove unecessary `sync` parameter in setup call | https://github.com/knative/client/pull/656[#656] | 🐛 From 5854bb3973d87ec2257c7f42a82b0cefa0dc5628 Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Fri, 28 Feb 2020 17:45:32 -0800 Subject: [PATCH 10/12] review comments for pr #669 --- docs/cmd/kn_service_export.md | 4 +-- pkg/kn/commands/service/export.go | 43 ++++++++++++-------------- pkg/kn/commands/service/export_test.go | 37 ++++++++++++++++++++-- 3 files changed, 56 insertions(+), 28 deletions(-) diff --git a/docs/cmd/kn_service_export.md b/docs/cmd/kn_service_export.md index 02b53c0569..f9491301d8 100644 --- a/docs/cmd/kn_service_export.md +++ b/docs/cmd/kn_service_export.md @@ -34,8 +34,8 @@ kn service export NAME [flags] ### Options inherited from parent commands ``` - --config string kn config file (default is $HOME/.kn/config.yaml) - --kubeconfig string kubectl config file (default is $HOME/.kube/config) + --config string kn config file (default is ~/.config/kn/config.yaml) + --kubeconfig string kubectl config file (default is ~/.kube/config) --log-http log http traffic ``` diff --git a/pkg/kn/commands/service/export.go b/pkg/kn/commands/service/export.go index 61e30065d1..a2cf1aa726 100644 --- a/pkg/kn/commands/service/export.go +++ b/pkg/kn/commands/service/export.go @@ -16,6 +16,7 @@ package service import ( "errors" + "fmt" "sort" "strconv" @@ -74,23 +75,19 @@ func NewServiceExportCommand(p *commands.KnParams) *cobra.Command { return err } - // Print out machine readable output if requested - if machineReadablePrintFlags.OutputFlagSpecified() { - printer, err := machineReadablePrintFlags.ToPrinter() - if err != nil { + printer, err := machineReadablePrintFlags.ToPrinter() + if err != nil { + return err + } + + if history { + if svcList, err := exportServicewithActiveRevisions(service, client); err != nil { return err - } - if history { - svcList, err := exportServicewithActiveRevisions(service, client) - if err != nil { - return err - } + } else { return printer.PrintObj(svcList, cmd.OutOrStdout()) } - return printer.PrintObj(exportService(service), cmd.OutOrStdout()) } - - return nil + return printer.PrintObj(exportService(service), cmd.OutOrStdout()) }, } flags := command.Flags() @@ -100,11 +97,7 @@ func NewServiceExportCommand(p *commands.KnParams) *cobra.Command { return command } -func exportService(latestService *servingv1.Service) *servingv1.Service { - return constructServiceTemplate(latestService) -} - -func constructServiceTemplate(latestSvc *servingv1.Service) *servingv1.Service { +func exportService(latestSvc *servingv1.Service) *servingv1.Service { exportedSvc := servingv1.Service{ ObjectMeta: metav1.ObjectMeta{ @@ -156,12 +149,16 @@ func exportServicewithActiveRevisions(latestSvc *servingv1.Service, client clien if err != nil { return nil, err } + if len(revisionList.Items) == 0 { + return nil, fmt.Errorf("No revisions found for the service %s", latestSvc.ObjectMeta.Name) + } + // sort revisions to main the order of generations sortRevisions(revisionList) for _, revision := range revisionList.Items { //construct service only for active revisions - if revsMap[revision.ObjectMeta.Name] != nil { + if revsMap[revision.ObjectMeta.Name] { exportedSvcItems = append(exportedSvcItems, constructServicefromRevision(latestSvc, revision)) } } @@ -188,15 +185,15 @@ func setTrafficSplit(latestSvc *servingv1.Service, exportedSvc servingv1.Service return exportedSvc } -func getRevisionstoExport(latestSvc *servingv1.Service) map[string]*int64 { +func getRevisionstoExport(latestSvc *servingv1.Service) map[string]bool { trafficList := latestSvc.Spec.RouteSpec.Traffic - revsMap := make(map[string]*int64) + revsMap := make(map[string]bool) for _, traffic := range trafficList { if traffic.RevisionName == "" { - revsMap[latestSvc.Spec.ConfigurationSpec.Template.ObjectMeta.Name] = traffic.Percent + revsMap[latestSvc.Spec.ConfigurationSpec.Template.ObjectMeta.Name] = true } else { - revsMap[traffic.RevisionName] = traffic.Percent + revsMap[traffic.RevisionName] = true } } return revsMap diff --git a/pkg/kn/commands/service/export_test.go b/pkg/kn/commands/service/export_test.go index fd1837ade7..3e55624d9a 100644 --- a/pkg/kn/commands/service/export_test.go +++ b/pkg/kn/commands/service/export_test.go @@ -19,6 +19,7 @@ import ( "fmt" "testing" + "gopkg.in/yaml.v2" "gotest.tools/assert" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -31,7 +32,6 @@ import ( ) func TestServiceExport(t *testing.T) { - var svcs []*servingv1.Service typeMeta := metav1.TypeMeta{ Kind: "service", @@ -120,6 +120,38 @@ func TestServiceExport(t *testing.T) { } +func TestServiceExportYaml(t *testing.T) { + typeMeta := metav1.TypeMeta{ + Kind: "service", + APIVersion: "serving.knative.dev/v1", + } + + expService := getService("foo") + expService.TypeMeta = typeMeta + client := knclient.NewMockKnServiceClient(t) + r := client.Recorder() + r.GetService(expService.ObjectMeta.Name, expService, nil) + + output, err := executeServiceCommand(client, "export", expService.ObjectMeta.Name, "-o", "yaml") + assert.NilError(t, err) + + var actSvc map[string]interface{} + + err = yaml.Unmarshal([]byte(output), &actSvc) + + assert.NilError(t, err) + + metaData := (actSvc["metadata"]).(map[interface{}]interface{}) + + actSvcName := fmt.Sprintf("%v", metaData["name"]) + + assert.DeepEqual(t, expService.ObjectMeta.Name, actSvcName) + + // Validate that all recorded API methods have been called + r.Validate() + +} + func callServiceExportTest(t *testing.T, expectedService *servingv1.Service) { // New mock client client := knclient.NewMockKnServiceClient(t) @@ -146,7 +178,6 @@ func callServiceExportTest(t *testing.T, expectedService *servingv1.Service) { } func TestServiceExportwithMultipleRevisions(t *testing.T) { - //case 1 = 2 revisions with traffic split trafficSplitService := createServiceTwoRevsionsWithTraffic("foo", true) @@ -154,7 +185,7 @@ func TestServiceExportwithMultipleRevisions(t *testing.T) { callServiceExportHistoryTest(t, trafficSplitService, multiRevs) - //case 2 - same revsions no traffic split + //case 2 - same revisions no traffic split noTrafficSplitService := createServiceTwoRevsionsWithTraffic("foo", false) callServiceExportHistoryTest(t, noTrafficSplitService, multiRevs) From ec9d369c45c2a0a464fe25553ed261dc3eeb9f6e Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Fri, 28 Feb 2020 17:53:29 -0800 Subject: [PATCH 11/12] review comments for pr #669 --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index 09b20bed92..b7d5cf8d2e 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.4.0 golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 + gopkg.in/yaml.v2 v2.2.4 gotest.tools v2.2.0+incompatible k8s.io/api v0.17.0 k8s.io/apimachinery v0.17.0 From d3174299aacb2767702762bce9723ee1f2d8a774 Mon Sep 17 00:00:00 2001 From: itsmurugappan Date: Fri, 28 Feb 2020 19:40:19 -0800 Subject: [PATCH 12/12] review comments for kn export --- go.mod | 1 - pkg/kn/commands/service/export_test.go | 45 ++++---------------------- 2 files changed, 7 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index b7d5cf8d2e..09b20bed92 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.4.0 golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 - gopkg.in/yaml.v2 v2.2.4 gotest.tools v2.2.0+incompatible k8s.io/api v0.17.0 k8s.io/apimachinery v0.17.0 diff --git a/pkg/kn/commands/service/export_test.go b/pkg/kn/commands/service/export_test.go index 3e55624d9a..6b9be103a2 100644 --- a/pkg/kn/commands/service/export_test.go +++ b/pkg/kn/commands/service/export_test.go @@ -19,8 +19,8 @@ import ( "fmt" "testing" - "gopkg.in/yaml.v2" "gotest.tools/assert" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" servinglib "knative.dev/client/pkg/serving" @@ -29,6 +29,7 @@ import ( "knative.dev/pkg/ptr" apiserving "knative.dev/serving/pkg/apis/serving" servingv1 "knative.dev/serving/pkg/apis/serving/v1" + "sigs.k8s.io/yaml" ) func TestServiceExport(t *testing.T) { @@ -120,38 +121,6 @@ func TestServiceExport(t *testing.T) { } -func TestServiceExportYaml(t *testing.T) { - typeMeta := metav1.TypeMeta{ - Kind: "service", - APIVersion: "serving.knative.dev/v1", - } - - expService := getService("foo") - expService.TypeMeta = typeMeta - client := knclient.NewMockKnServiceClient(t) - r := client.Recorder() - r.GetService(expService.ObjectMeta.Name, expService, nil) - - output, err := executeServiceCommand(client, "export", expService.ObjectMeta.Name, "-o", "yaml") - assert.NilError(t, err) - - var actSvc map[string]interface{} - - err = yaml.Unmarshal([]byte(output), &actSvc) - - assert.NilError(t, err) - - metaData := (actSvc["metadata"]).(map[interface{}]interface{}) - - actSvcName := fmt.Sprintf("%v", metaData["name"]) - - assert.DeepEqual(t, expService.ObjectMeta.Name, actSvcName) - - // Validate that all recorded API methods have been called - r.Validate() - -} - func callServiceExportTest(t *testing.T, expectedService *servingv1.Service) { // New mock client client := knclient.NewMockKnServiceClient(t) @@ -161,17 +130,17 @@ func callServiceExportTest(t *testing.T, expectedService *servingv1.Service) { r.GetService(expectedService.ObjectMeta.Name, expectedService, nil) - output, err := executeServiceCommand(client, "export", expectedService.ObjectMeta.Name, "-o", "json") + output, err := executeServiceCommand(client, "export", expectedService.ObjectMeta.Name, "-o", "yaml") assert.NilError(t, err) - stripExpectedSvcVariables(expectedService) + expectedService.ObjectMeta.Namespace = "" - actSvc := servingv1.Service{} + expSvcYaml, err := yaml.Marshal(expectedService) - json.Unmarshal([]byte(output), &actSvc) + assert.NilError(t, err) - assert.DeepEqual(t, expectedService, &actSvc) + assert.Equal(t, string(expSvcYaml), output) // Validate that all recorded API methods have been called r.Validate()