From 0cc52ef33859fe1c7afbb2d4c7af8990d3622eca Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Sat, 21 Oct 2023 01:57:05 +0300 Subject: [PATCH 1/6] Add container image refs to instance metadata Signed-off-by: Stefan Prodan --- api/v1alpha1/instance.go | 7 +++++++ api/v1alpha1/module.go | 9 +++++++++ api/v1alpha1/zz_generated.deepcopy.go | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/api/v1alpha1/instance.go b/api/v1alpha1/instance.go index 32716874..16c71fc5 100644 --- a/api/v1alpha1/instance.go +++ b/api/v1alpha1/instance.go @@ -25,6 +25,9 @@ const ( // InstanceSelector is the CUE path for the Timoni's instance. InstanceSelector Selector = "timoni.instance" + // ConfigValuesSelector is the CUE path for the Timoni's instance config. + ConfigValuesSelector Selector = "timoni.instance.config" + // ApplySelector is the CUE path for the Timoni's apply resource sets. ApplySelector Selector = "timoni.apply" @@ -63,4 +66,8 @@ type Instance struct { // Inventory contains the list of Kubernetes resource object references. // +optional Inventory *ResourceInventory `json:"inventory,omitempty"` + + // Images contains the list of container image references. + // +optional + Images []string `json:"images,omitempty"` } diff --git a/api/v1alpha1/module.go b/api/v1alpha1/module.go index ad4c5639..146c91c4 100644 --- a/api/v1alpha1/module.go +++ b/api/v1alpha1/module.go @@ -36,3 +36,12 @@ type ModuleReference struct { // Digest of the OCI artifact in the format ':'. Digest string `json:"digest"` } + +// ImageReference contains the information necessary to locate +// a container's OCI artifact in the registry. +type ImageReference struct { + Repository string `json:"repository"` + Tag string `json:"tag"` + Digest string `json:"digest"` + Reference string `json:"reference"` +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ef2a2c84..35a26eca 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -37,6 +37,21 @@ func (in *ArtifactReference) DeepCopy() *ArtifactReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageReference) DeepCopyInto(out *ImageReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageReference. +func (in *ImageReference) DeepCopy() *ImageReference { + if in == nil { + return nil + } + out := new(ImageReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Instance) DeepCopyInto(out *Instance) { *out = *in @@ -48,6 +63,11 @@ func (in *Instance) DeepCopyInto(out *Instance) { *out = new(ResourceInventory) (*in).DeepCopyInto(*out) } + if in.Images != nil { + in, out := &in.Images, &out.Images + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Instance. From 7a2a5e29894e684eebf1cd871f4e73bad7c1c703 Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Sat, 21 Oct 2023 01:57:29 +0300 Subject: [PATCH 2/6] Extract container image refs at build-time Signed-off-by: Stefan Prodan --- internal/engine/module_builder.go | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/engine/module_builder.go b/internal/engine/module_builder.go index 7f1dd4bf..187dae1f 100644 --- a/internal/engine/module_builder.go +++ b/internal/engine/module_builder.go @@ -20,6 +20,8 @@ import ( "fmt" "os" "path/filepath" + "reflect" + "slices" "cuelang.org/go/cue" "cuelang.org/go/cue/ast" @@ -267,3 +269,38 @@ func (b *ModuleBuilder) GetModuleName() (string, error) { return mod, nil } + +// GetContainerImages extracts the container images referenced in the instance config values. +func (b *ModuleBuilder) GetContainerImages(value cue.Value) ([]string, error) { + cfgValues := value.LookupPath(cue.ParsePath(apiv1.ConfigValuesSelector.String())) + if cfgValues.Err() != nil { + return nil, fmt.Errorf("lookup %s failed: %w", apiv1.ConfigValuesSelector, cfgValues.Err()) + } + + var images []string + imgExtract := func(v cue.Value) bool { + switch v.IncompleteKind() { + case cue.StructKind: + var img apiv1.ImageReference + imgVal := reflect.ValueOf(img) + for i := 0; i < imgVal.Type().NumField(); i++ { + if tag, ok := imgVal.Type().Field(i).Tag.Lookup("json"); ok { + if !v.LookupPath(cue.ParsePath(tag)).Exists() { + return true + } + } + } + if err := v.Decode(&img); err == nil { + images = append(images, img.Reference) + } + } + return true + } + + cfgValues.Walk(imgExtract, nil) + + images = slices.Compact(images) + slices.Sort(images) + + return images, nil +} From 18f7aab7ce954a071e8496fe9f1ed0bbcaaccdd0 Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Sat, 21 Oct 2023 01:58:42 +0300 Subject: [PATCH 3/6] Add container image refs to instance storage Signed-off-by: Stefan Prodan --- cmd/timoni/apply.go | 4 ++++ cmd/timoni/bundle_apply.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/cmd/timoni/apply.go b/cmd/timoni/apply.go index cfff84d6..34bd2b2b 100644 --- a/cmd/timoni/apply.go +++ b/cmd/timoni/apply.go @@ -326,6 +326,10 @@ func runApplyCmd(cmd *cobra.Command, args []string) error { } } + 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) } diff --git a/cmd/timoni/bundle_apply.go b/cmd/timoni/bundle_apply.go index 1c044ff5..8e887465 100644 --- a/cmd/timoni/bundle_apply.go +++ b/cmd/timoni/bundle_apply.go @@ -405,6 +405,10 @@ func applyBundleInstance(ctx context.Context, cuectx *cue.Context, instance *eng } } + 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) } From 36e41c7abfa1ee940b5c24e4a542508d510aea4d Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Sat, 21 Oct 2023 01:59:42 +0300 Subject: [PATCH 4/6] List container images referenced by an instance Signed-off-by: Stefan Prodan --- cmd/timoni/bundle_status.go | 6 ++++++ cmd/timoni/mod_vet.go | 17 +++++++++++++---- cmd/timoni/status.go | 16 ++++++++++++++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/cmd/timoni/bundle_status.go b/cmd/timoni/bundle_status.go index efe650ff..83df2411 100644 --- a/cmd/timoni/bundle_status.go +++ b/cmd/timoni/bundle_status.go @@ -103,7 +103,13 @@ func runBundleStatusCmd(cmd *cobra.Command, args []string) error { log.Info(fmt.Sprintf("digest %s", colorizeSubject(instance.Module.Digest))) + for _, image := range instance.Images { + log.Info(fmt.Sprintf("container image %s", + colorizeSubject(image))) + } + im := runtime.InstanceManager{Instance: apiv1.Instance{Inventory: instance.Inventory}} + objects, err := im.ListObjects() if err != nil { return err diff --git a/cmd/timoni/mod_vet.go b/cmd/timoni/mod_vet.go index 91946a8b..3efb62ac 100644 --- a/cmd/timoni/mod_vet.go +++ b/cmd/timoni/mod_vet.go @@ -38,11 +38,11 @@ var vetModCmd = &cobra.Command{ Aliases: []string{"lint"}, Short: "Validate a local module", Long: `The vet command builds the local module and validates the resulting Kubernetes objects.`, - Example: ` # validate module in the current path + Example: ` # validate module using default values timoni mod vet - # validate module using default values instead of debug_values.cue - timoni mod vet ./path/to/module --debug=false + # validate module using debug values + timoni mod vet ./path/to/module --debug `, RunE: runVetModCmd, } @@ -57,7 +57,7 @@ var vetModArgs vetModFlags func init() { vetModCmd.Flags().VarP(&vetModArgs.pkg, vetModArgs.pkg.Type(), vetModArgs.pkg.Shorthand(), vetModArgs.pkg.Description()) - vetModCmd.Flags().BoolVar(&vetModArgs.debug, "debug", true, + vetModCmd.Flags().BoolVar(&vetModArgs.debug, "debug", false, "Use debug_values.cue if found in the module root instead of the default values.") modCmd.AddCommand(vetModCmd) } @@ -157,6 +157,15 @@ func runVetModCmd(cmd *cobra.Command, args []string) error { log.Info(fmt.Sprintf("%s valid resource", colorizeSubject(ssa.FmtUnstructured(object)))) } + images, err := builder.GetContainerImages(buildResult) + if err != nil { + return fmt.Errorf("failed to extract images: %w", err) + } + + for _, image := range images { + log.Info(fmt.Sprintf("%s valid image", colorizeSubject(image))) + } + log.Info(fmt.Sprintf("%s valid module", colorizeSubject(mod.Name))) return nil diff --git a/cmd/timoni/status.go b/cmd/timoni/status.go index 7556771d..097603e4 100644 --- a/cmd/timoni/status.go +++ b/cmd/timoni/status.go @@ -75,12 +75,24 @@ func runStatusCmd(cmd *cobra.Command, args []string) error { defer cancel() st := runtime.NewStorageManager(rm) - inst, err := st.Get(ctx, statusArgs.name, *kubeconfigArgs.Namespace) + instance, err := st.Get(ctx, statusArgs.name, *kubeconfigArgs.Namespace) if err != nil { return err } - tm := runtime.InstanceManager{Instance: apiv1.Instance{Inventory: inst.Inventory}} + 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))) + + for _, image := range instance.Images { + log.Info(fmt.Sprintf("container image %s", + colorizeSubject(image))) + } + + tm := runtime.InstanceManager{Instance: apiv1.Instance{Inventory: instance.Inventory}} objects, err := tm.ListObjects() if err != nil { From aecd74b0b2bec32a45f249be5fc492ce4f65406c Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Sat, 21 Oct 2023 10:23:34 +0300 Subject: [PATCH 5/6] Add tests for container image listing Signed-off-by: Stefan Prodan --- cmd/timoni/bundle_status_test.go | 60 ++++++++++++++++++- cmd/timoni/mod_vet_test.go | 3 +- .../pkg/timoni.sh/core/v1alpha1/image.cue | 44 ++++++++++++++ .../pkg/timoni.sh/core/v1alpha1/metadata.cue | 36 +++++++++++ .../testdata/module/templates/config.cue | 33 +++++----- 5 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/image.cue create mode 100644 cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/metadata.cue diff --git a/cmd/timoni/bundle_status_test.go b/cmd/timoni/bundle_status_test.go index 0c78709f..be3c8be8 100644 --- a/cmd/timoni/bundle_status_test.go +++ b/cmd/timoni/bundle_status_test.go @@ -30,7 +30,7 @@ import ( func Test_BundleStatus(t *testing.T) { g := NewWithT(t) - bundleName := "my-bundle" + bundleName := rnd("my-bundle", 5) modPath := "testdata/module" namespace := rnd("my-namespace", 5) modName := rnd("my-mod", 5) @@ -112,4 +112,62 @@ bundle: { 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))) }) + + t.Run("fails for deleted bundle", func(t *testing.T) { + g := NewWithT(t) + + _, err := executeCommand(fmt.Sprintf("bundle delete %s --wait", bundleName)) + g.Expect(err).ToNot(HaveOccurred()) + + _, err = executeCommand(fmt.Sprintf("bundle status %s", bundleName)) + g.Expect(err).To(HaveOccurred()) + }) +} + +func Test_BundleStatus_Images(t *testing.T) { + g := NewWithT(t) + + bundleName := rnd("my-bundle", 5) + 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: { + timoni: { + module: { + url: "oci://%[2]s" + version: "%[3]s" + } + namespace: "%[4]s" + values: client: image: digest: "" + } + } +} +`, bundleName, modURL, modVer, namespace) + + _, err = executeCommandWithIn("bundle apply -f - -p main --wait", strings.NewReader(bundleData)) + g.Expect(err).ToNot(HaveOccurred()) + + t.Run("lists images", 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("timoni:latest-dev")) + g.Expect(output).ToNot(ContainSubstring("timoni:latest-dev@sha")) + }) } diff --git a/cmd/timoni/mod_vet_test.go b/cmd/timoni/mod_vet_test.go index 736e5186..85187167 100644 --- a/cmd/timoni/mod_vet_test.go +++ b/cmd/timoni/mod_vet_test.go @@ -34,7 +34,8 @@ func TestModVet(t *testing.T) { )) g.Expect(err).ToNot(HaveOccurred()) - g.Expect(output).To(ContainSubstring("valid")) + g.Expect(output).To(ContainSubstring("timoni:latest-dev@sha256:")) + g.Expect(output).To(ContainSubstring("timoni.sh/test valid")) }) t.Run("fails to vet with undefined package", func(t *testing.T) { diff --git a/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/image.cue b/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/image.cue new file mode 100644 index 00000000..3c6b93c5 --- /dev/null +++ b/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/image.cue @@ -0,0 +1,44 @@ +// Copyright 2023 Stefan Prodan +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import "strings" + +// Image defines the schema for OCI image reference used in Kubernetes PodSpec container image. +#Image: { + + // Repository is the address of a container registry repository. + // An image repository is made up of slash-separated name components, optionally + // prefixed by a registry hostname and port in the format [HOST[:PORT_NUMBER]/]PATH. + repository!: string + + // Tag identifies an image in the repository. + // A tag name may contain lowercase and uppercase characters, digits, underscores, periods and dashes. + // A tag name may not start with a period or a dash and may contain a maximum of 128 characters. + tag!: string & strings.MaxRunes(128) + + // Digest uniquely and immutably identifies an image in the repository. + // Spec: https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests. + digest!: string + + // Reference is the image address computed from repository, tag and digest + // in the format [REPOSITORY]:[TAG]@[DIGEST]. + reference: string + + if digest != "" && tag != "" { + reference: "\(repository):\(tag)@\(digest)" + } + + if digest != "" && tag == "" { + reference: "\(repository)@\(digest)" + } + + if digest == "" && tag != "" { + reference: "\(repository):\(tag)" + } + + if digest == "" && tag == "" { + reference: "\(repository):latest" + } +} diff --git a/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/metadata.cue b/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/metadata.cue new file mode 100644 index 00000000..2b6cfe88 --- /dev/null +++ b/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/metadata.cue @@ -0,0 +1,36 @@ +// Copyright 2023 Stefan Prodan +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import "strings" + +// Metadata defines the schema for Kubernetes object metadata. +#Metadata: { + // Version should be in the strict semver format. Is required when creating resources. + #Version!: string & strings.MaxRunes(63) + + // Name must be unique within a namespace. Is required when creating resources. + // Name is primarily intended for creation idempotence and configuration definition. + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names + name!: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63) + + // Namespace defines the space within which each name must be unique. + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces + namespace!: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63) + + // Annotations is an unstructured key value map stored with a resource that may be + // set to store and retrieve arbitrary metadata. + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations + annotations?: {[string & =~"^(([A-Za-z0-9][-A-Za-z0-9_./]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)]: string} + + // Map of string keys and values that can be used to organize and categorize (scope and select) objects. + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels + labels: {[string & =~"^(([A-Za-z0-9][-A-Za-z0-9_./]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)]: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)} + + // Standard Kubernetes labels: app name and version. + labels: { + "app.kubernetes.io/name": name + "app.kubernetes.io/version": #Version + } +} diff --git a/cmd/timoni/testdata/module/templates/config.cue b/cmd/timoni/testdata/module/templates/config.cue index 77745134..765d58b6 100644 --- a/cmd/timoni/testdata/module/templates/config.cue +++ b/cmd/timoni/testdata/module/templates/config.cue @@ -1,28 +1,33 @@ package templates +import ( + timoniv1 "timoni.sh/core/v1alpha1" +) + #Config: { - team!: string + moduleVersion!: string + kubeVersion!: string - metadata: { - name: *"test" | string - namespace: *"default" | string - labels: *{ - "app.kubernetes.io/name": metadata.name - "app.kubernetes.io/version": moduleVersion - "app.kubernetes.io/kube": kubeVersion - "app.kubernetes.io/team": team - } | {[ string]: string} - annotations?: {[ string]: string} + metadata: timoniv1.#Metadata & {#Version: moduleVersion} + metadata: labels: { + "app.kubernetes.io/kube": kubeVersion + "app.kubernetes.io/team": team } - moduleVersion: string - kubeVersion: string - client: enabled: *true | bool + + client: image: timoniv1.#Image & { + repository: *"cgr.dev/chainguard/timoni" | string + tag: *"latest-dev" | string + digest: *"sha256:b49fbaac0eedc22c1cfcd26684707179cccbed0df205171bae3e1bae61326a10" | string + } + server: enabled: *true | bool domain: *"example.internal" | string ns: enabled: *false | bool + + team!: string } #Instance: { From 0dd552d3d76b984bfa038bd2741bb79d401ff1b8 Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Sat, 21 Oct 2023 11:17:06 +0300 Subject: [PATCH 6/6] Report images without digests Signed-off-by: Stefan Prodan --- cmd/timoni/log.go | 8 ++++++++ cmd/timoni/mod_vet.go | 21 ++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/cmd/timoni/log.go b/cmd/timoni/log.go index 4d77dec4..bfc6582c 100644 --- a/cmd/timoni/log.go +++ b/cmd/timoni/log.go @@ -132,6 +132,14 @@ func colorizeSubject(subject string) string { return color.CyanString(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) } diff --git a/cmd/timoni/mod_vet.go b/cmd/timoni/mod_vet.go index 3efb62ac..c58c8501 100644 --- a/cmd/timoni/mod_vet.go +++ b/cmd/timoni/mod_vet.go @@ -23,7 +23,9 @@ import ( "path" "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/pkg/strings" "github.com/fluxcd/pkg/ssa" + "github.com/google/go-containerregistry/pkg/name" cp "github.com/otiai10/copy" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -154,7 +156,8 @@ func runVetModCmd(cmd *cobra.Command, args []string) error { } for _, object := range objects { - log.Info(fmt.Sprintf("%s valid resource", colorizeSubject(ssa.FmtUnstructured(object)))) + log.Info(fmt.Sprintf("%s %s", + colorizeSubject(ssa.FmtUnstructured(object)), colorizeInfo("valid resource"))) } images, err := builder.GetContainerImages(buildResult) @@ -163,10 +166,22 @@ func runVetModCmd(cmd *cobra.Command, args []string) error { } for _, image := range images { - log.Info(fmt.Sprintf("%s valid image", colorizeSubject(image))) + if _, err := name.ParseReference(image); err != nil { + log.Error(err, "invalid image") + continue + } + + if !strings.Contains(image, "@sha") { + log.Info(fmt.Sprintf("%s %s", + colorizeSubject(image), colorizeWarning("valid image (digest missing)"))) + } else { + log.Info(fmt.Sprintf("%s %s", + colorizeSubject(image), colorizeInfo("valid image"))) + } } - log.Info(fmt.Sprintf("%s valid module", colorizeSubject(mod.Name))) + log.Info(fmt.Sprintf("%s %s", + colorizeSubject(mod.Name), colorizeInfo("valid module"))) return nil }