diff --git a/pkg/kudoctl/cmd/install.go b/pkg/kudoctl/cmd/install.go index 99e5eb7b6..112133863 100644 --- a/pkg/kudoctl/cmd/install.go +++ b/pkg/kudoctl/cmd/install.go @@ -61,5 +61,7 @@ func newInstallCmd(fs afero.Fs) *cobra.Command { installCmd.Flags().StringVar(&options.OperatorVersion, "operator-version", "", "A specific operator version int the official GitHub repo. (default to the most recent)") installCmd.Flags().BoolVar(&options.SkipInstance, "skip-instance", false, "If set, install will install the Operator and OperatorVersion, but not an Instance. (default \"false\")") installCmd.Flags().BoolVar(&options.Wait, "wait", false, "Specify if the CLI should wait for the install to complete before returning (default \"false\")") + installCmd.Flags().Int64Var(&options.WaitTime, "wait-time", 300, "Specify the max wait time in seconds for CLI for the install to complete before returning (default \"300\")") + return installCmd } diff --git a/pkg/kudoctl/cmd/install/install.go b/pkg/kudoctl/cmd/install/install.go index 25d66ee2a..5911c21ca 100644 --- a/pkg/kudoctl/cmd/install/install.go +++ b/pkg/kudoctl/cmd/install/install.go @@ -2,6 +2,7 @@ package install import ( "fmt" + "time" "github.com/spf13/afero" @@ -27,6 +28,7 @@ type Options struct { SkipInstance bool RequestTimeout int64 Wait bool + WaitTime int64 } // DefaultOptions initializes the install command options to its defaults @@ -76,5 +78,5 @@ func installOperator(operatorArgument string, options *Options, fs afero.Fs, set return fmt.Errorf("failed to resolve operator package for: %s %w", operatorArgument, err) } - return kudo.InstallPackage(kc, pkg.Resources, options.SkipInstance, options.InstanceName, settings.Namespace, options.Parameters, options.Wait) + return kudo.InstallPackage(kc, pkg.Resources, options.SkipInstance, options.InstanceName, settings.Namespace, options.Parameters, options.Wait, time.Duration(options.WaitTime)*time.Second) } diff --git a/pkg/kudoctl/cmd/plan.go b/pkg/kudoctl/cmd/plan.go index 2fd5a8697..bfe882923 100644 --- a/pkg/kudoctl/cmd/plan.go +++ b/pkg/kudoctl/cmd/plan.go @@ -16,6 +16,9 @@ const ( ` planStatusExample = ` # View plan status kubectl kudo plan status --instance= +` + planWaitExample = ` # Wait on the current plan status to finish + kubectl kudo plan wait --instance= ` planTriggerExample = ` # Trigger an instance plan kubectl kudo plan trigger --instance= @@ -32,6 +35,7 @@ func newPlanCmd(out io.Writer) *cobra.Command { cmd.AddCommand(NewPlanHistoryCmd()) cmd.AddCommand(NewPlanStatusCmd(out)) + cmd.AddCommand(NewPlanWaitCmd(out)) cmd.AddCommand(NewPlanTriggerCmd()) return cmd @@ -67,6 +71,31 @@ func NewPlanStatusCmd(out io.Writer) *cobra.Command { } cmd.Flags().StringVar(&options.Instance, "instance", "", "The instance name available from 'kubectl get instances'") + cmd.Flags().BoolVar(&options.Wait, "wait", false, "Specify if the CLI should wait for the plan to complete before returning (default \"false\")") + + if err := cmd.MarkFlagRequired("instance"); err != nil { + clog.Printf("Please choose the instance with '--instance=': %v", err) + os.Exit(1) + } + + return cmd +} + +//NewPlanWaitCmd waits on the status of an instance to complete +func NewPlanWaitCmd(out io.Writer) *cobra.Command { + options := &plan.WaitOptions{Out: out, WaitTime: 300} + cmd := &cobra.Command{ + Use: "wait", + Short: "Waits on a plan to finish for a particular instance.", + Example: planWaitExample, + RunE: func(cmd *cobra.Command, args []string) error { + return plan.Wait(options, &Settings) + }, + } + + cmd.Flags().StringVar(&options.Instance, "instance", "", "The instance name available from 'kubectl get instances'") + cmd.Flags().Int64Var(&options.WaitTime, "wait-time", 300, "Specify the max wait time in seconds for CLI to wait for the current plan to complete (default \"300\")") + if err := cmd.MarkFlagRequired("instance"); err != nil { clog.Printf("Please choose the instance with '--instance=': %v", err) os.Exit(1) diff --git a/pkg/kudoctl/cmd/plan/plan_history.go b/pkg/kudoctl/cmd/plan/plan_history.go index f81bbc467..7aeaf583a 100644 --- a/pkg/kudoctl/cmd/plan/plan_history.go +++ b/pkg/kudoctl/cmd/plan/plan_history.go @@ -18,6 +18,7 @@ import ( type Options struct { Out io.Writer Instance string + Wait bool } var ( diff --git a/pkg/kudoctl/cmd/plan/plan_status.go b/pkg/kudoctl/cmd/plan/plan_status.go index d80b9baa0..b979091ed 100644 --- a/pkg/kudoctl/cmd/plan/plan_status.go +++ b/pkg/kudoctl/cmd/plan/plan_status.go @@ -2,7 +2,10 @@ package plan import ( "fmt" + "io" "sort" + "strings" + "time" "github.com/thoas/go-funk" "github.com/xlab/treeprint" @@ -12,9 +15,6 @@ import ( "github.com/kudobuilder/kudo/pkg/kudoctl/util/kudo" ) -// DefaultStatusOptions provides the default options for plan status -var DefaultStatusOptions = &Options{} - // Status runs the plan status command func Status(options *Options, settings *env.Settings) error { kc, err := env.GetClient(settings) @@ -26,81 +26,132 @@ func Status(options *Options, settings *env.Settings) error { } func status(kc *kudo.Client, options *Options, ns string) error { - tree := treeprint.New() - instance, err := kc.GetInstance(options.Instance, ns) - if err != nil { - return err - } - if instance == nil { - return fmt.Errorf("Instance %s/%s does not exist", ns, options.Instance) - } + firstPass := true + start := time.Now() - ov, err := kc.GetOperatorVersion(instance.Spec.OperatorVersion.Name, ns) - if err != nil { - return err - } - if ov == nil { - return fmt.Errorf("OperatorVersion %s from instance %s/%s does not exist", instance.Spec.OperatorVersion.Name, ns, options.Instance) - } + // for loop breaks if Wait==false, or when active plan completes (or when user exits process) + for { + tree := treeprint.New() - lastPlanStatus := instance.GetLastExecutedPlanStatus() + instance, err := kc.GetInstance(options.Instance, ns) + if err != nil { + return err + } + if instance == nil { + return fmt.Errorf("Instance %s/%s does not exist", ns, options.Instance) + } - if lastPlanStatus == nil { - fmt.Fprintf(options.Out, "No plan ever run for instance - nothing to show for instance %s\n", instance.Name) - return nil - } + ov, err := kc.GetOperatorVersion(instance.Spec.OperatorVersion.Name, ns) + if err != nil { + return err + } + if ov == nil { + return fmt.Errorf("OperatorVersion %s from instance %s/%s does not exist", instance.Spec.OperatorVersion.Name, ns, options.Instance) + } - getPhaseStrategy := func(s string) v1beta1.Ordering { - for _, plan := range ov.Spec.Plans { - for _, phase := range plan.Phases { - if phase.Name == s { - return phase.Strategy + lastPlanStatus := instance.GetLastExecutedPlanStatus() + + if lastPlanStatus == nil { + fmt.Fprintf(options.Out, "No plan ever run for instance - nothing to show for instance %s\n", instance.Name) + return nil + } + + getPhaseStrategy := func(s string) v1beta1.Ordering { + for _, plan := range ov.Spec.Plans { + for _, phase := range plan.Phases { + if phase.Name == s { + return phase.Strategy + } } } + return "" } - return "" - } - rootDisplay := fmt.Sprintf("%s (Operator-Version: \"%s\" Active-Plan: \"%s\")", instance.Name, instance.Spec.OperatorVersion.Name, lastPlanStatus.Name) - rootBranchName := tree.AddBranch(rootDisplay) + rootDisplay := fmt.Sprintf("%s (Operator-Version: \"%s\" Active-Plan: \"%s\")", instance.Name, instance.Spec.OperatorVersion.Name, lastPlanStatus.Name) + rootBranchName := tree.AddBranch(rootDisplay) - plans, _ := funk.Keys(ov.Spec.Plans).([]string) - sort.Strings(plans) + plans, _ := funk.Keys(ov.Spec.Plans).([]string) + sort.Strings(plans) - for _, plan := range plans { - if plan == lastPlanStatus.Name { - planDisplay := fmt.Sprintf("Plan %s (%s strategy) [%s]%s", plan, ov.Spec.Plans[plan].Strategy, lastPlanStatus.Status, printMessageIfAvailable(lastPlanStatus.Message)) - if lastPlanStatus.LastUpdatedTimestamp != nil { - planDisplay = fmt.Sprintf("%s, last updated %s", planDisplay, lastPlanStatus.LastUpdatedTimestamp.Format("2006-01-02 15:04:05")) - } - planBranchName := rootBranchName.AddBranch(planDisplay) - for _, phase := range lastPlanStatus.Phases { - phaseDisplay := fmt.Sprintf("Phase %s (%s strategy) [%s]%s", phase.Name, getPhaseStrategy(phase.Name), phase.Status, printMessageIfAvailable(phase.Message)) - phaseBranchName := planBranchName.AddBranch(phaseDisplay) - for _, steps := range phase.Steps { - stepsDisplay := fmt.Sprintf("Step %s [%s]%s", steps.Name, steps.Status, printMessageIfAvailable(steps.Message)) - phaseBranchName.AddBranch(stepsDisplay) + for _, plan := range plans { + if plan == lastPlanStatus.Name { + planDisplay := fmt.Sprintf("Plan %s (%s strategy) [%s]%s", plan, ov.Spec.Plans[plan].Strategy, lastPlanStatus.Status, printMessageIfAvailable(lastPlanStatus.Message)) + if lastPlanStatus.LastUpdatedTimestamp != nil { + planDisplay = fmt.Sprintf("%s, last updated %s", planDisplay, lastPlanStatus.LastUpdatedTimestamp.Format("2006-01-02 15:04:05")) } - } - } else { - planDisplay := fmt.Sprintf("Plan %s (%s strategy) [NOT ACTIVE]", plan, ov.Spec.Plans[plan].Strategy) - planBranchName := rootBranchName.AddBranch(planDisplay) - for _, phase := range ov.Spec.Plans[plan].Phases { - phaseDisplay := fmt.Sprintf("Phase %s (%s strategy) [NOT ACTIVE]", phase.Name, phase.Strategy) - phaseBranchName := planBranchName.AddBranch(phaseDisplay) - for _, steps := range phase.Steps { - stepDisplay := fmt.Sprintf("Step %s [NOT ACTIVE]", steps.Name) - phaseBranchName.AddBranch(stepDisplay) + planBranchName := rootBranchName.AddBranch(planDisplay) + for _, phase := range lastPlanStatus.Phases { + phaseDisplay := fmt.Sprintf("Phase %s (%s strategy) [%s]%s", phase.Name, getPhaseStrategy(phase.Name), phase.Status, printMessageIfAvailable(phase.Message)) + phaseBranchName := planBranchName.AddBranch(phaseDisplay) + for _, steps := range phase.Steps { + stepsDisplay := fmt.Sprintf("Step %s [%s]%s", steps.Name, steps.Status, printMessageIfAvailable(steps.Message)) + phaseBranchName.AddBranch(stepsDisplay) + } + } + } else { + planDisplay := fmt.Sprintf("Plan %s (%s strategy) [NOT ACTIVE]", plan, ov.Spec.Plans[plan].Strategy) + planBranchName := rootBranchName.AddBranch(planDisplay) + for _, phase := range ov.Spec.Plans[plan].Phases { + phaseDisplay := fmt.Sprintf("Phase %s (%s strategy) [NOT ACTIVE]", phase.Name, phase.Strategy) + phaseBranchName := planBranchName.AddBranch(phaseDisplay) + for _, steps := range phase.Steps { + stepDisplay := fmt.Sprintf("Step %s [NOT ACTIVE]", steps.Name) + phaseBranchName.AddBranch(stepDisplay) + } } } } + // exec on first go, otherwise don't + if firstPass { + fmt.Fprintf(options.Out, "Plan(s) for \"%s\" in namespace \"%s\":\n", instance.Name, ns) + } + // exec on all loop passes except the first + if !firstPass { + height := strings.Count(tree.String(), "\n") + 1 + clearLines(options.Out, height) + } + fmt.Fprintln(options.Out, tree.String()) + firstPass = false + if options.Wait { + elapsed := time.Since(start) + clearLine(options.Out) + fmt.Fprintf(options.Out, "elapsed time %s", elapsed) + } else { + break + } + done, err := kc.IsInstanceDone(instance, nil) + if err != nil { + return err + } + if done { + break + } + // freq of updates + time.Sleep(1 * time.Second) } + return nil +} + +// moves terminal cursor up number of lines specified +func moveCursorUp(w io.Writer, lines int) { + fmt.Fprintf(w, "\033[%dA", lines) +} - fmt.Fprintf(options.Out, "Plan(s) for \"%s\" in namespace \"%s\":\n", instance.Name, ns) - fmt.Fprintln(options.Out, tree.String()) +// clears the current terminal line +func clearLine(w io.Writer) { + fmt.Fprint(w, "\u001b[0K\r") +} - return nil +// clears multiple terminal lines from current position up to defined height +// useful to clear previous terminal output in order to rewrite to that screen section +func clearLines(w io.Writer, height int) { + moveCursorUp(w, height) + for i := 0; i < height; i++ { + clearLine(w) + fmt.Fprint(w, "\n") + } + moveCursorUp(w, height) } func printMessageIfAvailable(s string) string { diff --git a/pkg/kudoctl/cmd/plan/plan_wait.go b/pkg/kudoctl/cmd/plan/plan_wait.go new file mode 100644 index 000000000..40478cd3a --- /dev/null +++ b/pkg/kudoctl/cmd/plan/plan_wait.go @@ -0,0 +1,56 @@ +package plan + +import ( + "errors" + "fmt" + "io" + "time" + + pollwait "k8s.io/apimachinery/pkg/util/wait" + + "github.com/kudobuilder/kudo/pkg/kudoctl/env" + "github.com/kudobuilder/kudo/pkg/kudoctl/util/kudo" +) + +// Options are the configurable options for plans +type WaitOptions struct { + Out io.Writer + Instance string + WaitTime int64 +} + +// Status runs the plan status command +func Wait(options *WaitOptions, settings *env.Settings) error { + kc, err := env.GetClient(settings) + if err != nil { + return err + } + //return status(kc, options, settings.Namespace) + return wait(kc, options, settings.Namespace) +} + +func wait(kc *kudo.Client, options *WaitOptions, ns string) error { + instance, err := kc.GetInstance(options.Instance, ns) + if err != nil { + return err + } + if instance == nil { + return fmt.Errorf("instance %s/%s does not exist", ns, options.Instance) + } + + planStatus := instance.GetLastExecutedPlanStatus() + if planStatus == nil { + return fmt.Errorf("instance %s/%s does not have an active plan", ns, options.Instance) + } + + fmt.Fprintf(options.Out, "waiting on instance %s/%s with plan %q\n", ns, options.Instance, planStatus.Name) + err = kc.WaitForInstance(options.Instance, ns, nil, time.Duration(options.WaitTime)*time.Second) + if errors.Is(err, pollwait.ErrWaitTimeout) { + _, _ = fmt.Fprintf(options.Out, "timeout waiting for instance %s/%s on plan %q\n", ns, options.Instance, planStatus.Name) + } + if err != nil { + return err + } + _, _ = fmt.Fprintf(options.Out, "instance %s/%s plan %q finished\n", ns, options.Instance, planStatus.Name) + return nil +} diff --git a/pkg/kudoctl/util/kudo/install.go b/pkg/kudoctl/util/kudo/install.go index e91842fa1..444533211 100644 --- a/pkg/kudoctl/util/kudo/install.go +++ b/pkg/kudoctl/util/kudo/install.go @@ -1,10 +1,13 @@ package kudo import ( + "errors" "fmt" "strings" "time" + pollwait "k8s.io/apimachinery/pkg/util/wait" + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" "github.com/kudobuilder/kudo/pkg/kudoctl/clog" "github.com/kudobuilder/kudo/pkg/kudoctl/packages" @@ -12,7 +15,7 @@ import ( // InstallPackage installs package resources. // If skipInstance is set to true, only a package's Operator and OperatorVersion is installed. -func InstallPackage(kc *Client, resources *packages.Resources, skipInstance bool, instanceName, namespace string, parameters map[string]string, wait bool) error { +func InstallPackage(kc *Client, resources *packages.Resources, skipInstance bool, instanceName, namespace string, parameters map[string]string, w bool, waitTime time.Duration) error { // PRE-INSTALLATION SETUP operatorName := resources.Operator.ObjectMeta.Name clog.V(3).Printf("operator name: %v", operatorName) @@ -64,27 +67,20 @@ func InstallPackage(kc *Client, resources *packages.Resources, skipInstance bool if _, err := kc.InstallInstanceObjToCluster(resources.Instance, namespace); err != nil { return fmt.Errorf("failed to install instance %s: %v", instanceName, err) } - if wait { - for { - instance, err := kc.GetInstance(instanceName, namespace) - - if err != nil { - return fmt.Errorf("failed to get instance %s: %v", instanceName, err) - } - lastPlanStatus := instance.GetLastExecutedPlanStatus() - - if err != nil { - return fmt.Errorf("failed to get plan status %s: %v", instanceName, err) - } - if lastPlanStatus == nil || !lastPlanStatus.Status.IsFinished() { - fmt.Printf("plan status %s still pending, please wait...\n", instanceName) - time.Sleep(2 * time.Second) - } else { - break - } - } - } clog.Printf("instance.%s/%s created", resources.Instance.APIVersion, resources.Instance.Name) + var err error + if w { + err = kc.WaitForInstance(instanceName, namespace, nil, waitTime) + } + if errors.Is(err, pollwait.ErrWaitTimeout) { + clog.Printf("timeout waiting for instance.%s/%s ", resources.Instance.APIVersion, resources.Instance.Name) + } + if err != nil { + return fmt.Errorf("failed to wait on instance %s: %v", instanceName, err) + + } + clog.Printf("instance.%s/%s ready", resources.Instance.APIVersion, resources.Instance.Name) + } else { return clog.Errorf("cannot install instance '%s' of operator '%s-%s' because an instance of that name already exists in namespace %s", instanceName, operatorName, resources.OperatorVersion.Spec.Version, namespace) diff --git a/pkg/kudoctl/util/kudo/install_test.go b/pkg/kudoctl/util/kudo/install_test.go index 19771f7f3..45fc382c3 100644 --- a/pkg/kudoctl/util/kudo/install_test.go +++ b/pkg/kudoctl/util/kudo/install_test.go @@ -91,7 +91,7 @@ func Test_InstallPackage(t *testing.T) { testResources.OperatorVersion.Spec.Parameters = tt.parameters namespace := "default" //nolint:goconst - err := InstallPackage(kc, &testResources, tt.skipInstance, "", namespace, tt.installParameters, false) + err := InstallPackage(kc, &testResources, tt.skipInstance, "", namespace, tt.installParameters, false, 0) if tt.err != "" { assert.ErrorContains(t, err, tt.err) } diff --git a/pkg/kudoctl/util/kudo/kudo.go b/pkg/kudoctl/util/kudo/kudo.go index b96fad593..59aa10899 100644 --- a/pkg/kudoctl/util/kudo/kudo.go +++ b/pkg/kudoctl/util/kudo/kudo.go @@ -12,6 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/uuid" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/discovery" // Import Kubernetes authentication providers to support GKE, etc. @@ -206,6 +207,67 @@ func (c *Client) UpdateInstance(instanceName, namespace string, operatorVersion return err } +// WaitForInstance waits for instance to be "finished". oldInstance is nil if there is no oldInstance. oldInstance +// should be provided if there was an update or upgrade. The wait will then initially wait for the "new" plan to activate +// then return when completed. The error is either an error in working with kubernetes or a wait.ErrWaitTimeout +func (c *Client) WaitForInstance(name, namespace string, oldInstance *v1beta1.Instance, timeout time.Duration) error { + // polling interval 1 sec + interval := 1 * time.Second + return wait.PollImmediate(interval, timeout, func() (done bool, err error) { + instance, err := c.GetInstance(name, namespace) + if err != nil { + return false, err + } + + return c.IsInstanceDone(instance, oldInstance) + }) +} + +// IsInstanceDone provides a check on instance to see if it is "finished" without retries +// oldInstance is nil if there is no previous instance +func (c *Client) IsInstanceDone(instance, oldInstance *v1beta1.Instance) (bool, error) { + + // upgrade wait, needs to make sure the UID switches + if oldInstance != nil { + // We want one of the plans UIDs to change to identify that a new plan ran. + // If they're all the same, then nothing changed. + same := true + for planName, planStatus := range (*oldInstance).Status.PlanStatus { + same = same && planStatus.UID == instance.Status.PlanStatus[planName].UID + } + if same { + //Nothing changed yet... waiting on the right plan to wait on + return false, nil + } + } + lastPlanStatus := instance.GetLastExecutedPlanStatus() + // must have a status to check + if lastPlanStatus == nil { + clog.V(2).Printf("plan status for instance %q is not available\n", instance.Name) + return false, nil + } + status := lastPlanStatus.Status + if status.IsFinished() { + clog.V(2).Printf("plan status for %q is finished\n", instance.Name) + return true, nil + } + + clog.V(4).Printf("\rinstance plan %q is not not finished running: %v, term: %v, finished: %v", lastPlanStatus.Name, status.IsRunning(), status.IsTerminal(), status.IsFinished()) + return false, nil +} + +// IsInstanceByNameDone provides a check on instance based on name to see if it is "finished" without retries +// returns true if finished otherwise false +// oldInstance is nil if there is no previous instance +func (c *Client) IsInstanceByNameDone(name string, namespace string, oldInstance *v1beta1.Instance) (bool, error) { + instance, err := c.GetInstance(name, namespace) + if err != nil { + return false, err + } + + return c.IsInstanceDone(instance, oldInstance) +} + // ListInstances lists all instances of given operator installed in the cluster in a given ns func (c *Client) ListInstances(namespace string) ([]string, error) { instances, err := c.clientset.KudoV1beta1().Instances(namespace).List(v1.ListOptions{})