Skip to content
179 changes: 179 additions & 0 deletions engine/cld/legacy/cli/commands/addressbook.go
Original file line number Diff line number Diff line change
@@ -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(&timestamp, "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(&timestamp, "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
}
117 changes: 117 additions & 0 deletions engine/cld/legacy/cli/commands/addressbook_test.go
Original file line number Diff line number Diff line change
@@ -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) {
// Give each subtest its own fresh command tree
root := c.NewAddressBookCmds(dom)

t.Parallel()

Comment on lines +87 to +91
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command tree is created before calling t.Parallel(), which could lead to race conditions if multiple subtests access shared state. Move the root creation after t.Parallel() to ensure proper test isolation.

Suggested change
// Give each subtest its own fresh command tree
root := c.NewAddressBookCmds(dom)
t.Parallel()
t.Parallel()
// Give each subtest its own fresh command tree
root := c.NewAddressBookCmds(dom)

Copilot uses AI. Check for mistakes.
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)
}
})
}
}
Loading