diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index eda9aebe1..c561ba9a4 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -45,6 +45,10 @@ | 🎁 | Add WithLabel list filter to serving client lib | https://github.com/knative/client/pull/1054[#1054] + +| 🎁 +| Add `kn service import` command (experimental) +| https://github.com/knative/client/pull/1065[#1065] |=== ## v0.18.1 (2020-10-13) diff --git a/docs/cmd/kn_service.md b/docs/cmd/kn_service.md index 27ff8f534..5e96c2e01 100644 --- a/docs/cmd/kn_service.md +++ b/docs/cmd/kn_service.md @@ -32,6 +32,7 @@ kn service * [kn service delete](kn_service_delete.md) - Delete services * [kn service describe](kn_service_describe.md) - Show details of a service * [kn service export](kn_service_export.md) - Export a service and its revisions +* [kn service import](kn_service_import.md) - Import a service and its revisions (experimental) * [kn service list](kn_service_list.md) - List services * [kn service update](kn_service_update.md) - Update a service diff --git a/docs/cmd/kn_service_import.md b/docs/cmd/kn_service_import.md new file mode 100644 index 000000000..78440eab1 --- /dev/null +++ b/docs/cmd/kn_service_import.md @@ -0,0 +1,45 @@ +## kn service import + +Import a service and its revisions (experimental) + +### Synopsis + +Import a service and its revisions (experimental) + +``` +kn service import FILENAME +``` + +### Examples + +``` + + # Import a service from YAML file + kn service import /path/to/file.yaml + + # Import a service from JSON file + kn service import /path/to/file.json +``` + +### Options + +``` + -h, --help help for import + -n, --namespace string Specify the namespace to operate in. + --no-wait Do not wait for 'service import' operation to be completed. + --wait Wait for 'service import' operation to be completed. (default true) + --wait-timeout int Seconds to wait before giving up on waiting for service to be ready. (default 600) +``` + +### Options inherited from parent commands + +``` + --config string kn configuration file (default: ~/.config/kn/config.yaml) + --kubeconfig string kubectl configuration file (default: ~/.kube/config) + --log-http log http traffic +``` + +### SEE ALSO + +* [kn service](kn_service.md) - Manage Knative services + diff --git a/pkg/kn/commands/service/import.go b/pkg/kn/commands/service/import.go new file mode 100644 index 000000000..a785714d7 --- /dev/null +++ b/pkg/kn/commands/service/import.go @@ -0,0 +1,143 @@ +// 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" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/util/retry" + + clientv1alpha1 "knative.dev/client/pkg/apis/client/v1alpha1" + "knative.dev/client/pkg/kn/commands" + clientservingv1 "knative.dev/client/pkg/serving/v1" + "knative.dev/pkg/kmeta" + servingv1 "knative.dev/serving/pkg/apis/serving/v1" +) + +// NewServiceImportCommand returns a new command for importing a service. +func NewServiceImportCommand(p *commands.KnParams) *cobra.Command { + var waitFlags commands.WaitFlags + + command := &cobra.Command{ + Use: "import FILENAME", + Short: "Import a service and its revisions (experimental)", + Example: ` + # Import a service from YAML file + kn service import /path/to/file.yaml + + # Import a service from JSON file + kn service import /path/to/file.json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("'kn service import' requires filename of import file as single argument") + } + filename := args[0] + + namespace, err := p.GetNamespace(cmd) + if err != nil { + return err + } + + client, err := p.NewServingClient(namespace) + if err != nil { + return err + } + + return importWithOwnerRef(client, filename, cmd.OutOrStdout(), waitFlags) + }, + } + flags := command.Flags() + commands.AddNamespaceFlags(flags, false) + waitFlags.AddConditionWaitFlags(command, commands.WaitDefaultTimeout, "import", "service", "ready") + + return command +} + +func importWithOwnerRef(client clientservingv1.KnServingClient, filename string, out io.Writer, waitFlags commands.WaitFlags) error { + var export clientv1alpha1.Export + file, err := os.Open(filename) + if err != nil { + return err + } + decoder := yaml.NewYAMLOrJSONDecoder(file, 512) + err = decoder.Decode(&export) + if err != nil { + return err + } + if export.Spec.Service.Name == "" { + return fmt.Errorf("provided import file doesn't contain service name, please note that only kn's custom export format is supported") + } + + serviceName := export.Spec.Service.Name + + // Return error if service already exists + svcExists, err := serviceExists(client, serviceName) + if err != nil { + return err + } + if svcExists { + return fmt.Errorf("cannot import service '%s' in namespace '%s' because the service already exists", + serviceName, client.Namespace()) + } + + err = client.CreateService(&export.Spec.Service) + if err != nil { + return err + } + + // Retrieve current Configuration to be use in OwnerReference + currentConf, err := getConfigurationWithRetry(client, serviceName) + if err != nil { + return err + } + + // Create revision with current Configuration's OwnerReference + if len(export.Spec.Revisions) > 0 { + for _, r := range export.Spec.Revisions { + tmp := r.DeepCopy() + // OwnerRef ensures that Revisions are recognized by controller + tmp.OwnerReferences = []metav1.OwnerReference{*kmeta.NewControllerRef(currentConf)} + if err = client.CreateRevision(tmp); err != nil { + return err + } + } + } + + err = waitIfRequested(client, serviceName, waitFlags, "Importing", "imported", out) + if err != nil { + return err + } + return err +} + +func getConfigurationWithRetry(client clientservingv1.KnServingClient, name string) (*servingv1.Configuration, error) { + var conf *servingv1.Configuration + var err error + err = retry.OnError(retry.DefaultBackoff, func(err error) bool { + return apierrors.IsNotFound(err) + }, func() error { + conf, err = client.GetConfiguration(name) + return err + }) + return conf, err +} diff --git a/pkg/kn/commands/service/import_test.go b/pkg/kn/commands/service/import_test.go new file mode 100644 index 000000000..684812a2c --- /dev/null +++ b/pkg/kn/commands/service/import_test.go @@ -0,0 +1,280 @@ +// 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 ( + "io/ioutil" + "os" + "path/filepath" + "testing" + "time" + + "gotest.tools/assert" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + knclient "knative.dev/client/pkg/serving/v1" + "knative.dev/client/pkg/util" + "knative.dev/client/pkg/util/mock" + "knative.dev/client/pkg/wait" + servingv1 "knative.dev/serving/pkg/apis/serving/v1" +) + +func TestServiceImportFilenameError(t *testing.T) { + client := knclient.NewMockKnServiceClient(t) + r := client.Recorder() + + _, err := executeServiceCommand(client, "import") + + assert.Assert(t, err != nil) + assert.Assert(t, util.ContainsAll(err.Error(), "'kn service import'", "requires", "file", "single", "argument")) + assert.Error(t, err, "'kn service import' requires filename of import file as single argument") + r.Validate() +} + +func TestServiceImportExistError(t *testing.T) { + file, err := generateFile([]byte(exportYAML)) + assert.NilError(t, err) + defer os.RemoveAll(filepath.Dir(file)) + + client := knclient.NewMockKnServiceClient(t) + r := client.Recorder() + + r.GetService("foo", nil, nil) + _, err = executeServiceCommand(client, "import", file) + + assert.Assert(t, err != nil) + assert.Assert(t, util.ContainsAll(err.Error(), "'foo'", "default", "service", "already", "exists")) + r.Validate() +} + +func TestServiceImport(t *testing.T) { + file, err := generateFile([]byte(exportYAML)) + assert.NilError(t, err) + defer os.RemoveAll(filepath.Dir(file)) + + client := knclient.NewMockKnServiceClient(t) + r := client.Recorder() + + r.GetService("foo", nil, errors.NewNotFound(servingv1.Resource("service"), "foo")) + r.CreateService(mock.Any(), nil) + r.GetConfiguration("foo", nil, nil) + r.WaitForService("foo", mock.Any(), wait.NoopMessageCallback(), nil, time.Second) + r.GetService("foo", getServiceWithUrl("foo", "http://foo.example.com"), nil) + + out, err := executeServiceCommand(client, "import", file) + assert.NilError(t, err) + assert.Assert(t, util.ContainsAll(out, "Service", "'foo'", "default", "imported")) + r.Validate() +} + +func TestServiceImportNoWait(t *testing.T) { + file, err := generateFile([]byte(exportYAML)) + assert.NilError(t, err) + defer os.RemoveAll(filepath.Dir(file)) + + client := knclient.NewMockKnServiceClient(t) + r := client.Recorder() + + r.GetService("foo", nil, errors.NewNotFound(servingv1.Resource("service"), "foo")) + r.CreateService(mock.Any(), nil) + r.GetConfiguration("foo", nil, nil) + + out, err := executeServiceCommand(client, "import", file, "--no-wait") + assert.NilError(t, err) + assert.Assert(t, util.ContainsAll(out, "Service", "imported", "foo")) + r.Validate() +} + +func TestServiceImportWitRevisions(t *testing.T) { + file, err := generateFile([]byte(exportWithRevisionsYAML)) + assert.NilError(t, err) + defer os.RemoveAll(filepath.Dir(file)) + + client := knclient.NewMockKnServiceClient(t) + r := client.Recorder() + + r.GetService("foo", nil, errors.NewNotFound(servingv1.Resource("service"), "foo")) + r.CreateService(mock.Any(), nil) + r.GetConfiguration("foo", getConfiguration("foo"), nil) + // 2 previous Revisions to re-create + 1 latest from CreateService + r.CreateRevision(mock.Any(), nil) + r.CreateRevision(mock.Any(), nil) + r.WaitForService("foo", mock.Any(), wait.NoopMessageCallback(), nil, time.Second) + r.GetService("foo", getServiceWithUrl("foo", "http://foo.example.com"), nil) + + out, err := executeServiceCommand(client, "import", file) + assert.NilError(t, err) + assert.Assert(t, util.ContainsAll(out, "Service", "imported", "foo")) + r.Validate() +} + +func generateFile(fileContent []byte) (string, error) { + tempDir, err := ioutil.TempDir("", "kn-file") + if err != nil { + return "", err + } + + tempFile := filepath.Join(tempDir, "import.yaml") + if err = ioutil.WriteFile(tempFile, fileContent, os.FileMode(0666)); err != nil { + return "", err + } + return tempFile, nil +} + +func getConfiguration(name string) *servingv1.Configuration { + return &servingv1.Configuration{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } +} + +var exportYAML = ` +apiVersion: client.knative.dev/v1alpha1 +kind: Export +metadata: + creationTimestamp: null +spec: + revisions: null + service: + apiVersion: serving.knative.dev/v1 + kind: Service + metadata: + name: foo + spec: + template: + metadata: + spec: + containerConcurrency: 0 + containers: + - env: + - name: TARGET + value: v1 + image: gcr.io/foo/bar:baz + name: user-container + readinessProbe: + successThreshold: 1 + tcpSocket: + port: 0 + resources: {} + enableServiceLinks: false + timeoutSeconds: 300 + status: {} +` + +var exportWithRevisionsYAML = ` +apiVersion: client.knative.dev/v1alpha1 +kind: Export +metadata: + creationTimestamp: null +spec: + revisions: + - apiVersion: serving.knative.dev/v1 + kind: Revision + metadata: + annotations: + client.knative.dev/user-image: gcr.io/foo/bar:baz + serving.knative.dev/routes: foo + creationTimestamp: null + labels: + serving.knative.dev/configuration: foo + serving.knative.dev/configurationGeneration: "1" + serving.knative.dev/routingState: active + serving.knative.dev/service: foo + name: foo-rev-1 + spec: + containerConcurrency: 0 + containers: + - env: + - name: TARGET + value: v1 + image: gcr.io/foo/bar:baz + name: user-container + readinessProbe: + successThreshold: 1 + tcpSocket: + port: 0 + resources: {} + enableServiceLinks: false + timeoutSeconds: 300 + status: {} + - apiVersion: serving.knative.dev/v1 + kind: Revision + metadata: + annotations: + client.knative.dev/user-image: gcr.io/foo/bar:baz + serving.knative.dev/routes: foo + creationTimestamp: null + labels: + serving.knative.dev/configuration: foo + serving.knative.dev/configurationGeneration: "2" + serving.knative.dev/routingState: active + serving.knative.dev/service: foo + name: foo-rev-2 + spec: + containerConcurrency: 0 + containers: + - env: + - name: TARGET + value: v2 + image: gcr.io/foo/bar:baz + name: user-container + readinessProbe: + successThreshold: 1 + tcpSocket: + port: 0 + resources: {} + enableServiceLinks: false + timeoutSeconds: 300 + status: {} + service: + apiVersion: serving.knative.dev/v1 + kind: Service + metadata: + creationTimestamp: null + name: foo + spec: + template: + metadata: + annotations: + client.knative.dev/user-image: gcr.io/foo/bar:baz + name: foo-rev-3 + spec: + containerConcurrency: 0 + containers: + - env: + - name: TARGET + value: v3 + image: gcr.io/foo/bar:baz + name: user-container + readinessProbe: + successThreshold: 1 + tcpSocket: + port: 0 + resources: {} + enableServiceLinks: false + timeoutSeconds: 300 + traffic: + - latestRevision: false + percent: 25 + revisionName: foo-rev-1 + - latestRevision: false + percent: 25 + revisionName: foo-rev-2 + - latestRevision: false + percent: 50 + revisionName: foo-rev-3 + status: {} +` diff --git a/pkg/kn/commands/service/service.go b/pkg/kn/commands/service/service.go index 72c73a042..c8126e550 100644 --- a/pkg/kn/commands/service/service.go +++ b/pkg/kn/commands/service/service.go @@ -44,6 +44,7 @@ func NewServiceCommand(p *commands.KnParams) *cobra.Command { serviceCmd.AddCommand(NewServiceUpdateCommand(p)) serviceCmd.AddCommand(NewServiceApplyCommand(p)) serviceCmd.AddCommand(NewServiceExportCommand(p)) + serviceCmd.AddCommand(NewServiceImportCommand(p)) return serviceCmd } diff --git a/pkg/serving/v1/client.go b/pkg/serving/v1/client.go index aa60f6bd8..108a1890e 100644 --- a/pkg/serving/v1/client.go +++ b/pkg/serving/v1/client.go @@ -97,6 +97,12 @@ type KnServingClient interface { // current template. GetBaseRevision(service *servingv1.Service) (*servingv1.Revision, error) + // Create revision + CreateRevision(revision *servingv1.Revision) error + + // Update revision + UpdateRevision(revision *servingv1.Revision) error + // List revisions ListRevisions(opts ...ListConfig) (*servingv1.RevisionList, error) @@ -423,6 +429,24 @@ func getBaseRevision(cl KnServingClient, service *servingv1.Service) (*servingv1 return nil, noBaseRevisionError } +// Create a revision +func (cl *knServingClient) CreateRevision(revision *servingv1.Revision) error { + rev, err := cl.client.Revisions(cl.namespace).Create(context.TODO(), revision, v1.CreateOptions{}) + if err != nil { + return clienterrors.GetError(err) + } + return updateServingGvk(rev) +} + +// Update the given service +func (cl *knServingClient) UpdateRevision(revision *servingv1.Revision) error { + _, err := cl.client.Revisions(cl.namespace).Update(context.TODO(), revision, v1.UpdateOptions{}) + if err != nil { + return err + } + return updateServingGvk(revision) +} + // Delete a revision by name func (cl *knServingClient) DeleteRevision(name string, timeout time.Duration) error { revision, err := cl.client.Revisions(cl.namespace).Get(context.TODO(), name, v1.GetOptions{}) diff --git a/pkg/serving/v1/client_mock.go b/pkg/serving/v1/client_mock.go index 2586b269a..8d72373e5 100644 --- a/pkg/serving/v1/client_mock.go +++ b/pkg/serving/v1/client_mock.go @@ -202,6 +202,28 @@ func (c *MockKnServingClient) GetConfiguration(name string) (*servingv1.Configur return call.Result[0].(*servingv1.Configuration), mock.ErrorOrNil(call.Result[1]) } +// CreateRevision records a call CreateRevision +func (sr *ServingRecorder) CreateRevision(revision interface{}, err error) { + sr.r.Add("CreateRevision", []interface{}{revision}, []interface{}{err}) +} + +// CreateRevision creates a new revision +func (c *MockKnServingClient) CreateRevision(revision *servingv1.Revision) error { + call := c.recorder.r.VerifyCall("CreateRevision", revision) + return mock.ErrorOrNil(call.Result[0]) +} + +// UpdateRevision records a call UpdateRevision +func (sr *ServingRecorder) UpdateRevision(revision interface{}, err error) { + sr.r.Add("UpdateRevision", []interface{}{revision}, []interface{}{err}) +} + +// UpdateRevision updates given revision +func (c *MockKnServingClient) UpdateRevision(revision *servingv1.Revision) error { + call := c.recorder.r.VerifyCall("UpdateRevision", revision) + return mock.ErrorOrNil(call.Result[0]) +} + // Check that every recorded method has been called func (sr *ServingRecorder) Validate() { sr.r.CheckThatAllRecordedMethodsHaveBeenCalled() diff --git a/pkg/serving/v1/client_mock_test.go b/pkg/serving/v1/client_mock_test.go index 7ec9738af..a44f355cc 100644 --- a/pkg/serving/v1/client_mock_test.go +++ b/pkg/serving/v1/client_mock_test.go @@ -42,6 +42,8 @@ func TestMockKnClient(t *testing.T) { recorder.WaitForService("hello", time.Duration(10)*time.Second, wait.NoopMessageCallback(), nil, 10*time.Second) recorder.GetRevision("hello", nil, nil) recorder.ListRevisions(mock.Any(), nil, nil) + recorder.CreateRevision(&servingv1.Revision{}, nil) + recorder.UpdateRevision(&servingv1.Revision{}, nil) recorder.DeleteRevision("hello", time.Duration(10)*time.Second, nil) recorder.GetRoute("hello", nil, nil) recorder.ListRoutes(mock.Any(), nil, nil) @@ -58,6 +60,8 @@ func TestMockKnClient(t *testing.T) { client.WaitForService("hello", time.Duration(10)*time.Second, wait.NoopMessageCallback()) client.GetRevision("hello") client.ListRevisions(WithName("blub")) + client.CreateRevision(&servingv1.Revision{}) + client.UpdateRevision(&servingv1.Revision{}) client.DeleteRevision("hello", time.Duration(10)*time.Second) client.GetRoute("hello") client.ListRoutes(WithName("blub")) diff --git a/test/e2e/service_import_test.go b/test/e2e/service_import_test.go new file mode 100644 index 000000000..903e91cef --- /dev/null +++ b/test/e2e/service_import_test.go @@ -0,0 +1,94 @@ +// Copyright 2019 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 im +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build e2e +// +build !eventing + +package e2e + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "gotest.tools/assert" + "knative.dev/client/lib/test" + "knative.dev/client/pkg/util" +) + +func TestServiceImport(t *testing.T) { + t.Parallel() + it, err := test.NewKnTest() + assert.NilError(t, err) + defer func() { + assert.NilError(t, it.Teardown()) + }() + + r := test.NewKnRunResultCollector(t, it) + defer r.DumpIfFailed() + + tempDir, err := ioutil.TempDir("", "kn-file") + defer os.RemoveAll(tempDir) + assert.NilError(t, err) + + t.Log("import service foo with revision") + testFile := filepath.Join(tempDir, "foo-with-revisions") + + serviceCreateWithOptions(r, "foo", "--revision-name", "foo-rev-1") + test.ServiceUpdate(r, "foo", "--env", "TARGET=v2", "--revision-name", "foo-rev-2") + test.ServiceUpdate(r, "foo", "--traffic", "foo-rev-1=50,foo-rev-2=50") + serviceExportToFile(r, "foo", testFile, true) + test.ServiceDelete(r, "foo") + serviceImport(r, testFile) + + t.Log("import existing service foo error") + serviceImportExistsError(r, testFile) + + t.Log("import service from missing file error") + serviceImportFileError(r, testFile+"-missing") +} + +func serviceExportToFile(r *test.KnRunResultCollector, serviceName, filename string, withRevisions bool) { + command := []string{"service", "export", serviceName, "-o", "yaml", "--mode", "export"} + if withRevisions { + command = append(command, "--with-revisions") + } + out := r.KnTest().Kn().Run(command...) + r.AssertNoError(out) + err := ioutil.WriteFile(filename, []byte(out.Stdout), test.FileModeReadWrite) + assert.NilError(r.T(), err) +} + +func serviceImport(r *test.KnRunResultCollector, filename string) { + command := []string{"service", "import", filename} + + out := r.KnTest().Kn().Run(command...) + r.AssertNoError(out) + assert.Check(r.T(), util.ContainsAllIgnoreCase(out.Stdout, "service", "importing", "namespace", r.KnTest().Kn().Namespace(), "ready")) +} + +func serviceImportExistsError(r *test.KnRunResultCollector, filename string) { + command := []string{"service", "import", filename} + + out := r.KnTest().Kn().Run(command...) + r.AssertError(out) + assert.Check(r.T(), util.ContainsAllIgnoreCase(out.Stderr, "service", "already", "exists")) +} + +func serviceImportFileError(r *test.KnRunResultCollector, filePath string) { + out := r.KnTest().Kn().Run("service", "import", filePath) + r.AssertError(out) + assert.Check(r.T(), util.ContainsAllIgnoreCase(out.Stderr, "no", "such", "file", "directory", filePath)) +}