From f5ac4413d0b249aee9d2e8f748b3499581de0cd4 Mon Sep 17 00:00:00 2001 From: Murugappan Chetty Date: Thu, 14 Jan 2021 04:46:31 -0800 Subject: [PATCH] add git ops options (#1122) * add git ops options * add git ops options * add unit tests * add unit tests * add unit tests * add unit test * add unit test * add unit test * review comments * review comments * add single file mode * add single file mode * add single file mode * add single file mode * review comments --- docs/cmd/kn_service_create.md | 6 + docs/cmd/kn_service_delete.md | 6 + docs/cmd/kn_service_describe.md | 6 + docs/cmd/kn_service_list.md | 7 + docs/cmd/kn_service_update.md | 6 + pkg/kn/commands/human_readable_flags.go | 6 + pkg/kn/commands/service/apply.go | 2 +- pkg/kn/commands/service/create.go | 29 +-- pkg/kn/commands/service/delete.go | 10 +- pkg/kn/commands/service/describe.go | 10 +- pkg/kn/commands/service/import.go | 2 +- pkg/kn/commands/service/list.go | 12 +- pkg/kn/commands/service/service.go | 7 + pkg/kn/commands/service/update.go | 14 +- pkg/kn/commands/types.go | 30 ++- pkg/serving/v1/gitops.go | 219 ++++++++++++++++++++ pkg/serving/v1/gitops_test.go | 253 ++++++++++++++++++++++++ 17 files changed, 592 insertions(+), 33 deletions(-) create mode 100644 pkg/serving/v1/gitops.go create mode 100644 pkg/serving/v1/gitops_test.go diff --git a/docs/cmd/kn_service_create.md b/docs/cmd/kn_service_create.md index 120e3dba5..7c3b17938 100644 --- a/docs/cmd/kn_service_create.md +++ b/docs/cmd/kn_service_create.md @@ -48,6 +48,11 @@ kn service create NAME --image IMAGE # [https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/] # [https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/] kn service create s4gpu --image knativesamples/hellocuda-go --request memory=250Mi,cpu=200m --limit nvidia.com/gpu=1 + + # Create the service in offline mode instead of kubernetes cluster + kn service create gitopstest -n test-ns --image knativesamples/helloworld --target=/user/knfiles + kn service create gitopstest --image knativesamples/helloworld --target=/user/knfiles/test.yaml + kn service create gitopstest --image knativesamples/helloworld --target=/user/knfiles/test.json ``` ### Options @@ -88,6 +93,7 @@ kn service create NAME --image IMAGE --scale-max int Maximum number of replicas. --scale-min int Minimum number of replicas. --service-account string Service account name to set. An empty argument ("") clears the service account. The referenced service account must exist in the service's namespace. + --target string work on local directory instead of a remote cluster --user int The user ID to run the container (e.g., 1001). --volume stringArray Add a volume from a ConfigMap (prefix cm: or config-map:) or a Secret (prefix secret: or sc:). Example: --volume myvolume=cm:myconfigmap or --volume myvolume=secret:mysecret. You can use this flag multiple times. To unset a ConfigMap/Secret reference, append "-" to the name, e.g. --volume myvolume-. --wait Wait for 'service create' operation to be completed. (default true) diff --git a/docs/cmd/kn_service_delete.md b/docs/cmd/kn_service_delete.md index f94046c4a..3dd9e02cb 100644 --- a/docs/cmd/kn_service_delete.md +++ b/docs/cmd/kn_service_delete.md @@ -22,6 +22,11 @@ kn service delete NAME [NAME ...] # Delete all services in 'ns1' namespace kn service delete --all -n ns1 + + # Delete the services in offline mode instead of kubernetes cluster + kn service delete test -n test-ns --target=/user/knfiles + kn service delete test --target=/user/knfiles/test.yaml + kn service delete test --target=/user/knfiles/test.json ``` ### Options @@ -31,6 +36,7 @@ kn service delete NAME [NAME ...] -h, --help help for delete -n, --namespace string Specify the namespace to operate in. --no-wait Do not wait for 'service delete' operation to be completed. (default true) + --target string work on local directory instead of a remote cluster --wait Wait for 'service delete' operation to be completed. --wait-timeout int Seconds to wait before giving up on waiting for service to be deleted. (default 600) ``` diff --git a/docs/cmd/kn_service_describe.md b/docs/cmd/kn_service_describe.md index 41f81ecc8..81e621aa7 100644 --- a/docs/cmd/kn_service_describe.md +++ b/docs/cmd/kn_service_describe.md @@ -22,6 +22,11 @@ kn service describe NAME # Print only service URL kn service describe svc -o url + + # Describe the services in offline mode instead of kubernetes cluster + kn service describe test -n test-ns --target=/user/knfiles + kn service describe test --target=/user/knfiles/test.yaml + kn service describe test --target=/user/knfiles/test.json ``` ### Options @@ -31,6 +36,7 @@ kn service describe NAME -h, --help help for describe -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|url. + --target string work on local directory instead of a remote cluster --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]. -v, --verbose More output. ``` diff --git a/docs/cmd/kn_service_list.md b/docs/cmd/kn_service_list.md index 70fdc12de..8b54ec540 100644 --- a/docs/cmd/kn_service_list.md +++ b/docs/cmd/kn_service_list.md @@ -22,6 +22,12 @@ kn service list # List service 'web' kn service list web + + # List the services in offline mode instead of kubernetes cluster + kn service list --target=/user/knfiles + kn service list --target=/user/knfiles/test.json + kn service list --target=/user/knfiles/test.yaml + kn service list -n test-ns --target=/user/knfiles ``` ### Options @@ -33,6 +39,7 @@ kn service list -n, --namespace string Specify the namespace to operate in. --no-headers When using the default output format, don't print headers (default: print headers). -o, --output string Output format. One of: json|yaml|name|go-template|go-template-file|template|templatefile|jsonpath|jsonpath-file. + --target string work on local directory instead of a remote cluster --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]. ``` diff --git a/docs/cmd/kn_service_update.md b/docs/cmd/kn_service_update.md index 2b68fead2..ae87dcce8 100644 --- a/docs/cmd/kn_service_update.md +++ b/docs/cmd/kn_service_update.md @@ -33,6 +33,11 @@ kn service update NAME # Add tag 'test' to echo-v3 revision with 10% traffic and rest to latest ready revision of service kn service update svc --tag echo-v3=test --traffic test=10,@latest=90 + + # Update the service in offline mode instead of kubernetes cluster + kn service update gitopstest -n test-ns --env KEY1=VALUE1 --target=/user/knfiles + kn service update gitopstest --env KEY1=VALUE1 --target=/user/knfiles/test.yaml + kn service update gitopstest --env KEY1=VALUE1 --target=/user/knfiles/test.json ``` ### Options @@ -72,6 +77,7 @@ kn service update NAME --scale-min int Minimum number of replicas. --service-account string Service account name to set. An empty argument ("") clears the service account. The referenced service account must exist in the service's namespace. --tag strings Set tag (format: --tag revisionRef=tagName) where revisionRef can be a revision or '@latest' string representing latest ready revision. This flag can be specified multiple times. + --target string work on local directory instead of a remote cluster --traffic strings Set traffic distribution (format: --traffic revisionRef=percent) where revisionRef can be a revision or a tag or '@latest' string representing latest ready revision. This flag can be given multiple times with percent summing up to 100%. --untag strings Untag revision (format: --untag tagName). This flag can be specified multiple times. --user int The user ID to run the container (e.g., 1001). diff --git a/pkg/kn/commands/human_readable_flags.go b/pkg/kn/commands/human_readable_flags.go index 377aa3f82..c7478d606 100644 --- a/pkg/kn/commands/human_readable_flags.go +++ b/pkg/kn/commands/human_readable_flags.go @@ -19,6 +19,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/duration" hprinters "knative.dev/client/pkg/printers" @@ -113,3 +114,8 @@ func TranslateTimestampSince(timestamp metav1.Time) string { } return duration.HumanDuration(time.Since(timestamp.Time)) } + +// AddGitOpsFlags adds flags to enable gitops mode +func AddGitOpsFlags(flags *pflag.FlagSet) { + flags.String("target", "", "work on local directory instead of a remote cluster") +} diff --git a/pkg/kn/commands/service/apply.go b/pkg/kn/commands/service/apply.go index aaf0354c4..254f7c208 100644 --- a/pkg/kn/commands/service/apply.go +++ b/pkg/kn/commands/service/apply.go @@ -95,7 +95,7 @@ func NewServiceApplyCommand(p *commands.KnParams) *cobra.Command { return showUrl(client, service.Name, "unchanged", "", cmd.OutOrStdout()) } - return waitIfRequested(client, service.Name, waitFlags, waitDoing, waitVerb, cmd.OutOrStdout()) + return waitIfRequested(client, waitFlags, service.Name, waitDoing, waitVerb, "", cmd.OutOrStdout()) }, } commands.AddNamespaceFlags(serviceApplyCommand.Flags(), false) diff --git a/pkg/kn/commands/service/create.go b/pkg/kn/commands/service/create.go index befa33714..f4bcd77ff 100644 --- a/pkg/kn/commands/service/create.go +++ b/pkg/kn/commands/service/create.go @@ -70,7 +70,12 @@ var create_example = ` # Create a service with 250MB memory, 200m CPU requests and a GPU resource limit # [https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/] # [https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/] - kn service create s4gpu --image knativesamples/hellocuda-go --request memory=250Mi,cpu=200m --limit nvidia.com/gpu=1` + kn service create s4gpu --image knativesamples/hellocuda-go --request memory=250Mi,cpu=200m --limit nvidia.com/gpu=1 + + # Create the service in offline mode instead of kubernetes cluster + kn service create gitopstest -n test-ns --image knativesamples/helloworld --target=/user/knfiles + kn service create gitopstest --image knativesamples/helloworld --target=/user/knfiles/test.yaml + kn service create gitopstest --image knativesamples/helloworld --target=/user/knfiles/test.json` func NewServiceCreateCommand(p *commands.KnParams) *cobra.Command { var editFlags ConfigurationEditFlags @@ -106,8 +111,8 @@ func NewServiceCreateCommand(p *commands.KnParams) *cobra.Command { if err != nil { return err } - - client, err := p.NewServingClient(namespace) + targetFlag := cmd.Flag("target").Value.String() + client, err := newServingClient(p, namespace, targetFlag) if err != nil { return err } @@ -123,9 +128,9 @@ func NewServiceCreateCommand(p *commands.KnParams) *cobra.Command { "cannot create service '%s' in namespace '%s' "+ "because the service already exists and no --force option was given", service.Name, namespace) } - err = replaceService(client, service, waitFlags, out) + err = replaceService(client, service, waitFlags, out, targetFlag) } else { - err = createService(client, service, waitFlags, out) + err = createService(client, service, waitFlags, out, targetFlag) } if err != nil { return err @@ -134,34 +139,34 @@ func NewServiceCreateCommand(p *commands.KnParams) *cobra.Command { }, } commands.AddNamespaceFlags(serviceCreateCommand.Flags(), false) + commands.AddGitOpsFlags(serviceCreateCommand.Flags()) editFlags.AddCreateFlags(serviceCreateCommand) waitFlags.AddConditionWaitFlags(serviceCreateCommand, commands.WaitDefaultTimeout, "create", "service", "ready") return serviceCreateCommand } -func createService(client clientservingv1.KnServingClient, service *servingv1.Service, waitFlags commands.WaitFlags, out io.Writer) error { +func createService(client clientservingv1.KnServingClient, service *servingv1.Service, waitFlags commands.WaitFlags, out io.Writer, targetFlag string) error { err := client.CreateService(service) if err != nil { return err } - return waitIfRequested(client, service.Name, waitFlags, "Creating", "created", out) + return waitIfRequested(client, waitFlags, service.Name, "Creating", "created", targetFlag, out) } -func replaceService(client clientservingv1.KnServingClient, service *servingv1.Service, waitFlags commands.WaitFlags, out io.Writer) error { +func replaceService(client clientservingv1.KnServingClient, service *servingv1.Service, waitFlags commands.WaitFlags, out io.Writer, targetFlag string) error { err := prepareAndUpdateService(client, service) if err != nil { return err } - return waitIfRequested(client, service.Name, waitFlags, "Replacing", "replaced", out) + return waitIfRequested(client, waitFlags, service.Name, "Replacing", "replaced", targetFlag, out) } -func waitIfRequested(client clientservingv1.KnServingClient, serviceName string, waitFlags commands.WaitFlags, verbDoing string, verbDone string, out io.Writer) error { - if !waitFlags.Wait { +func waitIfRequested(client clientservingv1.KnServingClient, waitFlags commands.WaitFlags, serviceName, verbDoing, verbDone, targetFlag string, out io.Writer) error { + if !waitFlags.Wait || targetFlag != "" { fmt.Fprintf(out, "Service '%s' %s in namespace '%s'.\n", serviceName, verbDone, client.Namespace()) return nil } - fmt.Fprintf(out, "%s service '%s' in namespace '%s':\n", verbDoing, serviceName, client.Namespace()) return waitForServiceToGetReady(client, serviceName, waitFlags.TimeoutInSeconds, verbDone, out) } diff --git a/pkg/kn/commands/service/delete.go b/pkg/kn/commands/service/delete.go index 698cb52da..a58275450 100644 --- a/pkg/kn/commands/service/delete.go +++ b/pkg/kn/commands/service/delete.go @@ -41,7 +41,12 @@ func NewServiceDeleteCommand(p *commands.KnParams) *cobra.Command { kn service delete svc2 -n ns1 # Delete all services in 'ns1' namespace - kn service delete --all -n ns1`, + kn service delete --all -n ns1 + + # Delete the services in offline mode instead of kubernetes cluster + kn service delete test -n test-ns --target=/user/knfiles + kn service delete test --target=/user/knfiles/test.yaml + kn service delete test --target=/user/knfiles/test.json`, RunE: func(cmd *cobra.Command, args []string) error { all, err := cmd.Flags().GetBool("all") @@ -62,7 +67,7 @@ func NewServiceDeleteCommand(p *commands.KnParams) *cobra.Command { if err != nil { return err } - client, err := p.NewServingClient(namespace) + client, err := newServingClient(p, namespace, cmd.Flag("target").Value.String()) if err != nil { return err } @@ -100,6 +105,7 @@ func NewServiceDeleteCommand(p *commands.KnParams) *cobra.Command { flags := serviceDeleteCommand.Flags() flags.Bool("all", false, "Delete all services in a namespace.") commands.AddNamespaceFlags(serviceDeleteCommand.Flags(), false) + commands.AddGitOpsFlags(serviceDeleteCommand.Flags()) waitFlags.AddConditionWaitFlags(serviceDeleteCommand, commands.WaitDefaultTimeout, "delete", "service", "deleted") return serviceDeleteCommand } diff --git a/pkg/kn/commands/service/describe.go b/pkg/kn/commands/service/describe.go index 859e5115c..524979abb 100644 --- a/pkg/kn/commands/service/describe.go +++ b/pkg/kn/commands/service/describe.go @@ -79,7 +79,12 @@ var describe_example = ` kn service describe svc -o yaml # Print only service URL - kn service describe svc -o url` + kn service describe svc -o url + + # Describe the services in offline mode instead of kubernetes cluster + kn service describe test -n test-ns --target=/user/knfiles + kn service describe test --target=/user/knfiles/test.yaml + kn service describe test --target=/user/knfiles/test.json` // NewServiceDescribeCommand returns a new command for describing a service. func NewServiceDescribeCommand(p *commands.KnParams) *cobra.Command { @@ -102,7 +107,7 @@ func NewServiceDescribeCommand(p *commands.KnParams) *cobra.Command { return err } - client, err := p.NewServingClient(namespace) + client, err := newServingClient(p, namespace, cmd.Flag("target").Value.String()) if err != nil { return err } @@ -141,6 +146,7 @@ func NewServiceDescribeCommand(p *commands.KnParams) *cobra.Command { } flags := command.Flags() commands.AddNamespaceFlags(flags, false) + commands.AddGitOpsFlags(flags) flags.BoolP("verbose", "v", false, "More output.") machineReadablePrintFlags.AddFlags(command) command.Flag("output").Usage = fmt.Sprintf("Output format. One of: %s.", strings.Join(append(machineReadablePrintFlags.AllowedFormats(), "url"), "|")) diff --git a/pkg/kn/commands/service/import.go b/pkg/kn/commands/service/import.go index a785714d7..55ee327ef 100644 --- a/pkg/kn/commands/service/import.go +++ b/pkg/kn/commands/service/import.go @@ -123,7 +123,7 @@ func importWithOwnerRef(client clientservingv1.KnServingClient, filename string, } } - err = waitIfRequested(client, serviceName, waitFlags, "Importing", "imported", out) + err = waitIfRequested(client, waitFlags, serviceName, "Importing", "imported", "", out) if err != nil { return err } diff --git a/pkg/kn/commands/service/list.go b/pkg/kn/commands/service/list.go index 952d74e81..8990b2b95 100644 --- a/pkg/kn/commands/service/list.go +++ b/pkg/kn/commands/service/list.go @@ -42,13 +42,20 @@ func NewServiceListCommand(p *commands.KnParams) *cobra.Command { kn service list -o json # List service 'web' - kn service list web`, + kn service list web + + # List the services in offline mode instead of kubernetes cluster + kn service list --target=/user/knfiles + kn service list --target=/user/knfiles/test.json + kn service list --target=/user/knfiles/test.yaml + kn service list -n test-ns --target=/user/knfiles`, + RunE: func(cmd *cobra.Command, args []string) error { namespace, err := p.GetNamespace(cmd) if err != nil { return err } - client, err := p.NewServingClient(namespace) + client, err := newServingClient(p, namespace, cmd.Flag("target").Value.String()) if err != nil { return err } @@ -81,6 +88,7 @@ func NewServiceListCommand(p *commands.KnParams) *cobra.Command { }, } commands.AddNamespaceFlags(serviceListCommand.Flags(), true) + commands.AddGitOpsFlags(serviceListCommand.Flags()) serviceListFlags.AddFlags(serviceListCommand) return serviceListCommand } diff --git a/pkg/kn/commands/service/service.go b/pkg/kn/commands/service/service.go index c8126e550..c13fe369b 100644 --- a/pkg/kn/commands/service/service.go +++ b/pkg/kn/commands/service/service.go @@ -74,3 +74,10 @@ func showUrl(client clientservingv1.KnServingClient, serviceName string, origina return nil } + +func newServingClient(p *commands.KnParams, namespace, dir string) (clientservingv1.KnServingClient, error) { + if dir != "" { + return p.NewGitopsServingClient(namespace, dir) + } + return p.NewServingClient(namespace) +} diff --git a/pkg/kn/commands/service/update.go b/pkg/kn/commands/service/update.go index 4e4214167..ab2a18c7b 100644 --- a/pkg/kn/commands/service/update.go +++ b/pkg/kn/commands/service/update.go @@ -48,7 +48,12 @@ var updateExample = ` kn service update svc --untag testing --tag @latest=staging # Add tag 'test' to echo-v3 revision with 10% traffic and rest to latest ready revision of service - kn service update svc --tag echo-v3=test --traffic test=10,@latest=90` + kn service update svc --tag echo-v3=test --traffic test=10,@latest=90 + + # Update the service in offline mode instead of kubernetes cluster + kn service update gitopstest -n test-ns --env KEY1=VALUE1 --target=/user/knfiles + kn service update gitopstest --env KEY1=VALUE1 --target=/user/knfiles/test.yaml + kn service update gitopstest --env KEY1=VALUE1 --target=/user/knfiles/test.json` func NewServiceUpdateCommand(p *commands.KnParams) *cobra.Command { var editFlags ConfigurationEditFlags @@ -67,8 +72,8 @@ func NewServiceUpdateCommand(p *commands.KnParams) *cobra.Command { if err != nil { return err } - - client, err := p.NewServingClient(namespace) + targetFlag := cmd.Flag("target").Value.String() + client, err := newServingClient(p, namespace, targetFlag) if err != nil { return err } @@ -109,7 +114,7 @@ func NewServiceUpdateCommand(p *commands.KnParams) *cobra.Command { } out := cmd.OutOrStdout() - if waitFlags.Wait { + if waitFlags.Wait && targetFlag == "" { fmt.Fprintf(out, "Updating Service '%s' in namespace '%s':\n", args[0], namespace) fmt.Fprintln(out, "") err := waitForService(client, name, out, waitFlags.TimeoutInSeconds) @@ -131,6 +136,7 @@ func NewServiceUpdateCommand(p *commands.KnParams) *cobra.Command { } commands.AddNamespaceFlags(serviceUpdateCommand.Flags(), false) + commands.AddGitOpsFlags(serviceUpdateCommand.Flags()) editFlags.AddUpdateFlags(serviceUpdateCommand) waitFlags.AddConditionWaitFlags(serviceUpdateCommand, commands.WaitDefaultTimeout, "update", "service", "ready") trafficFlags.Add(serviceUpdateCommand) diff --git a/pkg/kn/commands/types.go b/pkg/kn/commands/types.go index 6e46164a6..7578a2584 100644 --- a/pkg/kn/commands/types.go +++ b/pkg/kn/commands/types.go @@ -40,14 +40,15 @@ import ( // KnParams for creating commands. Useful for inserting mocks for testing. type KnParams struct { - Output io.Writer - KubeCfgPath string - ClientConfig clientcmd.ClientConfig - NewServingClient func(namespace string) (clientservingv1.KnServingClient, error) - NewSourcesClient func(namespace string) (v1alpha2.KnSourcesClient, error) - NewEventingClient func(namespace string) (clienteventingv1beta1.KnEventingClient, error) - NewMessagingClient func(namespace string) (clientmessagingv1beta1.KnMessagingClient, error) - NewDynamicClient func(namespace string) (clientdynamic.KnDynamicClient, error) + Output io.Writer + KubeCfgPath string + ClientConfig clientcmd.ClientConfig + NewServingClient func(namespace string) (clientservingv1.KnServingClient, error) + NewGitopsServingClient func(namespace string, dir string) (clientservingv1.KnServingClient, error) + NewSourcesClient func(namespace string) (v1alpha2.KnSourcesClient, error) + NewEventingClient func(namespace string) (clienteventingv1beta1.KnEventingClient, error) + NewMessagingClient func(namespace string) (clientmessagingv1beta1.KnMessagingClient, error) + NewDynamicClient func(namespace string) (clientdynamic.KnDynamicClient, error) // General global options LogHTTP bool @@ -61,6 +62,10 @@ func (params *KnParams) Initialize() { params.NewServingClient = params.newServingClient } + if params.NewGitopsServingClient == nil { + params.NewGitopsServingClient = params.newGitopsServingClient + } + if params.NewSourcesClient == nil { params.NewSourcesClient = params.newSourcesClient } @@ -84,10 +89,17 @@ func (params *KnParams) newServingClient(namespace string) (clientservingv1.KnSe return nil, err } - client, _ := servingv1client.NewForConfig(restConfig) + client, err := servingv1client.NewForConfig(restConfig) + if err != nil { + return nil, err + } return clientservingv1.NewKnServingClient(client, namespace), nil } +func (params *KnParams) newGitopsServingClient(namespace string, dir string) (clientservingv1.KnServingClient, error) { + return clientservingv1.NewKnServingGitOpsClient(namespace, dir), nil +} + func (params *KnParams) newSourcesClient(namespace string) (v1alpha2.KnSourcesClient, error) { restConfig, err := params.RestConfig() if err != nil { diff --git a/pkg/serving/v1/gitops.go b/pkg/serving/v1/gitops.go new file mode 100644 index 000000000..5e342045d --- /dev/null +++ b/pkg/serving/v1/gitops.go @@ -0,0 +1,219 @@ +// 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 v1 + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/cli-runtime/pkg/genericclioptions" + + "knative.dev/client/pkg/wait" + servingv1 "knative.dev/serving/pkg/apis/serving/v1" +) + +const ( + ksvcKind = "ksvc" +) + +// knServingGitOpsClient - kn service client +// to work on a local repo instead of a remote cluster +type knServingGitOpsClient struct { + dir string + namespace string + fileMode bool + fileFormat string + KnServingClient +} + +// NewKnServingGitOpsClient returns an instance of the +// kn service gitops client +func NewKnServingGitOpsClient(namespace, dir string) KnServingClient { + mode, format := getFileModeAndType(dir) + return &knServingGitOpsClient{ + dir: dir, + namespace: namespace, + fileMode: mode, + fileFormat: format, + } +} + +func (cl *knServingGitOpsClient) getKsvcFilePath(name string) string { + if cl.fileMode { + return cl.dir + } + return filepath.Join(cl.dir, cl.namespace, ksvcKind, name+".yaml") +} + +func getFileModeAndType(dir string) (bool, string) { + switch { + case strings.HasSuffix(dir, ".yaml"): + return true, "yaml" + case strings.HasSuffix(dir, ".yml"): + return true, "yaml" + case strings.HasSuffix(dir, ".json"): + return true, "json" + } + return false, "yaml" +} + +// Namespace returns the namespace +func (cl *knServingGitOpsClient) Namespace() string { + return cl.namespace +} + +// GetService returns the knative service for the name +func (cl *knServingGitOpsClient) GetService(name string) (*servingv1.Service, error) { + return readServiceFromFile(cl.getKsvcFilePath(name), name) +} + +// ListServices lists the services in the path provided +func (cl *knServingGitOpsClient) ListServices(config ...ListConfig) (*servingv1.ServiceList, error) { + svcs, err := cl.listServicesFromDirectory() + if err != nil { + return nil, err + } + typeMeta := metav1.TypeMeta{ + APIVersion: "v1", + Kind: "List", + } + serviceList := &servingv1.ServiceList{ + TypeMeta: typeMeta, + Items: svcs, + } + return serviceList, nil +} + +func (cl *knServingGitOpsClient) listServicesFromDirectory() ([]servingv1.Service, error) { + if cl.fileMode { + svc, err := readServiceFromFile(cl.dir, "") + if err != nil { + return nil, err + } + return []servingv1.Service{*svc}, nil + } + var services []servingv1.Service + root := cl.dir + if cl.namespace != "" { + root = filepath.Join(cl.dir, cl.namespace) + } + if _, err := os.Stat(root); err != nil { + return nil, err + } + if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + switch { + // skip if dir is not ksvc + case info.IsDir(): + return nil + + // skip non yaml files + case !strings.HasSuffix(info.Name(), ".yaml"): + return nil + + // skip non ksvc dir + case !strings.Contains(path, ksvcKind): + return filepath.SkipDir + + default: + svc, err := readServiceFromFile(path, "") + if err != nil { + return err + } + services = append(services, *svc) + return nil + } + }); err != nil { + return nil, err + } + return services, nil +} + +// CreateService saves the knative service spec in +// yaml format in the local path provided +func (cl *knServingGitOpsClient) CreateService(service *servingv1.Service) error { + updateServingGvk(service) + if cl.fileMode { + return writeFile(service, cl.dir, cl.fileFormat) + } + //check if dir exist + if _, err := os.Stat(cl.dir); os.IsNotExist(err) { + return fmt.Errorf("directory '%s' not present, please create the directory and try again", cl.dir) + } + return writeFile(service, cl.getKsvcFilePath(service.ObjectMeta.Name), cl.fileFormat) +} + +func writeFile(obj runtime.Object, fp, format string) error { + if _, err := os.Stat(fp); os.IsNotExist(err) { + os.MkdirAll(filepath.Dir(fp), 0755) + } + w, err := os.Create(fp) + if err != nil { + return err + } + yamlPrinter, err := genericclioptions.NewJSONYamlPrintFlags().ToPrinter(format) + if err != nil { + return err + } + return yamlPrinter.PrintObj(obj, w) +} + +// UpdateService updates the service in +// the local directory +func (cl *knServingGitOpsClient) UpdateService(service *servingv1.Service) error { + // check if file exist + if _, err := cl.GetService(service.ObjectMeta.Name); err != nil { + return err + } + // replace file + return cl.CreateService(service) +} + +// UpdateServiceWithRetry updates the service in the local directory +func (cl *knServingGitOpsClient) UpdateServiceWithRetry(name string, updateFunc ServiceUpdateFunc, nrRetries int) error { + return updateServiceWithRetry(cl, name, updateFunc, nrRetries) +} + +// DeleteService removes the file from the local file system +func (cl *knServingGitOpsClient) DeleteService(serviceName string, timeout time.Duration) error { + return os.Remove(cl.getKsvcFilePath(serviceName)) +} + +// WaitForService always returns success for this client +func (cl *knServingGitOpsClient) WaitForService(name string, timeout time.Duration, msgCallback wait.MessageCallback) (error, time.Duration) { + return nil, 1 * time.Second +} + +func readServiceFromFile(fileKey, name string) (*servingv1.Service, error) { + var svc servingv1.Service + file, err := os.Open(fileKey) + if err != nil { + if os.IsNotExist(err) { + return nil, apierrors.NewNotFound(servingv1.Resource("services"), name) + } + return nil, err + } + decoder := yaml.NewYAMLOrJSONDecoder(file, 512) + if err := decoder.Decode(&svc); err != nil { + return nil, err + } + return &svc, nil +} diff --git a/pkg/serving/v1/gitops_test.go b/pkg/serving/v1/gitops_test.go new file mode 100644 index 000000000..a75b7354c --- /dev/null +++ b/pkg/serving/v1/gitops_test.go @@ -0,0 +1,253 @@ +// 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 v1 + +import ( + "context" + "io/ioutil" + "os" + "path/filepath" + "testing" + "time" + + "gotest.tools/assert" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + servingtest "knative.dev/serving/pkg/testing/v1" + + libtest "knative.dev/client/lib/test" + "knative.dev/pkg/ptr" + servingv1 "knative.dev/serving/pkg/apis/serving/v1" +) + +func TestGitOpsOperations(t *testing.T) { + c1TempDir, err := ioutil.TempDir("", "kn-files-cluster1") + assert.NilError(t, err) + c2TempDir, err := ioutil.TempDir("", "kn-files-cluster2") + assert.NilError(t, err) + defer os.RemoveAll(c1TempDir) + defer os.RemoveAll(c2TempDir) + // create clients + fooclient := NewKnServingGitOpsClient("foo-ns", c1TempDir) + bazclient := NewKnServingGitOpsClient("baz-ns", c1TempDir) + globalclient := NewKnServingGitOpsClient("", c1TempDir) + diffClusterClient := NewKnServingGitOpsClient("", "tmp") + + // set up test services + fooSvc := libtest.BuildServiceWithOptions("foo", servingtest.WithConfigSpec(buildConfiguration())) + barSvc := libtest.BuildServiceWithOptions("bar", servingtest.WithConfigSpec(buildConfiguration())) + fooUpdateSvc := libtest.BuildServiceWithOptions("foo", servingtest.WithConfigSpec(buildConfiguration()), servingtest.WithEnv(corev1.EnvVar{Name: "a", Value: "mouse"})) + + fooserviceList := getServiceList([]servingv1.Service{*barSvc, *fooSvc}) + allServices := getServiceList([]servingv1.Service{*barSvc, *barSvc, *fooSvc}) + + t.Run("get file path for foo service in foo namespace", func(t *testing.T) { + fp := fooclient.(*knServingGitOpsClient).getKsvcFilePath("foo") + assert.Equal(t, filepath.Join(c1TempDir, "foo-ns/ksvc/foo.yaml"), fp) + }) + t.Run("get namespace for bazclient client", func(t *testing.T) { + ns := bazclient.Namespace() + assert.Equal(t, "baz-ns", ns) + }) + t.Run("create service foo in foo namespace", func(t *testing.T) { + err := fooclient.CreateService(fooSvc) + assert.NilError(t, err) + }) + t.Run("wait for foo service in foo namespace", func(t *testing.T) { + err, d := fooclient.WaitForService("foo", 5*time.Second, nil) + assert.NilError(t, err) + assert.Equal(t, 1*time.Second, d) + }) + t.Run("get service foo", func(t *testing.T) { + result, err := fooclient.GetService("foo") + assert.NilError(t, err) + assert.DeepEqual(t, fooSvc, result) + }) + t.Run("create service bar in foo namespace", func(t *testing.T) { + err := fooclient.CreateService(barSvc) + assert.NilError(t, err) + }) + t.Run("create service bar in baz namespace", func(t *testing.T) { + err := bazclient.CreateService(barSvc) + assert.NilError(t, err) + }) + t.Run("list services in foo namespace", func(t *testing.T) { + result, err := fooclient.ListServices() + assert.NilError(t, err) + assert.DeepEqual(t, fooserviceList, result) + }) + t.Run("create service without master directory", func(t *testing.T) { + err := diffClusterClient.CreateService(fooSvc) + assert.ErrorContains(t, err, "directory 'tmp' not present, please create the directory and try again") + }) + diffClusterClient = NewKnServingGitOpsClient("", c2TempDir) + t.Run("create service foo in foo namespace in cluster 2", func(t *testing.T) { + err := diffClusterClient.CreateService(fooSvc) + assert.NilError(t, err) + }) + t.Run("list services in all namespaces in cluster 1", func(t *testing.T) { + result, err := globalclient.ListServices() + assert.NilError(t, err) + assert.DeepEqual(t, allServices, result) + }) + t.Run("update service with retry foo", func(t *testing.T) { + err := fooclient.UpdateServiceWithRetry("foo", func(svc *servingv1.Service) (*servingv1.Service, error) { + return svc, nil + }, 1) + assert.NilError(t, err) + }) + t.Run("update service foo", func(t *testing.T) { + err := fooclient.UpdateService(fooUpdateSvc) + assert.NilError(t, err) + }) + t.Run("check updated service foo", func(t *testing.T) { + result, err := fooclient.GetService("foo") + assert.NilError(t, err) + assert.DeepEqual(t, fooUpdateSvc, result) + }) + t.Run("delete service foo", func(t *testing.T) { + err := fooclient.DeleteService("foo", 5*time.Second) + assert.NilError(t, err) + }) + t.Run("get service foo", func(t *testing.T) { + _, err := fooclient.GetService("foo") + assert.ErrorType(t, err, apierrors.IsNotFound) + }) +} + +func TestGitOpsSingleFile(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "singlefile") + assert.NilError(t, err) + defer os.RemoveAll(tmpDir) + // create clients + fooclient := NewKnServingGitOpsClient("", filepath.Join(tmpDir, "test.yaml")) + barclient := NewKnServingGitOpsClient("", filepath.Join(tmpDir, "test.yml")) + bazclient := NewKnServingGitOpsClient("", filepath.Join(tmpDir, "test.json")) + + // set up test services + testSvc := libtest.BuildServiceWithOptions("test", servingtest.WithConfigSpec(buildConfiguration())) + updateSvc := libtest.BuildServiceWithOptions("test", servingtest.WithConfigSpec(buildConfiguration()), servingtest.WithEnv(corev1.EnvVar{Name: "a", Value: "mouse"})) + + svcList := getServiceList([]servingv1.Service{*updateSvc}) + + t.Run("get file path for fooclient", func(t *testing.T) { + fp := fooclient.(*knServingGitOpsClient).getKsvcFilePath("test") + assert.Equal(t, filepath.Join(tmpDir, "test.yaml"), fp) + }) + t.Run("get namespace for fooclient", func(t *testing.T) { + ns := fooclient.Namespace() + assert.Equal(t, "", ns) + }) + t.Run("create service in single file mode in different formats", func(t *testing.T) { + err := fooclient.CreateService(testSvc) + assert.NilError(t, err) + + err = barclient.CreateService(testSvc) + assert.NilError(t, err) + + err = bazclient.CreateService(testSvc) + assert.NilError(t, err) + }) + t.Run("retrieve services", func(t *testing.T) { + result, err := fooclient.GetService("test") + assert.NilError(t, err) + assert.DeepEqual(t, testSvc, result) + + result, err = barclient.GetService("test") + assert.NilError(t, err) + assert.DeepEqual(t, testSvc, result) + + result, err = bazclient.GetService("test") + assert.NilError(t, err) + assert.DeepEqual(t, testSvc, result) + }) + t.Run("update service foo", func(t *testing.T) { + err := fooclient.UpdateService(updateSvc) + assert.NilError(t, err) + + err = barclient.UpdateService(updateSvc) + assert.NilError(t, err) + + err = bazclient.UpdateService(updateSvc) + assert.NilError(t, err) + }) + t.Run("list services", func(t *testing.T) { + result, err := fooclient.ListServices() + assert.NilError(t, err) + assert.DeepEqual(t, svcList, result) + + result, err = barclient.ListServices() + assert.NilError(t, err) + assert.DeepEqual(t, svcList, result) + + result, err = bazclient.ListServices() + assert.NilError(t, err) + assert.DeepEqual(t, svcList, result) + }) + t.Run("delete service foo", func(t *testing.T) { + err := fooclient.DeleteService("test", 5*time.Second) + assert.NilError(t, err) + + err = barclient.DeleteService("test", 5*time.Second) + assert.NilError(t, err) + + err = bazclient.DeleteService("test", 5*time.Second) + assert.NilError(t, err) + }) + t.Run("get service foo", func(t *testing.T) { + _, err := fooclient.GetService("test") + assert.ErrorType(t, err, apierrors.IsNotFound) + + _, err = barclient.GetService("test") + assert.ErrorType(t, err, apierrors.IsNotFound) + + _, err = bazclient.GetService("test") + assert.ErrorType(t, err, apierrors.IsNotFound) + }) +} + +func getServiceList(services []servingv1.Service) *servingv1.ServiceList { + return &servingv1.ServiceList{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "List", + }, + Items: services, + } +} + +func buildConfiguration() *servingv1.ConfigurationSpec { + c := &servingv1.Configuration{ + Spec: servingv1.ConfigurationSpec{ + Template: servingv1.RevisionTemplateSpec{ + Spec: *revisionSpec.DeepCopy(), + }, + }, + } + c.SetDefaults(context.Background()) + return &c.Spec +} + +var revisionSpec = servingv1.RevisionSpec{ + PodSpec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Image: "busybox", + }}, + EnableServiceLinks: ptr.Bool(false), + }, + TimeoutSeconds: ptr.Int64(300), +}