Skip to content

Commit

Permalink
Merge pull request #223 from stefanprodan/containers-inventory
Browse files Browse the repository at this point in the history
Inventorying of container images referenced by a modules
  • Loading branch information
stefanprodan authored Oct 21, 2023
2 parents 929e84a + 0dd552d commit b73264c
Show file tree
Hide file tree
Showing 15 changed files with 299 additions and 24 deletions.
7 changes: 7 additions & 0 deletions api/v1alpha1/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"`
}
9 changes: 9 additions & 0 deletions api/v1alpha1/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,12 @@ type ModuleReference struct {
// Digest of the OCI artifact in the format '<sha-type>:<hex>'.
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"`
}
20 changes: 20 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions cmd/timoni/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/timoni/bundle_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/timoni/bundle_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 59 additions & 1 deletion cmd/timoni/bundle_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
})
}
8 changes: 8 additions & 0 deletions cmd/timoni/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
36 changes: 30 additions & 6 deletions cmd/timoni/mod_vet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -38,11 +40,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,
}
Expand All @@ -57,7 +59,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)
}
Expand Down Expand Up @@ -154,10 +156,32 @@ 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")))
}

log.Info(fmt.Sprintf("%s valid module", colorizeSubject(mod.Name)))
images, err := builder.GetContainerImages(buildResult)
if err != nil {
return fmt.Errorf("failed to extract images: %w", err)
}

for _, image := range images {
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 %s",
colorizeSubject(mod.Name), colorizeInfo("valid module")))

return nil
}
3 changes: 2 additions & 1 deletion cmd/timoni/mod_vet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
16 changes: 14 additions & 2 deletions cmd/timoni/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit b73264c

Please sign in to comment.