|
| 1 | +package scaffold |
| 2 | + |
| 3 | +import ( |
| 4 | + "embed" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "os/exec" |
| 8 | + "path/filepath" |
| 9 | + "strings" |
| 10 | + "text/template" |
| 11 | + |
| 12 | + cldf_domain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" |
| 13 | +) |
| 14 | + |
| 15 | +//go:embed templates/* |
| 16 | +var templates embed.FS |
| 17 | + |
| 18 | +// ScaffoldOptions holds configuration options for domain scaffolding. |
| 19 | +type ScaffoldOptions struct { |
| 20 | + runModTidy bool |
| 21 | +} |
| 22 | + |
| 23 | +// ScaffoldOption is a functional option type for configuring domain scaffolding. |
| 24 | +type ScaffoldOption func(*ScaffoldOptions) |
| 25 | + |
| 26 | +// WithModTidy enables running 'go mod tidy' after scaffolding. |
| 27 | +// This will resolve and download all dependencies for the new domain. |
| 28 | +func WithModTidy() ScaffoldOption { |
| 29 | + return func(opts *ScaffoldOptions) { |
| 30 | + opts.runModTidy = true |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +// defaultScaffoldOptions returns the default configuration for domain scaffolding. |
| 35 | +func defaultScaffoldOptions() *ScaffoldOptions { |
| 36 | + return &ScaffoldOptions{ |
| 37 | + runModTidy: false, // Don't run go mod tidy by default |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +// getRepositoryName extracts the repository name from the root directory path. |
| 42 | +func getRepositoryName(rootDir string) string { |
| 43 | + return filepath.Base(filepath.Dir(rootDir)) |
| 44 | +} |
| 45 | + |
| 46 | +// ScaffoldDomain creates a new domain directory structure within the specified base path. |
| 47 | +// Use WithModTidy() option to run 'go mod tidy' after scaffolding. |
| 48 | +func ScaffoldDomain(domain cldf_domain.Domain, opts ...ScaffoldOption) error { |
| 49 | + // Apply default options and then user-provided options |
| 50 | + options := defaultScaffoldOptions() |
| 51 | + for _, opt := range opts { |
| 52 | + opt(options) |
| 53 | + } |
| 54 | + |
| 55 | + var err error |
| 56 | + |
| 57 | + // Check if the directory already exists or if there is an error accessing it |
| 58 | + if err = checkDirExists(domain.DirPath()); err != nil { |
| 59 | + return fmt.Errorf("failed to create %s domain directory: %w", domain.String(), err) |
| 60 | + } |
| 61 | + |
| 62 | + // Setup all the args for the templates |
| 63 | + renderArgs := map[string]string{ |
| 64 | + "package": domain.Key(), |
| 65 | + "repo": getRepositoryName(domain.RootPath()), |
| 66 | + } |
| 67 | + |
| 68 | + // Define the structure |
| 69 | + structure := dirFSNode(domain.Key(), []*fsnode{ |
| 70 | + fileFSNode("go.mod", withTemplate("go.mod.tmpl")), |
| 71 | + dirFSNode("pkg", []*fsnode{gitKeepFSNode()}), |
| 72 | + dirFSNode(cldf_domain.LibDirName, []*fsnode{gitKeepFSNode()}), |
| 73 | + dirFSNode(cldf_domain.InternalDirName, []*fsnode{gitKeepFSNode()}), |
| 74 | + dirFSNode(cldf_domain.CmdDirName, []*fsnode{ |
| 75 | + fileFSNode("main.go", withTemplate("cmd_main.go.tmpl")), |
| 76 | + dirFSNode(cldf_domain.InternalDirName, []*fsnode{ |
| 77 | + dirFSNode("cli", []*fsnode{ |
| 78 | + fileFSNode("app.go", withTemplate("cmd_internal_app.go.tmpl")), |
| 79 | + }), |
| 80 | + }), |
| 81 | + }), |
| 82 | + dirFSNode(".config", []*fsnode{ |
| 83 | + dirFSNode("networks", []*fsnode{ |
| 84 | + fileFSNode("mainnet.yaml", withTemplate("mainnet.yaml.tmpl")), |
| 85 | + fileFSNode("testnet.yaml", withTemplate("testnet.yaml.tmpl")), |
| 86 | + }), |
| 87 | + dirFSNode("local", []*fsnode{gitKeepFSNode()}), |
| 88 | + dirFSNode("ci", []*fsnode{ |
| 89 | + fileFSNode("common.env", withTemplate("common.env.tmpl")), |
| 90 | + }), |
| 91 | + }), |
| 92 | + }) |
| 93 | + |
| 94 | + // Scaffold the domain structure |
| 95 | + if err := scaffold(structure, domain.RootPath(), renderArgs); err != nil { |
| 96 | + return err |
| 97 | + } |
| 98 | + |
| 99 | + // Run go mod tidy in the newly created domain directory if requested |
| 100 | + if options.runModTidy { |
| 101 | + domainPath := domain.DirPath() |
| 102 | + if err := runGoModTidy(domainPath); err != nil { |
| 103 | + return fmt.Errorf("failed to run 'go mod tidy' in %s: %w", domainPath, err) |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + return nil |
| 108 | +} |
| 109 | + |
| 110 | +// ScaffoldEnvDir creates a new environment directory structure within the specified base path. |
| 111 | +func ScaffoldEnvDir(envdir cldf_domain.EnvDir) error { |
| 112 | + // Check if the directory already exists or if there is an error accessing it |
| 113 | + if err := checkDirExists(envdir.DirPath()); err != nil { |
| 114 | + return fmt.Errorf("failed to create %s env directory: %w", envdir.String(), err) |
| 115 | + } |
| 116 | + |
| 117 | + // Setup all the args for the templates |
| 118 | + renderArgs := map[string]string{ |
| 119 | + "package": envdir.Key(), |
| 120 | + } |
| 121 | + |
| 122 | + // Define the structure |
| 123 | + structure := dirFSNode(envdir.Key(), []*fsnode{ |
| 124 | + fileFSNode(cldf_domain.AddressBookFileName, withTemplate("address_book.json.tmpl")), |
| 125 | + dirFSNode(cldf_domain.DatastoreDirName, []*fsnode{ |
| 126 | + fileFSNode(cldf_domain.AddressRefsFileName, withTemplate("address_refs.json.tmpl")), |
| 127 | + fileFSNode(cldf_domain.ChainMetadataFileName, withTemplate("chain_metadata.json.tmpl")), |
| 128 | + fileFSNode(cldf_domain.ContractMetadataFileName, withTemplate("contract_metadata.json.tmpl")), |
| 129 | + fileFSNode(cldf_domain.EnvMetadataFileName, withTemplate("env_metadata.json.tmpl")), |
| 130 | + }), |
| 131 | + fileFSNode(cldf_domain.NodesFileName, withTemplate("nodes.json.tmpl")), |
| 132 | + fileFSNode(cldf_domain.ViewStateFileName, withTemplate("view_state.json.tmpl")), |
| 133 | + fileFSNode(cldf_domain.MigrationsFileName, withTemplate("migrations.go.tmpl")), |
| 134 | + fileFSNode("migrations_test.go", withTemplate("migrations_test.go.tmpl")), |
| 135 | + fileFSNode(cldf_domain.MigrationsArchiveFileName, withTemplate("migrations_archive.go.tmpl")), |
| 136 | + fileFSNode(cldf_domain.DurablePipelinesFileName, withTemplate("durable_pipelines.go.tmpl")), |
| 137 | + fileFSNode("durable_pipelines_test.go", withTemplate("durable_pipelines_test.go.tmpl")), |
| 138 | + dirFSNode(cldf_domain.ArtifactsDirName, []*fsnode{gitKeepFSNode()}), |
| 139 | + dirFSNode(cldf_domain.ProposalsDirName, []*fsnode{gitKeepFSNode()}), |
| 140 | + dirFSNode(cldf_domain.ArchivedProposalsDirName, []*fsnode{gitKeepFSNode()}), |
| 141 | + dirFSNode(cldf_domain.DecodedProposalsDirName, []*fsnode{gitKeepFSNode()}), |
| 142 | + dirFSNode(cldf_domain.OperationsReportsDirName, []*fsnode{gitKeepFSNode()}), |
| 143 | + dirFSNode(cldf_domain.DurablePipelineDirName, []*fsnode{ |
| 144 | + gitKeepFSNode(), |
| 145 | + dirFSNode(cldf_domain.DurablePipelineInputsDirName, []*fsnode{gitKeepFSNode()}), |
| 146 | + }), |
| 147 | + }) |
| 148 | + |
| 149 | + return scaffold(structure, envdir.DomainDirPath(), renderArgs) |
| 150 | +} |
| 151 | + |
| 152 | +// fsnode represents a file system node, which can be either a directory or a file. |
| 153 | +type fsnode struct { |
| 154 | + // name is the name of the file or directory. |
| 155 | + name string |
| 156 | + // isDir indicates whether this node is a directory or a file. |
| 157 | + isDir bool |
| 158 | + // children contains the child nodes of this node. It is only used if this node is a directory. |
| 159 | + children []*fsnode |
| 160 | + // templateName is the name of the template file that will be used to render the file. It is |
| 161 | + // only used if this node is a file. |
| 162 | + templateName string |
| 163 | +} |
| 164 | + |
| 165 | +// scaffold walks the file system tree starting from the given root node and creates the |
| 166 | +// corresponding directories and files. It uses the provided basePath as the root directory |
| 167 | +// for the scaffolded structure. The renderArgs map is used to pass arguments to the template |
| 168 | +// rendering process for files that have a template associated with them. |
| 169 | +// |
| 170 | +// If an error occurs during the scaffolding process, the created directories and files will |
| 171 | +// be cleaned up to avoid leaving behind any partially created files or directories. |
| 172 | +func scaffold(root *fsnode, basePath string, renderArgs map[string]string) error { |
| 173 | + var err error |
| 174 | + |
| 175 | + currentPath := filepath.Join(basePath, root.name) |
| 176 | + |
| 177 | + // Clean up the directory if an error occurs |
| 178 | + defer func() { |
| 179 | + if err != nil { |
| 180 | + os.RemoveAll(currentPath) |
| 181 | + } |
| 182 | + }() |
| 183 | + |
| 184 | + if root.isDir { |
| 185 | + if err = os.MkdirAll(currentPath, os.ModePerm); err != nil { |
| 186 | + return err |
| 187 | + } |
| 188 | + |
| 189 | + for _, child := range root.children { |
| 190 | + if err := scaffold(child, currentPath, renderArgs); err != nil { |
| 191 | + return err |
| 192 | + } |
| 193 | + } |
| 194 | + } else { |
| 195 | + file, err := os.Create(currentPath) |
| 196 | + if err != nil { |
| 197 | + return err |
| 198 | + } |
| 199 | + defer file.Close() |
| 200 | + |
| 201 | + if root.templateName != "" { |
| 202 | + content, err := renderTemplate(root.templateName, renderArgs) |
| 203 | + if err != nil { |
| 204 | + return err |
| 205 | + } |
| 206 | + |
| 207 | + if _, err := file.WriteString(content); err != nil { |
| 208 | + return err |
| 209 | + } |
| 210 | + } |
| 211 | + } |
| 212 | + |
| 213 | + return nil |
| 214 | +} |
| 215 | + |
| 216 | +// dirFSNode constructs a directory fsnode with the given name and children. |
| 217 | +func dirFSNode(name string, children []*fsnode) *fsnode { |
| 218 | + return &fsnode{ |
| 219 | + name: name, |
| 220 | + isDir: true, |
| 221 | + children: children, |
| 222 | + } |
| 223 | +} |
| 224 | + |
| 225 | +// fileFSNode constructs a file fsnode with the given name. |
| 226 | +func fileFSNode(name string, opts ...func(*fsnode)) *fsnode { |
| 227 | + n := &fsnode{ |
| 228 | + name: name, |
| 229 | + isDir: false, |
| 230 | + } |
| 231 | + |
| 232 | + for _, opt := range opts { |
| 233 | + opt(n) |
| 234 | + } |
| 235 | + |
| 236 | + return n |
| 237 | +} |
| 238 | + |
| 239 | +// withTemplate sets the template name for the fsnode. |
| 240 | +func withTemplate(templateName string) func(*fsnode) { |
| 241 | + return func(n *fsnode) { |
| 242 | + n.templateName = templateName |
| 243 | + } |
| 244 | +} |
| 245 | + |
| 246 | +// gitKeepFSNode constructs a .gitkeep file fsnode to keep empty directories in git. Useful since |
| 247 | +// we have many empty directories in the scaffolded structures. |
| 248 | +func gitKeepFSNode() *fsnode { |
| 249 | + return fileFSNode(".gitkeep") |
| 250 | +} |
| 251 | + |
| 252 | +// renderTemplate renders a template with the given name and arguments. |
| 253 | +func renderTemplate(name string, renderArgs any) (string, error) { |
| 254 | + tmpls, err := template.New("").ParseFS(templates, "templates/*") |
| 255 | + if err != nil { |
| 256 | + return "", err |
| 257 | + } |
| 258 | + |
| 259 | + b := &strings.Builder{} |
| 260 | + if err = tmpls.ExecuteTemplate(b, name, renderArgs); err != nil { |
| 261 | + return "", err |
| 262 | + } |
| 263 | + |
| 264 | + return b.String(), nil |
| 265 | +} |
| 266 | + |
| 267 | +// checkDirExists checks if the domain directory exists, returning os.ErrExist or any access error. |
| 268 | +func checkDirExists(path string) error { |
| 269 | + _, err := os.Stat(path) |
| 270 | + if err == nil { |
| 271 | + return os.ErrExist |
| 272 | + } |
| 273 | + |
| 274 | + if !os.IsNotExist(err) { |
| 275 | + return err |
| 276 | + } |
| 277 | + |
| 278 | + return nil |
| 279 | +} |
| 280 | + |
| 281 | +// runGoModTidy runs 'go mod tidy' in the specified directory. |
| 282 | +func runGoModTidy(dir string) error { |
| 283 | + cmd := exec.Command("go", "mod", "tidy") |
| 284 | + cmd.Dir = dir |
| 285 | + |
| 286 | + // Capture both stdout and stderr |
| 287 | + output, err := cmd.CombinedOutput() |
| 288 | + if err != nil { |
| 289 | + return fmt.Errorf("go mod tidy failed: %w\nOutput: %s", err, string(output)) |
| 290 | + } |
| 291 | + |
| 292 | + return nil |
| 293 | +} |
0 commit comments