diff --git a/cmd/timoni/bundle_status.go b/cmd/timoni/bundle_status.go new file mode 100644 index 00000000..efe650ff --- /dev/null +++ b/cmd/timoni/bundle_status.go @@ -0,0 +1,133 @@ +/* +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" + "errors" + "fmt" + + "cuelang.org/go/cue/cuecontext" + "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/cli-utils/pkg/kstatus/status" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/runtime" + + apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" +) + +var bundleStatusCmd = &cobra.Command{ + Use: "status [BUNDLE NAME]", + Short: "Displays the current status of Kubernetes resources managed by the bundle instances", + Example: ` # Show the status of the resources managed by a bundle + timoni bundle status -f bundle.cue + + # Show the status using a named bundle + timoni bundle status my-app +`, + RunE: runBundleStatusCmd, +} + +type bundleStatusFlags struct { + name string + filename string +} + +var bundleStatusArgs bundleStatusFlags + +func init() { + bundleStatusCmd.Flags().StringVarP(&bundleStatusArgs.filename, "file", "f", "", + "The local path to bundle.cue file.") + bundleCmd.AddCommand(bundleStatusCmd) +} + +func runBundleStatusCmd(cmd *cobra.Command, args []string) error { + if len(args) < 1 && bundleStatusArgs.filename == "" { + return fmt.Errorf("bundle name is required") + } + + switch { + case bundleStatusArgs.filename != "": + cuectx := cuecontext.New() + name, err := engine.ExtractStringFromFile(cuectx, bundleStatusArgs.filename, apiv1.BundleName.String()) + if err != nil { + return err + } + bundleStatusArgs.name = name + default: + bundleStatusArgs.name = args[0] + } + + rm, err := runtime.NewResourceManager(kubeconfigArgs) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) + defer cancel() + + sm := runtime.NewStorageManager(rm) + instances, err := sm.List(ctx, "", bundleStatusArgs.name) + if err != nil { + return err + } + + if len(instances) == 0 { + return fmt.Errorf("no instances found in bundle") + } + + for _, instance := range instances { + log := LoggerBundleInstance(ctx, bundleStatusArgs.name, instance.Name) + + log.Info(fmt.Sprintf("last applied %s", + colorizeSubject(instance.LastTransitionTime))) + log.Info(fmt.Sprintf("module %s", + colorizeSubject(instance.Module.Repository+":"+instance.Module.Version))) + log.Info(fmt.Sprintf("digest %s", + colorizeSubject(instance.Module.Digest))) + + im := runtime.InstanceManager{Instance: apiv1.Instance{Inventory: instance.Inventory}} + objects, err := im.ListObjects() + if err != nil { + return err + } + + for _, obj := range objects { + err = rm.Client().Get(ctx, client.ObjectKeyFromObject(obj), obj) + if err != nil { + if apierrors.IsNotFound(err) { + log.Error(err, colorizeJoin(obj, errors.New("NotFound"))) + continue + } + log.Error(err, colorizeJoin(obj, errors.New("Unknown"))) + continue + } + + res, err := status.Compute(obj) + if err != nil { + log.Error(err, colorizeJoin(obj, errors.New("Failed"))) + continue + } + log.Info(colorizeJoin(obj, res.Status, "-", res.Message)) + } + } + + return nil +} diff --git a/cmd/timoni/bundle_status_test.go b/cmd/timoni/bundle_status_test.go new file mode 100644 index 00000000..0c78709f --- /dev/null +++ b/cmd/timoni/bundle_status_test.go @@ -0,0 +1,115 @@ +/* +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" + "strings" + "testing" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Test_BundleStatus(t *testing.T) { + g := NewWithT(t) + + bundleName := "my-bundle" + modPath := "testdata/module" + namespace := rnd("my-namespace", 5) + modName := rnd("my-mod", 5) + modURL := fmt.Sprintf("%s/%s", dockerRegistry, modName) + modVer := "1.0.0" + + _, err := executeCommand(fmt.Sprintf( + "mod push %s oci://%s -v %s", + modPath, + modURL, + modVer, + )) + g.Expect(err).ToNot(HaveOccurred()) + + bundleData := fmt.Sprintf(` +bundle: { + apiVersion: "v1alpha1" + name: "%[1]s" + instances: { + frontend: { + module: { + url: "oci://%[2]s" + version: "%[3]s" + } + namespace: "%[4]s" + values: server: enabled: false + } + backend: { + module: { + url: "oci://%[2]s" + version: "%[3]s" + } + namespace: "%[4]s" + values: client: enabled: false + } + } +} +`, bundleName, modURL, modVer, namespace) + + _, err = executeCommandWithIn("bundle apply -f - -p main --wait", strings.NewReader(bundleData)) + g.Expect(err).ToNot(HaveOccurred()) + + t.Run("lists modules", func(t *testing.T) { + g := NewWithT(t) + + output, err := executeCommand(fmt.Sprintf("bundle status %s", bundleName)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(output).To(ContainSubstring(fmt.Sprintf("oci://%s:%s", modURL, modVer))) + g.Expect(output).To(ContainSubstring("digest sha256:")) + }) + + t.Run("lists ready resources", func(t *testing.T) { + g := NewWithT(t) + + output, err := executeCommand(fmt.Sprintf("bundle status %s", bundleName)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(output).To(ContainSubstring(fmt.Sprintf("ConfigMap/%s/frontend-client Current", namespace))) + g.Expect(output).To(ContainSubstring(fmt.Sprintf("ConfigMap/%s/backend-server Current", namespace))) + }) + + t.Run("lists not found resources", func(t *testing.T) { + g := NewWithT(t) + + cm := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "v1", + APIVersion: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "backend-server", + Namespace: namespace, + }, + } + err = envTestClient.Delete(context.Background(), cm) + g.Expect(err).ToNot(HaveOccurred()) + + output, err := executeCommand(fmt.Sprintf("bundle status %s", bundleName)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(output).To(ContainSubstring(fmt.Sprintf("ConfigMap/%s/frontend-client Current", namespace))) + g.Expect(output).To(ContainSubstring(fmt.Sprintf("ConfigMap/%s/backend-server NotFound", namespace))) + }) +} diff --git a/cmd/timoni/main_test.go b/cmd/timoni/main_test.go index 8d3dd56b..44cbd135 100644 --- a/cmd/timoni/main_test.go +++ b/cmd/timoni/main_test.go @@ -120,6 +120,7 @@ func resetCmdArgs() { applyArgs = applyFlags{} buildArgs = buildFlags{} deleteArgs = deleteFlags{} + statusArgs = statusFlags{} inspectModuleArgs = inspectModuleFlags{} inspectResourcesArgs = inspectResourcesFlags{} inspectValuesArgs = inspectValuesFlags{} diff --git a/cmd/timoni/status.go b/cmd/timoni/status.go index 6c18834b..7556771d 100644 --- a/cmd/timoni/status.go +++ b/cmd/timoni/status.go @@ -35,9 +35,9 @@ var statusCmd = &cobra.Command{ Use: "status [INSTANCE NAME]", Short: "Displays the current status of Kubernetes resources managed by an instance", Example: ` # Show the current status of the managed resources - timoni status -n apps app + timoni -n apps status app `, - RunE: runstatusCmd, + RunE: runStatusCmd, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { switch len(args) { case 0: @@ -58,7 +58,7 @@ func init() { rootCmd.AddCommand(statusCmd) } -func runstatusCmd(cmd *cobra.Command, args []string) error { +func runStatusCmd(cmd *cobra.Command, args []string) error { if len(args) < 1 { return fmt.Errorf("instance name is required") } @@ -94,13 +94,13 @@ func runstatusCmd(cmd *cobra.Command, args []string) error { log.Error(err, colorizeJoin(obj, errors.New("NotFound"))) continue } - log.Error(err, colorizeJoin(obj, errors.New("query failed"))) + log.Error(err, colorizeJoin(obj, errors.New("Unknown"))) continue } res, err := status.Compute(obj) if err != nil { - log.Error(err, colorizeJoin(obj, errors.New("statusFailed failed"))) + log.Error(err, colorizeJoin(obj, errors.New("Failed"))) continue } log.Info(colorizeJoin(obj, res.Status, "-", res.Message)) diff --git a/cmd/timoni/status_test.go b/cmd/timoni/status_test.go new file mode 100644 index 00000000..fcbf87de --- /dev/null +++ b/cmd/timoni/status_test.go @@ -0,0 +1,98 @@ +/* +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" + "strings" + "testing" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestInstanceStatus(t *testing.T) { + g := NewWithT(t) + modPath := "testdata/module" + modURL := fmt.Sprintf("oci://%s/%s", dockerRegistry, rnd("my-status", 5)) + modVer := "1.0.0" + name := rnd("my-instance", 5) + namespace := rnd("my-namespace", 5) + + // Package the module as an OCI artifact and push it to registry + _, err := executeCommand(fmt.Sprintf( + "mod push %s %s -v %s", + modPath, + modURL, + modVer, + )) + g.Expect(err).ToNot(HaveOccurred()) + + // Install the module from the registry + _, err = executeCommandWithIn(fmt.Sprintf( + "apply -n %s %s %s -v %s -p main --wait -f-", + namespace, + name, + modURL, + modVer, + ), strings.NewReader(`values: domain: "app.internal"`)) + g.Expect(err).ToNot(HaveOccurred()) + + t.Run("ready status", func(t *testing.T) { + g := NewWithT(t) + output, err := executeCommand(fmt.Sprintf( + "status -n %s %s", + namespace, + name, + )) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify status output contains the expected resources + g.Expect(output).To(ContainSubstring(fmt.Sprintf("ConfigMap/%s/%s-client Current", namespace, name))) + g.Expect(output).To(ContainSubstring(fmt.Sprintf("ConfigMap/%s/%s-server Current", namespace, name))) + }) + + t.Run("not found status", func(t *testing.T) { + g := NewWithT(t) + + cm := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "v1", + APIVersion: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-server", + Namespace: namespace, + }, + } + err = envTestClient.Delete(context.Background(), cm) + g.Expect(err).ToNot(HaveOccurred()) + + output, err := executeCommand(fmt.Sprintf( + "status -n %s %s", + namespace, + name, + )) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify status output contains the expected resources + g.Expect(output).To(ContainSubstring(fmt.Sprintf("ConfigMap/%s/%s-client Current", namespace, name))) + g.Expect(output).To(ContainSubstring(fmt.Sprintf("ConfigMap/%s/%s-server NotFound", namespace, name))) + }) +} diff --git a/docs/bundle.md b/docs/bundle.md index c83a4095..6ad8e983 100644 --- a/docs/bundle.md +++ b/docs/bundle.md @@ -421,33 +421,20 @@ Build the Bundle and print the resulting Kubernetes resources for all the Bundle serviceAccountName: podinfo ``` -List the instances in Bundle `podinfo` across all namespaces: - -=== "command" - - ```sh - timoni list --bundle podinfo -A - ``` - -=== "output" - - ```text - NAME NAMESPACE MODULE VERSION LAST APPLIED BUNDLE - podinfo podinfo oci://ghcr.io/stefanprodan/modules/podinfo 6.5.0 2023-09-10T16:20:07Z podinfo - redis podinfo oci://ghcr.io/stefanprodan/modules/redis 7.2.1 2023-09-10T16:20:00Z podinfo - ``` - -List the instance resources and their rollout status: +List the managed resources from a bundle and their rollout status: === "command" ```sh - timoni status redis -n podinfo + timoni bundle status -f podinfo.bundle.cue ``` === "output" ```text + last applied 2023-10-08T20:21:19Z + module oci://ghcr.io/stefanprodan/modules/redis:7.2.1 + digest: sha256:9935e0b63db8a56c279d7722ced7683d5692a50815f715e336663509889b7e21 ServiceAccount/podinfo/redis Current Resource is current ConfigMap/podinfo/redis Current Resource is always ready Service/podinfo/redis Current Service is ready @@ -455,23 +442,29 @@ List the instance resources and their rollout status: Deployment/podinfo/redis-master Current Deployment is available. Replicas: 1 Deployment/podinfo/redis-replica Current Deployment is available. Replicas: 1 PersistentVolumeClaim/podinfo/redis-master Current PVC is Bound + + last applied 2023-10-08T20:21:19Z + module oci://ghcr.io/stefanprodan/modules/podinfo:6.5.0 + digest: sha256:d5cb5a8c625045ee1da01d629a2d46cd361f2b6472b8bd07bcabbd0012bc574b + ServiceAccount/podinfo/podinfo Current Resource is always ready + Service/podinfo/podinfo Current Service is ready + Deployment/podinfo/podinfo Current Deployment is available. Replicas: 1 ``` -See an instance module reference and its digest: +List the instances in Bundle `podinfo` across all namespaces: === "command" ```sh - timoni inspect module redis -n podinfo + timoni list --bundle podinfo -A ``` === "output" ```text - name: timoni.sh/redis - repository: oci://ghcr.io/stefanprodan/modules/redis - version: 7.2.1 - digest: sha256:9935e0b63db8a56c279d7722ced7683d5692a50815f715e336663509889b7e21 + NAME NAMESPACE MODULE VERSION LAST APPLIED BUNDLE + podinfo podinfo oci://ghcr.io/stefanprodan/modules/podinfo 6.5.0 2023-09-10T16:20:07Z podinfo + redis podinfo oci://ghcr.io/stefanprodan/modules/redis 7.2.1 2023-09-10T16:20:00Z podinfo ``` ## Writing a Bundle spec @@ -686,6 +679,24 @@ Example: timoni bundle apply --overwrite-ownership -f bundle.cue ``` +### Status + +To list the current status of the managed resources for each +instance including the last applied date, the module url and digest, +you can use the `timoni bundle status`. + +Example using the bundle name: + +```shell +timoni bundle status my-bundle +``` + +Example using a bundle CUE file: + +```shell +timoni bundle status -f bundle.cue +``` + ### Build To build the instances defined in a Bundle file and print the resulting Kubernetes resources, diff --git a/mkdocs.yml b/mkdocs.yml index abf08f3f..25156b39 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -97,6 +97,7 @@ nav: - Build: cmd/timoni_bundle_build.md - Delete: cmd/timoni_bundle_delete.md - Lint: cmd/timoni_bundle_lint.md + - Status: cmd/timoni_bundle_status.md - Registries: - Login: cmd/timoni_registry_login.md - Logout: cmd/timoni_registry_logout.md