diff --git a/cmd/porchctl/main.go b/cmd/porchctl/main.go new file mode 100644 index 00000000..f2861181 --- /dev/null +++ b/cmd/porchctl/main.go @@ -0,0 +1,88 @@ +// Copyright 2023 The kpt and Nephio 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 main + +import ( + "context" + "fmt" + "os" + + "github.com/nephio-project/porch/cmd/porchctl/run" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/errors/resolver" + "github.com/nephio-project/porch/internal/kpt/util/cmdutil" + "github.com/spf13/cobra" + _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/component-base/cli" + "k8s.io/klog/v2" + k8scmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +func main() { + // Handle all setup in the runMain function so os.Exit doesn't interfere + // with defer. + os.Exit(runMain()) +} + +// runMain does the initial setup in order to run kpt. The return value from +// this function will be the exit code when kpt terminates. +func runMain() int { + var err error + + ctx := context.Background() + + // Enable commandline flags for klog. + // logging will help in collecting debugging information from users + klog.InitFlags(nil) + + cmd := run.GetMain(ctx) + + err = cli.RunNoErrOutput(cmd) + if err != nil { + return handleErr(cmd, err) + } + return 0 +} + +// handleErr takes care of printing an error message for a given error. +func handleErr(cmd *cobra.Command, err error) int { + // First attempt to see if we can resolve the error into a specific + // error message. + if re, resolved := resolver.ResolveError(err); resolved { + if re.Message != "" { + fmt.Fprintf(cmd.ErrOrStderr(), "%s \n", re.Message) + } + return re.ExitCode + } + + // Then try to see if it is of type *errors.Error + var kptErr *errors.Error + if errors.As(err, &kptErr) { + unwrapped, ok := errors.UnwrapErrors(kptErr) + if ok && !cmdutil.PrintErrorStacktrace() { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s \n", unwrapped.Error()) + return 1 + } + fmt.Fprintf(cmd.ErrOrStderr(), "%s \n", kptErr.Error()) + return 1 + } + + // Finally just let the error handler for kubectl handle it. This handles + // printing of several error types used in kubectl + // TODO: See if we can handle this in kpt and get a uniform experience + // across all of kpt. + k8scmdutil.CheckErr(err) + return 1 +} diff --git a/cmd/porchctl/run/run.go b/cmd/porchctl/run/run.go new file mode 100644 index 00000000..6ecf417d --- /dev/null +++ b/cmd/porchctl/run/run.go @@ -0,0 +1,210 @@ +// Copyright 2023 The kpt and Nephio 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 run + +import ( + "bytes" + "context" + "flag" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/nephio-project/porch/internal/kpt/util/cmdutil" + "github.com/nephio-project/porch/pkg/cli/commands" + "github.com/nephio-project/porch/pkg/kpt/printer" + "github.com/spf13/cobra" +) + +var pgr []string + +const description = ` +porchctl interacts with a Kubernetes API server with the Porch +server installed as an aggregated API server. It allows you to +manage Porch repository registrations and the packages within +those repositories. +` + +func GetMain(ctx context.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "porchctl", + Short: "Manage porch repositories and packages", + Long: description, + SilenceUsage: true, + // We handle all errors in main after return from cobra so we can + // adjust the error message coming from libraries + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + h, err := cmd.Flags().GetBool("help") + if err != nil { + return err + } + if h { + return cmd.Help() + } + return cmd.Usage() + }, + } + + cmd.PersistentFlags().AddGoFlagSet(flag.CommandLine) + + cmd.PersistentFlags().BoolVar(&printer.TruncateOutput, "truncate-output", true, + "Enable the truncation for output") + // wire the global printer + pr := printer.New(cmd.OutOrStdout(), cmd.ErrOrStderr()) + + // create context with associated printer + ctx = printer.WithContext(ctx, pr) + + // find the pager if one exists + func() { + if val, found := os.LookupEnv("PORCHCTL_NO_PAGER_HELP"); !found || val != "1" { + // use a pager for printing tutorials + e, found := os.LookupEnv("PAGER") + var err error + if found { + pgr = []string{e} + return + } + e, err = exec.LookPath("pager") + if err == nil { + pgr = []string{e} + return + } + e, err = exec.LookPath("less") + if err == nil { + pgr = []string{e, "-R"} + return + } + } + }() + + // help and documentation + cmd.InitDefaultHelpCmd() + cmd.AddCommand(commands.GetCommands(ctx, "porchctl", version)...) + + // enable stack traces + cmd.PersistentFlags().BoolVar(&cmdutil.StackOnError, "stack-trace", false, + "Print a stack-trace on failure") + + if _, err := exec.LookPath("git"); err != nil { + fmt.Fprintf(os.Stderr, "porchctl requires that `git` is installed and on the PATH") + os.Exit(1) + } + + replace(cmd) + + cmd.AddCommand(versionCmd) + hideFlags(cmd) + return cmd +} + +func replace(c *cobra.Command) { + for i := range c.Commands() { + replace(c.Commands()[i]) + } + c.SetHelpFunc(newHelp(pgr, c)) +} + +func newHelp(e []string, c *cobra.Command) func(command *cobra.Command, strings []string) { + if len(pgr) == 0 { + return c.HelpFunc() + } + + fn := c.HelpFunc() + return func(command *cobra.Command, args []string) { + stty := exec.Command("stty", "size") + stty.Stdin = os.Stdin + out, err := stty.Output() + if err == nil { + terminalHeight, err := strconv.Atoi(strings.Split(string(out), " ")[0]) + helpHeight := strings.Count(command.Long, "\n") + + strings.Count(command.UsageString(), "\n") + if err == nil && terminalHeight > helpHeight { + // don't use a pager if the help is shorter than the console + fn(command, args) + return + } + } + + b := &bytes.Buffer{} + pager := exec.Command(e[0]) + if len(e) > 1 { + pager.Args = append(pager.Args, e[1:]...) + } + pager.Stdin = b + pager.Stdout = c.OutOrStdout() + c.SetOut(b) + fn(command, args) + if err := pager.Run(); err != nil { + fmt.Fprintf(c.ErrOrStderr(), "%v", err) + os.Exit(1) + } + } +} + +var version = "unknown" + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of porchctl", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("%s\n", version) + }, +} + +// hideFlags hides any cobra flags that are unlikely to be used by +// customers. +func hideFlags(cmd *cobra.Command) { + flags := []string{ + // Flags related to logging + "add_dir_header", + "alsologtostderr", + "log_backtrace_at", + "log_dir", + "log_file", + "log_file_max_size", + "logtostderr", + "one_output", + "skip_headers", + "skip_log_headers", + "stack-trace", + "stderrthreshold", + "vmodule", + + // Flags related to apiserver + "as", + "as-group", + "cache-dir", + "certificate-authority", + "client-certificate", + "client-key", + "insecure-skip-tls-verify", + "match-server-version", + "password", + "token", + "username", + } + for _, f := range flags { + _ = cmd.PersistentFlags().MarkHidden(f) + } + + // We need to recurse into subcommands otherwise flags aren't hidden on leaf commands + for _, child := range cmd.Commands() { + hideFlags(child) + } +} diff --git a/go.mod b/go.mod index 65a14cc3..b31ffa9d 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( google.golang.org/grpc v1.59.0 google.golang.org/protobuf v1.31.0 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 gotest.tools v2.2.0+incompatible k8s.io/api v0.26.9 k8s.io/apimachinery v0.26.9 @@ -101,6 +102,7 @@ require ( github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/fvbommel/sortorder v1.0.1 // indirect github.com/go-git/gcfg v1.5.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -110,7 +112,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.2 // indirect - github.com/google/cel-go v0.12.7 // indirect + github.com/google/cel-go v0.13.0 // indirect github.com/google/gnostic v0.6.9 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/s2a-go v0.1.4 // indirect @@ -184,7 +186,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.26.9 // indirect k8s.io/kms v0.26.9 // indirect k8s.io/kube-openapi v0.0.0-20230109183929-3758b55a6596 // indirect diff --git a/go.sum b/go.sum index d2ebdd76..61b7efe9 100644 --- a/go.sum +++ b/go.sum @@ -230,6 +230,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fvbommel/sortorder v1.0.1 h1:dSnXLt4mJYH25uDDGa3biZNQsozaUWDSWeKJ0qqFfzE= +github.com/fvbommel/sortorder v1.0.1/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -328,8 +330,8 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.12.7 h1:jM6p55R0MKBg79hZjn1zs2OlrywZ1Vk00rxVvad1/O0= -github.com/google/cel-go v0.12.7/go.mod h1:Jk7ljRzLBhkmiAwBoUxB1sZSCVBAzkqPF25olK/iRDw= +github.com/google/cel-go v0.13.0 h1:z+8OBOcmh7IeKyqwT/6IlnMvy621fYUqnTVPEdegGlU= +github.com/google/cel-go v0.13.0/go.mod h1:K2hpQgEjDp18J76a2DKFRlPBPpgRZgi6EbnpDgIhJ8s= github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ= github.com/google/gnostic v0.6.9 h1:ZK/5VhkoX835RikCHpSUJV9a+S3e1zLh59YnyWeBW+0= github.com/google/gnostic v0.6.9/go.mod h1:Nm8234We1lq6iB9OmlgNv3nH91XLLVZHCDayfA3xq+E= diff --git a/internal/kpt/options/get.go b/internal/kpt/options/get.go new file mode 100644 index 00000000..ee776ca9 --- /dev/null +++ b/internal/kpt/options/get.go @@ -0,0 +1,49 @@ +// Copyright 2022 The kpt and Nephio 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 options + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" +) + +// Get holds options for a list/get operation +type Get struct { + *genericclioptions.ConfigFlags + AllNamespaces bool +} + +func (o *Get) AddFlags(cmd *cobra.Command) { + cmd.Flags().BoolVarP(&o.AllNamespaces, "all-namespaces", "A", o.AllNamespaces, + "If present, list the requested object(s) across all namespaces. Namespace in current context is ignored even if specified with --namespace.") +} + +func (o *Get) ResourceBuilder() (*resource.Builder, error) { + if *o.ConfigFlags.Namespace == "" { + // Get the namespace from kubeconfig + namespace, _, err := o.ConfigFlags.ToRawKubeConfigLoader().Namespace() + if err != nil { + return nil, fmt.Errorf("error getting namespace: %w", err) + } + o.ConfigFlags.Namespace = &namespace + } + + b := resource.NewBuilder(o.ConfigFlags). + NamespaceParam(*o.ConfigFlags.Namespace).AllNamespaces(o.AllNamespaces) + return b, nil +} diff --git a/pkg/cli/commands/commands.go b/pkg/cli/commands/commands.go new file mode 100644 index 00000000..ec29ed1e --- /dev/null +++ b/pkg/cli/commands/commands.go @@ -0,0 +1,33 @@ +// Copyright 2023 The Nephio 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 commands + +import ( + "context" + + "github.com/nephio-project/porch/pkg/cli/commands/repo" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg" + "github.com/spf13/cobra" +) + +// GetCommands returns the set of porchctl commands to be registered +func GetCommands(ctx context.Context, name, version string) []*cobra.Command { + var c []*cobra.Command + repoCmd := repo.NewCommand(ctx, name) + rpkgCmd := rpkg.NewCommand(ctx, name) + + c = append(c, repoCmd, rpkgCmd) + return c +} diff --git a/pkg/cli/commands/repo/docs/docs.go b/pkg/cli/commands/repo/docs/docs.go new file mode 100644 index 00000000..1b53a5bb --- /dev/null +++ b/pkg/cli/commands/repo/docs/docs.go @@ -0,0 +1,107 @@ +// Copyright 2022 The kpt and Nephio 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. + +// Code originally generated by "mdtogo", but no +// longer maintained that way. +package docs + +var RepoShort = `Manage package repositories.` +var RepoLong = ` +The ` + "`" + `repo` + "`" + ` command group contains subcommands for managing package repositories. +` + +var GetShort = `List registered repositories.` +var GetLong = ` + porchctl repo get [REPOSITORY_NAME] [flags] + +Args: + + REPOSITORY_NAME: + The name of a repository. If provided, only that specific + repository will be shown. Defaults to showing all registered + repositories. +` +var GetExamples = ` + # list all repositories registered in the default namespace + $ porchctl repo get --namespace default + + # show the repository named foo in the bar namespace + $ porchctl repo get foo --namespace bar +` + +var RegShort = `Register a package repository.` +var RegLong = ` + porchctl repo reg REPOSITORY [flags] + +Args: + + REPOSITORY: + The URI for the registry. It can be either a git repository + or an oci repository. For the latter, the URI must have the + 'oci://' prefix. + +Flags: + + --branch: + Branch within the repository where finalized packages are + commited. The default is to use the 'main' branch. + + --deployment: + Tags the repository as a deployment repository. Packages in + a deployment repository are considered ready for deployment. + + --description: + Description of the repository. + + --directory: + Directory within the repository where packages are found. The + default is the root of the repository. + + --name: + Name of the repository. By default the last segment of the + repository URL will be used as the name. + + --repo-basic-username: + Username for authenticating to a repository with basic auth. + + --repo-basic-password: + Password for authenticating to a repository with basic auth. +` +var RegExamples = ` + # register a new git repository with the name generated from the URI. + $ porchctl repo register https://github.com/platkrm/demo-blueprints.git --namespace=default + + # register a new deployment repository with name foo. + $ porchctl repo register https://github.com/platkrm/blueprints-deployment.git --name=foo --deployment --namespace=bar +` + +var UnregShort = `Unregister a repository.` +var UnregLong = ` + porchctl repo unreg REPOSITORY_NAME [flags] + +Args: + + REPOSITORY_NAME: + The name of a repository. + +Flags: + + --keep-auth-secret: + Keep the Secret object with auth information referenced by the repository. + By default, it will be deleted when the repository is unregistered. +` +var UnregExamples = ` + # unregister a repository and keep the auth secret. + $ porchctl repo unreg registered-repository --namespace=default --keep-auth-secret +` diff --git a/pkg/cli/commands/repo/get/command.go b/pkg/cli/commands/repo/get/command.go new file mode 100644 index 00000000..d72aeabe --- /dev/null +++ b/pkg/cli/commands/repo/get/command.go @@ -0,0 +1,154 @@ +// Copyright 2022 The kpt and Nephio 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 get + +import ( + "context" + "strings" + + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/options" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/repo/docs" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/rest" + "k8s.io/kubectl/pkg/cmd/get" +) + +const ( + command = "cmdrepoget" +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + getFlags: options.Get{ConfigFlags: rcg}, + printFlags: get.NewGetPrintFlags(), + } + c := &cobra.Command{ + Use: "get [REPOSITORY_NAME]", + Aliases: []string{"ls", "list"}, + Short: docs.GetShort, + Long: docs.GetShort + "\n" + docs.GetLong, + Example: docs.GetExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + // Create flags + r.getFlags.AddFlags(c) + r.printFlags.AddFlags(c) + return r +} + +type runner struct { + ctx context.Context + Command *cobra.Command + + // Flags + getFlags options.Get + printFlags *get.PrintFlags + + requestTable bool +} + +func (r *runner) preRunE(cmd *cobra.Command, _ []string) error { + outputOption := cmd.Flags().Lookup("output").Value.String() + if strings.Contains(outputOption, "custom-columns") || outputOption == "yaml" || strings.Contains(outputOption, "json") { + r.requestTable = false + } else { + r.requestTable = true + } + return nil +} + +func (r *runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + // For some reason our use of k8s libraries result in error when decoding + // RepositoryList when we use strongly typed data. Therefore for now we + // use unstructured communication. + // The error is: `no kind "RepositoryList" is registered for the internal + // version of group "config.porch.kpt.dev" in scheme`. Of course there _is_ + // no such kind since CRDs seem to have only versioned resources. + b, err := r.getFlags.ResourceBuilder() + if err != nil { + return err + } + + // TODO: Support table mode over proto + // TODO: Print namespace in multi-namespace mode + b = b.Unstructured() + + if len(args) > 0 { + b.ResourceNames("repository", args...) + } else { + b = b.SelectAllParam(true). + ResourceTypes("repository") + } + + b = b.ContinueOnError().Latest().Flatten() + + if r.requestTable { + b = b.TransformRequests(func(req *rest.Request) { + req.SetHeader("Accept", strings.Join([]string{ + "application/json;as=Table;g=meta.k8s.io;v=v1", + "application/json", + }, ",")) + }) + } + res := b.Do() + if err := res.Err(); err != nil { + return errors.E(op, err) + } + + infos, err := res.Infos() + if err != nil { + return errors.E(op, err) + } + + printer, err := r.printFlags.ToPrinter() + if err != nil { + return errors.E(op, err) + } + + if r.requestTable { + printer = &get.TablePrinter{ + Delegate: printer, + } + } + + w := printers.GetNewTabWriter(cmd.OutOrStdout()) + + for _, i := range infos { + if err := printer.PrintObj(i.Object, w); err != nil { + return errors.E(op, err) + } + } + + if err := w.Flush(); err != nil { + return errors.E(op, err) + } + + return nil +} diff --git a/pkg/cli/commands/repo/reg/command.go b/pkg/cli/commands/repo/reg/command.go new file mode 100644 index 00000000..7121e205 --- /dev/null +++ b/pkg/cli/commands/repo/reg/command.go @@ -0,0 +1,226 @@ +// Copyright 2022 The kpt and Nephio 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 reg + +import ( + "context" + "fmt" + "strings" + + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/repo/docs" + "github.com/spf13/cobra" + coreapi "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdreporeg" +) + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + c := &cobra.Command{ + Use: "reg REPOSITORY", + Aliases: []string{"register"}, + Short: docs.RegShort, + Long: docs.RegShort + "\n" + docs.RegLong, + Example: docs.RegExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + c.Flags().StringVar(&r.directory, "directory", "/", "Directory within the repository where to look for packages.") + c.Flags().StringVar(&r.branch, "branch", "main", "Branch in the repository where finalized packages are committed.") + c.Flags().BoolVar(&r.createBranch, "create-branch", false, "Create the package branch if it doesn't already exist.") + c.Flags().StringVar(&r.name, "name", "", "Name of the package repository. If unspecified, will use the name portion (last segment) of the repository URL.") + c.Flags().StringVar(&r.description, "description", "", "Brief description of the package repository.") + c.Flags().BoolVar(&r.deployment, "deployment", false, "Repository is a deployment repository; packages in a deployment repository are considered deployment-ready.") + c.Flags().StringVar(&r.username, "repo-basic-username", "", "Username for repository authentication using basic auth.") + c.Flags().StringVar(&r.password, "repo-basic-password", "", "Password for repository authentication using basic auth.") + c.Flags().BoolVar(&r.workloadIdentity, "repo-workload-identity", false, "Use workload identity for authentication with the repo") + + return r +} + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + + // Flags + directory string + branch string + createBranch bool + name string + description string + deployment bool + username string + password string + workloadIdentity bool +} + +func (r *runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + client, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + return nil +} + +func (r *runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + if len(args) == 0 { + return errors.E(op, "repository is required positional argument") + } + + repository := args[0] + + var git *configapi.GitRepository + var oci *configapi.OciRepository + var rt configapi.RepositoryType + + if strings.HasPrefix(repository, "oci://") { + rt = configapi.RepositoryTypeOCI + oci = &configapi.OciRepository{ + Registry: repository[6:], + } + if r.name == "" { + r.name = porch.LastSegment(repository) + } + } else { + rt = configapi.RepositoryTypeGit + // TODO: better parsing. + // t, err := parse.GitParseArgs(r.ctx, []string{repository, "."}) + // if err != nil { + // return errors.E(op, err) + // } + git = &configapi.GitRepository{ + Repo: repository, + Branch: r.branch, + CreateBranch: r.createBranch, + Directory: r.directory, + } + + if r.name == "" { + r.name = porch.LastSegment(repository) + } + } + + secret, err := r.buildAuthSecret() + if err != nil { + return err + } + if secret != nil { + if err := r.client.Create(r.ctx, secret); err != nil { + return errors.E(op, err) + } + + if git != nil { + git.SecretRef.Name = secret.Name + } + if oci != nil { + oci.SecretRef.Name = secret.Name + } + } + + if err := r.client.Create(r.ctx, &configapi.Repository{ + TypeMeta: metav1.TypeMeta{ + Kind: "Repository", + APIVersion: configapi.GroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: r.name, + Namespace: *r.cfg.Namespace, + }, + Spec: configapi.RepositorySpec{ + Description: r.description, + Type: rt, + Content: configapi.RepositoryContentPackage, + Deployment: r.deployment, + Git: git, + Oci: oci, + }, + }); err != nil { + return errors.E(op, err) + } + + return nil +} + +func (r *runner) buildAuthSecret() (*coreapi.Secret, error) { + var basicAuth bool + var workloadIdentity bool + + if r.username != "" || r.password != "" { + basicAuth = true + } + + workloadIdentity = r.workloadIdentity + + if workloadIdentity && basicAuth { + return nil, fmt.Errorf("both username/password and workload identity specified") + } + + switch { + case workloadIdentity: + return &coreapi.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: coreapi.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-auth", r.name), + Namespace: *r.cfg.Namespace, + }, + Data: map[string][]byte{}, + Type: "kpt.dev/workload-identity-auth", + }, nil + case basicAuth: + return &coreapi.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: coreapi.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-auth", r.name), + Namespace: *r.cfg.Namespace, + }, + Data: map[string][]byte{ + "username": []byte(r.username), + "password": []byte(r.password), + }, + Type: coreapi.SecretTypeBasicAuth, + }, nil + } + return nil, nil +} diff --git a/pkg/cli/commands/repo/reg/command_test.go b/pkg/cli/commands/repo/reg/command_test.go new file mode 100644 index 00000000..077d2aed --- /dev/null +++ b/pkg/cli/commands/repo/reg/command_test.go @@ -0,0 +1,257 @@ +// Copyright 2022 The kpt and Nephio 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 reg + +import ( + "encoding/json" + "flag" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + fakeprinter "github.com/nephio-project/porch/pkg/kpt/printer/fake" + "gopkg.in/yaml.v3" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" +) + +var ( + update = flag.Bool("update", false, "update golden files") +) + +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} + +type httpAction struct { + method string + path string + wantRequest string + sendResponse string +} + +type testcase struct { + name string + args []string + actions []httpAction // http request to expect and responses to send back +} + +func TestRepoReg(t *testing.T) { + testdata, err := filepath.Abs(filepath.Join(".", "testdata")) + if err != nil { + t.Fatalf("Failed to find testdata: %v", err) + } + + for _, tc := range []testcase{ + { + name: "SimpleRegister", + args: []string{"https://github.com/platkrm/test-blueprints"}, + actions: []httpAction{ + { + method: http.MethodPost, + path: "/apis/config.porch.kpt.dev/v1alpha1/namespaces/default/repositories", + wantRequest: "simple-repository.yaml", + sendResponse: "simple-repository.yaml", + }, + }, + }, + { + name: "AuthRegister", + args: []string{"https://github.com/platkrm/test-blueprints.git", "--repo-basic-username=test-username", "--repo-basic-password=test-password"}, + actions: []httpAction{ + { + method: http.MethodPost, + path: "/api/v1/namespaces/default/secrets", + wantRequest: "auth-secret.yaml", + sendResponse: "auth-secret.yaml", + }, + { + method: http.MethodPost, + path: "/apis/config.porch.kpt.dev/v1alpha1/namespaces/default/repositories", + wantRequest: "auth-repository.yaml", + sendResponse: "auth-repository.yaml", + }, + }, + }, + { + name: "FullRegister", + args: []string{ + "https://github.com/platkrm/test-blueprints.git", + "--name=repository-resource-name", + "--description=\"Test Repository Description\"", + "--deployment", + "--directory=/catalog", + "--branch=main-branch", + "--create-branch", + "--namespace=repository-namespace", + }, + actions: []httpAction{ + { + method: http.MethodPost, + path: "/apis/config.porch.kpt.dev/v1alpha1/namespaces/repository-namespace/repositories", + wantRequest: "full-repository.yaml", + sendResponse: "full-repository.yaml", + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // Create fake Porch Server + porch := createFakePorch(t, tc.actions, func(action httpAction, w http.ResponseWriter, r *http.Request) { + // TODO: contents of this function is generic; move to shared utility in testutil. + var requestBody []byte + switch r.Header.Get("Content-Encoding") { + case "": + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed to read request body: %v", err) + } + requestBody = b + + default: + t.Fatalf("unhandled content-encoding %q", r.Header.Get("Content-Encoding")) + } + + var body interface{} + switch r.Header.Get("Content-Type") { + case "application/json": + if err := json.Unmarshal(requestBody, &body); err != nil { + t.Fatalf("Failed to unmarshal body: %v\n%s\n", err, string(requestBody)) + } + + // case "application/vnd.kubernetes.protobuf": + // Proto encoding is not handled https://kubernetes.io/docs/reference/using-api/api-concepts/#protobuf-encoding + + default: + t.Fatalf("unhandled content-type %q", r.Header.Get("Content-Type")) + } + + wantFile := filepath.Join(testdata, action.wantRequest) + + if *update { + data, err := yaml.Marshal(body) + if err != nil { + t.Fatalf("Failed to marshal request body as YAML: %v", err) + } + if err := os.WriteFile(wantFile, data, 0644); err != nil { + t.Fatalf("Failed to update golden file %q: %v", wantFile, err) + } + } + + var want interface{} + wantBytes, err := os.ReadFile(wantFile) + if err != nil { + t.Fatalf("Failed to reead golden file %q: %v", wantFile, err) + } + if err := yaml.Unmarshal(wantBytes, &want); err != nil { + t.Fatalf("Failed to unmarshal expected body %q: %v", wantFile, err) + } + + if !cmp.Equal(want, body) { + t.Errorf("Unexpected request body for %q (-want, +got) %s", r.RequestURI, cmp.Diff(want, body)) + } + + respData, err := os.ReadFile(filepath.Join(testdata, action.sendResponse)) + if err != nil { + t.Fatalf("Failed to read response file %q: %v", action.sendResponse, err) + } + var resp interface{} + if err := yaml.Unmarshal(respData, &resp); err != nil { + t.Fatalf("Failed to unmarshal desired response %q: %v", action.sendResponse, err) + } + respJSON, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Failed to marshal response body as JSON: %v", err) + } + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(respJSON); err != nil { + t.Errorf("Failed to write resonse body %q: %v", action.sendResponse, err) + } + }) + + // Create a test HTTP server. + server := httptest.NewServer(porch) + defer server.Close() + + // Create Kubeconfig + url := server.URL + usePersistentConfig := false + rcg := genericclioptions.NewConfigFlags(usePersistentConfig) + rcg.APIServer = &url + rcg.WrapConfigFn = func(restConfig *rest.Config) *rest.Config { + // Force use of JSON encoding + restConfig.ContentType = "application/json" + return restConfig + } + namespace := "default" + rcg.Namespace = &namespace + ctx := fakeprinter.CtxWithDefaultPrinter() + + cmd := NewCommand(ctx, rcg) + rcg.AddFlags(cmd.PersistentFlags()) // Add global flags + cmd.SetArgs(tc.args) + if err := cmd.Execute(); err != nil { + t.Errorf("Executing repo register %s failed: %v", strings.Join(tc.args, " "), err) + } + }) + } +} + +func createFakePorch(t *testing.T, actions []httpAction, handler func(action httpAction, w http.ResponseWriter, r *http.Request)) *fakePorch { + actionMap := map[request]httpAction{} + for _, a := range actions { + actionMap[request{ + method: a.method, + url: a.path, + }] = a + } + return &fakePorch{ + T: t, + actions: actionMap, + handler: handler, + } +} + +// TODO: Move the below to shared testing utility +type request struct { + method, url string +} + +type fakePorch struct { + *testing.T + actions map[request]httpAction + handler func(action httpAction, w http.ResponseWriter, r *http.Request) +} + +var _ http.Handler = &fakePorch{} + +func (p *fakePorch) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p.Logf("%s\n", r.RequestURI) + action, ok := p.actions[request{method: r.Method, url: r.URL.Path}] + if !ok { + p.Logf("handler not found for method %q url %q", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + p.handler(action, w, r) +} diff --git a/pkg/cli/commands/repo/reg/testdata/auth-repository.yaml b/pkg/cli/commands/repo/reg/testdata/auth-repository.yaml new file mode 100644 index 00000000..a7deb45c --- /dev/null +++ b/pkg/cli/commands/repo/reg/testdata/auth-repository.yaml @@ -0,0 +1,16 @@ +apiVersion: config.porch.kpt.dev/v1alpha1 +kind: Repository +metadata: + creationTimestamp: null + name: test-blueprints + namespace: default +spec: + content: Package + git: + branch: main + directory: / + repo: https://github.com/platkrm/test-blueprints.git + secretRef: + name: test-blueprints-auth + type: git +status: {} diff --git a/pkg/cli/commands/repo/reg/testdata/auth-secret.yaml b/pkg/cli/commands/repo/reg/testdata/auth-secret.yaml new file mode 100644 index 00000000..e1cc11d8 --- /dev/null +++ b/pkg/cli/commands/repo/reg/testdata/auth-secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +data: + password: dGVzdC1wYXNzd29yZA== + username: dGVzdC11c2VybmFtZQ== +kind: Secret +metadata: + creationTimestamp: null + name: test-blueprints-auth + namespace: default +type: kubernetes.io/basic-auth diff --git a/pkg/cli/commands/repo/reg/testdata/full-repository.yaml b/pkg/cli/commands/repo/reg/testdata/full-repository.yaml new file mode 100644 index 00000000..a3650ae6 --- /dev/null +++ b/pkg/cli/commands/repo/reg/testdata/full-repository.yaml @@ -0,0 +1,19 @@ +apiVersion: config.porch.kpt.dev/v1alpha1 +kind: Repository +metadata: + creationTimestamp: null + name: repository-resource-name + namespace: repository-namespace +spec: + content: Package + deployment: true + description: '"Test Repository Description"' + git: + branch: main-branch + createBranch: true + directory: /catalog + repo: https://github.com/platkrm/test-blueprints.git + secretRef: + name: "" + type: git +status: {} diff --git a/pkg/cli/commands/repo/reg/testdata/simple-repository.yaml b/pkg/cli/commands/repo/reg/testdata/simple-repository.yaml new file mode 100644 index 00000000..9739eb97 --- /dev/null +++ b/pkg/cli/commands/repo/reg/testdata/simple-repository.yaml @@ -0,0 +1,16 @@ +apiVersion: config.porch.kpt.dev/v1alpha1 +kind: Repository +metadata: + creationTimestamp: null + name: test-blueprints + namespace: default +spec: + content: Package + git: + branch: main + directory: / + repo: https://github.com/platkrm/test-blueprints + secretRef: + name: "" + type: git +status: {} diff --git a/pkg/cli/commands/repo/repocmd.go b/pkg/cli/commands/repo/repocmd.go new file mode 100644 index 00000000..fbbf9377 --- /dev/null +++ b/pkg/cli/commands/repo/repocmd.go @@ -0,0 +1,70 @@ +// Copyright 2022 The kpt and Nephio 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 repo + +import ( + "context" + "flag" + "fmt" + + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/repo/docs" + "github.com/nephio-project/porch/pkg/cli/commands/repo/get" + "github.com/nephio-project/porch/pkg/cli/commands/repo/reg" + "github.com/nephio-project/porch/pkg/cli/commands/repo/unreg" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" +) + +func NewCommand(ctx context.Context, version string) *cobra.Command { + repo := &cobra.Command{ + Use: "repo", + Aliases: []string{"repository"}, + Short: docs.RepoShort, + Long: docs.RepoLong, + RunE: func(cmd *cobra.Command, args []string) error { + h, err := cmd.Flags().GetBool("help") + if err != nil { + return err + } + if h { + return cmd.Help() + } + return cmd.Usage() + }, + Hidden: porch.HidePorchCommands, + } + + pf := repo.PersistentFlags() + + kubeflags := genericclioptions.NewConfigFlags(true) + kubeflags.AddFlags(pf) + + kubeflags.WrapConfigFn = func(rc *rest.Config) *rest.Config { + rc.UserAgent = fmt.Sprintf("porchctl/%s", version) + return rc + } + + pf.AddGoFlagSet(flag.CommandLine) + + repo.AddCommand( + reg.NewCommand(ctx, kubeflags), + get.NewCommand(ctx, kubeflags), + unreg.NewCommand(ctx, kubeflags), + ) + + return repo +} diff --git a/pkg/cli/commands/repo/unreg/command.go b/pkg/cli/commands/repo/unreg/command.go new file mode 100644 index 00000000..a853248a --- /dev/null +++ b/pkg/cli/commands/repo/unreg/command.go @@ -0,0 +1,144 @@ +// Copyright 2022 The kpt and Nephio 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 unreg + +import ( + "context" + "fmt" + + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/repo/docs" + "github.com/spf13/cobra" + coreapi "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrepounreg" +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + c := &cobra.Command{ + Use: "unreg REPOSITORY [flags]", + Aliases: []string{"unregister"}, + Short: docs.UnregShort, + Long: docs.UnregShort + "\n" + docs.UnregLong, + Example: docs.UnregExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + c.Flags().BoolVar(&r.keepSecret, "keep-auth-secret", false, "Keep the auth secret associated with the repository registration, if any") + + return r +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + + // Flags + keepSecret bool +} + +func (r *runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + client, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + return nil +} + +func (r *runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + if len(args) == 0 { + return errors.E(op, fmt.Errorf("REPOSITORY is a required positional argument")) + } + + repository := args[0] + + var repo configapi.Repository + if err := r.client.Get(r.ctx, client.ObjectKey{ + Namespace: *r.cfg.Namespace, + Name: repository, + }, &repo); err != nil { + return errors.E(op, err) + } + if err := r.client.Delete(r.ctx, &configapi.Repository{ + TypeMeta: metav1.TypeMeta{ + Kind: "Repository", + APIVersion: configapi.GroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: repo.Name, + Namespace: repo.Namespace, + }, + }); err != nil { + return errors.E(op, err) + } + + if r.keepSecret { + return nil + } + + secret := getSecretName(&repo) + if secret == "" { + return nil + } + + if err := r.client.Delete(r.ctx, &coreapi.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: coreapi.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secret, + Namespace: repo.Namespace, + }, + }); err != nil { + return errors.E(op, fmt.Errorf("failed to delete Secret %s: %w", secret, err)) + } + + return nil +} + +func getSecretName(repo *configapi.Repository) string { + if repo.Spec.Git != nil { + return repo.Spec.Git.SecretRef.Name + } + if repo.Spec.Oci != nil { + return repo.Spec.Oci.SecretRef.Name + } + return "" +} diff --git a/pkg/cli/commands/rpkg/approve/command.go b/pkg/cli/commands/rpkg/approve/command.go new file mode 100644 index 00000000..96813024 --- /dev/null +++ b/pkg/cli/commands/rpkg/approve/command.go @@ -0,0 +1,104 @@ +// Copyright 2022 The kpt and Nephio 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 approve + +import ( + "context" + "fmt" + "strings" + + "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkgapprove" +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + client: nil, + } + + c := &cobra.Command{ + Use: "approve PACKAGE", + Short: docs.ApproveShort, + Long: docs.ApproveShort + "\n" + docs.ApproveLong, + Example: docs.ApproveExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + return r +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client rest.Interface + Command *cobra.Command + + // Flags +} + +func (r *runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + + client, err := porch.CreateRESTClient(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + return nil +} + +func (r *runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + + namespace := *r.cfg.Namespace + + for _, name := range args { + if err := porch.UpdatePackageRevisionApproval(r.ctx, r.client, client.ObjectKey{ + Namespace: namespace, + Name: name, + }, v1alpha1.PackageRevisionLifecyclePublished); err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(r.Command.ErrOrStderr(), "%s failed (%s)\n", name, err) + } else { + fmt.Fprintf(r.Command.OutOrStderr(), "%s approved\n", name) + } + } + + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + + return nil +} diff --git a/pkg/cli/commands/rpkg/clone/command.go b/pkg/cli/commands/rpkg/clone/command.go new file mode 100644 index 00000000..3b6d0c51 --- /dev/null +++ b/pkg/cli/commands/rpkg/clone/command.go @@ -0,0 +1,228 @@ +// Copyright 2022 The kpt and Nephio 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 clone + +import ( + "context" + "fmt" + "strings" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/parse" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkgclone" +) + +var ( + strategies = []string{ + string(porchapi.ResourceMerge), + string(porchapi.FastForward), + string(porchapi.ForceDeleteReplace), + } +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + c := &cobra.Command{ + Use: "clone SOURCE_PACKAGE NAME", + Short: docs.CloneShort, + Long: docs.CloneShort + "\n" + docs.CloneLong, + Example: docs.CloneExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + c.Flags().StringVar(&r.strategy, "strategy", string(porchapi.ResourceMerge), + "update strategy that should be used when updating this package; one of: "+strings.Join(strategies, ",")) + c.Flags().StringVar(&r.directory, "directory", "", "Directory within the repository where the upstream package is located.") + c.Flags().StringVar(&r.ref, "ref", "", "Branch in the repository where the upstream package is located.") + c.Flags().StringVar(&r.repository, "repository", "", "Repository to which package will be cloned (downstream repository).") + c.Flags().StringVar(&r.workspace, "workspace", "v1", "Workspace name of the downstream package.") + + return r +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + + clone porchapi.PackageCloneTaskSpec + + // Flags + strategy string + directory string + ref string + repository string // Target repository + workspace string // Target workspaceName + target string // Target package name +} + +func (r *runner) preRunE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + client, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + + mergeStrategy, err := toMergeStrategy(r.strategy) + if err != nil { + return errors.E(op, err) + } + r.clone.Strategy = mergeStrategy + + if len(args) < 2 { + return errors.E(op, fmt.Errorf("SOURCE_PACKAGE and NAME are required positional arguments; %d provided", len(args))) + } + + if r.repository == "" { + return errors.E(op, fmt.Errorf("--repository is required to specify downstream repository")) + } + + if r.workspace == "" { + return errors.E(op, fmt.Errorf("--workspace is required to specify downstream workspace name")) + } + + source := args[0] + target := args[1] + + pkgExists, err := util.PackageAlreadyExists(r.ctx, r.client, r.repository, target, *r.cfg.Namespace) + if err != nil { + return err + } + if pkgExists { + return fmt.Errorf("`clone` cannot create a new revision for package %q that already exists in repo %q; make subsequent revisions using `copy`", + target, r.repository) + } + + switch { + case strings.HasPrefix(source, "oci://"): + r.clone.Upstream.Type = porchapi.RepositoryTypeOCI + r.clone.Upstream.Oci = &porchapi.OciPackage{ + Image: source, + } + + case strings.Contains(source, "/"): + if parse.HasGitSuffix(source) { // extra parsing required + repo, dir, ref, err := parse.URL(source) + if err != nil { + return err + } + // throw error if values set by flags contradict values parsed from SOURCE_PACKAGE + if r.directory != "" && dir != "" && r.directory != dir { + return errors.E(op, fmt.Errorf("directory %s specified by --directory contradicts directory %s specified by SOURCE_PACKAGE", + r.directory, dir)) + } + if r.ref != "" && ref != "" && r.ref != ref { + return errors.E(op, fmt.Errorf("ref %s specified by --ref contradicts ref %s specified by SOURCE_PACKAGE", + r.ref, ref)) + } + // grab the values parsed from SOURCE_PACKAGE + if r.directory == "" { + r.directory = dir + } + if r.ref == "" { + r.ref = ref + } + source = repo + ".git" // parse.ParseURL removes the git suffix, we need to add it back + } + if r.ref == "" { + r.ref = "main" + } + if r.directory == "" { + r.directory = "/" + } + r.clone.Upstream.Type = porchapi.RepositoryTypeGit + r.clone.Upstream.Git = &porchapi.GitPackage{ + Repo: source, + Ref: r.ref, + Directory: r.directory, + } + // TODO: support authn + + default: + r.clone.Upstream.UpstreamRef = &porchapi.PackageRevisionRef{ + Name: source, + } + } + + r.target = target + return nil +} + +func (r *runner) runE(cmd *cobra.Command, _ []string) error { + const op errors.Op = command + ".runE" + + pr := &porchapi.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchapi.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: *r.cfg.Namespace, + }, + Spec: porchapi.PackageRevisionSpec{ + PackageName: r.target, + WorkspaceName: porchapi.WorkspaceName(r.workspace), + RepositoryName: r.repository, + Tasks: []porchapi.Task{ + { + Type: porchapi.TaskTypeClone, + Clone: &r.clone, + }, + }, + }, + } + if err := r.client.Create(r.ctx, pr); err != nil { + return errors.E(op, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s created\n", pr.Name) + return nil +} + +func toMergeStrategy(strategy string) (porchapi.PackageMergeStrategy, error) { + switch strategy { + case string(porchapi.ResourceMerge): + return porchapi.ResourceMerge, nil + case string(porchapi.FastForward): + return porchapi.FastForward, nil + case string(porchapi.ForceDeleteReplace): + return porchapi.ForceDeleteReplace, nil + default: + return "", fmt.Errorf("invalid strategy: %q", strategy) + } +} diff --git a/pkg/cli/commands/rpkg/copy/command.go b/pkg/cli/commands/rpkg/copy/command.go new file mode 100644 index 00000000..b5674177 --- /dev/null +++ b/pkg/cli/commands/rpkg/copy/command.go @@ -0,0 +1,153 @@ +// Copyright 2022 The kpt and Nephio 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 copy + +import ( + "context" + "fmt" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkgcopy" +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + r.Command = &cobra.Command{ + Use: "copy SOURCE_PACKAGE NAME", + Aliases: []string{"edit"}, + Short: docs.CopyShort, + Long: docs.CopyShort + "\n" + docs.CopyLong, + Example: docs.CopyExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command.Flags().StringVar(&r.workspace, "workspace", "", "Workspace name of the copy of the package.") + r.Command.Flags().BoolVar(&r.replayStrategy, "replay-strategy", false, "Use replay strategy for creating new package revision.") + return r +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + + copy porchapi.PackageEditTaskSpec + + workspace string // Target package revision workspaceName + replayStrategy bool +} + +func (r *runner) preRunE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + client, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + + if len(args) < 1 { + return errors.E(op, fmt.Errorf("SOURCE_PACKAGE is a required positional argument")) + } + if len(args) > 1 { + return errors.E(op, fmt.Errorf("too many arguments; SOURCE_PACKAGE is the only accepted positional arguments")) + } + + r.copy.Source = &porchapi.PackageRevisionRef{ + Name: args[0], + } + return nil +} + +func (r *runner) runE(cmd *cobra.Command, _ []string) error { + const op errors.Op = command + ".runE" + + revisionSpec, err := r.getPackageRevisionSpec() + if err != nil { + return errors.E(op, err) + } + + pr := &porchapi.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchapi.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: *r.cfg.Namespace, + }, + Spec: *revisionSpec, + } + if err := r.client.Create(r.ctx, pr); err != nil { + return errors.E(op, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s created\n", pr.Name) + return nil +} + +func (r *runner) getPackageRevisionSpec() (*porchapi.PackageRevisionSpec, error) { + packageRevision := porchapi.PackageRevision{} + err := r.client.Get(r.ctx, types.NamespacedName{ + Name: r.copy.Source.Name, + Namespace: *r.cfg.Namespace, + }, &packageRevision) + if err != nil { + return nil, err + } + + if r.workspace == "" { + return nil, fmt.Errorf("--workspace is required to specify workspace name") + } + + spec := &porchapi.PackageRevisionSpec{ + PackageName: packageRevision.Spec.PackageName, + WorkspaceName: porchapi.WorkspaceName(r.workspace), + RepositoryName: packageRevision.Spec.RepositoryName, + } + + if len(packageRevision.Spec.Tasks) == 0 || !r.replayStrategy { + spec.Tasks = []porchapi.Task{ + { + Type: porchapi.TaskTypeEdit, + Edit: &porchapi.PackageEditTaskSpec{ + Source: &porchapi.PackageRevisionRef{ + Name: packageRevision.Name, + }, + }, + }, + } + } else { + spec.Tasks = packageRevision.Spec.Tasks + } + return spec, nil +} diff --git a/pkg/cli/commands/rpkg/del/command.go b/pkg/cli/commands/rpkg/del/command.go new file mode 100644 index 00000000..8bedf5f8 --- /dev/null +++ b/pkg/cli/commands/rpkg/del/command.go @@ -0,0 +1,111 @@ +// Copyright 2022 The kpt and Nephio 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 del + +import ( + "context" + "fmt" + "strings" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkgdel" +) + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + c := &cobra.Command{ + Use: "del PACKAGE", + Aliases: []string{"delete"}, + SuggestFor: []string{}, + Short: docs.DelShort, + Long: docs.DelShort + "\n" + docs.DelLong, + Example: docs.DelExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + // Create flags + + return r +} + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command +} + +func (r *runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + + client, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + return nil +} + +func (r *runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + + for _, pkg := range args { + pr := &porchapi.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchapi.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: *r.cfg.Namespace, + Name: pkg, + }, + } + + if err := r.client.Delete(r.ctx, pr); err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(r.Command.ErrOrStderr(), "%s failed (%s)\n", pkg, err) + } else { + fmt.Fprintf(r.Command.OutOrStderr(), "%s deleted\n", pkg) + } + } + + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + + return nil +} diff --git a/pkg/cli/commands/rpkg/docs/docs.go b/pkg/cli/commands/rpkg/docs/docs.go new file mode 100644 index 00000000..2b5dcf0f --- /dev/null +++ b/pkg/cli/commands/rpkg/docs/docs.go @@ -0,0 +1,313 @@ +// Copyright 2022 The kpt and Nephio 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. + +// Code originally generated by "mdtogo", but +// no longer maintained that way. +package docs + +var RpkgShort = `Manage packages.` +var RpkgLong = ` +The ` + "`" + `rpkg` + "`" + ` command group contains subcommands for managing packages and revisions. +` + +var ApproveShort = `Approve a proposal to publish a package revision.` +var ApproveLong = ` + porchctl rpkg approve [PACKAGE_REV_NAME...] [flags] + +Args: + + PACKAGE_REV_NAME...: + The name of one or more package revisions. If more than + one is provided, they must be space-separated. +` +var ApproveExamples = ` + # approve package revision blueprint-91817620282c133138177d16c981cf35f0083cad + $ porchctl rpkg approve blueprint-91817620282c133138177d16c981cf35f0083cad --namespace=default +` + +var CloneShort = `Create a clone of an existing package revision.` +var CloneLong = ` + porchctl rpkg clone SOURCE_PACKAGE_REV TARGET_PACKAGE_NAME [flags] + +Args: + + SOURCE_PACKAGE_REV: + The source package that will be cloned to create the new package revision. + The types of sources are supported: + + * OCI: A URI to a OCI repository must be provided. + oci://oci-repository/package-name + * Git: A URI to a git repository must be provided. + https://git-repository.git/package-name + * Package: The name of a package revision already available in the + repository. + blueprint-e982b2196b35a4f5e81e92f49a430fe463aa9f1a + + TARGET_PACKAGE_NAME: + The name of the new package. + + +Flags: + + --directory + Directory within the repository where the upstream + package revision is located. This only applies if the source package is in git + or oci. + + --ref + Ref in the repository where the upstream package revision + is located (branch, tag, SHA). This only applies when the source package + is in git. + + --repository + Repository to which package revision will be cloned + (downstream repository). + + --workspace + Workspace for the new package. The default value is v1. + + --strategy + Update strategy that should be used when updating the new + package revision. Must be one of: resource-merge, fast-forward, or + force-delete-replace. The default value is resource-merge. +` +var CloneExamples = ` + # clone the blueprint-e982b2196b35a4f5e81e92f49a430fe463aa9f1a package and create a new package revision called + # foo in the blueprint repository with a custom workspaceName. + $ porchctl rpkg clone blueprint-e982b2196b35a4f5e81e92f49a430fe463aa9f1a foo --repository blueprint --workspace=first-draft + + # clone the git repository at https://github.com/repo/blueprint.git at reference base/v0 and in directory base. The new + # package revision will be created in repository blueprint and namespace default. + $ porchctl rpkg clone https://github.com/repo/blueprint.git bar --repository=blueprint --ref=base/v0 --namespace=default --directory=base +` + +var CopyShort = `Create a new package revision from an existing one.` +var CopyLong = ` + porchctl rpkg copy SOURCE_PACKAGE_REV_NAME [flags] + +Args: + + SOURCE_PACKAGE_REV_NAME: + The name of the package revision that will be used as the source + for creating a new package revision. + +Flags: + + --workspace + Workspace for the new package revision. +` +var CopyExamples = ` + # create a new package from package blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2 + $ porchctl rpkg copy blueprint-b47eadc99f3c525571d3834cc61b974453bc6be2 --workspace=v10 --namespace=default +` + +var DelShort = `Delete a package revision.` +var DelLong = ` + porchctl rpkg del PACKAGE_REV_NAME... [flags] + +Args: + + PACKAGE_REV_NAME...: + The name of one or more package revisions. If more than + one is provided, they must be space-separated. +` +var DelExamples = ` + # remove package revision blueprint-e982b2196b35a4f5e81e92f49a430fe463aa9f1a from the default namespace + $ porchctl rpkg del blueprint-e982b2196b35a4f5e81e92f49a430fe463aa9f1a --namespace=default +` + +var GetShort = `List package revisions in registered repositories.` +var GetLong = ` + porchctl rpkg get [PACKAGE_REV_NAME] [flags] + +Args: + + PACKAGE_REV_NAME: + The name of a package revision. If provided, only that specific + package revision will be shown. Defaults to showing all package + revisions from all repositories. + +Flags: + + --name + Name of the packages to get. Any package whose name contains + this value will be included in the results. + + --revision + Revision of the package to get. Any package whose revision + matches this value will be included in the results. +` +var GetExamples = ` + # get a specific package revision in the default namespace + $ porchctl rpkg get blueprint-e982b2196b35a4f5e81e92f49a430fe463aa9f1a --namespace=default + + # get all package revisions in the bar namespace + $ porchctl rpkg get --namespace=bar + + # get all package revisions with revision v0 + $ porchctl rpkg get --revision=v0 +` + +var InitShort = `Initializes a new package in a repository.` +var InitLong = ` + porchctl rpkg init PACKAGE_NAME [flags] + +Args: + + PACKAGE_NAME: + The name of the new package. + +Flags: + + --repository + Repository in which the new package will be created. + + --workspace + Workspace of the new package. + + --description + Short description of the package + + --keywords + List of keywords for the package + + --site + Link to page with information about the package +` +var InitExamples = ` + # create a new package named foo in the repository blueprint. + $ porchctl rpkg init foo --namespace=default --repository=blueprint --workspace=v1 +` + +var ProposeShort = `Propose that a package revision should be published.` +var ProposeLong = ` + porchctl rpkg propose [PACKAGE_REV_NAME...] [flags] + +Args: + + PACKAGE_REV_NAME...: + The name of one or more package revisions. If more than + one is provided, they must be space-separated. +` +var ProposeExamples = ` + # propose that package revision blueprint-91817620282c133138177d16c981cf35f0083cad should be finalized. + $ porchctl rpkg propose blueprint-91817620282c133138177d16c981cf35f0083cad --namespace=default +` + +var ProposeDeleteShort = `Propose deletion of a published package revision.` +var ProposeDeleteLong = ` + porchctl rpkg propose-delete PACKAGE_REV_NAME... [flags] + +Args: + + PACKAGE_REV_NAME...: + The name of one or more package revisions. If more than + one is provided, they must be space-separated. +` +var ProposeDeleteExamples = ` + # Propose published package revision blueprint-e982b2196b35a4f5e81e92f49a430fe463aa9f1a for deletion. + $ porchctl rpkg propose-delete blueprint-e982b2196b35a4f5e81e92f49a430fe463aa9f1a --namespace=default +` + +var PullShort = `Pull the content of the package revision.` +var PullLong = ` + porchctl rpkg pull PACKAGE_REV_NAME [DIR] [flags] + +Args: + + PACKAGE_REV_NAME: + The name of a an existing package revision in a repository. + + DIR: + A local directory where the package manifests will be written. + If not provided, the manifests are written to stdout. +` +var PullExamples = ` + # pull the content of package revision blueprint-d5b944d27035efba53836562726fb96e51758d97 + $ porchctl rpkg pull blueprint-d5b944d27035efba53836562726fb96e51758d97 --namespace=default +` + +var PushShort = `Push resources to a package revision.` +var PushLong = ` + porchctl rpkg push PACKAGE_REV_NAME [DIR] [flags] + +Args: + + PACKAGE_REV_NAME: + The name of a an existing package revision in a repository. + + DIR: + A local directory with the new manifest. If not provided, + the manifests will be read from stdin. +` +var PushExamples = ` + # update the package revision blueprint-f977350dff904fa677100b087a5bd989106d0456 with the resources + # in the ./package directory + $ porchctl rpkg push blueprint-f977350dff904fa677100b087a5bd989106d0456 ./package --namespace=default +` + +var RejectShort = `Reject a proposal to publish or delete a package revision.` +var RejectLong = ` + porchctl rpkg reject [PACKAGE_REV_NAME...] [flags] + +Args: + + PACKAGE_REV_NAME...: + The name of one or more package revisions. If more than + one is provided, they must be space-separated. +` +var RejectExamples = ` + # reject the proposal for package revision blueprint-8f9a0c7bf29eb2cbac9476319cd1ad2e897be4f9 + $ porchctl rpkg reject blueprint-8f9a0c7bf29eb2cbac9476319cd1ad2e897be4f9 --namespace=default +` + +var UpdateShort = `Update a downstream package revision to a more recent revision of its upstream package.` +var UpdateLong = ` + porchctl rpkg update PACKAGE_REV_NAME [flags] + +Args: + + PACKAGE_REV_NAME: + The target downstream package revision to be updated. + + +Flags: + + --revision + The revision number of the upstream kpt package that the target + downstream package (PACKAGE_REV_NAME) should be updated to. With + this flag, you can only specify one target downstream package. + + --discover + If set, list packages revisions that need updates rather than + performing an update. Must be one of 'upstream' or 'downstream'. If + set to 'upstream', this will list downstream package revisions that + have upstream updates available. If set to 'downstream', this will list + upstream package revisions whose downstream package revisions need + to be updated. You can optionally pass in package revision names as arguments + in order to just list updates for those package revisions, or you can + pass in no arguments in order to list available updates for all package + revisions. + +` +var UpdateExamples = ` + # update deployment-e982b2196b35a4f5e81e92f49a430fe463aa9f1a package to v3 of its upstream + $ porchctl rpkg update deployment-e982b2196b35a4f5e81e92f49a430fe463aa9f1a --revision=v3 + + # see available upstream updates for all your downstream packages + $ porchctl rpkg update --discover=upstream + + # see available updates for any downstream packages that were created from the upstream blueprints-e982b2196b35a4f5e81e92f49a430fe463aa9f1a package + $ porchctl rpkg update --discover=downstream blueprints-e982b2196b35a4f5e81e92f49a430fe463aa9f1a +` diff --git a/pkg/cli/commands/rpkg/get/command.go b/pkg/cli/commands/rpkg/get/command.go new file mode 100644 index 00000000..f474c47e --- /dev/null +++ b/pkg/cli/commands/rpkg/get/command.go @@ -0,0 +1,306 @@ +// Copyright 2022 The kpt and Nephio 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 get + +import ( + "context" + "fmt" + "strings" + + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/options" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + "k8s.io/kubectl/pkg/cmd/get" +) + +const ( + command = "cmdrpkgget" +) + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + getFlags: options.Get{ConfigFlags: rcg}, + printFlags: get.NewGetPrintFlags(), + } + cmd := &cobra.Command{ + Use: "get", + Aliases: []string{"list"}, + SuggestFor: []string{}, + Short: docs.GetShort, + Long: docs.GetShort + "\n" + docs.GetLong, + Example: docs.GetExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = cmd + + // Create flags + cmd.Flags().StringVar(&r.packageName, "name", "", "Name of the packages to get. Any package whose name contains this value will be included in the results.") + cmd.Flags().StringVar(&r.revision, "revision", "", "Revision of the packages to get. Any package whose revision matches this value will be included in the results.") + cmd.Flags().StringVar(&r.workspace, "workspace", "", + "WorkspaceName of the packages to get. Any package whose workspaceName matches this value will be included in the results.") + + r.getFlags.AddFlags(cmd) + r.printFlags.AddFlags(cmd) + return r +} + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +type runner struct { + ctx context.Context + getFlags options.Get + Command *cobra.Command + + // Flags + packageName string + revision string + workspace string + printFlags *get.PrintFlags + + requestTable bool +} + +func (r *runner) preRunE(cmd *cobra.Command, _ []string) error { + // Print the namespace if we're spanning namespaces + if r.getFlags.AllNamespaces { + r.printFlags.HumanReadableFlags.WithNamespace = true + } + + outputOption := cmd.Flags().Lookup("output").Value.String() + if strings.Contains(outputOption, "custom-columns") || outputOption == "yaml" || strings.Contains(outputOption, "json") { + r.requestTable = false + } else { + r.requestTable = true + } + return nil +} + +func (r *runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + var objs []runtime.Object + b, err := r.getFlags.ResourceBuilder() + if err != nil { + return err + } + + if r.requestTable { + scheme := runtime.NewScheme() + // Accept PartialObjectMetadata and Table + if err := metav1.AddMetaToScheme(scheme); err != nil { + return fmt.Errorf("error building runtime.Scheme: %w", err) + } + b = b.WithScheme(scheme, schema.GroupVersion{Version: "v1"}) + } else { + // We want to print the server version, not whatever version we happen to have compiled in + b = b.Unstructured() + } + + useSelectors := true + if len(args) > 0 { + b = b.ResourceNames("packagerevisions", args...) + // We can't pass selectors here, get an error "Error: selectors and the all flag cannot be used when passing resource/name arguments" + // TODO: cli-utils bug? I think there is a metadata.name field selector (used for single object watch) + useSelectors = false + } else { + b = b.ResourceTypes("packagerevisions") + } + + if useSelectors { + fieldSelector := fields.Everything() + if r.revision != "" { + fieldSelector = fields.OneTermEqualSelector("spec.revision", r.revision) + } + if r.workspace != "" { + fieldSelector = fields.OneTermEqualSelector("spec.workspaceName", r.workspace) + } + if r.packageName != "" { + fieldSelector = fields.OneTermEqualSelector("spec.packageName", r.packageName) + } + if s := fieldSelector.String(); s != "" { + b = b.FieldSelectorParam(s) + } else { + b = b.SelectAllParam(true) + } + } + + b = b.ContinueOnError(). + Latest(). + Flatten() + + if r.requestTable { + b = b.TransformRequests(func(req *rest.Request) { + req.SetHeader("Accept", strings.Join([]string{ + "application/json;as=Table;g=meta.k8s.io;v=v1", + "application/json", + }, ",")) + }) + } + + res := b.Do() + if err := res.Err(); err != nil { + return errors.E(op, err) + } + + infos, err := res.Infos() + if err != nil { + return errors.E(op, err) + } + + // Decode json objects in tables (likely PartialObjectMetadata) + for _, i := range infos { + if table, ok := i.Object.(*metav1.Table); ok { + for i := range table.Rows { + row := &table.Rows[i] + if row.Object.Object == nil && row.Object.Raw != nil { + u := &unstructured.Unstructured{} + if err := u.UnmarshalJSON(row.Object.Raw); err != nil { + klog.Warningf("error parsing raw object: %v", err) + } + row.Object.Object = u + } + } + } + } + + // Apply any filters we couldn't pass down as field selectors + for _, i := range infos { + switch obj := i.Object.(type) { + case *unstructured.Unstructured: + match, err := r.packageRevisionMatches(obj) + if err != nil { + return errors.E(op, err) + } + if match { + objs = append(objs, obj) + } + case *metav1.Table: + // Technically we should have applied this as a field-selector, so this might not be necessary + if err := r.filterTableRows(obj); err != nil { + return err + } + objs = append(objs, obj) + default: + return errors.E(op, fmt.Sprintf("Unrecognized response %T", obj)) + } + } + + printer, err := r.printFlags.ToPrinter() + if err != nil { + return errors.E(op, err) + } + + w := printers.GetNewTabWriter(cmd.OutOrStdout()) + for _, obj := range objs { + if err := printer.PrintObj(obj, w); err != nil { + return errors.E(op, err) + } + } + if err := w.Flush(); err != nil { + return errors.E(op, err) + } + + return nil +} + +func (r *runner) packageRevisionMatches(o *unstructured.Unstructured) (bool, error) { + packageName, _, err := unstructured.NestedString(o.Object, "spec", "packageName") + if err != nil { + return false, err + } + revision, _, err := unstructured.NestedString(o.Object, "spec", "revision") + if err != nil { + return false, err + } + workspace, _, err := unstructured.NestedString(o.Object, "spec", "workspaceName") + if err != nil { + return false, err + } + if r.packageName != "" && r.packageName != packageName { + return false, nil + } + if r.revision != "" && r.revision != revision { + return false, nil + } + if r.workspace != "" && r.workspace != workspace { + return false, nil + } + return true, nil +} + +func findColumn(cols []metav1.TableColumnDefinition, name string) int { + for i := range cols { + if cols[i].Name == name { + return i + } + } + return -1 +} + +func getStringCell(cells []interface{}, col int) (string, bool) { + if col < 0 { + return "", false + } + s, ok := cells[col].(string) + return s, ok +} + +func (r *runner) filterTableRows(table *metav1.Table) error { + filtered := make([]metav1.TableRow, 0, len(table.Rows)) + packageNameCol := findColumn(table.ColumnDefinitions, "Package") + revisionCol := findColumn(table.ColumnDefinitions, "Revision") + workspaceCol := findColumn(table.ColumnDefinitions, "WorkspaceName") + + for i := range table.Rows { + row := &table.Rows[i] + + if packageName, ok := getStringCell(row.Cells, packageNameCol); ok { + if r.packageName != "" && r.packageName != packageName { + continue + } + } + if revision, ok := getStringCell(row.Cells, revisionCol); ok { + if r.revision != "" && r.revision != revision { + continue + } + } + if workspace, ok := getStringCell(row.Cells, workspaceCol); ok { + if r.workspace != "" && r.workspace != workspace { + continue + } + } + + // Row matches + filtered = append(filtered, *row) + } + table.Rows = filtered + return nil +} diff --git a/pkg/cli/commands/rpkg/init/command.go b/pkg/cli/commands/rpkg/init/command.go new file mode 100644 index 00000000..ddc1de94 --- /dev/null +++ b/pkg/cli/commands/rpkg/init/command.go @@ -0,0 +1,147 @@ +// Copyright 2022 The kpt and Nephio 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 init + +import ( + "context" + "fmt" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkginit" +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + c := &cobra.Command{ + Use: "init PACKAGE_NAME", + Short: docs.InitShort, + Long: docs.InitShort + "\n" + docs.InitLong, + Example: docs.InitExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + c.Flags().StringVar(&r.Description, "description", "sample description", "short description of the package.") + c.Flags().StringSliceVar(&r.Keywords, "keywords", []string{}, "list of keywords for the package.") + c.Flags().StringVar(&r.Site, "site", "", "link to page with information about the package.") + c.Flags().StringVar(&r.repository, "repository", "", "Repository to which package will be created.") + c.Flags().StringVar(&r.workspace, "workspace", "", "Workspace name of the package.") + + return r +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + + // Flags + Keywords []string + Description string + Site string + name string // Target package name + repository string // Target repository + workspace string // Target workspace name +} + +func (r *runner) preRunE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + + client, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + + if len(args) < 1 { + return errors.E(op, "PACKAGE_NAME is a required positional argument") + } + + if r.repository == "" { + return errors.E(op, fmt.Errorf("--repository is required to specify target repository")) + } + + if r.workspace == "" { + return errors.E(op, fmt.Errorf("--workspace is required to specify workspace name")) + } + + r.name = args[0] + pkgExists, err := util.PackageAlreadyExists(r.ctx, r.client, r.repository, r.name, *r.cfg.Namespace) + if err != nil { + return err + } + if pkgExists { + return fmt.Errorf("`init` cannot create a new revision for package %q that already exists in repo %q; make subsequent revisions using `copy`", + r.name, r.repository) + } + return nil +} + +func (r *runner) runE(cmd *cobra.Command, _ []string) error { + const op errors.Op = command + ".runE" + + pr := &porchapi.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchapi.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: *r.cfg.Namespace, + }, + Spec: porchapi.PackageRevisionSpec{ + PackageName: r.name, + WorkspaceName: porchapi.WorkspaceName(r.workspace), + RepositoryName: r.repository, + Tasks: []porchapi.Task{ + { + Type: porchapi.TaskTypeInit, + Init: &porchapi.PackageInitTaskSpec{ + Description: r.Description, + Keywords: r.Keywords, + Site: r.Site, + }, + }, + }, + }, + Status: porchapi.PackageRevisionStatus{}, + } + if err := r.client.Create(r.ctx, pr); err != nil { + return errors.E(op, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s created\n", pr.Name) + return nil +} diff --git a/pkg/cli/commands/rpkg/propose/command.go b/pkg/cli/commands/rpkg/propose/command.go new file mode 100644 index 00000000..417f4272 --- /dev/null +++ b/pkg/cli/commands/rpkg/propose/command.go @@ -0,0 +1,121 @@ +// Copyright 2022 The kpt and Nephio 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 propose + +import ( + "context" + "fmt" + "strings" + + "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkgpropose" +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + client: nil, + } + + c := &cobra.Command{ + Use: "propose [PACKAGE ...] [flags]", + Short: docs.ProposeShort, + Long: docs.ProposeShort + "\n" + docs.ProposeLong, + Example: docs.ProposeExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + return r +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + + // Flags +} + +func (r *runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + + client, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + return nil +} + +func (r *runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + namespace := *r.cfg.Namespace + + for _, name := range args { + pr := &v1alpha1.PackageRevision{} + if err := r.client.Get(r.ctx, client.ObjectKey{ + Namespace: namespace, + Name: name, + }, pr); err != nil { + return errors.E(op, err) + } + + switch pr.Spec.Lifecycle { + case v1alpha1.PackageRevisionLifecycleDraft: + // ok + case v1alpha1.PackageRevisionLifecycleProposed: + fmt.Fprintf(r.Command.OutOrStderr(), "%s is already proposed\n", name) + continue + default: + msg := fmt.Sprintf("cannot propose %s package", pr.Spec.Lifecycle) + messages = append(messages, msg) + fmt.Fprintln(r.Command.ErrOrStderr(), msg) + continue + } + + pr.Spec.Lifecycle = v1alpha1.PackageRevisionLifecycleProposed + if err := r.client.Update(r.ctx, pr); err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(r.Command.ErrOrStderr(), "%s failed (%s)\n", name, err) + } else { + fmt.Fprintf(r.Command.OutOrStderr(), "%s proposed\n", name) + } + } + + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + + return nil +} diff --git a/pkg/cli/commands/rpkg/proposedelete/command.go b/pkg/cli/commands/rpkg/proposedelete/command.go new file mode 100644 index 00000000..4dc64e42 --- /dev/null +++ b/pkg/cli/commands/rpkg/proposedelete/command.go @@ -0,0 +1,121 @@ +// Copyright 2022 The kpt and Nephio 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 proposedelete + +import ( + "context" + "fmt" + "strings" + + "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkgpropose-delete" +) + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + c := &cobra.Command{ + Use: "propose-delete PACKAGE", + Aliases: []string{"propose-del"}, + Short: docs.ProposeDeleteShort, + Long: docs.ProposeDeleteShort + "\n" + docs.ProposeDeleteLong, + Example: docs.ProposeDeleteExamples, + SuggestFor: []string{}, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + // Create flags + + return r +} + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command +} + +func (r *runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + + client, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + return nil +} + +func (r *runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + namespace := *r.cfg.Namespace + + for _, name := range args { + pr := &v1alpha1.PackageRevision{} + if err := r.client.Get(r.ctx, client.ObjectKey{ + Namespace: namespace, + Name: name, + }, pr); err != nil { + return errors.E(op, err) + } + + switch pr.Spec.Lifecycle { + case v1alpha1.PackageRevisionLifecyclePublished: + // ok + case v1alpha1.PackageRevisionLifecycleDeletionProposed: + fmt.Fprintf(r.Command.OutOrStderr(), "%s is already proposed for deletion\n", name) + continue + default: + msg := fmt.Sprintf("can only propose published packages for deletion; package %s is not published", name) + messages = append(messages, msg) + fmt.Fprintln(r.Command.ErrOrStderr(), msg) + continue + } + + pr.Spec.Lifecycle = v1alpha1.PackageRevisionLifecycleDeletionProposed + if err := r.client.Update(r.ctx, pr); err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(r.Command.ErrOrStderr(), "%s failed (%s)\n", name, err) + } else { + fmt.Fprintf(r.Command.OutOrStderr(), "%s proposed for deletion\n", name) + } + } + + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + + return nil +} diff --git a/pkg/cli/commands/rpkg/pull/command.go b/pkg/cli/commands/rpkg/pull/command.go new file mode 100644 index 00000000..0eb357d6 --- /dev/null +++ b/pkg/cli/commands/rpkg/pull/command.go @@ -0,0 +1,215 @@ +// Copyright 2022 The kpt and Nephio 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 pull + +import ( + "context" + "io" + "os" + "path/filepath" + "sort" + "strings" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/cmdutil" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + kptfilev1 "github.com/nephio-project/porch/pkg/kpt/api/kptfile/v1" + "github.com/nephio-project/porch/pkg/kpt/printer" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/kio/kioutil" +) + +const ( + command = "cmdrpkgpull" +) + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + c := &cobra.Command{ + Use: "pull PACKAGE [DIR]", + Aliases: []string{"source", "read"}, + SuggestFor: []string{}, + Short: docs.PullShort, + Long: docs.PullShort + "\n" + docs.PullLong, + Example: docs.PullExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + return r +} + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + printer printer.Printer +} + +func (r *runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + config, err := r.cfg.ToRESTConfig() + if err != nil { + return errors.E(op, err) + } + + scheme, err := createScheme() + if err != nil { + return errors.E(op, err) + } + + c, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return errors.E(op, err) + } + + r.client = c + r.printer = printer.FromContextOrDie(r.ctx) + return nil +} + +func (r *runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + if len(args) == 0 { + return errors.E(op, "PACKAGE is a required positional argument") + } + + packageName := args[0] + + var resources porchapi.PackageRevisionResources + if err := r.client.Get(r.ctx, client.ObjectKey{ + Namespace: *r.cfg.Namespace, + Name: packageName, + }, &resources); err != nil { + return errors.E(op, err) + } + + if err := util.AddRevisionMetadata(&resources); err != nil { + return errors.E(op, err) + } + + if len(args) > 1 { + if err := writeToDir(resources.Spec.Resources, args[1]); err != nil { + return errors.E(op, err) + } + } else { + if err := writeToWriter(resources.Spec.Resources, r.printer.OutStream()); err != nil { + return errors.E(op, err) + } + } + return nil +} + +func writeToDir(resources map[string]string, dir string) error { + if err := cmdutil.CheckDirectoryNotPresent(dir); err != nil { + return err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + for k, v := range resources { + f := filepath.Join(dir, k) + d := filepath.Dir(f) + if err := os.MkdirAll(d, 0755); err != nil { + return err + } + if err := os.WriteFile(f, []byte(v), 0644); err != nil { + return err + } + } + return nil +} + +func writeToWriter(resources map[string]string, out io.Writer) error { + keys := make([]string, 0, len(resources)) + for k := range resources { + if !includeFile(k) { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + + // Create kio readers + inputs := []kio.Reader{} + for _, k := range keys { + v := resources[k] + inputs = append(inputs, &kio.ByteReader{ + Reader: strings.NewReader(v), + SetAnnotations: map[string]string{ + kioutil.PathAnnotation: k, + }, + DisableUnwrapping: true, + }) + } + + return kio.Pipeline{ + Inputs: inputs, + Outputs: []kio.Writer{ + kio.ByteWriter{ + Writer: out, + KeepReaderAnnotations: true, + WrappingKind: kio.ResourceListKind, + WrappingAPIVersion: kio.ResourceListAPIVersion, + Sort: true, + }, + }, + }.Execute() +} + +func createScheme() (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + + for _, api := range (runtime.SchemeBuilder{ + porchapi.AddToScheme, + }) { + if err := api(scheme); err != nil { + return nil, err + } + } + return scheme, nil +} + +var matchResourceContents = append(kio.MatchAll, kptfilev1.KptFileName, kptfilev1.RevisionMetaDataFileName) + +func includeFile(path string) bool { + for _, m := range matchResourceContents { + // Only use the filename for the check for whether we should + // include the file. + f := filepath.Base(path) + if matched, err := filepath.Match(m, f); err == nil && matched { + return true + } + } + return false +} diff --git a/pkg/cli/commands/rpkg/pull/command_test.go b/pkg/cli/commands/rpkg/pull/command_test.go new file mode 100644 index 00000000..18573bc9 --- /dev/null +++ b/pkg/cli/commands/rpkg/pull/command_test.go @@ -0,0 +1,211 @@ +// Copyright 2022 The kpt and Nephio 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 pull + +import ( + "bytes" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/pkg/kpt/printer" + fakeprint "github.com/nephio-project/porch/pkg/kpt/printer/fake" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestCmd(t *testing.T) { + pkgRevName := "repo-fjdos9u2nfe2f32" + ns := "ns" + + scheme, err := createScheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + testCases := map[string]struct { + resources map[string]string + output string + }{ + "simple package": { + resources: map[string]string{ + "Kptfile": strings.TrimSpace(` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: bar + annotations: + config.kubernetes.io/local-config: "true" +info: + description: sample description + `), + "cm.yaml": strings.TrimSpace(` +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-config + namespace: default +data: + foo: bar + `), + }, + output: ` +apiVersion: config.kubernetes.io/v1 +kind: ResourceList +items: +- apiVersion: porch.kpt.dev/v1alpha1 + kind: KptRevisionMetadata + metadata: + name: repo-fjdos9u2nfe2f32 + namespace: ns + creationTimestamp: null + resourceVersion: "999" + annotations: + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + internal.config.kubernetes.io/path: '.KptRevisionMetadata' + config.kubernetes.io/path: '.KptRevisionMetadata' +- apiVersion: kpt.dev/v1 + kind: Kptfile + metadata: + name: bar + annotations: + config.kubernetes.io/local-config: "true" + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + internal.config.kubernetes.io/path: 'Kptfile' + config.kubernetes.io/path: 'Kptfile' + info: + description: sample description +- apiVersion: v1 + kind: ConfigMap + metadata: + name: game-config + namespace: default + annotations: + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + internal.config.kubernetes.io/path: 'cm.yaml' + config.kubernetes.io/path: 'cm.yaml' + data: + foo: bar + `, + }, + "package with subdirectory": { + resources: map[string]string{ + "Kptfile": strings.TrimSpace(` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: bar + annotations: + config.kubernetes.io/local-config: "true" +info: + description: sample description + `), + "sub/cm.yaml": strings.TrimSpace(` +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-config + namespace: default +data: + foo: bar + `), + }, + output: ` +apiVersion: config.kubernetes.io/v1 +kind: ResourceList +items: +- apiVersion: porch.kpt.dev/v1alpha1 + kind: KptRevisionMetadata + metadata: + name: repo-fjdos9u2nfe2f32 + namespace: ns + creationTimestamp: null + resourceVersion: "999" + annotations: + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + internal.config.kubernetes.io/path: '.KptRevisionMetadata' + config.kubernetes.io/path: '.KptRevisionMetadata' +- apiVersion: kpt.dev/v1 + kind: Kptfile + metadata: + name: bar + annotations: + config.kubernetes.io/local-config: "true" + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + internal.config.kubernetes.io/path: 'Kptfile' + config.kubernetes.io/path: 'Kptfile' + info: + description: sample description +- apiVersion: v1 + kind: ConfigMap + metadata: + name: game-config + namespace: default + annotations: + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + internal.config.kubernetes.io/path: 'sub/cm.yaml' + config.kubernetes.io/path: 'sub/cm.yaml' + data: + foo: bar + `, + }, + } + + for tn := range testCases { + tc := testCases[tn] + t.Run(tn, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&porchapi.PackageRevisionResources{ + ObjectMeta: metav1.ObjectMeta{ + Name: pkgRevName, + Namespace: "ns", + }, + Spec: porchapi.PackageRevisionResourcesSpec{ + PackageName: "foo", + Resources: tc.resources, + }, + }). + Build() + output := &bytes.Buffer{} + ctx := fakeprint.CtxWithPrinter(output, output) + r := &runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{ + Namespace: &ns, + }, + client: c, + printer: printer.FromContextOrDie(ctx), + } + cmd := &cobra.Command{} + err = r.runE(cmd, []string{pkgRevName}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if diff := cmp.Diff(strings.TrimSpace(tc.output), strings.TrimSpace(output.String())); diff != "" { + t.Errorf("Unexpected result (-want, +got): %s", diff) + } + }) + } +} diff --git a/pkg/cli/commands/rpkg/push/command.go b/pkg/cli/commands/rpkg/push/command.go new file mode 100644 index 00000000..8e578626 --- /dev/null +++ b/pkg/cli/commands/rpkg/push/command.go @@ -0,0 +1,327 @@ +// Copyright 2022 The kpt and Nephio 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 push + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/fnruntime" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/nephio-project/porch/pkg/kpt/printer" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/kio/kioutil" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +const ( + command = "cmdrpkgpush" +) + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + c := &cobra.Command{ + Use: "push PACKAGE [DIR]", + Aliases: []string{"sink", "write"}, + SuggestFor: []string{}, + Short: docs.PushShort, + Long: docs.PushShort + "\n" + docs.PushLong, + Example: docs.PushExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + return r +} + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + printer printer.Printer +} + +func (r *runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + config, err := r.cfg.ToRESTConfig() + if err != nil { + return errors.E(op, err) + } + + scheme, err := createScheme() + if err != nil { + return errors.E(op, err) + } + + c, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return errors.E(op, err) + } + + r.client = c + r.printer = printer.FromContextOrDie(r.ctx) + return nil +} + +func (r *runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + if len(args) == 0 { + return errors.E(op, "PACKAGE is a required positional argument") + } + + packageName := args[0] + var resources map[string]string + var err error + + if len(args) > 1 { + resources, err = readFromDir(args[1]) + } else { + resources, err = readFromReader(cmd.InOrStdin()) + } + if err != nil { + return errors.E(op, err) + } + + pkgResources := porchapi.PackageRevisionResources{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevisionResources", + APIVersion: porchapi.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: packageName, + Namespace: *r.cfg.Namespace, + }, + Spec: porchapi.PackageRevisionResourcesSpec{ + Resources: resources, + }, + } + + rv, err := util.GetResourceVersion(&pkgResources) + if err != nil { + return errors.E(op, err) + } + pkgResources.ResourceVersion = rv + if err = util.RemoveRevisionMetadata(&pkgResources); err != nil { + return errors.E(op, err) + } + + if err := r.client.Update(r.ctx, &pkgResources); err != nil { + return errors.E(op, err) + } + rs := pkgResources.Status.RenderStatus + if rs.Err != "" { + r.printer.Printf("Package is updated, but failed to render the package.\n") + r.printer.Printf("Error: %s\n", rs.Err) + } + if len(rs.Result.Items) > 0 { + for _, result := range rs.Result.Items { + r.printer.Printf("[RUNNING] %q \n", result.Image) + printOpt := printer.NewOpt() + if result.ExitCode != 0 { + r.printer.OptPrintf(printOpt, "[FAIL] %q\n", result.Image) + } else { + r.printer.OptPrintf(printOpt, "[PASS] %q\n", result.Image) + } + r.printFnResult(result, printOpt) + } + } + return nil +} + +// printFnResult prints given function result in a user friendly +// format on kpt CLI. +func (r *runner) printFnResult(fnResult *porchapi.Result, opt *printer.Options) { + if len(fnResult.Results) > 0 { + // function returned structured results + var lines []string + for _, item := range fnResult.Results { + lines = append(lines, str(item)) + } + ri := &fnruntime.MultiLineFormatter{ + Title: "Results", + Lines: lines, + TruncateOutput: printer.TruncateOutput, + } + r.printer.OptPrintf(opt, "%s", ri.String()) + } +} + +// String provides a human-readable message for the result item +func str(i porchapi.ResultItem) string { + identifier := i.ResourceRef + var idStringList []string + if identifier != nil { + if identifier.APIVersion != "" { + idStringList = append(idStringList, identifier.APIVersion) + } + if identifier.Kind != "" { + idStringList = append(idStringList, identifier.Kind) + } + if identifier.Namespace != "" { + idStringList = append(idStringList, identifier.Namespace) + } + if identifier.Name != "" { + idStringList = append(idStringList, identifier.Name) + } + } + formatString := "[%s]" + severity := i.Severity + // We default Severity to Info when converting a result to a message. + if i.Severity == "" { + severity = "info" + } + list := []interface{}{severity} + if len(idStringList) > 0 { + formatString += " %s" + list = append(list, strings.Join(idStringList, "/")) + } + if i.Field != nil { + formatString += " %s" + list = append(list, i.Field.Path) + } + formatString += ": %s" + list = append(list, i.Message) + return fmt.Sprintf(formatString, list...) +} + +func readFromDir(dir string) (map[string]string, error) { + resources := map[string]string{} + if err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + contents, err := os.ReadFile(path) + if err != nil { + return err + } + resources[rel] = string(contents) + return nil + }); err != nil { + return nil, err + } + return resources, nil +} + +func readFromReader(in io.Reader) (map[string]string, error) { + rw := &resourceWriter{ + resources: map[string]string{}, + } + + if err := (kio.Pipeline{ + Inputs: []kio.Reader{&kio.ByteReader{ + Reader: in, + PreserveSeqIndent: true, + WrapBareSeqNode: true, + }}, + Outputs: []kio.Writer{rw}, + }.Execute()); err != nil { + return nil, err + } + return rw.resources, nil +} + +func createScheme() (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + + for _, api := range (runtime.SchemeBuilder{ + porchapi.AddToScheme, + }) { + if err := api(scheme); err != nil { + return nil, err + } + } + return scheme, nil +} + +type resourceWriter struct { + resources map[string]string +} + +var _ kio.Writer = &resourceWriter{} + +func (w *resourceWriter) Write(nodes []*yaml.RNode) error { + paths := map[string][]*yaml.RNode{} + for _, node := range nodes { + path := getPath(node) + paths[path] = append(paths[path], node) + } + + buf := &bytes.Buffer{} + for path, nodes := range paths { + bw := kio.ByteWriter{ + Writer: buf, + ClearAnnotations: []string{ + kioutil.PathAnnotation, + kioutil.IndexAnnotation, + }, + } + if err := bw.Write(nodes); err != nil { + return err + } + w.resources[path] = buf.String() + buf.Reset() + } + return nil +} + +func getPath(node *yaml.RNode) string { + ann := node.GetAnnotations() + if path, ok := ann[kioutil.PathAnnotation]; ok { + return path + } + ns := node.GetNamespace() + if ns == "" { + ns = "non-namespaced" + } + name := node.GetName() + if name == "" { + name = "unnamed" + } + // TODO: harden for escaping etc. + return path.Join(ns, fmt.Sprintf("%s.yaml", name)) +} diff --git a/pkg/cli/commands/rpkg/reject/command.go b/pkg/cli/commands/rpkg/reject/command.go new file mode 100644 index 00000000..5bad3fb9 --- /dev/null +++ b/pkg/cli/commands/rpkg/reject/command.go @@ -0,0 +1,137 @@ +// Copyright 2022 The kpt and Nephio 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 reject + +import ( + "context" + "fmt" + "strings" + + "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkgreject" +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + client: nil, + } + + c := &cobra.Command{ + Use: "reject PACKAGE", + Short: docs.RejectShort, + Long: docs.RejectShort + "\n" + docs.RejectLong, + Example: docs.RejectExamples, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: porch.HidePorchCommands, + } + r.Command = c + + return r +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client rest.Interface + porchClient client.Client + Command *cobra.Command + + // Flags +} + +func (r *runner) preRunE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + + if len(args) < 1 { + return errors.E(op, "PACKAGE_REVISION is a required positional argument") + } + + client, err := porch.CreateRESTClient(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = client + + porchClient, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.porchClient = porchClient + return nil +} + +func (r *runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + + namespace := *r.cfg.Namespace + + for _, name := range args { + pr := &v1alpha1.PackageRevision{} + if err := r.porchClient.Get(r.ctx, client.ObjectKey{ + Namespace: namespace, + Name: name, + }, pr); err != nil { + return errors.E(op, err) + } + switch pr.Spec.Lifecycle { + case v1alpha1.PackageRevisionLifecycleProposed: + if err := porch.UpdatePackageRevisionApproval(r.ctx, r.client, client.ObjectKey{ + Namespace: namespace, + Name: name, + }, v1alpha1.PackageRevisionLifecycleDraft); err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(r.Command.ErrOrStderr(), "%s failed (%s)\n", name, err) + } else { + fmt.Fprintf(r.Command.OutOrStderr(), "%s rejected\n", name) + } + case v1alpha1.PackageRevisionLifecycleDeletionProposed: + pr.Spec.Lifecycle = v1alpha1.PackageRevisionLifecyclePublished + if err := r.porchClient.Update(r.ctx, pr); err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(r.Command.ErrOrStderr(), "%s failed (%s)\n", name, err) + } else { + fmt.Fprintf(r.Command.OutOrStderr(), "%s no longer proposed for deletion\n", name) + } + default: + msg := fmt.Sprintf("cannot reject %s with lifecycle '%s'", name, pr.Spec.Lifecycle) + messages = append(messages, msg) + fmt.Fprintln(r.Command.ErrOrStderr(), msg) + } + } + + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + + return nil +} diff --git a/pkg/cli/commands/rpkg/rpkgcmd.go b/pkg/cli/commands/rpkg/rpkgcmd.go new file mode 100644 index 00000000..9afe171b --- /dev/null +++ b/pkg/cli/commands/rpkg/rpkgcmd.go @@ -0,0 +1,88 @@ +// Copyright 2022 The kpt and Nephio 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 rpkg + +import ( + "context" + "flag" + "fmt" + + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/approve" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/clone" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/copy" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/del" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/get" + initialization "github.com/nephio-project/porch/pkg/cli/commands/rpkg/init" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/propose" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/proposedelete" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/pull" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/push" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/reject" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/update" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" +) + +func NewCommand(ctx context.Context, version string) *cobra.Command { + repo := &cobra.Command{ + Use: "rpkg", + Aliases: []string{"rpackage"}, + Short: docs.RpkgShort, + Long: docs.RpkgLong, + RunE: func(cmd *cobra.Command, args []string) error { + h, err := cmd.Flags().GetBool("help") + if err != nil { + return err + } + if h { + return cmd.Help() + } + return cmd.Usage() + }, + Hidden: porch.HidePorchCommands, + } + + pf := repo.PersistentFlags() + + kubeflags := genericclioptions.NewConfigFlags(true) + kubeflags.AddFlags(pf) + + kubeflags.WrapConfigFn = func(rc *rest.Config) *rest.Config { + rc.UserAgent = fmt.Sprintf("porchctl/%s", version) + return rc + } + + pf.AddGoFlagSet(flag.CommandLine) + + repo.AddCommand( + get.NewCommand(ctx, kubeflags), + pull.NewCommand(ctx, kubeflags), + push.NewCommand(ctx, kubeflags), + clone.NewCommand(ctx, kubeflags), + initialization.NewCommand(ctx, kubeflags), + propose.NewCommand(ctx, kubeflags), + approve.NewCommand(ctx, kubeflags), + reject.NewCommand(ctx, kubeflags), + del.NewCommand(ctx, kubeflags), + copy.NewCommand(ctx, kubeflags), + update.NewCommand(ctx, kubeflags), + proposedelete.NewCommand(ctx, kubeflags), + ) + + return repo +} diff --git a/pkg/cli/commands/rpkg/update/command.go b/pkg/cli/commands/rpkg/update/command.go new file mode 100644 index 00000000..810f0f2e --- /dev/null +++ b/pkg/cli/commands/rpkg/update/command.go @@ -0,0 +1,191 @@ +// Copyright 2022 The kpt and Nephio 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 update + +import ( + "context" + "fmt" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/internal/kpt/errors" + "github.com/nephio-project/porch/internal/kpt/util/porch" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + command = "cmdrpkgupdate" + + upstream = "upstream" + downstream = "downstream" +) + +func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { + return newRunner(ctx, rcg).Command +} + +func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { + r := &runner{ + ctx: ctx, + cfg: rcg, + } + r.Command = &cobra.Command{ + Use: "update SOURCE_PACKAGE", + PreRunE: r.preRunE, + RunE: r.runE, + Short: docs.UpdateShort, + Long: docs.UpdateShort + "\n" + docs.UpdateLong, + Example: docs.UpdateExamples, + Hidden: porch.HidePorchCommands, + } + r.Command.Flags().StringVar(&r.revision, "revision", "", "Revision of the upstream package to update to.") + r.Command.Flags().StringVar(&r.discover, "discover", "", + `If set, search for available updates instead of performing an update. +Setting this to 'upstream' will discover upstream updates of downstream packages. +Setting this to 'downstream' will discover downstream package revisions of upstream packages that need to be updated.`) + return r +} + +type runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command + + revision string // Target package revision + discover string // If set, discover updates rather than do updates + + // there are multiple places where we need access to all package revisions, so + // we store it in the runner + prs []porchapi.PackageRevision +} + +func (r *runner) preRunE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + c, err := porch.CreateClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + + if r.discover == "" { + if len(args) < 1 { + return errors.E(op, fmt.Errorf("SOURCE_PACKAGE is a required positional argument")) + } + if len(args) > 1 { + return errors.E(op, fmt.Errorf("too many arguments; SOURCE_PACKAGE is the only accepted positional arguments")) + } + // TODO: This should use the latest available revision if one isn't specified. + if r.revision == "" { + return errors.E(op, fmt.Errorf("revision is required")) + } + } else if r.discover != upstream && r.discover != downstream { + return errors.E(op, fmt.Errorf("argument for 'discover' must be one of 'upstream' or 'downstream'")) + } + + packageRevisionList := porchapi.PackageRevisionList{} + if err := r.client.List(r.ctx, &packageRevisionList, &client.ListOptions{}); err != nil { + return errors.E(op, err) + } + r.prs = packageRevisionList.Items + + return nil +} + +func (r *runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + if r.discover == "" { + pr := r.findPackageRevision(args[0]) + if pr == nil { + return errors.E(op, fmt.Errorf("could not find package revision %s", args[0])) + } + if err := r.doUpdate(pr); err != nil { + return errors.E(op, err) + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s updated\n", pr.Name); err != nil { + return errors.E(op, err) + } + } else if err := r.discoverUpdates(cmd, args); err != nil { + return errors.E(op, err) + } + return nil +} + +func (r *runner) doUpdate(pr *porchapi.PackageRevision) error { + cloneTask := r.findCloneTask(pr) + if cloneTask == nil { + return fmt.Errorf("upstream source not found for package rev %q; only cloned packages can be updated", pr.Spec.PackageName) + } + + switch cloneTask.Clone.Upstream.Type { + case porchapi.RepositoryTypeGit: + cloneTask.Clone.Upstream.Git.Ref = r.revision + case porchapi.RepositoryTypeOCI: + return fmt.Errorf("update not implemented for oci packages") + default: + upstreamPr := r.findPackageRevision(cloneTask.Clone.Upstream.UpstreamRef.Name) + if upstreamPr == nil { + return fmt.Errorf("upstream package revision %s no longer exists", cloneTask.Clone.Upstream.UpstreamRef.Name) + } + newUpstreamPr := r.findPackageRevisionForRef(upstreamPr.Spec.PackageName) + if newUpstreamPr == nil { + return fmt.Errorf("revision %s does not exist for package %s", r.revision, pr.Spec.PackageName) + } + newTask := porchapi.Task{ + Type: porchapi.TaskTypeUpdate, + Update: &porchapi.PackageUpdateTaskSpec{ + Upstream: cloneTask.Clone.Upstream, + }, + } + newTask.Update.Upstream.UpstreamRef.Name = newUpstreamPr.Name + pr.Spec.Tasks = append(pr.Spec.Tasks, newTask) + } + + return r.client.Update(r.ctx, pr) +} + +func (r *runner) findPackageRevision(prName string) *porchapi.PackageRevision { + for i := range r.prs { + pr := r.prs[i] + if pr.Name == prName { + return &pr + } + } + return nil +} + +func (r *runner) findCloneTask(pr *porchapi.PackageRevision) *porchapi.Task { + if len(pr.Spec.Tasks) == 0 { + return nil + } + firstTask := pr.Spec.Tasks[0] + if firstTask.Type == porchapi.TaskTypeClone { + return &firstTask + } + return nil +} + +func (r *runner) findPackageRevisionForRef(name string) *porchapi.PackageRevision { + for i := range r.prs { + pr := r.prs[i] + if pr.Spec.PackageName == name && pr.Spec.Revision == r.revision { + return &pr + } + } + return nil +} diff --git a/pkg/cli/commands/rpkg/update/discover.go b/pkg/cli/commands/rpkg/update/discover.go new file mode 100644 index 00000000..84d71e15 --- /dev/null +++ b/pkg/cli/commands/rpkg/update/discover.go @@ -0,0 +1,256 @@ +// Copyright 2022 The kpt and Nephio 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 update + +import ( + "fmt" + "io" + "strings" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/spf13/cobra" + "golang.org/x/mod/semver" + "k8s.io/cli-runtime/pkg/printers" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *runner) discoverUpdates(cmd *cobra.Command, args []string) error { + var prs []porchapi.PackageRevision + var errs []string + if len(args) == 0 || r.discover == downstream { + prs = r.prs + } else { + for i := range args { + pr := r.findPackageRevision(args[i]) + if pr == nil { + errs = append(errs, fmt.Sprintf("could not find package revision %s", args[i])) + continue + } + prs = append(prs, *pr) + } + } + if len(errs) > 0 { + return fmt.Errorf("errors:\n %s", strings.Join(errs, "\n ")) + } + + repositories, err := r.getRepositories() + if err != nil { + return err + } + + switch r.discover { + case upstream: + return r.findUpstreamUpdates(prs, repositories, cmd.OutOrStdout()) + case downstream: + return r.findDownstreamUpdates(prs, repositories, args, cmd.OutOrStdout()) + default: // this should never happen, because we validate in preRunE + return fmt.Errorf("invalid argument %q for --discover", r.discover) + } +} + +func (r *runner) findUpstreamUpdates(prs []porchapi.PackageRevision, repositories *configapi.RepositoryList, w io.Writer) error { + var upstreamUpdates [][]string + for _, pr := range prs { + availableUpdates, upstreamName, _, err := r.availableUpdates(pr.Status.UpstreamLock, repositories) + if err != nil { + return fmt.Errorf("could not parse upstreamLock in Kptfile of package %q: %s", pr.Name, err.Error()) + } + if len(availableUpdates) == 0 { + upstreamUpdates = append(upstreamUpdates, []string{pr.Name, upstreamName, "No update available"}) + } else { + var revisions []string + for i := range availableUpdates { + revisions = append(revisions, availableUpdates[i].Spec.Revision) + } + upstreamUpdates = append(upstreamUpdates, []string{pr.Name, upstreamName, strings.Join(revisions, ", ")}) + } + } + return printUpstreamUpdates(upstreamUpdates, w) +} + +func (r *runner) findDownstreamUpdates(prs []porchapi.PackageRevision, repositories *configapi.RepositoryList, + args []string, w io.Writer) error { + // map from the upstream package revision to a list of its downstream package revisions + downstreamUpdatesMap := make(map[string][]porchapi.PackageRevision) + + for _, pr := range prs { + availableUpdates, _, draftName, err := r.availableUpdates(pr.Status.UpstreamLock, repositories) + if err != nil { + return fmt.Errorf("could not parse upstreamLock in Kptfile of package %q: %s", pr.Name, err.Error()) + } + for _, update := range availableUpdates { + key := fmt.Sprintf("%s:%s:%s", update.Name, update.Spec.Revision, draftName) + downstreamUpdatesMap[key] = append(downstreamUpdatesMap[key], pr) + } + } + return printDownstreamUpdates(downstreamUpdatesMap, args, w) +} + +func (r *runner) availableUpdates(upstreamLock *porchapi.UpstreamLock, repositories *configapi.RepositoryList) ([]porchapi.PackageRevision, string, string, error) { + var availableUpdates []porchapi.PackageRevision + var upstream string + + if upstreamLock == nil || upstreamLock.Git == nil { + return nil, "", "", nil + } + var currentUpstreamRevision string + var draftName string + + // separate the revision number from the package name + lastIndex := strings.LastIndex(upstreamLock.Git.Ref, "/") + if lastIndex < 0 { + // "/" not found - upstreamLock.Git.Ref is not in the expected format + return nil, "", "", fmt.Errorf("malformed upstreamLock.Git.Ref %q", upstreamLock.Git.Ref) + } + + if strings.HasPrefix(upstreamLock.Git.Ref, "drafts") { + // The upstream is not a published package, so doesn't have a revision number. + // Use v0 as a placeholder, so that all published packages get returned as available + // updates. + currentUpstreamRevision = "v0" + draftName = upstreamLock.Git.Ref[lastIndex+1:] + } else { + currentUpstreamRevision = upstreamLock.Git.Ref[lastIndex+1:] + } + + // upstream.git.ref could look like drafts/pkgname/version or pkgname/version + upstreamPackageName := upstreamLock.Git.Ref[:lastIndex] + upstreamPackageName = strings.TrimPrefix(upstreamPackageName, "drafts") + upstreamPackageName = strings.TrimPrefix(upstreamPackageName, "/") + + if !strings.HasSuffix(upstreamLock.Git.Repo, ".git") { + upstreamLock.Git.Repo += ".git" + } + + // find a repo that matches the upstreamLock + var revisions []porchapi.PackageRevision + for _, repo := range repositories.Items { + if repo.Spec.Type != configapi.RepositoryTypeGit { + // we are not currently supporting non-git repos for updates + continue + } + if !strings.HasSuffix(repo.Spec.Git.Repo, ".git") { + repo.Spec.Git.Repo += ".git" + } + if upstreamLock.Git.Repo == repo.Spec.Git.Repo { + upstream = repo.Name + revisions = r.getUpstreamRevisions(repo, upstreamPackageName) + } + } + + for _, upstreamRevision := range revisions { + switch cmp := semver.Compare(upstreamRevision.Spec.Revision, currentUpstreamRevision); { + case cmp > 0: // upstreamRevision > currentUpstreamRevision + availableUpdates = append(availableUpdates, upstreamRevision) + case cmp == 0, cmp < 0: // upstreamRevision <= currentUpstreamRevision, do nothing + } + } + + return availableUpdates, upstream, draftName, nil +} + +// fetches all registered repositories +func (r *runner) getRepositories() (*configapi.RepositoryList, error) { + repoList := configapi.RepositoryList{} + err := r.client.List(r.ctx, &repoList, &client.ListOptions{}) + return &repoList, err +} + +// fetches all package revision numbers for packages with the name upstreamPackageName from the repo +func (r *runner) getUpstreamRevisions(repo configapi.Repository, upstreamPackageName string) []porchapi.PackageRevision { + var result []porchapi.PackageRevision + for _, pkgRev := range r.prs { + if !porchapi.LifecycleIsPublished(pkgRev.Spec.Lifecycle) { + // only consider published packages + continue + } + if pkgRev.Spec.RepositoryName == repo.Name && pkgRev.Spec.PackageName == upstreamPackageName { + result = append(result, pkgRev) + } + } + return result +} + +func printUpstreamUpdates(upstreamUpdates [][]string, w io.Writer) error { + printer := printers.GetNewTabWriter(w) + if _, err := fmt.Fprintln(printer, "PACKAGE REVISION\tUPSTREAM REPOSITORY\tUPSTREAM UPDATES"); err != nil { + return err + } + for _, pkgRev := range upstreamUpdates { + if _, err := fmt.Fprintln(printer, strings.Join(pkgRev, "\t")); err != nil { + return err + } + } + return printer.Flush() +} + +func printDownstreamUpdates(downstreamUpdatesMap map[string][]porchapi.PackageRevision, args []string, w io.Writer) error { + var downstreamUpdates [][]string + for upstreamPkgRev, downstreamPkgRevs := range downstreamUpdatesMap { + split := strings.Split(upstreamPkgRev, ":") + upstreamPkgRevName := split[0] + upstreamPkgRevNum := split[1] + draftName := split[2] + for _, downstreamPkgRev := range downstreamPkgRevs { + if draftName != "" { + // the upstream package revision is not published, so does not have a revision number + downstreamUpdates = append(downstreamUpdates, + []string{upstreamPkgRevName, downstreamPkgRev.Name, fmt.Sprintf("(draft %q)->%s", draftName, upstreamPkgRevNum)}) + continue + } + // figure out which upstream revision the downstream revision is based on + lastIndex := strings.LastIndex(downstreamPkgRev.Status.UpstreamLock.Git.Ref, "v") + if lastIndex < 0 { + // this ref isn't formatted the way that porch expects + continue + } + downstreamRev := downstreamPkgRev.Status.UpstreamLock.Git.Ref[lastIndex:] + downstreamUpdates = append(downstreamUpdates, + []string{upstreamPkgRevName, downstreamPkgRev.Name, fmt.Sprintf("%s->%s", downstreamRev, upstreamPkgRevNum)}) + } + } + + var pkgRevsToPrint [][]string + if len(args) != 0 { + for _, arg := range args { + for _, pkgRev := range downstreamUpdates { + // filter out irrelevant packages based on provided args + if arg == pkgRev[0] { + pkgRevsToPrint = append(pkgRevsToPrint, pkgRev) + } + } + } + } else { + pkgRevsToPrint = downstreamUpdates + } + + printer := printers.GetNewTabWriter(w) + if len(pkgRevsToPrint) == 0 { + if _, err := fmt.Fprintln(printer, "All downstream packages are up to date."); err != nil { + return err + } + } else { + if _, err := fmt.Fprintln(printer, "PACKAGE REVISION\tDOWNSTREAM PACKAGE\tDOWNSTREAM UPDATE"); err != nil { + return err + } + for _, pkgRev := range pkgRevsToPrint { + if _, err := fmt.Fprintln(printer, strings.Join(pkgRev, "\t")); err != nil { + return err + } + } + } + return printer.Flush() +} diff --git a/pkg/cli/commands/rpkg/util/common.go b/pkg/cli/commands/rpkg/util/common.go new file mode 100644 index 00000000..e918b634 --- /dev/null +++ b/pkg/cli/commands/rpkg/util/common.go @@ -0,0 +1,95 @@ +// Copyright 2023 The kpt and Nephio 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 util + +import ( + "context" + "fmt" + + fnsdk "github.com/GoogleContainerTools/kpt-functions-sdk/go/fn" + api "github.com/nephio-project/porch/api/porch/v1alpha1" + kptfilev1 "github.com/nephio-project/porch/pkg/kpt/api/kptfile/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + ResourceVersionAnnotation = "internal.kpt.dev/resource-version" +) + +func PackageAlreadyExists(ctx context.Context, c client.Client, repository, packageName, namespace string) (bool, error) { + // only the first package revision can be created from init or clone, so + // we need to check that the package doesn't already exist. + packageRevisionList := api.PackageRevisionList{} + if err := c.List(ctx, &packageRevisionList, &client.ListOptions{ + Namespace: namespace, + }); err != nil { + return false, err + } + for _, pr := range packageRevisionList.Items { + if pr.Spec.RepositoryName == repository && pr.Spec.PackageName == packageName { + return true, nil + } + } + return false, nil +} + +func GetResourceFileKubeObject(prr *api.PackageRevisionResources, file, kind, name string) (*fnsdk.KubeObject, error) { + if prr.Spec.Resources == nil { + return nil, fmt.Errorf("nil resources found for PackageRevisionResources '%s/%s'", prr.Namespace, prr.Name) + } + + if _, ok := prr.Spec.Resources[file]; !ok { + return nil, fmt.Errorf("%q not found in PackageRevisionResources '%s/%s'", file, prr.Namespace, prr.Name) + } + + ko, err := fnsdk.ParseKubeObject([]byte(prr.Spec.Resources[file])) + if err != nil { + return nil, fmt.Errorf("failed to parse %q of PackageRevisionResources %s/%s: %w", file, prr.Namespace, prr.Name, err) + } + if kind != "" && ko.GetKind() != kind { + return nil, fmt.Errorf("%q does not contain kind %q in PackageRevisionResources '%s/%s'", file, kind, prr.Namespace, prr.Name) + } + if name != "" && ko.GetName() != name { + return nil, fmt.Errorf("%q does not contain resource named %q in PackageRevisionResources '%s/%s'", file, name, prr.Namespace, prr.Name) + } + + return ko, nil +} + +func GetResourceVersion(prr *api.PackageRevisionResources) (string, error) { + ko, err := GetResourceFileKubeObject(prr, kptfilev1.RevisionMetaDataFileName, kptfilev1.RevisionMetaDataKind, "") + if err != nil { + return "", err + } + rv, _, _ := ko.NestedString("metadata", "resourceVersion") + return rv, nil +} + +func AddRevisionMetadata(prr *api.PackageRevisionResources) error { + kptMetaDataKo := fnsdk.NewEmptyKubeObject() + kptMetaDataKo.SetAPIVersion(prr.APIVersion) + kptMetaDataKo.SetKind(kptfilev1.RevisionMetaDataKind) + if err := kptMetaDataKo.SetNestedField(prr.GetObjectMeta(), "metadata"); err != nil { + return fmt.Errorf("cannot set metadata: %v", err) + } + prr.Spec.Resources[kptfilev1.RevisionMetaDataFileName] = kptMetaDataKo.String() + + return nil +} + +func RemoveRevisionMetadata(prr *api.PackageRevisionResources) error { + delete(prr.Spec.Resources, kptfilev1.RevisionMetaDataFileName) + return nil +} diff --git a/pkg/cli/commands/util/factory.go b/pkg/cli/commands/util/factory.go new file mode 100644 index 00000000..8eedfdc6 --- /dev/null +++ b/pkg/cli/commands/util/factory.go @@ -0,0 +1,99 @@ +// Copyright 2020 The kpt and Nephio 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 util + +import ( + "context" + "flag" + "fmt" + "time" + + "github.com/nephio-project/porch/internal/kpt/util/cfgflags" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + cluster "k8s.io/kubectl/pkg/cmd/util" + "sigs.k8s.io/cli-utils/pkg/flowcontrol" +) + +func NewFactory(cmd *cobra.Command, version string) cluster.Factory { + flags := cmd.PersistentFlags() + kubeConfigFlags := genericclioptions.NewConfigFlags(true). + WithDeprecatedPasswordFlag() + kubeConfigFlags.AddFlags(flags) + UpdateQPS(kubeConfigFlags) + userAgentKubeConfigFlags := &cfgflags.UserAgentKubeConfigFlags{ + Delegate: kubeConfigFlags, + UserAgent: fmt.Sprintf("kpt/%s", version), + } + cmd.PersistentFlags().AddGoFlagSet(flag.CommandLine) + return cluster.NewFactory(userAgentKubeConfigFlags) +} + +// UpdateQPS modifies a genericclioptions.ConfigFlags to update the client-side +// throttling QPS and Burst QPS (including for discovery). +// +// If Flow Control is enabled on the apiserver, client-side throttling is +// disabled! +// +// If Flow Control is disabled or undetected on the apiserver, client-side +// throttling QPS will be increased to at least 30 (burst: 60). +// +// Flow Control is enabled by default on Kubernetes v1.20+. +// https://kubernetes.io/docs/concepts/cluster-administration/flow-control/ +func UpdateQPS(flags *genericclioptions.ConfigFlags) { + flags. + WithWrapConfigFn(func(c *rest.Config) *rest.Config { + // Timeout if the query takes too long, defaulting to the lower QPS limits. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + enabled, err := flowcontrol.IsEnabled(ctx, c) + if err != nil { + klog.Warning("Failed to query apiserver to check for flow control enablement: %v", err) + // Default to the lower QPS limits. + } + + qps := float32(-1) + burst := -1 + if enabled { + klog.V(1).Infof("Flow control enabled on apiserver: client-side throttling QPS set to %.0f (burst: %d)", qps, burst) + } else { + qps = maxIfNotNegative(c.QPS, 30) + burst = int(maxIfNotNegative(float32(c.Burst), 60)) + klog.V(1).Infof("Flow control disabled on apiserver: client-side throttling QPS set to %.0f (burst: %d)", qps, burst) + } + + c.QPS = qps + c.Burst = burst + flags. + WithDiscoveryQPS(qps). + WithDiscoveryBurst(burst) + + return c + }) +} + +func maxIfNotNegative(a, b float32) float32 { + switch { + case a < 0: + return a + case a > b: + return a + default: + return b + } +}