From 25fc4f11ce761a6a36305b2a695cc709abff9ef8 Mon Sep 17 00:00:00 2001 From: Ramon Quitales Date: Tue, 1 Feb 2022 18:32:41 +0000 Subject: [PATCH] feat: enable writing inventory to resourcegroup object This commit enables `kpt live init` to write the package inventory information to a separate resourcegroup file. This enables kpt to allow inputs from other hydration tools. This feature is currently being feature gated, and not exposed to users. A future commit/PR will enable this once all `migrate`, `apply` and `detroy` functionality exists. --- internal/cmdliveinit/cmdliveinit.go | 149 +++++++++++++++++++++++ internal/cmdliveinit/cmdliveinit_test.go | 99 ++++++++++++++- internal/errors/resolver/live.go | 28 +++++ internal/pkg/pkg.go | 66 ++++++++++ pkg/api/resourcegroup/v1alpha1/types.go | 3 + 5 files changed, 340 insertions(+), 5 deletions(-) diff --git a/internal/cmdliveinit/cmdliveinit.go b/internal/cmdliveinit/cmdliveinit.go index c16792c4c5..6203dbfcc5 100644 --- a/internal/cmdliveinit/cmdliveinit.go +++ b/internal/cmdliveinit/cmdliveinit.go @@ -6,8 +6,11 @@ package cmdliveinit import ( "context" "crypto/sha1" + goerrors "errors" "fmt" + "io/ioutil" "os" + "path/filepath" "strconv" "strings" "time" @@ -16,14 +19,17 @@ import ( "github.com/GoogleContainerTools/kpt/internal/errors" "github.com/GoogleContainerTools/kpt/internal/pkg" "github.com/GoogleContainerTools/kpt/internal/printer" + "github.com/GoogleContainerTools/kpt/internal/types" "github.com/GoogleContainerTools/kpt/internal/util/attribution" kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1" + rgfilev1alpha1 "github.com/GoogleContainerTools/kpt/pkg/api/resourcegroup/v1alpha1" "github.com/GoogleContainerTools/kpt/pkg/kptfile/kptfileutil" "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" cmdutil "k8s.io/kubectl/pkg/cmd/util" "sigs.k8s.io/cli-utils/pkg/common" "sigs.k8s.io/cli-utils/pkg/config" + "sigs.k8s.io/kustomize/kyaml/yaml" ) const defaultInventoryName = "inventory" @@ -36,6 +42,24 @@ func (i *InvExistsError) Error() string { return "inventory information already set for package" } +// InvInRGExistsError defines new error when the inventory +// values have already been set on the ResourceGroup file and we will warn +// the user to migrate rather than init. This is part of kpt live STDIN work. +type InvInRGExistsError struct{} + +func (i *InvInRGExistsError) Error() string { + return "inventory information already set for package" +} + +// InvInKfExistsError defines new error when the inventory +// values have already been set on the Kptfile and we will warn +// the user to migrate rather than init. This is part of kpt live STDIN work. +type InvInKfExistsError struct{} + +func (i *InvInKfExistsError) Error() string { + return "inventory information already set within Kptfile for package" +} + func NewRunner(ctx context.Context, factory cmdutil.Factory, ioStreams genericclioptions.IOStreams) *Runner { r := &Runner{ @@ -74,6 +98,7 @@ type Runner struct { Force bool // Set inventory values even if already set in Kptfile Name string // Inventory object name namespace string // Inventory object namespace + RGFile string // resourcegroup object filepath InventoryID string // Inventory object unique identifier label Quiet bool // Output message during initialization } @@ -105,6 +130,7 @@ func (r *Runner) runE(_ *cobra.Command, args []string) error { Quiet: r.Quiet, Name: r.Name, InventoryID: r.InventoryID, + RGFileName: r.RGFile, Force: r.Force, }).Run(r.ctx) if err != nil { @@ -122,12 +148,22 @@ type ConfigureInventoryInfo struct { Name string InventoryID string + RGFileName string Force bool } // Run updates the inventory info in the package given by the Path. func (c *ConfigureInventoryInfo) Run(ctx context.Context) error { + // Use ResourceGroup file for inventory logic if the resourcegroup file + // is set directly. For this feature gate, the resourcegroup must be directly set + // through our tests since we are not exposing this through the command surface as a + // flag, currently. When we promote this, the resourcegroup filename can be empty and + // the default filename value will be inferred/used. + if c.RGFileName != "" { + return c.runLiveInitWithRGFile(ctx) + } + const op errors.Op = "cmdliveinit.Run" pr := printer.FromContextOrDie(ctx) @@ -182,6 +218,119 @@ func (c *ConfigureInventoryInfo) Run(ctx context.Context) error { return nil } +// func runLiveInitWithRGFile is a modified version of ConfigureInventoryInfo.Run that stores the +// package inventory information in a separate resourcegroup file. The logic for this is branched into +// a separate function to enable feature gating. +func (c *ConfigureInventoryInfo) runLiveInitWithRGFile(ctx context.Context) error { + const op errors.Op = "cmdliveinit.runLiveInitWithRGFile" + pr := printer.FromContextOrDie(ctx) + + namespace, err := config.FindNamespace(c.Factory.ToRawKubeConfigLoader(), c.Pkg.UniquePath.String()) + if err != nil { + return errors.E(op, c.Pkg.UniquePath, err) + } + namespace = strings.TrimSpace(namespace) + if !c.Quiet { + pr.Printf("initializing ResourceGroup inventory info (namespace: %s)...", namespace) + } + + // Autogenerate the name if it is not provided through the flag. + if c.Name == "" { + randomSuffix := common.RandomStr() + c.Name = fmt.Sprintf("%s-%s", defaultInventoryName, randomSuffix) + } + + // Finally, create a ResourceGroup containing the inventory information. + err = createRGFile(c.Pkg, &kptfilev1.Inventory{ + Namespace: namespace, + Name: c.Name, + InventoryID: c.InventoryID, + }, c.RGFileName, c.Force) + if !c.Quiet { + if err == nil { + pr.Printf("success\n") + } else { + pr.Printf("failed\n") + } + } + if err != nil { + return errors.E(op, c.Pkg.UniquePath, err) + } + // add metrics annotation to package resources to track the usage as the resources + // will be applied using kpt live group + at := attribution.Attributor{PackagePaths: []string{c.Pkg.UniquePath.String()}, CmdGroup: "live"} + at.Process() + return nil +} + +// createRGFile fills in the inventory object values into the resourcegroup object and writes to file storage. +func createRGFile(p *pkg.Pkg, inv *kptfilev1.Inventory, filename string, force bool) error { + const op errors.Op = "cmdliveinit.createRGFile" + // Read the resourcegroup object io io.dir + rg, err := p.ReadRGFile(filename) + if err != nil && !goerrors.Is(err, os.ErrNotExist) { + return errors.E(op, p.UniquePath, err) + } + + // Read the Kptfile to ensure that inventory information is not in Kptfile either. + kf, err := p.Kptfile() + if err != nil { + return errors.E(op, p.UniquePath, err) + } + // Validate the inventory values don't exist in Kptfile. + isEmpty := kptfileInventoryEmpty(kf.Inventory) + if !isEmpty && !force { + return errors.E(op, p.UniquePath, &InvInKfExistsError{}) + } + // Set the Kptfile inventory to be nil if we force write to resourcegroup instead. + kf.Inventory = nil + + // Validate the inventory values don't already exist in Resourcegroup. + if rg != nil && !force { + return errors.E(op, p.UniquePath, &InvExistsError{}) + } + // Initialize new resourcegroup object, as rg should have been nil. + rg = &rgfilev1alpha1.ResourceGroup{ResourceMeta: rgfilev1alpha1.DefaultMeta} + // // Finally, set the inventory parameters in the ResourceGroup object and write it. + rg.Name = inv.Name + rg.Namespace = inv.Namespace + if inv.InventoryID != "" { + rg.Labels = map[string]string{rgfilev1alpha1.RGInventoryIDLabel: inv.InventoryID} + } + if err := writeRGFile(p.UniquePath.String(), rg, filename); err != nil { + return errors.E(op, p.UniquePath, err) + } + + // Rewrite Kptfile without inventory existing Kptfile contains inventory info. This + // is required when a user appends the force flag. + if !isEmpty { + if err := kptfileutil.WriteFile(p.UniquePath.String(), kf); err != nil { + return errors.E(op, p.UniquePath, err) + } + } + + return nil +} + +// writeRGFile writes a ResourceGroup inventory to local disk. +func writeRGFile(dir string, rg *rgfilev1alpha1.ResourceGroup, filename string) error { + const op errors.Op = "cmdliveinit.writeRGFile" + b, err := yaml.MarshalWithOptions(rg, &yaml.EncoderOptions{SeqIndent: yaml.WideSequenceStyle}) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(dir, filename)); err != nil && !goerrors.Is(err, os.ErrNotExist) { + return errors.E(op, errors.IO, types.UniquePath(dir), err) + } + + // fyi: perm is ignored if the file already exists + err = ioutil.WriteFile(filepath.Join(dir, filename), b, 0600) + if err != nil { + return errors.E(op, errors.IO, types.UniquePath(dir), err) + } + return nil +} + // Run fills in the inventory object values into the Kptfile. func updateKptfile(p *pkg.Pkg, inv *kptfilev1.Inventory, force bool) error { const op errors.Op = "cmdliveinit.updateKptfile" diff --git a/internal/cmdliveinit/cmdliveinit_test.go b/internal/cmdliveinit/cmdliveinit_test.go index 8fdee25299..e8a1563a30 100644 --- a/internal/cmdliveinit/cmdliveinit_test.go +++ b/internal/cmdliveinit/cmdliveinit_test.go @@ -14,6 +14,7 @@ import ( "github.com/GoogleContainerTools/kpt/internal/printer/fake" "github.com/GoogleContainerTools/kpt/internal/testutil" kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1" + rgfilev1alpha1 "github.com/GoogleContainerTools/kpt/pkg/api/resourcegroup/v1alpha1" "github.com/stretchr/testify/assert" "k8s.io/cli-runtime/pkg/genericclioptions" cmdtesting "k8s.io/kubectl/pkg/cmd/testing" @@ -58,6 +59,14 @@ inventory: var testTime = time.Unix(5555555, 66666666) +var resourceGroupInventory = ` +apiVersion: kpt.dev/v1alpha1 +kind: ResourceGroup +metadata: + name: foo + namespace: test-namespace +` + func TestCmd_generateID(t *testing.T) { testCases := map[string]struct { namespace string @@ -130,6 +139,8 @@ func TestCmd_Run_NoKptfile(t *testing.T) { func TestCmd_Run(t *testing.T) { testCases := map[string]struct { kptfile string + resourcegroup string + rgfilename string name string namespace string inventoryID string @@ -161,15 +172,47 @@ func TestCmd_Run(t *testing.T) { InventoryID: "my-inv-id", }, }, + "Provided values are used with custom resourcegroup filename": { + kptfile: kptFile, + rgfilename: "custom-rg.yaml", + name: "my-pkg", + namespace: "my-ns", + inventoryID: "my-inv-id", + expectedInventory: kptfilev1.Inventory{ + Namespace: "my-ns", + Name: "my-pkg", + InventoryID: "my-inv-id", + }, + }, "Kptfile with inventory already set is error": { kptfile: kptFileWithInventory, name: inventoryName, namespace: inventoryNamespace, inventoryID: inventoryID, force: false, + expectedErrorMsg: "inventory information already set", + }, + "ResourceGroup with inventory already set is error": { + kptfile: kptFile, + resourcegroup: resourceGroupInventory, + rgfilename: "resourcegroup.yaml", + name: inventoryName, + namespace: inventoryNamespace, + inventoryID: inventoryID, + force: false, expectedErrorMsg: "inventory information already set for package", }, - "The force flag allows changing inventory information even if already set": { + "ResourceGroup with inventory and Kptfile with inventory already set is error": { + kptfile: kptFileWithInventory, + resourcegroup: resourceGroupInventory, + rgfilename: "resourcegroup.yaml", + name: inventoryName, + namespace: inventoryNamespace, + inventoryID: inventoryID, + force: false, + expectedErrorMsg: "inventory information already set", + }, + "The force flag allows changing inventory information even if already set in Kptfile": { kptfile: kptFileWithInventory, name: inventoryName, namespace: inventoryNamespace, @@ -181,6 +224,20 @@ func TestCmd_Run(t *testing.T) { InventoryID: inventoryID, }, }, + "The force flag allows changing inventory information even if already set in ResourceGroup": { + kptfile: kptFile, + resourcegroup: resourceGroupInventory, + rgfilename: "resourcegroup.yaml", + name: inventoryName, + namespace: inventoryNamespace, + inventoryID: inventoryID, + force: true, + expectedInventory: kptfilev1.Inventory{ + Namespace: inventoryNamespace, + Name: inventoryName, + InventoryID: inventoryID, + }, + }, } for tn, tc := range testCases { @@ -198,11 +255,21 @@ func TestCmd_Run(t *testing.T) { t.FailNow() } + // Create ResourceGroup file if testing the STDIN feature. + if tc.resourcegroup != "" && tc.rgfilename != "" { + err := ioutil.WriteFile(filepath.Join(w.WorkspaceDirectory, tc.rgfilename), + []byte(tc.resourcegroup), 0600) + if !assert.NoError(t, err) { + t.FailNow() + } + } + revert := testutil.Chdir(t, w.WorkspaceDirectory) defer revert() runner := NewRunner(fake.CtxWithDefaultPrinter(), tf, ioStreams) runner.namespace = tc.namespace + runner.RGFile = tc.rgfilename args := []string{ "--name", tc.name, "--inventory-id", tc.inventoryID, @@ -223,17 +290,39 @@ func TestCmd_Run(t *testing.T) { return } - // Otherwise, validate the kptfile values + // Otherwise, validate the kptfile values and/or resourcegroup values. + var actualInv kptfilev1.Inventory assert.NoError(t, err) kf, err := pkg.ReadKptfile(w.WorkspaceDirectory) assert.NoError(t, err) - if !assert.NotNil(t, kf.Inventory) { - t.FailNow() + + switch tc.rgfilename { + case "": + if !assert.NotNil(t, kf.Inventory) { + t.FailNow() + } + actualInv = *kf.Inventory + default: + // Check resourcegroup file if testing the STDIN feature. + rg, err := pkg.ReadRGFile(w.WorkspaceDirectory, tc.rgfilename) + assert.NoError(t, err) + if !assert.NotNil(t, rg) { + t.FailNow() + } + + // Convert resourcegroup inventory back to Kptfile structure so we can share assertion + // logic for Kptfile inventory and ResourceGroup inventory structure. + actualInv = kptfilev1.Inventory{ + Name: rg.Name, + Namespace: rg.Namespace, + InventoryID: rg.Labels[rgfilev1alpha1.RGInventoryIDLabel], + } } - actualInv := *kf.Inventory + expectedInv := tc.expectedInventory assertInventoryName(t, expectedInv.Name, actualInv.Name) assert.Equal(t, expectedInv.Namespace, actualInv.Namespace) + if tc.expectAutoGenID { assertGenInvID(t, actualInv.Name, actualInv.Namespace, actualInv.InventoryID) } else { diff --git a/internal/errors/resolver/live.go b/internal/errors/resolver/live.go index d01c6dbddd..3e9ad18c93 100644 --- a/internal/errors/resolver/live.go +++ b/internal/errors/resolver/live.go @@ -60,6 +60,16 @@ Error: The ResourceGroup CRD was not found in the cluster. Please install it eit //nolint:lll invInfoAlreadyExistsMsg = ` Error: Inventory information has already been added to the package Kptfile. Changing it after a package has been applied to the cluster can lead to undesired results. Use the --force flag to suppress this error. +` + + //nolint:lll + invInfoInRGAlreadyExistsMsg = ` +Error: Inventory information has already been added to the package ResourceGroup object. Changing it after a package has been applied to the cluster can lead to undesired results. Use the --force flag to suppress this error. +` + + //nolint:lll + invInfoInKfAlreadyExistsMsg = ` +Error: Inventory information has already been added to the package Kptfile object. Please consider migrating to a standalone resourcegroup object using the 'kpt live migrate' command. ` multipleInvInfoMsg = ` @@ -136,6 +146,24 @@ func (*liveErrorResolver) Resolve(err error) (ResolvedResult, bool) { }, true } + var invInfoInRGAlreadyExistsError *cmdliveinit.InvInRGExistsError + if errors.As(err, &invInfoInRGAlreadyExistsError) { + return ResolvedResult{ + Message: ExecuteTemplate(invInfoInRGAlreadyExistsMsg, map[string]interface{}{ + "err": *invInfoInRGAlreadyExistsError, + }), + }, true + } + + var invInKfExistsError *cmdliveinit.InvInKfExistsError + if errors.As(err, &invInKfExistsError) { + return ResolvedResult{ + Message: ExecuteTemplate(invInfoInKfAlreadyExistsMsg, map[string]interface{}{ + "err": *invInKfExistsError, + }), + }, true + } + var multipleInvInfoError *live.MultipleInventoryInfoError if errors.As(err, &multipleInvInfoError) { return ResolvedResult{ diff --git a/internal/pkg/pkg.go b/internal/pkg/pkg.go index 49bc6a5b2e..7862493f69 100644 --- a/internal/pkg/pkg.go +++ b/internal/pkg/pkg.go @@ -28,6 +28,7 @@ import ( "github.com/GoogleContainerTools/kpt/internal/types" "github.com/GoogleContainerTools/kpt/internal/util/git" kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1" + rgfilev1alpha1 "github.com/GoogleContainerTools/kpt/pkg/api/resourcegroup/v1alpha1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/kubectl/pkg/util/slice" "sigs.k8s.io/kustomize/kyaml/kio" @@ -99,6 +100,21 @@ func (e *UnknownKptfileResourceError) Error() string { return fmt.Sprintf("unknown resource type %q found in Kptfile", e.GVK.String()) } +// RGError is an implementation of the error interface that is returned whenever +// kpt encounters errors reading a resourcegroup object file. +type RGError struct { + Path types.UniquePath + Err error +} + +func (rg *RGError) Error() string { + return fmt.Sprintf("error reading ResourceGroup file at %q: %s", rg.Path.String(), rg.Err.Error()) +} + +func (rg *RGError) Unwrap() error { + return rg.Err +} + // Pkg represents a kpt package with a one-to-one mapping to a directory on the local filesystem. type Pkg struct { // UniquePath represents absolute unique OS-defined path to the package directory on the filesystem. @@ -119,6 +135,9 @@ type Pkg struct { // A package can contain zero or one Kptfile meta resource. // A nil value represents an implicit package. kptfile *kptfilev1.KptFile + + // A package can contain zero or one ResourceGroup object. + rgFile *rgfilev1alpha1.ResourceGroup } // New returns a pkg given an absolute or relative OS-defined path. @@ -695,3 +714,50 @@ func SetPkgPathAnnotation(rn *yaml.RNode, pkgPath types.UniquePath) error { func RemovePkgPathAnnotation(rn *yaml.RNode) error { return rn.PipeE(yaml.ClearAnnotation(pkgPathAnnotation)) } + +// ReadRGFile returns the resourcegroup object by lazy loading it from the filesytem. +func (p *Pkg) ReadRGFile(filename string) (*rgfilev1alpha1.ResourceGroup, error) { + if p.rgFile == nil { + rg, err := ReadRGFile(p.UniquePath.String(), filename) + if err != nil { + return nil, err + } + p.rgFile = rg + } + return p.rgFile, nil +} + +// TODO(rquitales): Consolidate both Kptfile and ResourceGroup file reading functions to use +// shared logic/function. + +// ReadRGFile reads the KptFile in the given pkg. +func ReadRGFile(path, filename string) (*rgfilev1alpha1.ResourceGroup, error) { + f, err := os.Open(filepath.Join(path, filename)) + if err != nil { + return nil, &RGError{ + Path: types.UniquePath(path), + Err: err, + } + } + defer f.Close() + + rg := &rgfilev1alpha1.ResourceGroup{} + c, err := io.ReadAll(f) + if err != nil { + return nil, &RGError{ + Path: types.UniquePath(path), + Err: err, + } + } + + d := yaml.NewDecoder(bytes.NewBuffer(c)) + d.KnownFields(true) + if err := d.Decode(rg); err != nil { + return nil, &RGError{ + Path: types.UniquePath(path), + Err: err, + } + } + return rg, nil + +} diff --git a/pkg/api/resourcegroup/v1alpha1/types.go b/pkg/api/resourcegroup/v1alpha1/types.go index f3ea6ccf5d..0d9741ffdd 100644 --- a/pkg/api/resourcegroup/v1alpha1/types.go +++ b/pkg/api/resourcegroup/v1alpha1/types.go @@ -18,6 +18,7 @@ package v1alpha1 import ( + "sigs.k8s.io/cli-utils/pkg/common" "sigs.k8s.io/kustomize/kyaml/yaml" ) @@ -27,6 +28,8 @@ const ( RGFileGroup = "kpt.dev" RGFileVersion = "v1alpha1" RGFileAPIVersion = RGFileGroup + "/" + RGFileVersion + // RGInventoryIDLabel is the label name used for storing an inventory ID. + RGInventoryIDLabel = common.InventoryLabel ) // DefaultMeta is the ResourceMeta for ResourceGroup instances.