diff --git a/cmd/timoni/apply.go b/cmd/timoni/apply.go index 9867d369..28a0274d 100644 --- a/cmd/timoni/apply.go +++ b/cmd/timoni/apply.go @@ -22,18 +22,16 @@ import ( "fmt" "os" "strings" - "time" "cuelang.org/go/cue/cuecontext" - "github.com/fluxcd/pkg/ssa" - "github.com/go-logr/logr" "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" "github.com/stefanprodan/timoni/internal/engine" "github.com/stefanprodan/timoni/internal/engine/fetcher" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" + "github.com/stefanprodan/timoni/internal/reconciler" "github.com/stefanprodan/timoni/internal/runtime" ) @@ -142,7 +140,7 @@ func runApplyCmd(cmd *cobra.Command, args []string) error { applyArgs.name = args[0] applyArgs.module = args[1] - log := LoggerInstance(cmd.Context(), applyArgs.name) + log := loggerInstance(cmd.Context(), applyArgs.name, true) version := applyArgs.version.String() if version == "" { @@ -224,155 +222,36 @@ func runApplyCmd(cmd *cobra.Command, args []string) error { return describeErr(f.GetModuleRoot(), "build failed", err) } - finalValues, err := builder.GetDefaultValues() - if err != nil { - return fmt.Errorf("failed to extract values: %w", err) - } - - applySets, err := builder.GetApplySets(buildResult) - if err != nil { - return fmt.Errorf("failed to extract objects: %w", err) - } - - var objects []*unstructured.Unstructured - for _, set := range applySets { - objects = append(objects, set.Objects...) - } - - rm, err := runtime.NewResourceManager(kubeconfigArgs) - if err != nil { - return err - } - - rm.SetOwnerLabels(objects, applyArgs.name, *kubeconfigArgs.Namespace) - ctx, cancel := context.WithTimeout(cmd.Context(), rootArgs.timeout) defer cancel() - exists := false - sm := runtime.NewStorageManager(rm) - instance, err := sm.Get(ctx, applyArgs.name, *kubeconfigArgs.Namespace) - if err == nil { - exists = true - } - - nsExists, err := sm.NamespaceExists(ctx, *kubeconfigArgs.Namespace) - if err != nil { - return fmt.Errorf("instance init failed: %w", err) - } - - if !applyArgs.overwriteOwnership && exists { - err = instanceOwnershipConflicts(*instance) - if err != nil { - return err - } + instance := &engine.BundleInstance{ + Name: applyArgs.name, + Namespace: *kubeconfigArgs.Namespace, + Module: *mod, + Bundle: "", } - im := runtime.NewInstanceManager(applyArgs.name, *kubeconfigArgs.Namespace, finalValues, *mod) - - if err := im.AddObjects(objects); err != nil { - return fmt.Errorf("adding objects to instance failed: %w", err) - } - - staleObjects, err := sm.GetStaleObjects(ctx, &im.Instance) - if err != nil { - return fmt.Errorf("getting stale objects failed: %w", err) - } - - if applyArgs.dryrun || applyArgs.diff { - if !nsExists { - log.Info(colorizeJoin(colorizeNamespaceFromArgs(), ssa.CreatedAction, dryRunServer)) - } - return instanceDryRunDiff(logr.NewContext(ctx, log), rm, objects, staleObjects, nsExists, tmpDir, applyArgs.diff) - } - - if !exists { - log.Info(fmt.Sprintf("installing %s in namespace %s", applyArgs.name, *kubeconfigArgs.Namespace)) - - if err := sm.Apply(ctx, &im.Instance, true); err != nil { - return fmt.Errorf("instance init failed: %w", err) - } - - if !nsExists { - log.Info(colorizeJoin(colorizeNamespaceFromArgs(), ssa.CreatedAction)) - } - } else { - log.Info(fmt.Sprintf("upgrading %s in namespace %s", applyArgs.name, *kubeconfigArgs.Namespace)) - } - - applyOpts := runtime.ApplyOptions(applyArgs.force, rootArgs.timeout) - applyOpts.WaitInterval = 5 * time.Second - - waitOptions := ssa.WaitOptions{ - Interval: applyOpts.WaitInterval, - Timeout: rootArgs.timeout, - FailFast: true, - } - - for _, set := range applySets { - if len(applySets) > 1 { - log.Info(fmt.Sprintf("applying %s", set.Name)) - } - - cs, err := rm.ApplyAllStaged(ctx, set.Objects, applyOpts) - if err != nil { - return err - } - for _, change := range cs.Entries { - log.Info(colorizeJoin(change)) - } - - if applyArgs.wait { - spin := StartSpinner(fmt.Sprintf("waiting for %v resource(s) to become ready...", len(set.Objects))) - err = rm.Wait(set.Objects, waitOptions) - spin.Stop() - if err != nil { - return err - } - log.Info("resources are ready") - } - } - - if images, err := builder.GetContainerImages(buildResult); err == nil { - im.Instance.Images = images - } - - if err := sm.Apply(ctx, &im.Instance, true); err != nil { - return fmt.Errorf("storing instance failed: %w", err) - } - - var deletedObjects []*unstructured.Unstructured - if len(staleObjects) > 0 { - deleteOpts := runtime.DeleteOptions(applyArgs.name, *kubeconfigArgs.Namespace) - changeSet, err := rm.DeleteAll(ctx, staleObjects, deleteOpts) - if err != nil { - return fmt.Errorf("pruning objects failed: %w", err) - } - deletedObjects = runtime.SelectObjectsFromSet(changeSet, ssa.DeletedAction) - for _, change := range changeSet.Entries { - log.Info(colorizeJoin(change)) - } - } - - if applyArgs.wait { - if len(deletedObjects) > 0 { - spin := StartSpinner(fmt.Sprintf("waiting for %v resource(s) to be finalized...", len(deletedObjects))) - err = rm.WaitForTermination(deletedObjects, waitOptions) - spin.Stop() - if err != nil { - return fmt.Errorf("waiting for termination failed: %w", err) - } - - log.Info("all resources are ready") - } - } - - return nil -} - -func instanceOwnershipConflicts(instance apiv1.Instance) error { - if currentOwnerBundle := instance.Labels[apiv1.BundleNameLabelKey]; currentOwnerBundle != "" { - return fmt.Errorf("instance ownership conflict encountered. Apply with \"--overwrite-ownership\" to gain instance ownership. Conflict: instance \"%s\" exists and is managed by bundle \"%s\"", instance.Name, currentOwnerBundle) + r := reconciler.NewInteractiveReconciler(log, + &reconciler.CommonOptions{ + Dir: tmpDir, + Wait: applyArgs.wait, + Force: applyArgs.force, + OverwriteOwnership: applyArgs.overwriteOwnership, + }, + &reconciler.InteractiveOptions{ + DryRun: applyArgs.dryrun, + Diff: applyArgs.diff, + DiffOutput: cmd.OutOrStdout(), + ProgressStart: logger.StartSpinner, + }, + rootArgs.timeout, + ) + if err := r.Init(ctx, builder, buildResult, instance, kubeconfigArgs); err != nil { + return annotateInstanceOwnershipConflictErr(err) } - return nil + return r.ApplyInstance(ctx, log, + builder, + buildResult, + ) } diff --git a/cmd/timoni/apply_test.go b/cmd/timoni/apply_test.go index 471010c4..f4ffc104 100644 --- a/cmd/timoni/apply_test.go +++ b/cmd/timoni/apply_test.go @@ -235,7 +235,7 @@ bundle: { modPath, )) g.Expect(err).To(HaveOccurred()) - g.Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("instance \"%s\" exists and is managed by bundle \"%s\"", instanceName, bundleName))) + g.Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("instance \"%s\" exists and is managed by another bundle \"%s\"", instanceName, bundleName))) output, err := executeCommand(fmt.Sprintf("ls -n %[1]s", namespace)) g.Expect(err).ToNot(HaveOccurred()) diff --git a/cmd/timoni/artifact_list.go b/cmd/timoni/artifact_list.go index 1b9b8744..a2b2ff40 100644 --- a/cmd/timoni/artifact_list.go +++ b/cmd/timoni/artifact_list.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -64,7 +65,7 @@ func listArtifactCmdRun(cmd *cobra.Command, args []string) error { } ociURL := args[0] - spin := StartSpinner("fetching tags") + spin := logger.StartSpinner("fetching tags") defer spin.Stop() ctx, cancel := context.WithTimeout(cmd.Context(), rootArgs.timeout) diff --git a/cmd/timoni/artifact_pull.go b/cmd/timoni/artifact_pull.go index d98b5db8..78bf81d9 100644 --- a/cmd/timoni/artifact_pull.go +++ b/cmd/timoni/artifact_pull.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/cobra" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -125,7 +126,7 @@ func pullArtifactCmdRun(cmd *cobra.Command, args []string) error { } } - spin := StartSpinner("pulling artifact") + spin := logger.StartSpinner("pulling artifact") defer spin.Stop() ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) @@ -138,7 +139,7 @@ func pullArtifactCmdRun(cmd *cobra.Command, args []string) error { } spin.Stop() - log.Info(fmt.Sprintf("extracted: %s", colorizeSubject(pullArtifactArgs.output))) + log.Info(fmt.Sprintf("extracted: %s", logger.ColorizeSubject(pullArtifactArgs.output))) return nil } diff --git a/cmd/timoni/artifact_push.go b/cmd/timoni/artifact_push.go index 45f6cc5b..bc67c3d8 100644 --- a/cmd/timoni/artifact_push.go +++ b/cmd/timoni/artifact_push.go @@ -26,6 +26,7 @@ import ( apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" "github.com/stefanprodan/timoni/internal/engine" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -128,7 +129,7 @@ func pushArtifactCmdRun(cmd *cobra.Command, args []string) error { } oci.AppendGitMetadata(pushArtifactArgs.path, annotations) - spin := StartSpinner("pushing artifact") + spin := logger.StartSpinner("pushing artifact") defer spin.Stop() opts := oci.Options(ctx, pushArtifactArgs.creds.String(), rootArgs.registryInsecure) @@ -164,8 +165,8 @@ func pushArtifactCmdRun(cmd *cobra.Command, args []string) error { if err != nil { return err } - log.Info(fmt.Sprintf("artifact: %s", colorizeSubject(ociURL))) - log.Info(fmt.Sprintf("digest: %s", colorizeSubject(digest.DigestStr()))) + log.Info(fmt.Sprintf("artifact: %s", logger.ColorizeSubject(ociURL))) + log.Info(fmt.Sprintf("digest: %s", logger.ColorizeSubject(digest.DigestStr()))) return nil } diff --git a/cmd/timoni/artifact_tag.go b/cmd/timoni/artifact_tag.go index 32c6ce81..5feb19cd 100644 --- a/cmd/timoni/artifact_tag.go +++ b/cmd/timoni/artifact_tag.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -62,7 +63,7 @@ func tagArtifactCmdRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("at least one tag is required") } - spin := StartSpinner("tagging artifact") + spin := logger.StartSpinner("tagging artifact") defer spin.Stop() log := LoggerFrom(cmd.Context()) @@ -86,7 +87,7 @@ func tagArtifactCmdRun(cmd *cobra.Command, args []string) error { } for _, tag := range tagArtifactArgs.tags { - log.Info(fmt.Sprintf("tagged: %s", colorizeSubject(fmt.Sprintf("%s:%s", baseURL, tag)))) + log.Info(fmt.Sprintf("tagged: %s", logger.ColorizeSubject(fmt.Sprintf("%s:%s", baseURL, tag)))) } return nil diff --git a/cmd/timoni/bundle_apply.go b/cmd/timoni/bundle_apply.go index 3891ab85..ed8af239 100644 --- a/cmd/timoni/bundle_apply.go +++ b/cmd/timoni/bundle_apply.go @@ -24,20 +24,19 @@ import ( "maps" "os" "path" - "strings" "time" "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" - "github.com/fluxcd/pkg/ssa" "github.com/go-logr/logr" "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" "github.com/stefanprodan/timoni/internal/engine" "github.com/stefanprodan/timoni/internal/engine/fetcher" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" + "github.com/stefanprodan/timoni/internal/reconciler" "github.com/stefanprodan/timoni/internal/runtime" ) @@ -191,17 +190,17 @@ func runBundleApplyCmd(cmd *cobra.Command, _ []string) error { return err } - log := LoggerBundle(cmd.Context(), bundle.Name, cluster.Name) + log := loggerBundle(cmd.Context(), bundle.Name, cluster.Name, true) if !bundleApplyArgs.overwriteOwnership { err = bundleInstancesOwnershipConflicts(bundle.Instances) if err != nil { - return err + return annotateInstanceOwnershipConflictErr(err) } } for _, instance := range bundle.Instances { - spin := StartSpinner(fmt.Sprintf("pulling %s", instance.Module.Repository)) + spin := logger.StartSpinner(fmt.Sprintf("pulling %s", instance.Module.Repository)) pullErr := fetchBundleInstanceModule(ctxPull, instance, tmpDir) spin.Stop() if pullErr != nil { @@ -216,18 +215,18 @@ func runBundleApplyCmd(cmd *cobra.Command, _ []string) error { startMsg := fmt.Sprintf("applying %v instance(s)", len(bundle.Instances)) if !cluster.IsDefault() { - startMsg = fmt.Sprintf("%s on %s", startMsg, colorizeSubject(cluster.Group)) + startMsg = fmt.Sprintf("%s on %s", startMsg, logger.ColorizeSubject(cluster.Group)) } if bundleApplyArgs.dryrun || bundleApplyArgs.diff { - log.Info(fmt.Sprintf("%s %s", startMsg, colorizeDryRun("(server dry run)"))) + log.Info(fmt.Sprintf("%s %s", startMsg, logger.ColorizeDryRun("(server dry run)"))) } else { log.Info(startMsg) } for _, instance := range bundle.Instances { instance.Cluster = cluster.Name - if err := applyBundleInstance(logr.NewContext(ctx, log), cuectx, instance, kubeVersion, tmpDir); err != nil { + if err := applyBundleInstance(logr.NewContext(ctx, log), cuectx, instance, kubeVersion, tmpDir, cmd.OutOrStdout()); err != nil { return err } } @@ -235,7 +234,7 @@ func runBundleApplyCmd(cmd *cobra.Command, _ []string) error { elapsed := time.Since(start) if bundleApplyArgs.dryrun || bundleApplyArgs.diff { log.Info(fmt.Sprintf("applied successfully %s", - colorizeDryRun("(server dry run)"))) + logger.ColorizeDryRun("(server dry run)"))) } else { log.Info(fmt.Sprintf("applied successfully in %s", elapsed.Round(time.Second))) } @@ -280,8 +279,8 @@ func fetchBundleInstanceModule(ctx context.Context, instance *engine.BundleInsta return nil } -func applyBundleInstance(ctx context.Context, cuectx *cue.Context, instance *engine.BundleInstance, kubeVersion string, rootDir string) error { - log := LoggerBundleInstance(ctx, instance.Bundle, instance.Cluster, instance.Name) +func applyBundleInstance(ctx context.Context, cuectx *cue.Context, instance *engine.BundleInstance, kubeVersion string, rootDir string, diffOutput io.Writer) error { + log := loggerBundleInstance(ctx, instance.Bundle, instance.Cluster, instance.Name, true) modDir := path.Join(rootDir, instance.Name, "module") builder := engine.NewModuleBuilder( @@ -303,7 +302,7 @@ func applyBundleInstance(ctx context.Context, cuectx *cue.Context, instance *eng instance.Module.Name = modName log.Info(fmt.Sprintf("applying module %s version %s", - colorizeSubject(instance.Module.Name), colorizeSubject(instance.Module.Version))) + logger.ColorizeSubject(instance.Module.Name), logger.ColorizeSubject(instance.Module.Version))) err = builder.WriteValuesFileWithDefaults(instance.Values) if err != nil { return err @@ -316,162 +315,56 @@ func applyBundleInstance(ctx context.Context, cuectx *cue.Context, instance *eng return describeErr(modDir, "build failed for "+instance.Name, err) } - finalValues, err := builder.GetDefaultValues() - if err != nil { - return fmt.Errorf("failed to extract values: %w", err) - } - - bundleApplySets, err := builder.GetApplySets(buildResult) - if err != nil { - return fmt.Errorf("failed to extract objects: %w", err) - } - - var objects []*unstructured.Unstructured - for _, set := range bundleApplySets { - objects = append(objects, set.Objects...) - } - - rm, err := runtime.NewResourceManager(kubeconfigArgs) - if err != nil { - return err - } - - rm.SetOwnerLabels(objects, instance.Name, instance.Namespace) + r := reconciler.NewInteractiveReconciler(log, + &reconciler.CommonOptions{ + Dir: rootDir, + Wait: bundleApplyArgs.wait, + Force: bundleApplyArgs.force, + OverwriteOwnership: bundleApplyArgs.overwriteOwnership, + }, + &reconciler.InteractiveOptions{ + DryRun: bundleApplyArgs.dryrun, + Diff: bundleApplyArgs.diff, + DiffOutput: diffOutput, + ProgressStart: logger.StartSpinner, + }, + rootArgs.timeout, + ) - exists := false - sm := runtime.NewStorageManager(rm) - if _, err = sm.Get(ctx, instance.Name, instance.Namespace); err == nil { - exists = true + if err := r.Init(ctx, builder, buildResult, instance, kubeconfigArgs); err != nil { + return annotateInstanceOwnershipConflictErr(err) } - nsExists, err := sm.NamespaceExists(ctx, instance.Namespace) - if err != nil { - return fmt.Errorf("instance init failed: %w", err) - } - - im := runtime.NewInstanceManager(instance.Name, instance.Namespace, finalValues, instance.Module) - - if im.Instance.Labels == nil { - im.Instance.Labels = make(map[string]string) - } - im.Instance.Labels[apiv1.BundleNameLabelKey] = instance.Bundle + return r.ApplyInstance(ctx, log, + builder, + buildResult, + ) +} - if err := im.AddObjects(objects); err != nil { - return fmt.Errorf("adding objects to instance failed: %w", err) +func annotateInstanceOwnershipConflictErr(err error) error { + if errors.Is(err, &reconciler.InstanceOwnershipConflictErr{}) { + return fmt.Errorf("%s %s", err, "Apply with \"--overwrite-ownership\" to gain instance ownership.") } + return err +} - staleObjects, err := sm.GetStaleObjects(ctx, &im.Instance) +func saveReaderToFile(reader io.Reader) (string, error) { + f, err := os.CreateTemp("", "*.cue") if err != nil { - return fmt.Errorf("getting stale objects failed: %w", err) - } - - if bundleApplyArgs.dryrun || bundleApplyArgs.diff { - if !nsExists { - log.Info(colorizeJoin(colorizeSubject("Namespace/"+instance.Namespace), - ssa.CreatedAction, dryRunServer)) - } - if err := instanceDryRunDiff( - logr.NewContext(ctx, log), - rm, - objects, - staleObjects, - nsExists, - rootDir, - bundleApplyArgs.diff, - ); err != nil { - return err - } - - log.Info(colorizeJoin("applied successfully", colorizeDryRun("(server dry run)"))) - return nil - } - - if !exists { - log.Info(fmt.Sprintf("installing %s in namespace %s", - colorizeSubject(instance.Name), colorizeSubject(instance.Namespace))) - - if err := sm.Apply(ctx, &im.Instance, true); err != nil { - return fmt.Errorf("instance init failed: %w", err) - } - - if !nsExists { - log.Info(colorizeJoin(colorizeSubject("Namespace/"+instance.Namespace), ssa.CreatedAction)) - } - } else { - log.Info(fmt.Sprintf("upgrading %s in namespace %s", - colorizeSubject(instance.Name), colorizeSubject(instance.Namespace))) - } - - applyOpts := runtime.ApplyOptions(bundleApplyArgs.force, rootArgs.timeout) - applyOpts.WaitInterval = 5 * time.Second - - waitOptions := ssa.WaitOptions{ - Interval: applyOpts.WaitInterval, - Timeout: rootArgs.timeout, - FailFast: true, - } - - for _, set := range bundleApplySets { - if len(bundleApplySets) > 1 { - log.Info(fmt.Sprintf("applying %s", set.Name)) - } - - cs, err := rm.ApplyAllStaged(ctx, set.Objects, applyOpts) - if err != nil { - return err - } - for _, change := range cs.Entries { - log.Info(colorizeJoin(change)) - } - - if bundleApplyArgs.wait { - spin := StartSpinner(fmt.Sprintf("waiting for %v resource(s) to become ready...", len(set.Objects))) - err = rm.Wait(set.Objects, waitOptions) - spin.Stop() - if err != nil { - return err - } - log.Info(fmt.Sprintf("%s resources %s", set.Name, colorizeReady("ready"))) - } - } - - if images, err := builder.GetContainerImages(buildResult); err == nil { - im.Instance.Images = images - } - - if err := sm.Apply(ctx, &im.Instance, true); err != nil { - return fmt.Errorf("storing instance failed: %w", err) + return "", errors.New("unable to create temp dir for stdin") } - var deletedObjects []*unstructured.Unstructured - if len(staleObjects) > 0 { - deleteOpts := runtime.DeleteOptions(instance.Name, instance.Namespace) - changeSet, err := rm.DeleteAll(ctx, staleObjects, deleteOpts) - if err != nil { - return fmt.Errorf("pruning objects failed: %w", err) - } - deletedObjects = runtime.SelectObjectsFromSet(changeSet, ssa.DeletedAction) - for _, change := range changeSet.Entries { - log.Info(colorizeJoin(change)) - } - } + defer f.Close() - if bundleApplyArgs.wait { - if len(deletedObjects) > 0 { - spin := StartSpinner(fmt.Sprintf("waiting for %v resource(s) to be finalized...", len(deletedObjects))) - err = rm.WaitForTermination(deletedObjects, waitOptions) - spin.Stop() - if err != nil { - return fmt.Errorf("waiting for termination failed: %w", err) - } - } + if _, err := io.Copy(f, reader); err != nil { + return "", fmt.Errorf("error writing stdin to file: %w", err) } - return nil + return f.Name(), nil } func bundleInstancesOwnershipConflicts(bundleInstances []*engine.BundleInstance) error { - var conflicts []string + var conflicts reconciler.InstanceOwnershipConflictErr rm, err := runtime.NewResourceManager(kubeconfigArgs) if err != nil { return err @@ -484,31 +377,17 @@ func bundleInstancesOwnershipConflicts(bundleInstances []*engine.BundleInstance) for _, instance := range bundleInstances { if existingInstance, err := sm.Get(ctx, instance.Name, instance.Namespace); err == nil { currentOwnerBundle := existingInstance.Labels[apiv1.BundleNameLabelKey] - if currentOwnerBundle == "" { - conflicts = append(conflicts, fmt.Sprintf("instance \"%s\" exists and is managed by no bundle", instance.Name)) - } else if currentOwnerBundle != instance.Bundle { - conflicts = append(conflicts, fmt.Sprintf("instance \"%s\" exists and is managed by another bundle \"%s\"", instance.Name, currentOwnerBundle)) + if currentOwnerBundle == "" || currentOwnerBundle != instance.Bundle { + conflicts = append(conflicts, reconciler.InstanceOwnershipConflict{ + InstanceName: instance.Name, + CurrentOwnerBundle: currentOwnerBundle, + }) } } } + if len(conflicts) > 0 { - return fmt.Errorf("instance ownership conflicts encountered. Apply with \"--overwrite-ownership\" to gain instance ownership. Conflicts: %s", strings.Join(conflicts, "; ")) + return &conflicts } - return nil } - -func saveReaderToFile(reader io.Reader) (string, error) { - f, err := os.CreateTemp("", "*.cue") - if err != nil { - return "", errors.New("unable to create temp dir for stdin") - } - - defer f.Close() - - if _, err := io.Copy(f, reader); err != nil { - return "", fmt.Errorf("error writing stdin to file: %w", err) - } - - return f.Name(), nil -} diff --git a/cmd/timoni/bundle_apply_test.go b/cmd/timoni/bundle_apply_test.go index 272ea702..31b480d9 100644 --- a/cmd/timoni/bundle_apply_test.go +++ b/cmd/timoni/bundle_apply_test.go @@ -155,8 +155,8 @@ bundle: { _, err = executeCommandWithIn("bundle apply -f - -p main --wait", strings.NewReader(anotherBundleData)) g.Expect(err).To(HaveOccurred()) - g.Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("instance \"%s\" exists and is managed by another bundle \"%s\"", "frontend", bundleName))) - g.Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("instance \"%s\" exists and is managed by another bundle \"%s\"", "backend", bundleName))) + g.Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("instance %q exists and is managed by another bundle %q", "frontend", bundleName))) + g.Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("instance %q exists and is managed by another bundle %q", "backend", bundleName))) }) t.Run("fails to create instances from partially overlapping bundle", func(t *testing.T) { @@ -247,7 +247,7 @@ bundle: { _, err = executeCommandWithIn("bundle apply -f - -p main --wait", strings.NewReader(bundleData)) g.Expect(err).To(HaveOccurred()) - g.Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("instance \"%s\" exists and is managed by no bundle", instanceName))) + g.Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("instance \"%s\" exists and is not managed by any bundle", instanceName))) }) } diff --git a/cmd/timoni/bundle_delete.go b/cmd/timoni/bundle_delete.go index 647124e1..58986d49 100644 --- a/cmd/timoni/bundle_delete.go +++ b/cmd/timoni/bundle_delete.go @@ -29,6 +29,7 @@ import ( apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/runtime" ) @@ -120,7 +121,7 @@ func runBundleDelCmd(cmd *cobra.Command, args []string) error { return err } - log := LoggerBundle(ctx, bundleDelArgs.name, cluster.Name) + log := loggerBundle(ctx, bundleDelArgs.name, cluster.Name, true) if len(instances) == 0 { log.Error(nil, "no instances found in bundle") @@ -131,7 +132,7 @@ func runBundleDelCmd(cmd *cobra.Command, args []string) error { for index := len(instances) - 1; index >= 0; index-- { instance := instances[index] log.Info(fmt.Sprintf("deleting instance %s in namespace %s", - colorizeSubject(instance.Name), colorizeSubject(instance.Namespace))) + logger.ColorizeSubject(instance.Name), logger.ColorizeSubject(instance.Namespace))) if err := deleteBundleInstance(ctx, &engine.BundleInstance{ Bundle: bundleDelArgs.name, Cluster: cluster.Name, @@ -146,7 +147,7 @@ func runBundleDelCmd(cmd *cobra.Command, args []string) error { } func deleteBundleInstance(ctx context.Context, instance *engine.BundleInstance, wait bool, dryrun bool) error { - log := LoggerBundle(ctx, instance.Bundle, instance.Cluster) + log := loggerBundle(ctx, instance.Bundle, instance.Cluster, true) sm, err := runtime.NewResourceManager(kubeconfigArgs) if err != nil { @@ -172,7 +173,7 @@ func deleteBundleInstance(ctx context.Context, instance *engine.BundleInstance, if dryrun { for _, object := range objects { - log.Info(colorizeJoin(object, ssa.DeletedAction, dryRunClient)) + log.Info(logger.ColorizeJoin(object, ssa.DeletedAction, logger.DryRunClient)) } return nil } @@ -188,7 +189,7 @@ func deleteBundleInstance(ctx context.Context, instance *engine.BundleInstance, continue } cs.Add(*change) - log.Info(colorizeJoin(change)) + log.Info(logger.ColorizeJoin(change)) } if hasErrors { @@ -203,7 +204,7 @@ func deleteBundleInstance(ctx context.Context, instance *engine.BundleInstance, if wait && len(deletedObjects) > 0 { waitOpts := ssa.DefaultWaitOptions() waitOpts.Timeout = rootArgs.timeout - spin := StartSpinner(fmt.Sprintf("waiting for %v resource(s) to be finalized...", len(deletedObjects))) + spin := logger.StartSpinner(fmt.Sprintf("waiting for %v resource(s) to be finalized...", len(deletedObjects))) err = sm.WaitForTermination(deletedObjects, waitOpts) spin.Stop() if err != nil { diff --git a/cmd/timoni/bundle_status.go b/cmd/timoni/bundle_status.go index 13e101b3..6d6eb8db 100644 --- a/cmd/timoni/bundle_status.go +++ b/cmd/timoni/bundle_status.go @@ -28,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/runtime" apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" @@ -103,7 +104,7 @@ func runBundleStatusCmd(cmd *cobra.Command, args []string) error { return err } - log := LoggerBundle(ctx, bundleStatusArgs.name, cluster.Name) + log := loggerBundle(ctx, bundleStatusArgs.name, cluster.Name, true) if len(instances) == 0 { log.Error(nil, "no instances found in bundle") @@ -112,18 +113,18 @@ func runBundleStatusCmd(cmd *cobra.Command, args []string) error { } for _, instance := range instances { - log := LoggerBundleInstance(ctx, bundleStatusArgs.name, cluster.Name, instance.Name) + log := loggerBundleInstance(ctx, bundleStatusArgs.name, cluster.Name, instance.Name, true) log.Info(fmt.Sprintf("last applied %s", - colorizeSubject(instance.LastTransitionTime))) + logger.ColorizeSubject(instance.LastTransitionTime))) log.Info(fmt.Sprintf("module %s", - colorizeSubject(instance.Module.Repository+":"+instance.Module.Version))) + logger.ColorizeSubject(instance.Module.Repository+":"+instance.Module.Version))) log.Info(fmt.Sprintf("digest %s", - colorizeSubject(instance.Module.Digest))) + logger.ColorizeSubject(instance.Module.Digest))) for _, image := range instance.Images { log.Info(fmt.Sprintf("container image %s", - colorizeSubject(image))) + logger.ColorizeSubject(image))) } im := runtime.InstanceManager{Instance: apiv1.Instance{Inventory: instance.Inventory}} @@ -137,23 +138,23 @@ func runBundleStatusCmd(cmd *cobra.Command, args []string) error { err = rm.Client().Get(ctx, client.ObjectKeyFromObject(obj), obj) if err != nil { if apierrors.IsNotFound(err) { - log.Error(err, colorizeJoin(obj, errors.New("NotFound"))) + log.Error(err, logger.ColorizeJoin(obj, errors.New("NotFound"))) failed = true continue } - log.Error(err, colorizeJoin(obj, errors.New("Unknown"))) + log.Error(err, logger.ColorizeJoin(obj, errors.New("Unknown"))) failed = true continue } res, err := status.Compute(obj) if err != nil { - log.Error(err, colorizeJoin(obj, errors.New("Failed"))) + log.Error(err, logger.ColorizeJoin(obj, errors.New("Failed"))) failed = true continue } - log.Info(colorizeJoin(obj, res.Status, "-", res.Message)) + log.Info(logger.ColorizeJoin(obj, res.Status, "-", res.Message)) } } } diff --git a/cmd/timoni/bundle_vet.go b/cmd/timoni/bundle_vet.go index 0ef5760e..15acb445 100644 --- a/cmd/timoni/bundle_vet.go +++ b/cmd/timoni/bundle_vet.go @@ -169,7 +169,7 @@ func runBundleVetCmd(cmd *cobra.Command, args []string) error { return err } - log = LoggerBundle(logr.NewContext(cmd.Context(), log), bundle.Name, apiv1.RuntimeDefaultName) + log = loggerBundle(logr.NewContext(cmd.Context(), log), bundle.Name, apiv1.RuntimeDefaultName, true) if len(bundle.Instances) == 0 { return fmt.Errorf("no instances found in bundle") @@ -193,7 +193,7 @@ func runBundleVetCmd(cmd *cobra.Command, args []string) error { if i.Namespace == "" { return fmt.Errorf("instance %s does not have a namespace", i.Name) } - log := LoggerBundleInstance(logr.NewContext(cmd.Context(), log), bundle.Name, cluster.Name, i.Name) + log := loggerBundleInstance(logr.NewContext(cmd.Context(), log), bundle.Name, cluster.Name, i.Name, true) log.Info("instance is valid") } } diff --git a/cmd/timoni/delete.go b/cmd/timoni/delete.go index 2887737d..a5b68f6c 100644 --- a/cmd/timoni/delete.go +++ b/cmd/timoni/delete.go @@ -25,6 +25,7 @@ import ( "github.com/fluxcd/pkg/ssa" "github.com/spf13/cobra" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/runtime" ) @@ -72,7 +73,7 @@ func runDeleteCmd(cmd *cobra.Command, args []string) error { deleteArgs.name = args[0] - log := LoggerInstance(cmd.Context(), deleteArgs.name) + log := loggerInstance(cmd.Context(), deleteArgs.name, true) sm, err := runtime.NewResourceManager(kubeconfigArgs) if err != nil { return err @@ -97,7 +98,7 @@ func runDeleteCmd(cmd *cobra.Command, args []string) error { if deleteArgs.dryrun { for _, object := range objects { - log.Info(colorizeJoin(object, ssa.DeletedAction, dryRunClient)) + log.Info(logger.ColorizeJoin(object, ssa.DeletedAction, logger.DryRunClient)) } return nil } @@ -114,7 +115,7 @@ func runDeleteCmd(cmd *cobra.Command, args []string) error { continue } cs.Add(*change) - log.Info(colorizeJoin(change)) + log.Info(logger.ColorizeJoin(change)) } if hasErrors { @@ -129,7 +130,7 @@ func runDeleteCmd(cmd *cobra.Command, args []string) error { if deleteArgs.wait && len(deletedObjects) > 0 { waitOpts := ssa.DefaultWaitOptions() waitOpts.Timeout = rootArgs.timeout - spin := StartSpinner(fmt.Sprintf("waiting for %v resource(s) to be finalized...", len(deletedObjects))) + spin := logger.StartSpinner(fmt.Sprintf("waiting for %v resource(s) to be finalized...", len(deletedObjects))) err = sm.WaitForTermination(deletedObjects, waitOpts) spin.Stop() if err != nil { diff --git a/cmd/timoni/inspect_resources.go b/cmd/timoni/inspect_resources.go index 3e691f82..ef48e20d 100644 --- a/cmd/timoni/inspect_resources.go +++ b/cmd/timoni/inspect_resources.go @@ -81,7 +81,7 @@ func runInspectResourcesCmd(cmd *cobra.Command, args []string) error { //} // //for _, meta := range metas { - // fmt.Fprintln(cmd.OutOrStdout(), colorizeSubject(ssa.FmtObjMetadata(meta))) + // fmt.Fprintln(cmd.OutOrStdout(), logger.ColorizeSubject(ssa.FmtObjMetadata(meta))) //} objects, err := iManager.ListObjects() diff --git a/cmd/timoni/log.go b/cmd/timoni/log.go deleted file mode 100644 index d130cd00..00000000 --- a/cmd/timoni/log.go +++ /dev/null @@ -1,291 +0,0 @@ -/* -Copyright 2023 Stefan Prodan - -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" - "io" - "os" - "strings" - "time" - - "github.com/briandowns/spinner" - "github.com/fatih/color" - "github.com/fluxcd/cli-utils/pkg/kstatus/status" - "github.com/fluxcd/pkg/ssa" - ssautil "github.com/fluxcd/pkg/ssa/utils" - "github.com/go-logr/logr" - "github.com/go-logr/zerologr" - gcrLog "github.com/google/go-containerregistry/pkg/logs" - "github.com/rs/zerolog" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - runtimeLog "sigs.k8s.io/controller-runtime/pkg/log" - - apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" -) - -// NewConsoleLogger returns a human-friendly Logger. -// Pretty print adds timestamp, log level and colorized output to the logs. -func NewConsoleLogger() logr.Logger { - color.NoColor = !rootArgs.coloredLog - zconfig := zerolog.ConsoleWriter{Out: color.Error, NoColor: !rootArgs.coloredLog} - if !rootArgs.prettyLog { - zconfig.PartsExclude = []string{ - zerolog.TimestampFieldName, - zerolog.LevelFieldName, - } - } - - zlog := zerolog.New(zconfig).With().Timestamp().Logger() - - // Discard the container registry client logger. - gcrLog.Warn.SetOutput(io.Discard) - - // Create a logr.Logger using zerolog as sink. - zerologr.VerbosityFieldName = "" - log := zerologr.New(&zlog) - - // Set controller-runtime logger. - runtimeLog.SetLogger(log) - - return log -} - -var ( - colorDryRun = color.New(color.FgHiBlack, color.Italic) - colorError = color.New(color.FgHiRed) - colorReady = color.New(color.FgHiGreen) - colorCallerPrefix = color.New(color.FgHiBlack) - colorBundle = color.New(color.FgHiMagenta) - colorInstance = color.New(color.FgHiMagenta) - colorPerAction = map[ssa.Action]*color.Color{ - ssa.CreatedAction: color.New(color.FgHiGreen), - ssa.ConfiguredAction: color.New(color.FgHiCyan), - ssa.UnchangedAction: color.New(color.FgHiBlack), - ssa.DeletedAction: color.New(color.FgRed), - ssa.SkippedAction: color.New(color.FgHiBlack), - ssa.UnknownAction: color.New(color.FgYellow, color.Italic), - } - colorPerStatus = map[status.Status]*color.Color{ - status.InProgressStatus: color.New(color.FgHiCyan, color.Italic), - status.FailedStatus: color.New(color.FgHiRed), - status.CurrentStatus: color.New(color.FgHiGreen), - status.TerminatingStatus: color.New(color.FgRed), - status.NotFoundStatus: color.New(color.FgYellow, color.Italic), - status.UnknownStatus: color.New(color.FgYellow, color.Italic), - } -) - -type dryRunType string - -const ( - dryRunClient dryRunType = "(dry run)" - dryRunServer dryRunType = "(server dry run)" -) - -func colorizeJoin(values ...any) string { - var sb strings.Builder - for i, v := range values { - if i > 0 { - sb.WriteByte(' ') - } - sb.WriteString(colorizeAny(v)) - } - return sb.String() -} - -func colorizeAny(v any) string { - switch v := v.(type) { - case *unstructured.Unstructured: - return colorizeUnstructured(v) - case dryRunType: - return colorizeDryRun(v) - case ssa.Action: - return colorizeAction(v) - case ssa.ChangeSetEntry: - return colorizeChangeSetEntry(v) - case *ssa.ChangeSetEntry: - return colorizeChangeSetEntry(*v) - case status.Status: - return colorizeStatus(v) - case error: - return colorizeError(v) - case string: - return v - default: - return fmt.Sprint(v) - } -} - -func colorizeSubject(subject string) string { - return color.CyanString(subject) -} - -func colorizeReady(subject string) string { - return colorReady.Sprint(subject) -} - -func colorizeInfo(subject string) string { - return color.GreenString(subject) -} - -func colorizeWarning(subject string) string { - return color.YellowString(subject) -} - -func colorizeNamespaceFromArgs() string { - return colorizeSubject("Namespace/" + *kubeconfigArgs.Namespace) -} - -func colorizeUnstructured(object *unstructured.Unstructured) string { - return colorizeSubject(ssautil.FmtUnstructured(object)) -} - -func colorizeAction(action ssa.Action) string { - if c, ok := colorPerAction[action]; ok { - return c.Sprint(action) - } - return action.String() -} - -func colorizeChange(subject string, action ssa.Action) string { - return fmt.Sprintf("%s %s", colorizeSubject(subject), colorizeAction(action)) -} - -func colorizeChangeSetEntry(change ssa.ChangeSetEntry) string { - return colorizeChange(change.Subject, change.Action) -} - -func colorizeDryRun(dryRun dryRunType) string { - return colorDryRun.Sprint(string(dryRun)) -} - -func colorizeError(err error) string { - return colorError.Sprint(err.Error()) -} - -func colorizeStatus(status status.Status) string { - if c, ok := colorPerStatus[status]; ok { - return c.Sprint(status) - } - return status.String() -} - -func colorizeBundle(bundle string) string { - return colorCallerPrefix.Sprint("b:") + colorBundle.Sprint(bundle) -} - -func colorizeInstance(instance string) string { - return colorCallerPrefix.Sprint("i:") + colorInstance.Sprint(instance) -} - -func colorizeRuntime(runtime string) string { - return colorCallerPrefix.Sprint("r:") + colorInstance.Sprint(runtime) -} - -func colorizeCluster(cluster string) string { - return colorCallerPrefix.Sprint("c:") + colorInstance.Sprint(cluster) -} - -func LoggerBundle(ctx context.Context, bundle, cluster string) logr.Logger { - switch cluster { - case apiv1.RuntimeDefaultName: - if !rootArgs.prettyLog { - return LoggerFrom(ctx, "bundle", bundle) - } - return LoggerFrom(ctx, "caller", colorizeBundle(bundle)) - default: - if !rootArgs.prettyLog { - return LoggerFrom(ctx, "bundle", bundle, "cluster", cluster) - } - return LoggerFrom(ctx, "caller", - fmt.Sprintf("%s %s %s", - colorizeBundle(bundle), - color.CyanString(">"), - colorizeCluster(cluster))) - } -} - -func LoggerInstance(ctx context.Context, instance string) logr.Logger { - if !rootArgs.prettyLog { - return LoggerFrom(ctx, "instance", instance) - } - return LoggerFrom(ctx, "caller", colorizeInstance(instance)) -} - -func LoggerBundleInstance(ctx context.Context, bundle, cluster, instance string) logr.Logger { - switch cluster { - case apiv1.RuntimeDefaultName: - if !rootArgs.prettyLog { - return LoggerFrom(ctx, "bundle", bundle, "instance", instance) - } - return LoggerFrom(ctx, "caller", - fmt.Sprintf("%s %s %s", - colorizeBundle(bundle), - color.CyanString(">"), - colorizeInstance(instance))) - default: - if !rootArgs.prettyLog { - return LoggerFrom(ctx, "bundle", bundle, "cluster", cluster, "instance", instance) - } - return LoggerFrom(ctx, "caller", - fmt.Sprintf("%s %s %s %s %s", - colorizeBundle(bundle), - color.CyanString(">"), - colorizeCluster(cluster), - color.CyanString(">"), - colorizeInstance(instance))) - - } -} - -func LoggerRuntime(ctx context.Context, runtime, cluster string) logr.Logger { - switch cluster { - case apiv1.RuntimeDefaultName: - if !rootArgs.prettyLog { - return LoggerFrom(ctx, "runtime", runtime) - } - return LoggerFrom(ctx, "caller", colorizeRuntime(runtime)) - default: - if !rootArgs.prettyLog { - return LoggerFrom(ctx, "runtime", runtime, "cluster", cluster) - } - return LoggerFrom(ctx, "caller", - fmt.Sprintf("%s %s %s", colorizeRuntime(runtime), - color.CyanString(">"), colorizeCluster(cluster))) - } -} - -// LoggerFrom returns a logr.Logger with predefined values from a context.Context. -func LoggerFrom(ctx context.Context, keysAndValues ...interface{}) logr.Logger { - newLogger := logger - if ctx != nil { - if l, err := logr.FromContext(ctx); err == nil { - newLogger = l - } - } - return newLogger.WithValues(keysAndValues...) -} - -// StartSpinner starts a spinner with the given message. -func StartSpinner(msg string) *spinner.Spinner { - s := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriter(os.Stderr)) - s.Suffix = " " + msg - s.Start() - return s -} diff --git a/cmd/timoni/logger.go b/cmd/timoni/logger.go new file mode 100644 index 00000000..b3ea4aa5 --- /dev/null +++ b/cmd/timoni/logger.go @@ -0,0 +1,111 @@ +/* +Copyright 2024 Stefan Prodan + +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" + + "github.com/fatih/color" + "github.com/go-logr/logr" + + apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" + "github.com/stefanprodan/timoni/internal/logger" +) + +func loggerBundle(ctx context.Context, bundle, cluster string, prettify bool) logr.Logger { + switch cluster { + case apiv1.RuntimeDefaultName: + if !prettify { + return LoggerFrom(ctx, "bundle", bundle) + } + return LoggerFrom(ctx, "caller", logger.ColorizeBundle(bundle)) + default: + if !prettify { + return LoggerFrom(ctx, "bundle", bundle, "cluster", cluster) + } + return LoggerFrom(ctx, "caller", + fmt.Sprintf("%s %s %s", + logger.ColorizeBundle(bundle), + color.CyanString(">"), + logger.ColorizeCluster(cluster))) + } +} + +func loggerInstance(ctx context.Context, instance string, prettify bool) logr.Logger { + if !prettify { + return LoggerFrom(ctx, "instance", instance) + } + return LoggerFrom(ctx, "caller", logger.ColorizeInstance(instance)) +} + +func loggerBundleInstance(ctx context.Context, bundle, cluster, instance string, prettify bool) logr.Logger { + switch cluster { + case apiv1.RuntimeDefaultName: + if !prettify { + return LoggerFrom(ctx, "bundle", bundle, "instance", instance) + } + return LoggerFrom(ctx, "caller", + fmt.Sprintf("%s %s %s", + logger.ColorizeBundle(bundle), + color.CyanString(">"), + logger.ColorizeInstance(instance))) + default: + if !prettify { + return LoggerFrom(ctx, "bundle", bundle, "cluster", cluster, "instance", instance) + } + return LoggerFrom(ctx, "caller", + fmt.Sprintf("%s %s %s %s %s", + logger.ColorizeBundle(bundle), + color.CyanString(">"), + logger.ColorizeCluster(cluster), + color.CyanString(">"), + logger.ColorizeInstance(instance))) + + } +} + +func loggerRuntime(ctx context.Context, runtime, cluster string, prettify bool) logr.Logger { + switch cluster { + case apiv1.RuntimeDefaultName: + if !prettify { + return LoggerFrom(ctx, "runtime", runtime) + } + return LoggerFrom(ctx, "caller", logger.ColorizeRuntime(runtime)) + default: + if !prettify { + return LoggerFrom(ctx, "runtime", runtime, "cluster", cluster) + } + return LoggerFrom(ctx, "caller", + fmt.Sprintf("%s %s %s", logger.ColorizeRuntime(runtime), + color.CyanString(">"), logger.ColorizeCluster(cluster))) + } +} + +// LoggerFrom returns a logr.Logger with predefined values from a context.Context. +func LoggerFrom(ctx context.Context, keysAndValues ...interface{}) logr.Logger { + if cliLogger.IsZero() { + cliLogger = logger.NewConsoleLogger(false, false) + } + newLogger := cliLogger + if ctx != nil { + if l, err := logr.FromContext(ctx); err == nil { + newLogger = l + } + } + return newLogger.WithValues(keysAndValues...) +} diff --git a/cmd/timoni/main.go b/cmd/timoni/main.go index 975cf8b9..3f5118bb 100644 --- a/cmd/timoni/main.go +++ b/cmd/timoni/main.go @@ -29,6 +29,8 @@ import ( "k8s.io/cli-runtime/pkg/genericclioptions" _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/client-go/tools/clientcmd" + + "github.com/stefanprodan/timoni/internal/logger" ) var ( @@ -46,12 +48,12 @@ var rootCmd = &cobra.Command{ // Initialize the console logger just before running // a command only if one wasn't provided. This allows other // callers (e.g. unit tests) to inject their own logger ahead of time. - if logger.IsZero() { - logger = NewConsoleLogger() + if cliLogger.IsZero() { + cliLogger = logger.NewConsoleLogger(true, true) } // Inject the logger in the command context. - ctx := logr.NewContext(context.Background(), logger) + ctx := logr.NewContext(context.Background(), cliLogger) cmd.SetContext(ctx) }, } @@ -70,7 +72,7 @@ var ( coloredLog: !color.NoColor, timeout: 5 * time.Minute, } - logger logr.Logger + cliLogger logr.Logger kubeconfigArgs = genericclioptions.NewConfigFlags(false) ) @@ -98,13 +100,13 @@ func main() { if err := rootCmd.Execute(); err != nil { // Ensure a logger is initialized even if the rootCmd // failed before running its hooks. - if logger.IsZero() { - logger = NewConsoleLogger() - } + if cliLogger.IsZero() { + cliLogger = logger.NewConsoleLogger(true, true) + } // Set the logger err to nil to pretty print // the error message on multiple lines. - logger.Error(nil, err.Error()) + cliLogger.Error(nil, err.Error()) os.Exit(1) } } diff --git a/cmd/timoni/main_test.go b/cmd/timoni/main_test.go index 123c26be..569dea39 100644 --- a/cmd/timoni/main_test.go +++ b/cmd/timoni/main_test.go @@ -107,8 +107,8 @@ func executeCommandWithIn(cmd string, in io.Reader) (string, error) { zerolog.LevelFieldName, } zl := zerolog.New(zcfg) - logger = zerologr.New(&zl) - runtimeLog.SetLogger(logger) + cliLogger = zerologr.New(&zl) + runtimeLog.SetLogger(cliLogger) _, err = rootCmd.ExecuteC() result := buf.String() diff --git a/cmd/timoni/mod_init.go b/cmd/timoni/mod_init.go index bec3a02f..4f2b1b3c 100644 --- a/cmd/timoni/mod_init.go +++ b/cmd/timoni/mod_init.go @@ -28,6 +28,7 @@ import ( "github.com/spf13/cobra" apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -98,7 +99,7 @@ func runInitModCmd(cmd *cobra.Command, args []string) error { templateName = "blueprint" } - spin := StartSpinner(fmt.Sprintf("pulling template from %s", templateURL)) + spin := logger.StartSpinner(fmt.Sprintf("pulling template from %s", templateURL)) defer spin.Stop() opts := oci.Options(ctx, "", rootArgs.registryInsecure) diff --git a/cmd/timoni/mod_list.go b/cmd/timoni/mod_list.go index 4a101590..b88f5074 100644 --- a/cmd/timoni/mod_list.go +++ b/cmd/timoni/mod_list.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -64,7 +65,7 @@ func listModCmdRun(cmd *cobra.Command, args []string) error { } ociURL := args[0] - spin := StartSpinner("fetching versions") + spin := logger.StartSpinner("fetching versions") defer spin.Stop() ctx, cancel := context.WithTimeout(cmd.Context(), rootArgs.timeout) diff --git a/cmd/timoni/mod_pull.go b/cmd/timoni/mod_pull.go index 88d606aa..9da09c5e 100644 --- a/cmd/timoni/mod_pull.go +++ b/cmd/timoni/mod_pull.go @@ -26,6 +26,7 @@ import ( apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -141,7 +142,7 @@ func pullCmdRun(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) defer cancel() - spin := StartSpinner(fmt.Sprintf("pulling %s", ociURL)) + spin := logger.StartSpinner(fmt.Sprintf("pulling %s", ociURL)) opts := oci.Options(ctx, pullModArgs.creds.String(), rootArgs.registryInsecure) err := oci.PullArtifact(ociURL, pullModArgs.output, apiv1.AnyContentType, opts) spin.Stop() @@ -149,7 +150,7 @@ func pullCmdRun(cmd *cobra.Command, args []string) error { return err } - log.Info(fmt.Sprintf("extracted: %s", colorizeSubject(pullModArgs.output))) + log.Info(fmt.Sprintf("extracted: %s", logger.ColorizeSubject(pullModArgs.output))) return nil } diff --git a/cmd/timoni/mod_push.go b/cmd/timoni/mod_push.go index 7d42af8b..5ef237d2 100644 --- a/cmd/timoni/mod_push.go +++ b/cmd/timoni/mod_push.go @@ -29,6 +29,7 @@ import ( apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" "github.com/stefanprodan/timoni/internal/engine" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -142,7 +143,7 @@ func pushModCmdRun(cmd *cobra.Command, args []string) error { } pushModArgs.ignorePaths = append(pushModArgs.ignorePaths, ps...) - spin := StartSpinner("pushing module") + spin := logger.StartSpinner("pushing module") defer spin.Stop() opts := oci.Options(ctx, pushModArgs.creds.String(), rootArgs.registryInsecure) @@ -201,8 +202,8 @@ func pushModCmdRun(cmd *cobra.Command, args []string) error { if err != nil { return err } - log.Info(fmt.Sprintf("artifact: %s", colorizeSubject(ociURL))) - log.Info(fmt.Sprintf("digest: %s", colorizeSubject(digest.DigestStr()))) + log.Info(fmt.Sprintf("artifact: %s", logger.ColorizeSubject(ociURL))) + log.Info(fmt.Sprintf("digest: %s", logger.ColorizeSubject(digest.DigestStr()))) } return nil diff --git a/cmd/timoni/mod_vendor_crd.go b/cmd/timoni/mod_vendor_crd.go index 0658553e..c60bdbf4 100644 --- a/cmd/timoni/mod_vendor_crd.go +++ b/cmd/timoni/mod_vendor_crd.go @@ -34,6 +34,7 @@ import ( "sigs.k8s.io/yaml" "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/logger" ) var vendorCrdCmd = &cobra.Command{ @@ -81,7 +82,7 @@ func runVendorCrdCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("cue.mod not found in the module path %s", vendorCrdArgs.modRoot) } - spin := StartSpinner("importing schemas") + spin := logger.StartSpinner("importing schemas") defer spin.Stop() // Load the YAML manifest into memory either from disk or by fetching the file over HTTPS. @@ -160,7 +161,7 @@ func runVendorCrdCmd(cmd *cobra.Command, args []string) error { // Write the definitions to the module's 'cue.mod/gen' dir. for _, k := range keys { - log.Info(fmt.Sprintf("schemas vendored: %s", colorizeSubject(k))) + log.Info(fmt.Sprintf("schemas vendored: %s", logger.ColorizeSubject(k))) dstDir := path.Join(cueModDir, "gen", k) if err := os.MkdirAll(dstDir, os.ModePerm); err != nil { diff --git a/cmd/timoni/mod_vendor_k8s.go b/cmd/timoni/mod_vendor_k8s.go index cf55d620..ac369ad0 100644 --- a/cmd/timoni/mod_vendor_k8s.go +++ b/cmd/timoni/mod_vendor_k8s.go @@ -26,6 +26,7 @@ import ( "github.com/spf13/cobra" apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/oci" ) @@ -78,7 +79,7 @@ func runVendorK8sCmd(cmd *cobra.Command, args []string) error { ociURL = fmt.Sprintf("%s:v%s", k8sSchemaURL, ver) } - spin := StartSpinner(fmt.Sprintf("importing schemas from %s", ociURL)) + spin := logger.StartSpinner(fmt.Sprintf("importing schemas from %s", ociURL)) defer spin.Stop() opts := oci.Options(ctx, "", rootArgs.registryInsecure) @@ -88,7 +89,7 @@ func runVendorK8sCmd(cmd *cobra.Command, args []string) error { } spin.Stop() - log.Info(fmt.Sprintf("schemas vendored: %s", colorizeSubject(path.Join(cueModDir, "gen", "k8s.io", "api")))) + log.Info(fmt.Sprintf("schemas vendored: %s", logger.ColorizeSubject(path.Join(cueModDir, "gen", "k8s.io", "api")))) return nil } diff --git a/cmd/timoni/mod_vet.go b/cmd/timoni/mod_vet.go index d53b8f47..04fb240e 100644 --- a/cmd/timoni/mod_vet.go +++ b/cmd/timoni/mod_vet.go @@ -34,6 +34,7 @@ import ( "github.com/stefanprodan/timoni/internal/engine" "github.com/stefanprodan/timoni/internal/engine/fetcher" "github.com/stefanprodan/timoni/internal/flags" + "github.com/stefanprodan/timoni/internal/logger" ) var vetModCmd = &cobra.Command{ @@ -179,7 +180,7 @@ func runVetModCmd(cmd *cobra.Command, args []string) error { for _, object := range objects { log.Info(fmt.Sprintf("%s %s", - colorizeSubject(ssautil.FmtUnstructured(object)), colorizeInfo("valid resource"))) + logger.ColorizeSubject(ssautil.FmtUnstructured(object)), logger.ColorizeInfo("valid resource"))) } images, err := builder.GetContainerImages(buildResult) @@ -195,15 +196,15 @@ func runVetModCmd(cmd *cobra.Command, args []string) error { if !strings.Contains(image, "@sha") { log.Info(fmt.Sprintf("%s %s", - colorizeSubject(image), colorizeWarning("valid image (digest missing)"))) + logger.ColorizeSubject(image), logger.ColorizeWarning("valid image (digest missing)"))) } else { log.Info(fmt.Sprintf("%s %s", - colorizeSubject(image), colorizeInfo("valid image"))) + logger.ColorizeSubject(image), logger.ColorizeInfo("valid image"))) } } log.Info(fmt.Sprintf("%s %s", - colorizeSubject(mod.Name), colorizeInfo("valid module"))) + logger.ColorizeSubject(mod.Name), logger.ColorizeInfo("valid module"))) return nil } diff --git a/cmd/timoni/runtime_build.go b/cmd/timoni/runtime_build.go index 5d6ae1f5..86810c7e 100644 --- a/cmd/timoni/runtime_build.go +++ b/cmd/timoni/runtime_build.go @@ -28,6 +28,7 @@ import ( apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/runtime" ) @@ -93,7 +94,7 @@ func runRuntimeBuildCmd(cmd *cobra.Command, args []string) error { } for _, cluster := range clusters { - log := LoggerRuntime(cmd.Context(), rt.Name, cluster.Name) + log := loggerRuntime(cmd.Context(), rt.Name, cluster.Name, true) kubeconfigArgs.Context = &cluster.KubeContext rm, err := runtime.NewResourceManager(kubeconfigArgs) @@ -116,7 +117,7 @@ func runRuntimeBuildCmd(cmd *cobra.Command, args []string) error { sort.Strings(keys) for _, k := range keys { - log.Info(fmt.Sprintf("%s: %s", colorizeSubject(k), values[k])) + log.Info(fmt.Sprintf("%s: %s", logger.ColorizeSubject(k), values[k])) } if len(values) == 0 { diff --git a/cmd/timoni/status.go b/cmd/timoni/status.go index 0e97cb15..de61227c 100644 --- a/cmd/timoni/status.go +++ b/cmd/timoni/status.go @@ -26,6 +26,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/stefanprodan/timoni/internal/logger" "github.com/stefanprodan/timoni/internal/runtime" apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" @@ -65,7 +66,7 @@ func runStatusCmd(cmd *cobra.Command, args []string) error { statusArgs.name = args[0] - log := LoggerInstance(cmd.Context(), statusArgs.name) + log := loggerInstance(cmd.Context(), statusArgs.name, true) rm, err := runtime.NewResourceManager(kubeconfigArgs) if err != nil { return err @@ -81,15 +82,15 @@ func runStatusCmd(cmd *cobra.Command, args []string) error { } log.Info(fmt.Sprintf("last applied %s", - colorizeSubject(instance.LastTransitionTime))) + logger.ColorizeSubject(instance.LastTransitionTime))) log.Info(fmt.Sprintf("module %s", - colorizeSubject(instance.Module.Repository+":"+instance.Module.Version))) + logger.ColorizeSubject(instance.Module.Repository+":"+instance.Module.Version))) log.Info(fmt.Sprintf("digest %s", - colorizeSubject(instance.Module.Digest))) + logger.ColorizeSubject(instance.Module.Digest))) for _, image := range instance.Images { log.Info(fmt.Sprintf("container image %s", - colorizeSubject(image))) + logger.ColorizeSubject(image))) } tm := runtime.InstanceManager{Instance: apiv1.Instance{Inventory: instance.Inventory}} @@ -103,19 +104,19 @@ func runStatusCmd(cmd *cobra.Command, args []string) error { err = rm.Client().Get(ctx, client.ObjectKeyFromObject(obj), obj) if err != nil { if apierrors.IsNotFound(err) { - log.Error(err, colorizeJoin(obj, errors.New("NotFound"))) + log.Error(err, logger.ColorizeJoin(obj, errors.New("NotFound"))) continue } - log.Error(err, colorizeJoin(obj, errors.New("Unknown"))) + log.Error(err, logger.ColorizeJoin(obj, errors.New("Unknown"))) continue } res, err := status.Compute(obj) if err != nil { - log.Error(err, colorizeJoin(obj, errors.New("Failed"))) + log.Error(err, logger.ColorizeJoin(obj, errors.New("Failed"))) continue } - log.Info(colorizeJoin(obj, res.Status, "-", res.Message)) + log.Info(logger.ColorizeJoin(obj, res.Status, "-", res.Message)) } return nil diff --git a/cmd/timoni/dyff.go b/internal/dyff/dyff.go similarity index 79% rename from cmd/timoni/dyff.go rename to internal/dyff/dyff.go index 77bd3ab3..701b0ab7 100644 --- a/cmd/timoni/dyff.go +++ b/internal/dyff/dyff.go @@ -1,5 +1,5 @@ /* -Copyright 2023 Stefan Prodan +Copyright 2024 Stefan Prodan Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package main +package dyff import ( "context" @@ -27,12 +27,14 @@ import ( "github.com/fluxcd/pkg/ssa" ssaerr "github.com/fluxcd/pkg/ssa/errors" ssautil "github.com/fluxcd/pkg/ssa/utils" + "github.com/go-logr/logr" "github.com/gonvenience/ytbx" "github.com/homeport/dyff/pkg/dyff" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/yaml" apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" + "github.com/stefanprodan/timoni/internal/logger" ) // DyffPrinter is a printer that prints dyff reports. @@ -67,7 +69,7 @@ func (p *DyffPrinter) Print(w io.Writer, args ...interface{}) error { return nil } -func diffYAML(liveFile, mergedFile string, output io.Writer) error { +func DiffYAML(liveFile, mergedFile string, output io.Writer) error { from, to, err := ytbx.LoadFiles(liveFile, mergedFile) if err != nil { return fmt.Errorf("failed to load input files: %w", err) @@ -85,20 +87,21 @@ func diffYAML(liveFile, mergedFile string, output io.Writer) error { return printer.Print(output, report) } -func instanceDryRunDiff(ctx context.Context, +func InstanceDryRunDiff(ctx context.Context, rm *ssa.ResourceManager, objects []*unstructured.Unstructured, staleObjects []*unstructured.Unstructured, nsExists bool, tmpDir string, - withDiff bool) error { - log := LoggerFrom(ctx) + withDiff bool, + w io.Writer) error { + log := logr.FromContextOrDiscard(ctx) diffOpts := ssa.DefaultDiffOptions() sort.Sort(ssa.SortableUnstructureds(objects)) for _, r := range objects { if !nsExists { - log.Info(colorizeJoin(r, ssa.CreatedAction, dryRunServer)) + log.Info(logger.ColorizeJoin(r, ssa.CreatedAction, logger.DryRunServer)) continue } @@ -108,18 +111,18 @@ func instanceDryRunDiff(ctx context.Context, if ssautil.AnyInMetadata(r, map[string]string{ apiv1.ForceAction: apiv1.EnabledValue, }) { - log.Info(colorizeJoin(r, ssa.CreatedAction, dryRunServer)) + log.Info(logger.ColorizeJoin(r, ssa.CreatedAction, logger.DryRunServer)) } else { - log.Error(nil, colorizeJoin(r, "immutable", dryRunServer)) + log.Error(nil, logger.ColorizeJoin(r, "immutable", logger.DryRunServer)) } } else { - log.Error(err, colorizeUnstructured(r)) + log.Error(err, logger.ColorizeUnstructured(r)) } continue } - log.Info(colorizeJoin(change, dryRunServer)) + log.Info(logger.ColorizeJoin(change, logger.DryRunServer)) if withDiff && change.Action == ssa.ConfiguredAction { liveYAML, _ := yaml.Marshal(liveObject) liveFile := filepath.Join(tmpDir, "live.yaml") @@ -133,14 +136,14 @@ func instanceDryRunDiff(ctx context.Context, return err } - if err := diffYAML(liveFile, mergedFile, rootCmd.OutOrStdout()); err != nil { + if err := DiffYAML(liveFile, mergedFile, w); err != nil { return err } } } for _, r := range staleObjects { - log.Info(colorizeJoin(r, ssa.DeletedAction, dryRunServer)) + log.Info(logger.ColorizeJoin(r, ssa.DeletedAction, logger.DryRunServer)) } return nil diff --git a/cmd/timoni/dyff_test.go b/internal/dyff/dyff_test.go similarity index 95% rename from cmd/timoni/dyff_test.go rename to internal/dyff/dyff_test.go index 6aa5ceed..afb62638 100644 --- a/cmd/timoni/dyff_test.go +++ b/internal/dyff/dyff_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package main +package dyff import ( "bytes" @@ -42,7 +42,7 @@ func TestDiffYAML(t *testing.T) { g.Expect(err).ToNot(HaveOccurred()) buf := new(bytes.Buffer) - err = diffYAML(liveFile.Name(), mergedFile.Name(), buf) + err = DiffYAML(liveFile.Name(), mergedFile.Name(), buf) g.Expect(err).ToNot(HaveOccurred()) g.Expect(buf.String()).To(ContainSubstring("name: test-pod-merged")) } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 00000000..a999cbce --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,204 @@ +/* +Copyright 2024 Stefan Prodan + +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 logger + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/briandowns/spinner" + "github.com/fatih/color" + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/pkg/ssa" + ssautil "github.com/fluxcd/pkg/ssa/utils" + "github.com/go-logr/logr" + "github.com/go-logr/zerologr" + gcrLog "github.com/google/go-containerregistry/pkg/logs" + "github.com/rs/zerolog" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + runtimeLog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// NewConsoleLogger returns a human-friendly Logger. +// Pretty print adds timestamp, log level and colorized output to the logs. +func NewConsoleLogger(colorize, prettify bool) logr.Logger { + color.NoColor = !colorize + zconfig := zerolog.ConsoleWriter{Out: color.Error, NoColor: !colorize} + if !prettify { + zconfig.PartsExclude = []string{ + zerolog.TimestampFieldName, + zerolog.LevelFieldName, + } + } + + zlog := zerolog.New(zconfig).With().Timestamp().Logger() + + // Discard the container registry client logger. + gcrLog.Warn.SetOutput(io.Discard) + + // Create a logr.Logger using zerolog as sink. + zerologr.VerbosityFieldName = "" + log := zerologr.New(&zlog) + + // Set controller-runtime logger. + runtimeLog.SetLogger(log) + + return log +} + +var ( + colorDryRun = color.New(color.FgHiBlack, color.Italic) + colorError = color.New(color.FgHiRed) + colorReady = color.New(color.FgHiGreen) + colorCallerPrefix = color.New(color.FgHiBlack) + colorBundle = color.New(color.FgHiMagenta) + colorInstance = color.New(color.FgHiMagenta) + colorPerAction = map[ssa.Action]*color.Color{ + ssa.CreatedAction: color.New(color.FgHiGreen), + ssa.ConfiguredAction: color.New(color.FgHiCyan), + ssa.UnchangedAction: color.New(color.FgHiBlack), + ssa.DeletedAction: color.New(color.FgRed), + ssa.SkippedAction: color.New(color.FgHiBlack), + ssa.UnknownAction: color.New(color.FgYellow, color.Italic), + } + colorPerStatus = map[status.Status]*color.Color{ + status.InProgressStatus: color.New(color.FgHiCyan, color.Italic), + status.FailedStatus: color.New(color.FgHiRed), + status.CurrentStatus: color.New(color.FgHiGreen), + status.TerminatingStatus: color.New(color.FgRed), + status.NotFoundStatus: color.New(color.FgYellow, color.Italic), + status.UnknownStatus: color.New(color.FgYellow, color.Italic), + } +) + +type DryRunType string + +const ( + DryRunClient DryRunType = "(dry run)" + DryRunServer DryRunType = "(server dry run)" +) + +func ColorizeJoin(values ...any) string { + var sb strings.Builder + for i, v := range values { + if i > 0 { + sb.WriteByte(' ') + } + sb.WriteString(ColorizeAny(v)) + } + return sb.String() +} + +func ColorizeAny(v any) string { + switch v := v.(type) { + case *unstructured.Unstructured: + return ColorizeUnstructured(v) + case DryRunType: + return ColorizeDryRun(v) + case ssa.Action: + return ColorizeAction(v) + case ssa.ChangeSetEntry: + return ColorizeChangeSetEntry(v) + case *ssa.ChangeSetEntry: + return ColorizeChangeSetEntry(*v) + case status.Status: + return ColorizeStatus(v) + case error: + return ColorizeError(v) + case string: + return v + default: + return fmt.Sprint(v) + } +} + +func ColorizeSubject(subject string) string { + return color.CyanString(subject) +} + +func ColorizeReady(subject string) string { + return colorReady.Sprint(subject) +} + +func ColorizeInfo(subject string) string { + return color.GreenString(subject) +} + +func ColorizeWarning(subject string) string { + return color.YellowString(subject) +} + +func ColorizeUnstructured(object *unstructured.Unstructured) string { + return ColorizeSubject(ssautil.FmtUnstructured(object)) +} + +func ColorizeAction(action ssa.Action) string { + if c, ok := colorPerAction[action]; ok { + return c.Sprint(action) + } + return action.String() +} + +func ColorizeChange(subject string, action ssa.Action) string { + return fmt.Sprintf("%s %s", ColorizeSubject(subject), ColorizeAction(action)) +} + +func ColorizeChangeSetEntry(change ssa.ChangeSetEntry) string { + return ColorizeChange(change.Subject, change.Action) +} + +func ColorizeDryRun(dryRun DryRunType) string { + return colorDryRun.Sprint(string(dryRun)) +} + +func ColorizeError(err error) string { + return colorError.Sprint(err.Error()) +} + +func ColorizeStatus(status status.Status) string { + if c, ok := colorPerStatus[status]; ok { + return c.Sprint(status) + } + return status.String() +} + +func ColorizeBundle(bundle string) string { + return colorCallerPrefix.Sprint("b:") + colorBundle.Sprint(bundle) +} + +func ColorizeInstance(instance string) string { + return colorCallerPrefix.Sprint("i:") + colorInstance.Sprint(instance) +} + +func ColorizeRuntime(runtime string) string { + return colorCallerPrefix.Sprint("r:") + colorInstance.Sprint(runtime) +} + +func ColorizeCluster(cluster string) string { + return colorCallerPrefix.Sprint("c:") + colorInstance.Sprint(cluster) +} + +// StartSpinner starts a spinner with the given message. +func StartSpinner(msg string) interface{ Stop() } { + s := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriter(os.Stderr)) + s.Suffix = " " + msg + s.Start() + return s +} diff --git a/internal/reconciler/interactive.go b/internal/reconciler/interactive.go new file mode 100644 index 00000000..c581cb7f --- /dev/null +++ b/internal/reconciler/interactive.go @@ -0,0 +1,123 @@ +/* +Copyright 2024 Stefan Prodan + +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 reconciler + +import ( + "context" + "fmt" + "io" + "time" + + "cuelang.org/go/cue" + "github.com/fluxcd/pkg/ssa" + "github.com/go-logr/logr" + kerrors "k8s.io/apimachinery/pkg/util/errors" + + "github.com/stefanprodan/timoni/internal/dyff" + "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/logger" +) + +func NewInteractiveReconciler(log logr.Logger, copts *CommonOptions, iopts *InteractiveOptions, timeout time.Duration) *InteractiveReconciler { + reconciler := &InteractiveReconciler{ + Reconciler: NewReconciler(log, copts, timeout), + InteractiveOptions: iopts, + } + + if reconciler.DiffOutput == nil { + reconciler.DiffOutput = io.Discard + } + + if iopts.ProgressStart != nil { + reconciler.progressStartFn = iopts.ProgressStart + } + + return reconciler +} + +func (r *InteractiveReconciler) ApplyInstance(ctx context.Context, log logr.Logger, builder *engine.ModuleBuilder, buildResult cue.Value) error { + namespaceExists, err := r.NamespaceExists(ctx) + if err != nil { + return err + } + + if r.DryRun || r.Diff { + if !namespaceExists { + log.Info(logger.ColorizeJoin(logger.ColorizeSubject("Namespace/"+r.Namespace()), + ssa.CreatedAction, logger.DryRunServer)) + } + if err := r.DryRunDiff(logr.NewContext(ctx, log), namespaceExists); err != nil { + return err + } + + log.Info(logger.ColorizeJoin("applied successfully", logger.ColorizeDryRun("(server dry run)"))) + return nil + } + + if !r.instanceExists { + log.Info(fmt.Sprintf("installing %s in namespace %s", + logger.ColorizeSubject(r.Name()), logger.ColorizeSubject(r.Namespace()))) + + if err := r.UpdateStoredInstance(ctx); err != nil { + return fmt.Errorf("instance init failed: %w", err) + } + + if !namespaceExists { + log.Info(logger.ColorizeJoin(logger.ColorizeSubject("Namespace/"+r.Namespace()), ssa.CreatedAction)) + } + } else { + log.Info(fmt.Sprintf("upgrading %s in namespace %s", + logger.ColorizeSubject(r.Name()), logger.ColorizeSubject(r.Namespace()))) + } + + return kerrors.NewAggregate([]error{ + r.ApplyAllSets(ctx, log, r.Wait), + r.PostApplyUpdateInventory(ctx, builder, buildResult), + r.PostApplyPruneStaleObjects(ctx, log, r.WaitForTermination), + }) +} + +func (r *InteractiveReconciler) DryRunDiff(ctx context.Context, namespaceExists bool) error { + return dyff.InstanceDryRunDiff( + ctx, + r.resourceManager, + r.currentObjects, + r.staleObjects, + namespaceExists, + r.opts.Dir, + r.Diff, + r.DiffOutput, + ) +} + +func (r *InteractiveReconciler) Wait(ctx context.Context, log logr.Logger, cs *ssa.ChangeSet, rs *engine.ResourceSet) error { + for _, change := range cs.Entries { + log.Info(logger.ColorizeJoin(change)) + } + doneMsg := "" + if rs != nil && rs.Name != "" { + doneMsg = fmt.Sprintf("%s resources %s", rs.Name, logger.ColorizeReady("ready")) + } + return r.doWait(ctx, log, rs, "waiting for %d resource(s) to become ready...", doneMsg) +} + +func (r *InteractiveReconciler) WaitForTermination(ctx context.Context, log logr.Logger, cs *ssa.ChangeSet, _ *engine.ResourceSet) error { + for _, change := range cs.Entries { + log.Info(logger.ColorizeJoin(change)) + } + return r.doWaitForTermination(ctx, log, cs, "waiting for %d resource(s) to be finalized...") +} diff --git a/internal/reconciler/reconciler.go b/internal/reconciler/reconciler.go new file mode 100644 index 00000000..eaffdfb1 --- /dev/null +++ b/internal/reconciler/reconciler.go @@ -0,0 +1,253 @@ +/* +Copyright 2024 Stefan Prodan + +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 reconciler + +import ( + "context" + "fmt" + "time" + + "cuelang.org/go/cue" + "github.com/fluxcd/pkg/ssa" + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + + apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" + "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/runtime" +) + +func NewReconciler(log logr.Logger, opts *CommonOptions, timeout time.Duration) *Reconciler { + reconciler := &Reconciler{ + opts: opts, + currentObjects: []*unstructured.Unstructured{}, + staleObjects: []*unstructured.Unstructured{}, + applyOptions: runtime.ApplyOptions(opts.Force, timeout), + waitOptions: ssa.WaitOptions{ + Interval: 5 * time.Second, + Timeout: timeout, + FailFast: true, + }, + progressStartFn: func(msg string) interface{ Stop() } { + log.Info(msg) + return &noopProgressStopper{} + }, + } + reconciler.applyOptions.WaitInterval = reconciler.waitOptions.Interval + + return reconciler +} + +func (r *Reconciler) Init(ctx context.Context, builder *engine.ModuleBuilder, buildResult cue.Value, instance *engine.BundleInstance, rcg genericclioptions.RESTClientGetter) error { + finalValues, err := builder.GetDefaultValues() + if err != nil { + return fmt.Errorf("failed to extract values: %w", err) + } + + r.sets, err = builder.GetApplySets(buildResult) + if err != nil { + return fmt.Errorf("failed to extract objects: %w", err) + } + + for _, set := range r.sets { + r.currentObjects = append(r.currentObjects, set.Objects...) + } + + r.resourceManager, err = runtime.NewResourceManager(rcg) + if err != nil { + return err + } + + r.resourceManager.SetOwnerLabels(r.currentObjects, instance.Name, instance.Namespace) + + r.storageManager = runtime.NewStorageManager(r.resourceManager) + storedInstance, err := r.storageManager.Get(ctx, instance.Name, instance.Namespace) + if err == nil { + r.instanceExists = true + } + + isStandaloneInstance := instance.Bundle == "" + + if !r.opts.OverwriteOwnership && r.instanceExists && isStandaloneInstance { + if currentOwnerBundle := storedInstance.Labels[apiv1.BundleNameLabelKey]; currentOwnerBundle != "" { + return &InstanceOwnershipConflictErr{{ + InstanceName: instance.Name, + CurrentOwnerBundle: currentOwnerBundle, + }} + } + } + + r.instanceManager = runtime.NewInstanceManager(instance.Name, instance.Namespace, finalValues, instance.Module) + + if !isStandaloneInstance { + if r.instanceManager.Instance.Labels == nil { + r.instanceManager.Instance.Labels = make(map[string]string) + } + r.instanceManager.Instance.Labels[apiv1.BundleNameLabelKey] = instance.Bundle + } + + if err := r.instanceManager.AddObjects(r.currentObjects); err != nil { + return fmt.Errorf("adding objects to instance failed: %w", err) + } + + r.staleObjects, err = r.storageManager.GetStaleObjects(ctx, &r.instanceManager.Instance) + if err != nil { + return fmt.Errorf("getting stale objects failed: %w", err) + } + return nil +} + +func (r *Reconciler) ApplyInstance(ctx context.Context, log logr.Logger, builder *engine.ModuleBuilder, buildResult cue.Value, opts InteractiveOptions) error { + if !r.instanceExists { + if err := r.UpdateStoredInstance(ctx); err != nil { + return fmt.Errorf("instance init failed: %w", err) + } + } + + return kerrors.NewAggregate([]error{ + r.ApplyAllSets(ctx, log, r.Wait), + r.PostApplyUpdateInventory(ctx, builder, buildResult), + r.PostApplyPruneStaleObjects(ctx, log, r.WaitForTermination), + }) +} + +func (a *Reconciler) Wait(ctx context.Context, log logr.Logger, _ *ssa.ChangeSet, rs *engine.ResourceSet) error { + doneMsg := "" + if rs != nil && rs.Name != "" { + doneMsg = fmt.Sprintf("%s resources ready", rs.Name) + } + return a.doWait(ctx, log, rs, "waiting for %d resource(s) to become ready", doneMsg) +} + +func (r *Reconciler) doWait(_ context.Context, log logr.Logger, rs *engine.ResourceSet, progressMsgFmt string, doneMsg string) error { + if !r.opts.Wait { + return nil + } + progress := r.progressStartFn(fmt.Sprintf(progressMsgFmt, len(rs.Objects))) + err := r.resourceManager.Wait(rs.Objects, r.waitOptions) + progress.Stop() + if err != nil { + return err + } + if doneMsg != "" { + doneMsg = "resources are ready" + } + log.Info(doneMsg) + return nil +} + +func (r *Reconciler) WaitForTermination(ctx context.Context, log logr.Logger, cs *ssa.ChangeSet, _ *engine.ResourceSet) error { + return r.doWaitForTermination(ctx, log, cs, "waiting for %d resource(s) to be finalized") +} + +func (r *Reconciler) doWaitForTermination(_ context.Context, _ logr.Logger, cs *ssa.ChangeSet, progressMsgFmt string) error { + if !r.opts.Wait { + return nil + } + deletedObjects := runtime.SelectObjectsFromSet(cs, ssa.DeletedAction) + if len(deletedObjects) == 0 { + return nil + } + progress := r.progressStartFn(fmt.Sprintf(progressMsgFmt, len(deletedObjects))) + err := r.resourceManager.WaitForTermination(deletedObjects, r.waitOptions) + progress.Stop() + if err != nil { + return fmt.Errorf("waiting for termination failed: %w", err) + } + return nil +} + +func (r *Reconciler) ApplyAllSets(ctx context.Context, log logr.Logger, withChangeSet withChangeSetFunc) error { + if !r.instanceExists { + if err := r.UpdateStoredInstance(ctx); err != nil { + return fmt.Errorf("instance init failed: %w", err) + } + } + + multiSet := len(r.sets) > 1 + for s := range r.sets { + set := r.sets[s] + if multiSet { + log.Info(fmt.Sprintf("applying %s", set.Name)) + } + + cs, err := r.ApplyAllStaged(ctx, set) + if err != nil { + return err + } + + if withChangeSet != nil { + if err := withChangeSet(ctx, log, cs, &set); err != nil { + return err + } + } + } + return nil +} + +func (r *Reconciler) ApplyAllStaged(ctx context.Context, set engine.ResourceSet) (*ssa.ChangeSet, error) { + return r.resourceManager.ApplyAllStaged(ctx, set.Objects, r.applyOptions) +} + +func (r *Reconciler) PostApplyPruneStaleObjects(ctx context.Context, log logr.Logger, withChangeSet withChangeSetFunc) error { + if len(r.staleObjects) == 0 { + return nil + } + deleteOpts := runtime.DeleteOptions(r.Name(), r.Namespace()) + cs, err := r.resourceManager.DeleteAll(ctx, r.staleObjects, deleteOpts) + if err != nil { + return fmt.Errorf("pruning objects failed: %w", err) + } + if withChangeSet != nil { + if err := withChangeSet(ctx, log, cs, nil); err != nil { + return err + } + } + return nil +} + +func (r *Reconciler) PostApplyUpdateInventory(ctx context.Context, builder *engine.ModuleBuilder, buildResult cue.Value) error { + r.UpdateImages(builder, buildResult) + if err := r.UpdateStoredInstance(ctx); err != nil { + return fmt.Errorf("storing instance failed: %w", err) + } + return nil +} + +func (r *Reconciler) UpdateStoredInstance(ctx context.Context) error { + return r.storageManager.Apply(ctx, &r.instanceManager.Instance, true) +} + +func (r *Reconciler) UpdateImages(builder *engine.ModuleBuilder, buildResult cue.Value) { + if images, err := builder.GetContainerImages(buildResult); err == nil { + r.instanceManager.Instance.Images = images + } +} + +func (r *Reconciler) Name() string { return r.instanceManager.Instance.Name } + +func (r *Reconciler) Namespace() string { return r.instanceManager.Instance.Namespace } + +func (r *Reconciler) NamespaceExists(ctx context.Context) (bool, error) { + ok, err := r.storageManager.NamespaceExists(ctx, r.Namespace()) + if err != nil { + return false, fmt.Errorf("cannot determine if namespace %q already exists: %w", r.Namespace(), err) + } + return ok, nil +} diff --git a/internal/reconciler/types.go b/internal/reconciler/types.go new file mode 100644 index 00000000..41dafda9 --- /dev/null +++ b/internal/reconciler/types.go @@ -0,0 +1,97 @@ +/* +Copyright 2024 Stefan Prodan + +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 reconciler + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/fluxcd/pkg/ssa" + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/runtime" +) + +type CommonOptions struct { + Dir string + Wait bool + Force bool + OverwriteOwnership bool +} + +type InteractiveOptions struct { + DryRun bool + Diff bool + DiffOutput io.Writer + + ProgressStart func(string) interface{ Stop() } +} + +type Reconciler struct { + opts *CommonOptions + + instanceExists bool + + sets []engine.ResourceSet + + currentObjects, staleObjects []*unstructured.Unstructured + + storageManager *runtime.StorageManager + instanceManager *runtime.InstanceManager + resourceManager *ssa.ResourceManager + + applyOptions ssa.ApplyOptions + waitOptions ssa.WaitOptions + + progressStartFn func(string) interface{ Stop() } +} + +type InteractiveReconciler struct { + *Reconciler + *InteractiveOptions +} + +type noopProgressStopper struct{} + +func (*noopProgressStopper) Stop() {} + +type withChangeSetFunc func(context.Context, logr.Logger, *ssa.ChangeSet, *engine.ResourceSet) error + +type InstanceOwnershipConflict struct{ InstanceName, CurrentOwnerBundle string } +type InstanceOwnershipConflictErr []InstanceOwnershipConflict + +func (e *InstanceOwnershipConflictErr) Error() string { + s := &strings.Builder{} + s.WriteString("instance ownership conflict encountered. ") + s.WriteString("Conflict: ") + numConflicts := len(*e) + for i, c := range *e { + if c.CurrentOwnerBundle != "" { + s.WriteString(fmt.Sprintf("instance %q exists and is managed by another bundle %q", c.InstanceName, c.CurrentOwnerBundle)) + } else { + s.WriteString(fmt.Sprintf("instance %q exists and is not managed by any bundle", c.InstanceName)) + } + if numConflicts > 1 && i != numConflicts { + s.WriteString("; ") + } + } + return s.String() +}