diff --git a/.changeset/tangy-jokes-jam.md b/.changeset/tangy-jokes-jam.md new file mode 100644 index 00000000..a631f895 --- /dev/null +++ b/.changeset/tangy-jokes-jam.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +Add top-level address-book and datastore CLI commands diff --git a/engine/cld/legacy/cli/commands/addressbook.go b/engine/cld/legacy/cli/commands/addressbook.go new file mode 100644 index 00000000..425d96ba --- /dev/null +++ b/engine/cld/legacy/cli/commands/addressbook.go @@ -0,0 +1,179 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/legacy/cli" +) + +// NewAddressBookCmds creates a new set of commands for address book operations. +func (c Commands) NewAddressBookCmds(domain domain.Domain) *cobra.Command { + addressBookCmd := &cobra.Command{ + Use: "address-book", + Short: "Address book operations", + } + + addressBookCmd.AddCommand(c.newAddressBookMerge(domain)) + addressBookCmd.AddCommand(c.newAddressBookMigrate(domain)) + addressBookCmd.AddCommand(c.newAddressBookRemove(domain)) + + addressBookCmd.PersistentFlags().StringP("environment", "e", "", "Deployment environment (required)") + if err := addressBookCmd.MarkPersistentFlagRequired("environment"); err != nil { + panic(fmt.Sprintf("failed to mark environment flag as required: %v", err)) + } + + return addressBookCmd +} + +var ( + addressBookMergeLong = cli.LongDesc(` + Merges the address book artifact of a specific changeset to the main address book within a + given Domain Environment. This is to ensure that the address book is up-to-date with the + latest changeset changes. + `) + + addressBookMergeExample = cli.Examples(` + # Merge the address book for the 0001_deploy_cap changeset in the ccip staging domain environment + ccip address-book merge --environment staging --name 0001_deploy_cap + + # Merge with a specific durable pipeline timestamp + ccip address-book merge --environment staging --name 0001_deploy_cap --timestamp 1234567890 + `) +) + +// newAddressBookMerge creates a command to merge the address books for a changeset to +// the main address book within a given domain environment. +func (Commands) newAddressBookMerge(domain domain.Domain) *cobra.Command { + var ( + name string + timestamp string + ) + + cmd := cobra.Command{ + Use: "merge", + Short: "Merge the address book for a changeset to the main address book", + Long: addressBookMergeLong, + Example: addressBookMergeExample, + RunE: func(cmd *cobra.Command, args []string) error { + envKey, _ := cmd.Flags().GetString("environment") + envDir := domain.EnvDir(envKey) + + if err := envDir.MergeMigrationAddressBook(name, timestamp); err != nil { + return fmt.Errorf("error during address book merge for %s %s %s: %w", + domain, envKey, name, err, + ) + } + + cmd.Printf("Merged address books for %s %s %s\n", + domain, envKey, name, + ) + + return nil + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "name (required)") + cmd.Flags().StringVarP(×tamp, "timestamp", "t", "", "Durable Pipeline timestamp (optional)") + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(fmt.Sprintf("failed to mark name flag as required: %v", err)) + } + + return &cmd +} + +var ( + addressBookMigrateLong = cli.LongDesc(` + Converts the address book artifact format to the new datastore schema within a + given Domain Environment. This updates your on-chain address book to the latest storage format. + `) + + addressBookMigrateExample = cli.Examples(` + # Migrate the address book for the ccip staging domain to the new datastore format + ccip address-book migrate --environment staging + `) +) + +// newAddressBookMigrate creates a command to convert the address book +// artifact to the new datastore format within a given domain environment. +func (Commands) newAddressBookMigrate(domain domain.Domain) *cobra.Command { + cmd := cobra.Command{ + Use: "migrate", + Short: "Migrate address book to the new datastore format", + Long: addressBookMigrateLong, + Example: addressBookMigrateExample, + RunE: func(cmd *cobra.Command, args []string) error { + envKey, _ := cmd.Flags().GetString("environment") + envDir := domain.EnvDir(envKey) + + if err := envDir.MigrateAddressBook(); err != nil { + return fmt.Errorf("error during address book conversion for %s %s: %w", + domain, envKey, err, + ) + } + + cmd.Printf("Address book for %s %s successfully migrated to the new datastore format\n", + domain, envKey, + ) + + return nil + }, + } + + return &cmd +} + +var ( + addressBookRemoveLong = cli.LongDesc(` + Removes the address book entries introduced by a specific changeset from the main + address book within a given Domain Environment. This can be used to rollback + address-book merge changes. + `) + + addressBookRemoveExample = cli.Examples(` + # Remove the address book entries for the 0001_deploy_cap changeset in the ccip staging domain + ccip address-book remove --environment staging --name 0001_deploy_cap + `) +) + +// newAddressBookRemove creates a command to remove a changeset's +// address book entries from the main address book within a given domain environment. +func (Commands) newAddressBookRemove(domain domain.Domain) *cobra.Command { + var ( + name string + timestamp string + ) + + cmd := cobra.Command{ + Use: "remove", + Short: "Remove changeset address book entries", + Long: addressBookRemoveLong, + Example: addressBookRemoveExample, + RunE: func(cmd *cobra.Command, args []string) error { + envKey, _ := cmd.Flags().GetString("environment") + envDir := domain.EnvDir(envKey) + + if err := envDir.RemoveMigrationAddressBook(name, timestamp); err != nil { + return fmt.Errorf("error during address book remove for %s %s %s: %w", + domain, envKey, name, err, + ) + } + + cmd.Printf("Removed address books for %s %s %s\n", + domain, envKey, name, + ) + + return nil + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "name (required)") + cmd.Flags().StringVarP(×tamp, "timestamp", "t", "", "Durable Pipeline timestamp (optional)") + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(fmt.Sprintf("failed to mark name flag as required: %v", err)) + } + + return &cmd +} diff --git a/engine/cld/legacy/cli/commands/addressbook_test.go b/engine/cld/legacy/cli/commands/addressbook_test.go new file mode 100644 index 00000000..295ce4c6 --- /dev/null +++ b/engine/cld/legacy/cli/commands/addressbook_test.go @@ -0,0 +1,117 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" +) + +func TestNewAddressBookCmds_Structure(t *testing.T) { + t.Parallel() + c := NewCommands(nil) + var dom domain.Domain + root := c.NewAddressBookCmds(dom) + + require.Equal(t, "address-book", root.Use) + + subs := root.Commands() + require.Len(t, subs, 3, "expected 3 subcommands under 'address-book'") + + uses := make([]string, len(subs)) + for i, sc := range subs { + uses[i] = sc.Use + } + require.ElementsMatch(t, + []string{"merge", "migrate", "remove"}, + uses, + ) + + // The "environment" flag is persistent on root + flag := root.PersistentFlags().Lookup("environment") + require.NotNil(t, flag, "persistent flag 'environment' should exist") +} + +func TestAddressBookCommandMetadata(t *testing.T) { + t.Parallel() + c := NewCommands(nil) + dom := domain.Domain{} + + tests := []struct { + name string + cmdKey string + wantUse string + wantShort string + wantLongPrefix string + wantExampleContains string + wantFlags []string + }{ + { + name: "merge", + cmdKey: "merge", + wantUse: "merge", + wantShort: "Merge the address book", + wantLongPrefix: "Merges the address book artifact", + wantExampleContains: "address-book merge --environment staging --name", + wantFlags: []string{ + "name", "timestamp", + }, + }, + { + name: "migrate", + cmdKey: "migrate", + wantUse: "migrate", + wantShort: "Migrate address book to the new datastore format", + wantLongPrefix: "Converts the address book artifact format", + wantExampleContains: "address-book migrate --environment staging", + wantFlags: []string{}, + }, + { + name: "remove", + cmdKey: "remove", + wantUse: "remove", + wantShort: "Remove changeset address book entries", + wantLongPrefix: "Removes the address book entries", + wantExampleContains: "address-book remove --environment staging --name", + wantFlags: []string{ + "name", "timestamp", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Give each subtest its own fresh command tree + root := c.NewAddressBookCmds(dom) + + parts := strings.Split(tc.cmdKey, " ") + cmd, _, err := root.Find(parts) + require.NoError(t, err) + require.NotNil(t, cmd, "command not found: %s", tc.cmdKey) + + require.Equal(t, tc.wantUse, cmd.Use) + require.Contains(t, cmd.Short, tc.wantShort) + require.Contains(t, cmd.Long, tc.wantLongPrefix) + require.Contains(t, cmd.Example, tc.wantExampleContains) + + for _, flagName := range tc.wantFlags { + var flag *pflag.Flag + if flagName == "environment" { + // persistent flag lives on root + flag = root.PersistentFlags().Lookup("environment") + } else { + flag = cmd.Flags().Lookup(flagName) + if flag == nil { + flag = cmd.PersistentFlags().Lookup(flagName) + } + } + require.NotNil(t, flag, "flag %q not found on %s", flagName, tc.name) + } + }) + } +} diff --git a/engine/cld/legacy/cli/commands/datastore.go b/engine/cld/legacy/cli/commands/datastore.go new file mode 100644 index 00000000..6b7444fe --- /dev/null +++ b/engine/cld/legacy/cli/commands/datastore.go @@ -0,0 +1,207 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + cldcatalog "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/catalog" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config" + cfgdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/legacy/cli" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +// NewDatastoreCmds creates a new set of commands for datastore operations. +func (c Commands) NewDatastoreCmds(domain domain.Domain) *cobra.Command { + datastoreCmd := &cobra.Command{ + Use: "datastore", + Short: "Datastore operations", + } + + datastoreCmd.AddCommand(c.newDatastoreMerge(domain)) + datastoreCmd.AddCommand(c.newDatastoreSyncToCatalog(domain)) + + datastoreCmd.PersistentFlags().StringP("environment", "e", "", "Deployment environment (required)") + if err := datastoreCmd.MarkPersistentFlagRequired("environment"); err != nil { + panic(fmt.Sprintf("failed to mark environment flag as required: %v", err)) + } + + return datastoreCmd +} + +var ( + datastoreMergeLong = cli.LongDesc(` + Merges the datastore artifact of a specific changeset to the main datastore within a + given Domain Environment. The merge destination depends on the datastore configuration: + - file: merges to local JSON files + - catalog: merges to the remote catalog service + - all: merges to both local files and catalog + `) + + datastoreMergeExample = cli.Examples(` + # Merge the datastore for the 0001_deploy_cap changeset in the ccip staging domain + ccip datastore merge --environment staging --name 0001_deploy_cap + + # Merge with a specific durable pipeline timestamp + ccip datastore merge --environment staging --name 0001_deploy_cap --timestamp 1234567890 + `) +) + +// newDatastoreMerge creates a command to merge the datastore for a changeset +func (Commands) newDatastoreMerge(domain domain.Domain) *cobra.Command { + var ( + name string + timestamp string + ) + + cmd := cobra.Command{ + Use: "merge", + Short: "Merge datastore artifacts", + Long: datastoreMergeLong, + Example: datastoreMergeExample, + RunE: func(cmd *cobra.Command, args []string) error { + envKey, _ := cmd.Flags().GetString("environment") + envDir := domain.EnvDir(envKey) + + // Load config to check datastore type + cfg, err := config.Load(domain, envKey, logger.Nop()) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Determine which merge method to use based on datastore configuration + switch cfg.DatastoreType { + case cfgdomain.DatastoreTypeCatalog: + // Catalog mode - merge to catalog service + cmd.Printf("📡 Using catalog datastore mode (endpoint: %s)\n", cfg.Env.Catalog.GRPC) + + catalog, catalogErr := cldcatalog.LoadCatalog(cmd.Context(), envKey, cfg, domain) + if catalogErr != nil { + return fmt.Errorf("failed to load catalog: %w", catalogErr) + } + + if err := envDir.MergeMigrationDataStoreCatalog(cmd.Context(), name, timestamp, catalog); err != nil { + return fmt.Errorf("error during datastore merge to catalog for %s %s %s: %w", + domain, envKey, name, err, + ) + } + + cmd.Printf("✅ Merged datastore to catalog for %s %s %s\n", + domain, envKey, name, + ) + case cfgdomain.DatastoreTypeFile: + // File mode - merge to local files + cmd.Printf("📁 Using file-based datastore mode\n") + + if err := envDir.MergeMigrationDataStore(name, timestamp); err != nil { + return fmt.Errorf("error during datastore merge to file for %s %s %s: %w", + domain, envKey, name, err, + ) + } + + cmd.Printf("✅ Merged datastore to local files for %s %s %s\n", + domain, envKey, name, + ) + case cfgdomain.DatastoreTypeAll: + // All mode - merge to both catalog and local files + cmd.Printf("📡 Using all datastore mode (catalog: %s, file: %s)\n", cfg.Env.Catalog.GRPC, envDir.DataStoreDirPath()) + + catalog, catalogErr := cldcatalog.LoadCatalog(cmd.Context(), envKey, cfg, domain) + if catalogErr != nil { + return fmt.Errorf("failed to load catalog: %w", catalogErr) + } + + if err := envDir.MergeMigrationDataStoreCatalog(cmd.Context(), name, timestamp, catalog); err != nil { + return fmt.Errorf("error during datastore merge to catalog for %s %s %s: %w", + domain, envKey, name, err, + ) + } + + if err := envDir.MergeMigrationDataStore(name, timestamp); err != nil { + return fmt.Errorf("error during datastore merge to file for %s %s %s: %w", + domain, envKey, name, err, + ) + } + + cmd.Printf("✅ Merged datastore to both catalog and local files for %s %s %s\n", + domain, envKey, name, + ) + default: + return fmt.Errorf("invalid datastore type: %s", cfg.DatastoreType) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "name (required)") + cmd.Flags().StringVarP(×tamp, "timestamp", "t", "", "Durable Pipeline timestamp (optional)") + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(fmt.Sprintf("failed to mark name flag as required: %v", err)) + } + + return &cmd +} + +var ( + datastoreSyncToCatalogLong = cli.LongDesc(` + Syncs the entire local datastore to the catalog service. This is used for initial + migration from file-based to catalog-based datastore management. + + The environment must have catalog configured (datastore type: catalog or all). + `) + + datastoreSyncToCatalogExample = cli.Examples(` + # Sync the entire local datastore to catalog + ccip datastore sync-to-catalog --environment staging + `) +) + +// newDatastoreSyncToCatalog creates a command to sync the entire local datastore to catalog +func (Commands) newDatastoreSyncToCatalog(domain domain.Domain) *cobra.Command { + cmd := cobra.Command{ + Use: "sync-to-catalog", + Short: "Sync local datastore to catalog", + Long: datastoreSyncToCatalogLong, + Example: datastoreSyncToCatalogExample, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + envKey, _ := cmd.Flags().GetString("environment") + envDir := domain.EnvDir(envKey) + + // Load config to get catalog connection details + cfg, err := config.Load(domain, envKey, logger.Nop()) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Verify catalog is configured + if cfg.DatastoreType != cfgdomain.DatastoreTypeCatalog && cfg.DatastoreType != cfgdomain.DatastoreTypeAll { + return fmt.Errorf("catalog is not configured for environment %s (datastore type: %s)", envKey, cfg.DatastoreType) + } + + cmd.Printf("📡 Syncing local datastore to catalog (endpoint: %s)\n", cfg.Env.Catalog.GRPC) + + catalog, catalogErr := cldcatalog.LoadCatalog(ctx, envKey, cfg, domain) + if catalogErr != nil { + return fmt.Errorf("failed to load catalog: %w", catalogErr) + } + + if err := envDir.SyncDataStoreToCatalog(ctx, catalog); err != nil { + return fmt.Errorf("error syncing datastore to catalog for %s %s: %w", + domain, envKey, err, + ) + } + + cmd.Printf("✅ Successfully synced entire datastore to catalog for %s %s\n", + domain, envKey, + ) + + return nil + }, + } + + return &cmd +} diff --git a/engine/cld/legacy/cli/commands/datastore_test.go b/engine/cld/legacy/cli/commands/datastore_test.go new file mode 100644 index 00000000..1313fe9d --- /dev/null +++ b/engine/cld/legacy/cli/commands/datastore_test.go @@ -0,0 +1,106 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" +) + +func TestNewDatastoreCmds_Structure(t *testing.T) { + t.Parallel() + c := NewCommands(nil) + var dom domain.Domain + root := c.NewDatastoreCmds(dom) + + require.Equal(t, "datastore", root.Use) + + subs := root.Commands() + require.Len(t, subs, 2, "expected 2 subcommands under 'datastore'") + + uses := make([]string, len(subs)) + for i, sc := range subs { + uses[i] = sc.Use + } + require.ElementsMatch(t, + []string{"merge", "sync-to-catalog"}, + uses, + ) + + // The "environment" flag is persistent on root + flag := root.PersistentFlags().Lookup("environment") + require.NotNil(t, flag, "persistent flag 'environment' should exist") +} + +func TestDatastoreCommandMetadata(t *testing.T) { + t.Parallel() + c := NewCommands(nil) + dom := domain.Domain{} + + tests := []struct { + name string + cmdKey string + wantUse string + wantShort string + wantLongPrefix string + wantExampleContains string + wantFlags []string + }{ + { + name: "merge", + cmdKey: "merge", + wantUse: "merge", + wantShort: "Merge datastore artifacts", + wantLongPrefix: "Merges the datastore artifact", + wantExampleContains: "datastore merge --environment staging --name", + wantFlags: []string{ + "name", "timestamp", + }, + }, + { + name: "sync-to-catalog", + cmdKey: "sync-to-catalog", + wantUse: "sync-to-catalog", + wantShort: "Sync local datastore to catalog", + wantLongPrefix: "Syncs the entire local datastore", + wantExampleContains: "datastore sync-to-catalog --environment staging", + wantFlags: []string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Give each subtest its own fresh command tree + root := c.NewDatastoreCmds(dom) + + parts := strings.Split(tc.cmdKey, " ") + cmd, _, err := root.Find(parts) + require.NoError(t, err) + require.NotNil(t, cmd, "command not found: %s", tc.cmdKey) + + require.Equal(t, tc.wantUse, cmd.Use) + require.Contains(t, cmd.Short, tc.wantShort) + require.Contains(t, cmd.Long, tc.wantLongPrefix) + require.Contains(t, cmd.Example, tc.wantExampleContains) + + for _, flagName := range tc.wantFlags { + var flag *pflag.Flag + if flagName == "environment" { + // persistent flag lives on root + flag = root.PersistentFlags().Lookup("environment") + } else { + flag = cmd.Flags().Lookup(flagName) + if flag == nil { + flag = cmd.PersistentFlags().Lookup(flagName) + } + } + require.NotNil(t, flag, "flag %q not found on %s", flagName, tc.name) + } + }) + } +}