diff --git a/.changeset/lemon-hornets-decide.md b/.changeset/lemon-hornets-decide.md new file mode 100644 index 00000000..e022dc2c --- /dev/null +++ b/.changeset/lemon-hornets-decide.md @@ -0,0 +1,7 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat: introduce template-input command for generating YAML input + +This commit introduces a new template-input command that generates YAML input templates from Go struct types for durable pipeline changesets. The command uses reflection to analyze changeset input types and produces well-formatted YAML templates with type comments to guide users in creating valid input files. diff --git a/engine/cld/changeset/common.go b/engine/cld/changeset/common.go index fe54160f..9212c81c 100644 --- a/engine/cld/changeset/common.go +++ b/engine/cld/changeset/common.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "reflect" fresolvers "github.com/smartcontractkit/chainlink-deployments-framework/changeset/resolvers" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -17,6 +18,10 @@ type Configurations struct { // Present only when the migration was wired with // Configure(...).WithConfigResolver(...) ConfigResolver fresolvers.ConfigResolver + + // InputType contains the reflect.Type of the input struct for this changeset + // This is useful for tools that need to generate templates or analyze the expected input + InputType reflect.Type } // internalChangeSet provides an opaque type, to force the usage of only the ChangeSetImpl @@ -269,18 +274,24 @@ func (ccs ChangeSetImpl[C]) Apply(env fdeployment.Environment) (fdeployment.Chan } func (ccs ChangeSetImpl[C]) Configurations() (Configurations, error) { - if ccs.inputChainOverrides == nil { - // If no inputChainOverrides function is provided, return an empty Configurations - return Configurations{}, nil - } - overrides, err := ccs.inputChainOverrides() - if err != nil { - return Configurations{}, err + var chainOverrides []uint64 + var err error + + if ccs.inputChainOverrides != nil { + chainOverrides, err = ccs.inputChainOverrides() + if err != nil { + return Configurations{}, err + } } + // Get the type of C (the input struct type) + var zero C + inputType := reflect.TypeOf(zero) + return Configurations{ - InputChainOverrides: overrides, + InputChainOverrides: chainOverrides, ConfigResolver: ccs.ConfigResolver, + InputType: inputType, }, nil } diff --git a/engine/cld/legacy/cli/commands/durable-pipelines.go b/engine/cld/legacy/cli/commands/durable-pipelines.go index 21a29b4d..4247fbd7 100644 --- a/engine/cld/legacy/cli/commands/durable-pipelines.go +++ b/engine/cld/legacy/cli/commands/durable-pipelines.go @@ -16,22 +16,22 @@ import ( fresolvers "github.com/smartcontractkit/chainlink-deployments-framework/changeset/resolvers" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" - fchangeset "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" - "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" - fenvironment "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" + cs "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" + dom "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + cldenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" "github.com/smartcontractkit/chainlink-deployments-framework/operations" "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" ) // a temporary workaround to allow test to mock the LoadEnvironment function -var loadEnv = fenvironment.Load +var loadEnv = cldenv.Load // TODO: envLoader needs to be refactored to an interface so we can mock it for testing // to avoid using real backends func (c Commands) NewDurablePipelineCmds( - domain domain.Domain, - loadMigration func(envName string) (*fchangeset.ChangesetsRegistry, error), + domain dom.Domain, + loadMigration func(envName string) (*cs.ChangesetsRegistry, error), decodeProposalCtxProvider func(env fdeployment.Environment) (analyzer.ProposalContext, error), loadConfigResolvers *fresolvers.ConfigResolverManager) *cobra.Command { evmCmd := &cobra.Command{ @@ -42,7 +42,8 @@ func (c Commands) NewDurablePipelineCmds( evmCmd.AddCommand( c.newDurablePipelineRun(domain, loadMigration, decodeProposalCtxProvider, loadConfigResolvers), c.newDurablePipelineInputGenerate(domain, loadMigration, loadConfigResolvers), - c.newDurablePipelineListBuild(domain, loadMigration, loadConfigResolvers)) + c.newDurablePipelineListBuild(domain, loadMigration, loadConfigResolvers), + c.newDurablePipelineTemplateInput(domain, loadMigration, loadConfigResolvers)) evmCmd.PersistentFlags().StringP("environment", "e", "", "Deployment environment (required)") _ = evmCmd.MarkPersistentFlagRequired("environment") @@ -75,8 +76,8 @@ var ( // newDurablePipelineRun builds the 'run' subcommand for executing durable pipelines func (c Commands) newDurablePipelineRun( - domain domain.Domain, - loadMigration func(envName string) (*fchangeset.ChangesetsRegistry, error), + domain dom.Domain, + loadMigration func(envName string) (*cs.ChangesetsRegistry, error), decodeProposalCtxProvider func(env fdeployment.Environment) (analyzer.ProposalContext, error), loadConfigResolvers *fresolvers.ConfigResolverManager, ) *cobra.Command { @@ -140,7 +141,7 @@ func (c Commands) newDurablePipelineRun( c.lggr.Infof("Loaded %d operations reports", originalReportsLen) reporter := operations.NewMemoryReporter(operations.WithReports(reports)) - envOptions = append(envOptions, fenvironment.WithReporter(reporter)) + envOptions = append(envOptions, cldenv.WithReporter(reporter)) env, err := loadEnv(cmd.Context(), domain, envKey, envOptions...) if err != nil { return err @@ -228,8 +229,8 @@ var ( // newDurablePipelineInputGenerate builds the config-generate subcommand for generating // durable pipeline configurations using config resolvers func (c Commands) newDurablePipelineInputGenerate( - domain domain.Domain, - loadMigrationsRegistry func(envName string) (*fchangeset.ChangesetsRegistry, error), + domain dom.Domain, + loadMigrationsRegistry func(envName string) (*cs.ChangesetsRegistry, error), loadConfigResolvers *fresolvers.ConfigResolverManager, ) *cobra.Command { var ( @@ -280,7 +281,7 @@ func (c Commands) newDurablePipelineInputGenerate( // Build changeset to resolver map resolverByKey := make(map[string]fresolvers.ConfigResolver) for _, key := range registry.ListKeys() { - var cfg fchangeset.Configurations + var cfg cs.Configurations cfg, err = registry.GetConfigurations(key) if err != nil { return fmt.Errorf("get configurations for %s: %w", key, err) @@ -497,7 +498,7 @@ var ( ) // newDurablePipelineListBuild builds the list subcommand for listing durable pipeline info including registered changesets and config fresolvers -func (Commands) newDurablePipelineListBuild(domain domain.Domain, loadMigrationsRegistry func(envName string) (*fchangeset.ChangesetsRegistry, error), loadConfigResolvers *fresolvers.ConfigResolverManager) *cobra.Command { +func (Commands) newDurablePipelineListBuild(domain dom.Domain, loadMigrationsRegistry func(envName string) (*cs.ChangesetsRegistry, error), loadConfigResolvers *fresolvers.ConfigResolverManager) *cobra.Command { cmd := cobra.Command{ Use: "list", Short: "List durable pipeline info", @@ -565,3 +566,88 @@ func (Commands) newDurablePipelineListBuild(domain domain.Domain, loadMigrations return &cmd } + +// Long and Example for 'template-input' subcommand +var ( + longDescription = ` + Generate YAML input templates from Changeset input Go struct types. + + This command helps create YAML input files by analyzing Go struct types + from changesets and generating properly formatted YAML templates with + example values and comments. +` + example = ` + # Generate YAML template for a single changeset + chainlink-deployments durable-pipeline template-input \ + --environment testnet \ + --changeset test_migration_dynamic_inputs + + # Generate YAML template for multiple changesets + chainlink-deployments durable-pipeline template-input \ + --environment testnet \ + --changeset changeset1,changeset2,changeset3 + + # Configure depth limit for nested structures + chainlink-deployments durable-pipeline template-input \ + --environment testnet \ + --changeset test_migration_dynamic_inputs \ + --depth 3 + + # Save output to file + chainlink-deployments durable-pipeline template-input \ + --environment testnet \ + --changeset test_migration_dynamic_inputs > example.yaml + ` +) + +// newDurablePipelineTemplateInput builds the template-input subcommand for generating +// YAML input templates from Go struct types +func (c Commands) newDurablePipelineTemplateInput( + domain dom.Domain, + loadRegistry func(envName string) (*cs.ChangesetsRegistry, error), + loadConfigResolvers *fresolvers.ConfigResolverManager, +) *cobra.Command { + var ( + changesetList string + depthLimit int + ) + + cmd := cobra.Command{ + Use: "template-input", + Short: "Generate YAML input templates from Changesets", + Long: longDescription, + Example: example, + RunE: func(cmd *cobra.Command, args []string) error { + envKey, _ := cmd.Flags().GetString("environment") + + registry, err := loadRegistry(envKey) + if err != nil { + return fmt.Errorf("load registry: %w", err) + } + + // Parse changeset names (comma-separated) + changesetNames := strings.Split(strings.TrimSpace(changesetList), ",") + for i, name := range changesetNames { + changesetNames[i] = strings.TrimSpace(name) + } + + yamlTemplate, err := generateMultiChangesetYAMLTemplate(domain.String(), envKey, changesetNames, registry, loadConfigResolvers, depthLimit) + if err != nil { + return fmt.Errorf("generate YAML template: %w", err) + } + + // Use fmt.Fprint with cmd.OutOrStdout() to ensure output goes to stdout + // and can be properly redirected to files and captured in tests + fmt.Fprint(cmd.OutOrStdout(), yamlTemplate) + + return nil + }, + } + + cmd.Flags().StringVarP(&changesetList, "changeset", "c", "", "Changeset name(s) to generate YAML template for - comma-separated for multiple (required)") + cmd.Flags().IntVarP(&depthLimit, "depth", "d", 5, "Maximum recursion depth generation for nested struct, configure this based on your struct complexity") + + _ = cmd.MarkFlagRequired("changeset") + + return &cmd +} diff --git a/engine/cld/legacy/cli/commands/template_input.go b/engine/cld/legacy/cli/commands/template_input.go new file mode 100644 index 00000000..aa89e087 --- /dev/null +++ b/engine/cld/legacy/cli/commands/template_input.go @@ -0,0 +1,333 @@ +package commands + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/resolvers" + cs "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" +) + +// generateMultiChangesetYAMLTemplate creates a YAML template with multiple changesets +func generateMultiChangesetYAMLTemplate( + domainName string, + envKey string, + changesetNames []string, + registry *cs.ChangesetsRegistry, + resolverManager *resolvers.ConfigResolverManager, + depthLimit int, +) (string, error) { + if len(changesetNames) == 0 { + return "", errors.New("no changeset names provided") + } + + // Start with header + yamlTemplate := fmt.Sprintf(`# Generated via template-input command +environment: %s +domain: %s +changesets: +`, envKey, domainName) + + // Generate each changeset section + for i, changesetName := range changesetNames { + if changesetName == "" { + continue + } + + // Add separator between changesets + if i > 0 { + yamlTemplate += "\n # ----------------------------------------\n" + } + + // Get changeset configuration + cfg, err := registry.GetConfigurations(changesetName) + if err != nil { + return "", fmt.Errorf("get configurations for changeset %s: %w", changesetName, err) + } + + // Generate changeset section + changesetSection, err := generateChangesetSection(changesetName, cfg, resolverManager, " ", depthLimit) + if err != nil { + return "", fmt.Errorf("generate section for changeset %s: %w", changesetName, err) + } + + yamlTemplate += changesetSection + } + + return yamlTemplate, nil +} + +// generateChangesetSection generates a single changeset section within a multi-changeset YAML +func generateChangesetSection( + changesetName string, + cfg cs.Configurations, + resolverManager *resolvers.ConfigResolverManager, + indent string, + depthLimit int, +) (string, error) { + var section strings.Builder + + // Add changeset header comment + if cfg.ConfigResolver != nil { + resolverName := resolverManager.NameOf(cfg.ConfigResolver) + if resolverName == "" { + return "", fmt.Errorf("resolver for changeset %s is not registered", changesetName) + } + + // Use reflection to get the input type of the resolver + rf := reflect.TypeOf(cfg.ConfigResolver) + if rf.Kind() != reflect.Func || rf.NumIn() != 1 { + return "", fmt.Errorf("invalid resolver signature for %s", changesetName) + } + + inputType := rf.In(0) + + section.WriteString(fmt.Sprintf("%s# Config Resolver: %s\n", indent, resolverName)) + section.WriteString(fmt.Sprintf("%s# Input type: %s\n", indent, inputType.String())) + section.WriteString(fmt.Sprintf("%s- %s:\n", indent, changesetName)) + + // Add chainOverrides at the changeset level (before payload) + writeChainOverridesSection(§ion, indent) + + section.WriteString(indent + " payload:\n") + + // Generate the payload structure from the struct + payloadYAML, err := generateStructYAMLWithDepthLimit(inputType, indent+" ", 0, make(map[reflect.Type]bool), depthLimit) + if err != nil { + return "", fmt.Errorf("generate struct YAML for %s: %w", inputType.String(), err) + } + + section.WriteString(payloadYAML) + } else if cfg.InputType != nil { + // We have type information - generate template based on it + section.WriteString(fmt.Sprintf("%s# Input type: %s\n", indent, cfg.InputType.String())) + section.WriteString(fmt.Sprintf("%s- %s:\n", indent, changesetName)) + + // Add chainOverrides at the changeset level (before payload) + writeChainOverridesSection(§ion, indent) + + section.WriteString(indent + " payload:\n") + + // Generate the payload structure from the struct + payloadYAML, err := generateStructYAMLWithDepthLimit(cfg.InputType, indent+" ", 0, make(map[reflect.Type]bool), depthLimit) + if err != nil { + return "", fmt.Errorf("generate struct YAML for %s: %w", cfg.InputType.String(), err) + } + + section.WriteString(payloadYAML) + } + + return section.String(), nil +} + +// generateStructYAMLWithDepthLimit recursively generates YAML structure with user-configurable depth limiting +func generateStructYAMLWithDepthLimit( + t reflect.Type, + indent string, + depth int, + visited map[reflect.Type]bool, + maxDepth int, +) (string, error) { + if depth > maxDepth { + return "", nil + } + + // Handle pointers + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + // Check for cycles + if visited[t] { + return fmt.Sprintf("# ... (circular reference to %s)\n", t.String()), nil + } + + switch t.Kind() { //nolint:exhaustive // default case handles unspecified types + case reflect.Struct: + // Mark this type as visited for cycle detection + visited[t] = true + defer func() { delete(visited, t) }() + + var result strings.Builder + fieldCount := 0 + maxFields := 20 // Limit number of fields to show + + for i := 0; i < t.NumField() && fieldCount < maxFields; i++ { + field := t.Field(i) + + // Skip unexported fields + if !field.IsExported() { + continue + } + + // Skip fields with yaml:"-" or json:"-" tag + if yamlTag := field.Tag.Get("yaml"); yamlTag == "-" { + continue + } + if jsonTag := field.Tag.Get("json"); jsonTag == "-" { + continue + } + + // Get field name from yaml/json tags or use field name + fieldName := getFieldName(field) + fieldType := field.Type + + // Generate value based on field type + fieldValue, err := generateFieldValueWithDepthLimit(fieldType, indent+" ", depth+1, visited, maxDepth) + if err != nil { + return "", fmt.Errorf("generate field value for %s: %w", field.Name, err) + } + + result.WriteString(fmt.Sprintf("%s%s:", indent, fieldName)) + result.WriteString(fieldValue) + if !strings.HasSuffix(fieldValue, "\n") { + result.WriteString("\n") + } + fieldCount++ + } + + if t.NumField() > maxFields { + result.WriteString(fmt.Sprintf("%s# ... and %d more fields\n", indent, t.NumField()-maxFields)) + } + + return result.String(), nil + + case reflect.Slice, reflect.Array: + elemType := t.Elem() + result := fmt.Sprintf("%s# Array of %s\n%s- ", indent, elemType.String(), indent) + + elemValue, err := generateFieldValueWithDepthLimit(elemType, indent+" ", depth+1, visited, maxDepth) + if err != nil { + return "", err + } + + return result + strings.TrimSpace(elemValue) + "\n", nil + + case reflect.Map: + keyType := t.Key() + valueType := t.Elem() + + // Special handling for map[string]interface{} - show the interface{} type + if keyType.Kind() == reflect.String && valueType.Kind() == reflect.Interface { + result := fmt.Sprintf("%s# Map[%s]%s\n", indent, keyType.String(), valueType.String()) + result += fmt.Sprintf("%sexample_key: # %s\n", indent, valueType.String()) + + return result, nil + } + + result := fmt.Sprintf("%s# Map[%s]%s\n%sexample_key: ", indent, keyType.String(), valueType.String(), indent) + + valueStr, err := generateFieldValueWithDepthLimit(valueType, indent+" ", depth+1, visited, maxDepth) + if err != nil { + return "", err + } + + return result + strings.TrimSpace(valueStr) + "\n", nil + + default: + return " # " + t.String(), nil + } +} + +// generateFieldValueWithDepthLimit generates an example value for a field based on its type with user-configurable depth limiting +func generateFieldValueWithDepthLimit( + t reflect.Type, + indent string, + depth int, + visited map[reflect.Type]bool, + maxDepth int, +) (string, error) { + if depth > maxDepth { + return " ...", nil + } + + // Handle pointers + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch t.Kind() { //nolint:exhaustive // default case handles unspecified types + case reflect.String: + return " # string", nil + case reflect.Bool: + return " # bool", nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return " # " + t.String(), nil + case reflect.Slice, reflect.Array: + // Special case: if it's a slice/array of uint8 (bytes), treat it as a string type + if t.Elem().Kind() == reflect.Uint8 { + return " # " + t.String(), nil + } + // Regular slice/array handling + elemType := t.Elem() + elemValue, err := generateFieldValueWithDepthLimit(elemType, indent+" ", depth+1, visited, maxDepth) + if err != nil { + return "", err + } + + return fmt.Sprintf("\n%s- %s", indent, strings.TrimSpace(elemValue)), nil + case reflect.Struct: + structYAML, err := generateStructYAMLWithDepthLimit(t, indent, depth+1, visited, maxDepth) + if err != nil { + return "", err + } + + return "\n" + structYAML, nil + case reflect.Map: + keyType := t.Key() + valueType := t.Elem() + valueStr, err := generateFieldValueWithDepthLimit(valueType, indent+" ", depth+1, visited, maxDepth) + if err != nil { + return "", err + } + + var keyExample string + switch keyType.Kind() { //nolint:exhaustive // default case handles unspecified types + case reflect.String: + keyExample = "example_key" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + keyExample = "123" + default: + keyExample = "example_key" + } + + return fmt.Sprintf("\n%s%s: %s", indent, keyExample, strings.TrimSpace(valueStr)), nil + case reflect.Interface: + return `"interface{} - provide appropriate value"`, nil + default: + return fmt.Sprintf(`"unknown_type_%s"`, t.Kind().String()), nil + } +} + +// getFieldName extracts the field name from yaml or json tags, falling back to the struct field name +func getFieldName(field reflect.StructField) string { + // Try yaml tag first + if yamlTag := field.Tag.Get("yaml"); yamlTag != "" { + if parts := strings.Split(yamlTag, ","); len(parts) > 0 && parts[0] != "" { + return parts[0] + } + } + + // Try json tag + if jsonTag := field.Tag.Get("json"); jsonTag != "" { + if parts := strings.Split(jsonTag, ","); len(parts) > 0 && parts[0] != "" { + return parts[0] + } + } + + // Fall back to field name in lowercase + return strings.ToLower(field.Name) +} + +// writeChainOverridesSection writes the common chain overrides comment section +func writeChainOverridesSection(section *strings.Builder, indent string) { + section.WriteString(indent + " # Optional: Chain overrides (uncomment if needed)\n") + section.WriteString(indent + " # chainOverrides:\n") + section.WriteString(indent + " # - 1 # Chain selector 1\n") + section.WriteString(indent + " # - 2 # Chain selector 2\n") +} diff --git a/engine/cld/legacy/cli/commands/template_input_test.go b/engine/cld/legacy/cli/commands/template_input_test.go new file mode 100644 index 00000000..67f345da --- /dev/null +++ b/engine/cld/legacy/cli/commands/template_input_test.go @@ -0,0 +1,837 @@ +package commands + +import ( + "reflect" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/stretchr/testify/require" + + fresolvers "github.com/smartcontractkit/chainlink-deployments-framework/changeset/resolvers" + fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset" + fdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" +) + +// Test input types for template generation +type SimpleInput struct { + Name string `yaml:"name" json:"name"` + Value int `yaml:"value" json:"value"` + Flag bool `yaml:"flag" json:"flag"` +} + +type ComplexInput struct { + BasicField string `yaml:"basic_field" json:"basic_field"` + NumberField uint64 `yaml:"number_field" json:"number_field"` + Address common.Address `yaml:"address" json:"address"` + FloatField float64 `yaml:"float_field" json:"float_field"` + SliceField []string `yaml:"slice_field" json:"slice_field"` + MapField map[string]int `yaml:"map_field" json:"map_field"` + InterfaceMap map[string]any `yaml:"interface_map" json:"interface_map"` + NestedStruct *SimpleInput `yaml:"nested_struct" json:"nested_struct"` + PointerField *string `yaml:"pointer_field" json:"pointer_field"` +} + +type DeepNestedInput struct { + Level1 struct { + Level2 struct { + Level3 struct { + Value string `yaml:"value" json:"value"` + } `yaml:"level3" json:"level3"` + } `yaml:"level2" json:"level2"` + } `yaml:"level1" json:"level1"` +} + +// Mock resolver for testing +func MockTemplateResolver(input map[string]any) (any, error) { + return map[string]any{"resolved": true}, nil +} + +// Test changesets that implement fdeployment.ChangeSetV2 with specific input types +type SimpleInputChangeset struct{} + +func (s *SimpleInputChangeset) Apply(_ fdeployment.Environment, _ SimpleInput) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{}, nil +} + +func (s *SimpleInputChangeset) VerifyPreconditions(_ fdeployment.Environment, _ SimpleInput) error { + return nil +} + +type ComplexInputChangeset struct{} + +func (c *ComplexInputChangeset) Apply(_ fdeployment.Environment, _ ComplexInput) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{}, nil +} + +func (c *ComplexInputChangeset) VerifyPreconditions(_ fdeployment.Environment, _ ComplexInput) error { + return nil +} + +type DeepNestedInputChangeset struct{} + +func (d *DeepNestedInputChangeset) Apply(_ fdeployment.Environment, _ DeepNestedInput) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{}, nil +} + +func (d *DeepNestedInputChangeset) VerifyPreconditions(_ fdeployment.Environment, _ DeepNestedInput) error { + return nil +} + +type SliceInputChangeset struct{} + +func (s *SliceInputChangeset) Apply(_ fdeployment.Environment, _ []uint64) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{}, nil +} + +func (s *SliceInputChangeset) VerifyPreconditions(_ fdeployment.Environment, _ []uint64) error { + return nil +} + +type MapInputChangeset struct{} + +func (m *MapInputChangeset) Apply(_ fdeployment.Environment, _ map[string]int) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{}, nil +} + +func (m *MapInputChangeset) VerifyPreconditions(_ fdeployment.Environment, _ map[string]int) error { + return nil +} + +type IgnoredFieldsInput struct { + VisibleField string `yaml:"visible_field" json:"visible_field"` + AnotherVisible int `yaml:"another_visible" json:"another_visible"` + YamlIgnored string `yaml:"-"` + JsonIgnored string `json:"-"` + BothIgnored string `yaml:"-" json:"-"` + unexportedField string //nolint:unused // This should also be ignored +} + +type IgnoredFieldsChangeset struct{} + +func (i *IgnoredFieldsChangeset) Apply(_ fdeployment.Environment, _ IgnoredFieldsInput) (fdeployment.ChangesetOutput, error) { + return fdeployment.ChangesetOutput{}, nil +} + +func (i *IgnoredFieldsChangeset) VerifyPreconditions(_ fdeployment.Environment, _ IgnoredFieldsInput) error { + return nil +} + +func TestNewDurablePipelineTemplateInputCmd(t *testing.T) { + t.Parallel() + + env := "testnet" + + tests := []struct { + name string + args []string + setupMocks func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) + expectedErr string + expectedYAML string + checkOutputFile func(t *testing.T, outputPath string) + }{ + { + name: "successful template generation for simple input", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0001_simple_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs := &SimpleInputChangeset{} + registry.Add("0001_simple_changeset", changeset.Configure(cs).With(SimpleInput{})) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedYAML: `# Generated via template-input command +environment: testnet +domain: test +changesets: + # Input type: commands.SimpleInput + - 0001_simple_changeset: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + name: # string + value: # int + flag: # bool +`, + }, + { + name: "successful template generation with config resolver", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0002_resolver_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + resolverManager.Register(MockTemplateResolver, fresolvers.ResolverInfo{ + Description: "Test Template Resolver", + ExampleYAML: "test: value", + }) + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs := &stubChangeset{resolver: MockTemplateResolver} + registry.Add("0002_resolver_changeset", changeset.Configure(cs).WithConfigResolver(MockTemplateResolver)) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedYAML: `# Generated via template-input command +environment: testnet +domain: test +changesets: + # Config Resolver: github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/legacy/cli/commands.MockTemplateResolver + # Input type: map[string]interface {} + - 0002_resolver_changeset: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + # Map[string]interface {} + example_key: # interface {} +`, + }, + { + name: "template generation with complex input types", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0003_complex_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs := &ComplexInputChangeset{} + registry.Add("0003_complex_changeset", changeset.Configure(cs).With(ComplexInput{})) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedYAML: `# Generated via template-input command +environment: testnet +domain: test +changesets: + # Input type: commands.ComplexInput + - 0003_complex_changeset: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + basic_field: # string + number_field: # uint64 + address: # common.Address + float_field: # float64 + slice_field: + - # string + map_field: + example_key: # int + interface_map: + example_key: "interface{} - provide appropriate value" + nested_struct: + name: # string + value: # int + flag: # bool + pointer_field: # string +`, + }, + { + name: "template generation with multiple changesets", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0004_changeset1,0005_changeset2", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs1 := &SimpleInputChangeset{} + registry.Add("0004_changeset1", changeset.Configure(cs1).With(SimpleInput{})) + + cs2 := &SimpleInputChangeset{} + registry.Add("0005_changeset2", changeset.Configure(cs2).With(SimpleInput{})) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedYAML: `# Generated via template-input command +environment: testnet +domain: test +changesets: + # Input type: commands.SimpleInput + - 0004_changeset1: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + name: # string + value: # int + flag: # bool + + # ---------------------------------------- + # Input type: commands.SimpleInput + - 0005_changeset2: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + name: # string + value: # int + flag: # bool +`, + }, + { + name: "template generation with depth limit", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0006_deep_changeset", + "--depth", "2", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs := &DeepNestedInputChangeset{} + registry.Add("0006_deep_changeset", changeset.Configure(cs).With(DeepNestedInput{})) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedYAML: `# Generated via template-input command +environment: testnet +domain: test +changesets: + # Input type: commands.DeepNestedInput + - 0006_deep_changeset: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + level1: + level2: ... +`, + }, + { + name: "template generation with root-level slice type", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0008_slice_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs := &SliceInputChangeset{} + registry.Add("0008_slice_changeset", changeset.Configure(cs).With([]uint64{})) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedYAML: `# Generated via template-input command +environment: testnet +domain: test +changesets: + # Input type: []uint64 + - 0008_slice_changeset: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + # Array of uint64 + - # uint64 +`, + }, + { + name: "template generation with root-level map type", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0009_map_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs := &MapInputChangeset{} + registry.Add("0009_map_changeset", changeset.Configure(cs).With(map[string]int{})) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedYAML: `# Generated via template-input command +environment: testnet +domain: test +changesets: + # Input type: map[string]int + - 0009_map_changeset: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + # Map[string]int + example_key: # int +`, + }, + { + name: "template generation with ignored fields", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0010_ignored_fields_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs := &IgnoredFieldsChangeset{} + registry.Add("0010_ignored_fields_changeset", changeset.Configure(cs).With(IgnoredFieldsInput{})) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedYAML: `# Generated via template-input command +environment: testnet +domain: test +changesets: + # Input type: commands.IgnoredFieldsInput + - 0010_ignored_fields_changeset: + # Optional: Chain overrides (uncomment if needed) + # chainOverrides: + # - 1 # Chain selector 1 + # - 2 # Chain selector 2 + payload: + visible_field: # string + another_visible: # int +`, + }, + { + name: "missing environment flag", + args: []string{ + "template-input", + "--changeset", "0008_test_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + return changeset.NewChangesetsRegistry(), fresolvers.NewConfigResolverManager(), nil + }, + expectedErr: "required flag(s) \"environment\" not set", + }, + { + name: "missing changeset flag", + args: []string{ + "template-input", + "--environment", env, + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + return changeset.NewChangesetsRegistry(), fresolvers.NewConfigResolverManager(), nil + }, + expectedErr: "required flag(s) \"changeset\" not set", + }, + { + name: "nonexistent changeset", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "nonexistent_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + // Don't add the changeset + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), fresolvers.NewConfigResolverManager(), nil + }, + expectedErr: "get configurations for changeset nonexistent_changeset:", + }, + { + name: "unregistered resolver", + args: []string{ + "template-input", + "--environment", env, + "--changeset", "0007_unregistered_resolver_changeset", + }, + setupMocks: func() (*changeset.ChangesetsRegistry, *fresolvers.ConfigResolverManager, error) { + resolverManager := fresolvers.NewConfigResolverManager() + // Don't register the resolver + + rp := migrationsRegistryProviderStub{ + BaseRegistryProvider: changeset.NewBaseRegistryProvider(), + AddMigrationAction: func(registry *changeset.ChangesetsRegistry) { + cs := &stubChangeset{resolver: MockTemplateResolver} + registry.Add("0007_unregistered_resolver_changeset", changeset.Configure(cs).WithConfigResolver(MockTemplateResolver)) + }, + } + + if err := rp.Init(); err != nil { + return nil, nil, err + } + + return rp.Registry(), resolverManager, nil + }, + expectedErr: "resolver for changeset 0007_unregistered_resolver_changeset is not registered", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + testDomain := fdomain.NewDomain(t.TempDir(), "test") + registry, resolverManager, mockErr := tt.setupMocks() + + sharedCommands := NewCommands(logger.Test(t)) + rootCmd := sharedCommands.NewDurablePipelineCmds( + testDomain, + func(envName string) (*changeset.ChangesetsRegistry, error) { + require.Equal(t, env, envName) + if mockErr != nil { + return nil, mockErr + } + + return registry, nil + }, + nil, // No proposal context needed for template generation + resolverManager, + ) + + require.NotNil(t, rootCmd) + rootCmd.SetArgs(tt.args) + + // Capture output using SetOut + var output strings.Builder + rootCmd.SetOut(&output) + + err := rootCmd.Execute() + + if tt.expectedErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErr) + } else { + require.NoError(t, err) + + outputStr := output.String() + + // Assert exact YAML match + require.Equal(t, tt.expectedYAML, outputStr, "Generated YAML should match expected format exactly") + + // Check output file if specified + if tt.checkOutputFile != nil { + // Find --output flag and check the file + for i, arg := range tt.args { + if arg == "--output" && i+1 < len(tt.args) { + tt.checkOutputFile(t, tt.args[i+1]) + break + } + } + } + } + }) + } +} + +func TestGenerateFieldValueWithDepthLimit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputType reflect.Type + expectedOutput string + maxDepth int + description string + }{ + { + name: "string type", + inputType: reflect.TypeOf(""), + expectedOutput: " # string", + maxDepth: 5, + description: "Should generate type comment for string", + }, + { + name: "int type", + inputType: reflect.TypeOf(0), + expectedOutput: " # int", + maxDepth: 5, + description: "Should generate type comment for int", + }, + { + name: "bool type", + inputType: reflect.TypeOf(false), + expectedOutput: " # bool", + maxDepth: 5, + description: "Should generate type comment for bool", + }, + { + name: "uint64 type", + inputType: reflect.TypeOf(uint64(0)), + expectedOutput: " # uint64", + maxDepth: 5, + description: "Should generate type comment for uint64", + }, + { + name: "common.Address type", + inputType: reflect.TypeOf(common.Address{}), + expectedOutput: " # common.Address", + maxDepth: 5, + description: "Should generate type comment for common.Address", + }, + { + name: "float64 type", + inputType: reflect.TypeOf(float64(0)), + expectedOutput: " # float64", + maxDepth: 5, + description: "Should generate type comment for float64", + }, + { + name: "pointer type", + inputType: reflect.TypeOf((*string)(nil)), + expectedOutput: " # string", + maxDepth: 5, + description: "Should handle pointer types by dereferencing", + }, + { + name: "slice type", + inputType: reflect.TypeOf([]string{}), + expectedOutput: "\n - # string", + maxDepth: 5, + description: "Should generate array format for slice", + }, + { + name: "map type", + inputType: reflect.TypeOf(map[string]int{}), + expectedOutput: "\n example_key: # int", + maxDepth: 5, + description: "Should generate map format", + }, + { + name: "interface type", + inputType: reflect.TypeOf((*interface{})(nil)).Elem(), + expectedOutput: `"interface{} - provide appropriate value"`, + maxDepth: 5, + description: "Should handle interface{} type", + }, + { + name: "depth exceeded", + inputType: reflect.TypeOf(""), + expectedOutput: " ...", + maxDepth: 0, + description: "Should return ... when depth is exceeded", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := generateFieldValueWithDepthLimit(tt.inputType, " ", 1, make(map[reflect.Type]bool), tt.maxDepth) + require.NoError(t, err, tt.description) + require.Equal(t, tt.expectedOutput, result, tt.description) + }) + } +} + +func TestGenerateStructYAMLWithDepthLimit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputType reflect.Type + expectedFields []string + maxDepth int + description string + }{ + { + name: "simple struct", + inputType: reflect.TypeOf(SimpleInput{}), + expectedFields: []string{ + "name: # string", + "value: # int", + "flag: # bool", + }, + maxDepth: 5, + description: "Should generate YAML for simple struct", + }, + { + name: "complex struct with nested types", + inputType: reflect.TypeOf(ComplexInput{}), + expectedFields: []string{ + "basic_field: # string", + "slice_field:", + "- # string", + "map_field:", + "example_key: # int", + }, + maxDepth: 5, + description: "Should generate YAML for complex struct with nested types", + }, + { + name: "depth limited struct", + inputType: reflect.TypeOf(DeepNestedInput{}), + expectedFields: []string{ + "level1:", + }, + maxDepth: 1, + description: "Should respect depth limit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := generateStructYAMLWithDepthLimit(tt.inputType, " ", 0, make(map[reflect.Type]bool), tt.maxDepth) + require.NoError(t, err, tt.description) + + for _, expectedField := range tt.expectedFields { + require.Contains(t, result, expectedField, "Result should contain field: %q\nActual result:\n%s", expectedField, result) + } + }) + } +} + +func TestGetFieldName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + field reflect.StructField + expectedName string + description string + }{ + { + name: "yaml tag present", + field: reflect.StructField{ + Name: "TestField", + Tag: `yaml:"test_field" json:"testField"`, + }, + expectedName: "test_field", + description: "Should use yaml tag when present", + }, + { + name: "json tag only", + field: reflect.StructField{ + Name: "TestField", + Tag: `json:"testField"`, + }, + expectedName: "testField", + description: "Should use json tag when yaml tag is not present", + }, + { + name: "no tags", + field: reflect.StructField{ + Name: "TestField", + Tag: "", + }, + expectedName: "testfield", + description: "Should use lowercase field name when no tags present", + }, + { + name: "yaml tag with options", + field: reflect.StructField{ + Name: "TestField", + Tag: `yaml:"test_field,omitempty"`, + }, + expectedName: "test_field", + description: "Should extract field name from yaml tag ignoring options", + }, + { + name: "empty yaml tag", + field: reflect.StructField{ + Name: "TestField", + Tag: `yaml:"" json:"testField"`, + }, + expectedName: "testField", + description: "Should fallback to json tag when yaml tag is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := getFieldName(tt.field) + require.Equal(t, tt.expectedName, result, tt.description) + }) + } +}