feat: Add service import command (#1065)

* feat: Add service import command

* fix: Fix e2e test

* fix: Add retry when retrieving Configuration

* fix: Reflect review feedback

* fix: Fix error message

Co-authored-by: Roland Huß <rhuss@redhat.com>

* fix: Add missing mock tests

* fix: Polish unit test assertions

* fix: Mark import as experimental

* chore: Add changelog entry

* Update CHANGELOG.adoc

Co-authored-by: Roland Huß <rhuss@redhat.com>

* fix: Remove deprecated flag

Co-authored-by: Roland Huß <rhuss@redhat.com>
This commit is contained in:
David Simansky 2020-11-09 15:04:09 +01:00 committed by GitHub
parent 45ffadecec
commit b72e4be300
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 618 additions and 0 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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: {}
`

View File

@ -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
}

View File

@ -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{})

View File

@ -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()

View File

@ -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"))

View File

@ -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))
}