diff --git a/Makefile b/Makefile index ad8930a..369c2df 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ build: validate_plugin validate_version go get $(plugin_github_url)@$(plugin_version); \ fi && \ go mod tidy && \ + if [ -f replace.mod ]; then \ + cat replace.mod >> go.mod && \ + go mod tidy; \ + fi && \ $(MAKE) -f out/Makefile build # Copy the created binary from the work directory @@ -57,7 +61,11 @@ render: validate_plugin validate_version echo "go get $(plugin_github_url)@$(plugin_version)" && \ go get $(plugin_github_url)@$(plugin_version); \ fi && \ - go mod tidy + go mod tidy && \ + if [ -f replace.mod ]; then \ + cat replace.mod >> go.mod && \ + go mod tidy; \ + fi # Note: The work directory will contain the full code tree with rendered changes diff --git a/generate/generator.go b/generate/generator.go index cc62b22..1cea649 100644 --- a/generate/generator.go +++ b/generate/generator.go @@ -3,10 +3,13 @@ package main import ( + "encoding/json" "fmt" "os" + "os/exec" "path" "path/filepath" + "regexp" "strings" "text/template" ) @@ -17,9 +20,270 @@ type RenderData struct { Plugin string PluginGithubUrl string PluginVersion string + GoVersion string + GoToolchain string } -func RenderDir(templatePath, root, pluginAlias, pluginGithubUrl, pluginVersion string) { +type ModuleInfo struct { + Path string `json:"Path"` + Version string `json:"Version"` + Dir string `json:"Dir"` +} + +type GoVersionInfo struct { + Version string // Go version (e.g., "1.23.1") + Toolchain string // Go toolchain (e.g., "go1.24.1") +} + +// extractGoVersionInfo extracts Go version and toolchain information from go.mod content +func extractGoVersionInfo(content string) GoVersionInfo { + var info GoVersionInfo + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "go ") { + info.Version = strings.TrimSpace(strings.TrimPrefix(line, "go")) + } else if strings.HasPrefix(line, "toolchain ") { + info.Toolchain = strings.TrimSpace(strings.TrimPrefix(line, "toolchain")) + } + } + return info +} + +// validateGoVersionInfo validates the Go version format +func validateGoVersionInfo(info GoVersionInfo) error { + if info.Version != "" { + versionRegex := regexp.MustCompile(`^\d+\.\d+(\.\d+)?$`) + if !versionRegex.MatchString(info.Version) { + return fmt.Errorf("invalid Go version format: %s", info.Version) + } + } + if info.Toolchain != "" { + toolchainRegex := regexp.MustCompile(`^go\d+\.\d+(\.\d+)?(rc\d+|beta\d+)?$`) + if !toolchainRegex.MatchString(info.Toolchain) { + return fmt.Errorf("invalid Go toolchain format: %s", info.Toolchain) + } + } + return nil +} + +// validateGithubUrl validates that the URL is a valid GitHub repository URL +func validateGithubUrl(urlStr string) error { + if !strings.HasPrefix(urlStr, "github.com/") { + return fmt.Errorf("URL must be a GitHub repository URL (github.com/...)") + } + parts := strings.Split(urlStr, "/") + if len(parts) != 3 { + return fmt.Errorf("URL must be in format github.com/owner/repo") + } + return nil +} + +// validateModulePath validates a Go module path format +func validateModulePath(path string) error { + // Module path should be lowercase alphanumeric with dots, hyphens, and slashes + // Each component must be valid and follow Go module naming conventions + re := regexp.MustCompile(`^[a-z0-9]+(?:[-./][a-z0-9]+)*$`) + if !re.MatchString(path) { + return fmt.Errorf("invalid module path format: %s", path) + } + return nil +} + +// validateVersion validates a Go module version format +func validateVersion(version string) error { + if version == "" { + return nil // Version is optional + } + // Version should be in format: + // - v1.2.3 + // - v1.2.3-beta.1 + // - v1.2.3+build.1 + // - v0.0.0-20231201123456-abcdef123456 (pseudo-versions) + re := regexp.MustCompile(`^v\d+(\.\d+){0,2}(-\d{14}-[a-f0-9]{12}|(-[\w\-\.]+)?(\+[\w\-\.]+)?)?$`) + if !re.MatchString(version) { + return fmt.Errorf("invalid version format: %s", version) + } + return nil +} + +// validateReplaceDirective validates a replace directive format +func validateReplaceDirective(directive string) error { + // Split into original module and replacement + parts := strings.Split(strings.TrimSpace(directive), "=>") + if len(parts) != 2 { + return fmt.Errorf("invalid replace directive format (should be 'module => replacement [version]'): %s", directive) + } + + // Validate original module path + origModule := strings.TrimSpace(parts[0]) + if err := validateModulePath(origModule); err != nil { + return fmt.Errorf("invalid original module: %v", err) + } + + // Split replacement into module and version + replacement := strings.TrimSpace(parts[1]) + repParts := strings.Fields(replacement) + if len(repParts) == 0 || len(repParts) > 2 { + return fmt.Errorf("invalid replacement format (should be 'module [version]'): %s", replacement) + } + + // Validate replacement module path + if err := validateModulePath(repParts[0]); err != nil { + return fmt.Errorf("invalid replacement module: %v", err) + } + + // Validate version if present + if len(repParts) == 2 { + if err := validateVersion(repParts[1]); err != nil { + return fmt.Errorf("invalid replacement version: %v", err) + } + } + + return nil +} + +func getPluginReplaceDirectives(pluginGithubUrl string, targetDir string) (string, GoVersionInfo, error) { + // Validate GitHub URL + if err := validateGithubUrl(pluginGithubUrl); err != nil { + return "", GoVersionInfo{}, fmt.Errorf("invalid GitHub URL: %v", err) + } + + fmt.Printf("Getting replace directives for plugin: %s\n", pluginGithubUrl) + + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "plugin-mod") + if err != nil { + return "", GoVersionInfo{}, fmt.Errorf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + fmt.Printf("Created temp dir: %s\n", tmpDir) + + // Initialize a go module + cmd := exec.Command("go", "mod", "init", "temp") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + return "", GoVersionInfo{}, fmt.Errorf("failed to init module: %v", err) + } + + fmt.Printf("Initialized temp module\n") + + // Extract owner and repo from URL for safer command execution + urlParts := strings.Split(pluginGithubUrl, "/") + if len(urlParts) != 3 { + return "", GoVersionInfo{}, fmt.Errorf("invalid GitHub URL format") + } + safeUrl := fmt.Sprintf("github.com/%s/%s", urlParts[1], urlParts[2]) + + // Get the latest version of the plugin + cmd = exec.Command("go", "get") + cmd.Dir = tmpDir + cmd.Args = append(cmd.Args, safeUrl) // Append URL directly + if output, err := cmd.CombinedOutput(); err != nil { + return "", GoVersionInfo{}, fmt.Errorf("failed to get plugin: %v\nOutput: %s", err, output) + } + + fmt.Printf("Got plugin\n") + + // Get the module info + cmd = exec.Command("go", "list", "-m", "-json") + cmd.Dir = tmpDir + cmd.Args = append(cmd.Args, safeUrl) // Append the URL directly + output, err := cmd.Output() + if err != nil { + return "", GoVersionInfo{}, fmt.Errorf("failed to get module info: %v", err) + } + + fmt.Printf("Got module info: %s\n", output) + + var info ModuleInfo + if err := json.Unmarshal(output, &info); err != nil { + return "", GoVersionInfo{}, fmt.Errorf("failed to parse module info: %v", err) + } + + fmt.Printf("Module dir: %s\n", info.Dir) + + // Read the go.mod file + goModPath := filepath.Join(info.Dir, "go.mod") + content, err := os.ReadFile(goModPath) + if err != nil { + return "", GoVersionInfo{}, fmt.Errorf("failed to read go.mod: %v", err) + } + + fmt.Printf("Read go.mod: %s\n", content) + + // Extract Go version info + goInfo := extractGoVersionInfo(string(content)) + if err := validateGoVersionInfo(goInfo); err != nil { + return "", GoVersionInfo{}, fmt.Errorf("invalid Go version info in plugin go.mod: %v", err) + } + + // Extract replace directives + var replaces []string + lines := strings.Split(string(content), "\n") + inReplace := false + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "replace (") { + inReplace = true + continue + } + if inReplace { + if line == ")" { + inReplace = false + continue + } + if line != "" { + // Validate replace directive + if err := validateReplaceDirective(line); err != nil { + return "", GoVersionInfo{}, fmt.Errorf("invalid replace directive in plugin go.mod: %v", err) + } + replaces = append(replaces, line) + } + } else if strings.HasPrefix(line, "replace ") { + directive := strings.TrimPrefix(line, "replace ") + // Validate replace directive + if err := validateReplaceDirective(directive); err != nil { + return "", GoVersionInfo{}, fmt.Errorf("invalid replace directive in plugin go.mod: %v", err) + } + replaces = append(replaces, directive) + } + } + + fmt.Printf("Found replace directives: %v\n", replaces) + + var replaceContent string + if len(replaces) > 0 { + // Create replace directives content + var content strings.Builder + content.WriteString("\n// Replace directives from plugin\nreplace (\n") + for _, r := range replaces { + content.WriteString("\t") + content.WriteString(r) + content.WriteString("\n") + } + content.WriteString(")\n") + replaceContent = content.String() + + // Write to replace.mod file in the target directory + replaceModPath := filepath.Join(targetDir, "replace.mod") + if err := os.WriteFile(replaceModPath, []byte(replaceContent), 0644); err != nil { + return "", GoVersionInfo{}, fmt.Errorf("failed to write replace directives: %v", err) + } + } + + return replaceContent, goInfo, nil +} + +func RenderDir(templatePath, root, pluginAlias, pluginGithubUrl string) { + // Cache for plugin info + var ( + cachedReplaces string + cachedGoInfo GoVersionInfo + cachedUrl string + ) + var targetFilePath string err := filepath.Walk(templatePath, func(filePath string, info os.FileInfo, err error) error { if err != nil { @@ -29,27 +293,19 @@ func RenderDir(templatePath, root, pluginAlias, pluginGithubUrl, pluginVersion s fmt.Println("filePath:", filePath) if info.IsDir() { - // fmt.Println("not a file, continuing...\n") return nil } relativeFilePath := strings.TrimPrefix(filePath, root) - // fmt.Println("relative path:", relativeFilePath) ext := filepath.Ext(filePath) - // fmt.Println("extension:", ext) if ext != templateExt { - // fmt.Println("not tmpl, continuing...\n") return nil } templateFileName := strings.TrimPrefix(relativeFilePath, "/templates/") - // fmt.Println("template fileName:", templateFileName) fileName := strings.TrimSuffix(templateFileName, ext) - // fmt.Println("actual fileName:", fileName) - targetFilePath = path.Join(root, fileName) - // fmt.Println("targetFilePath:", targetFilePath) // read template file templateContent, err := os.ReadFile(filePath) @@ -64,11 +320,28 @@ func RenderDir(templatePath, root, pluginAlias, pluginGithubUrl, pluginVersion s // create a buffer to render the template var renderedContent strings.Builder + // If this is go.mod, get plugin info first + var goInfo GoVersionInfo + var replaces string + if strings.HasSuffix(targetFilePath, "go.mod") { + if cachedUrl != pluginGithubUrl { + var err error + cachedReplaces, cachedGoInfo, err = getPluginReplaceDirectives(pluginGithubUrl, root) + if err != nil { + fmt.Printf("Error getting plugin replace directives: %v\n", err) + } + cachedUrl = pluginGithubUrl + } + replaces = cachedReplaces + goInfo = cachedGoInfo + } + // define the data to be used in the template data := RenderData{ Plugin: pluginAlias, PluginGithubUrl: pluginGithubUrl, - PluginVersion: pluginVersion, + GoVersion: goInfo.Version, + GoToolchain: goInfo.Toolchain, } // execute the template with the data @@ -77,13 +350,20 @@ func RenderDir(templatePath, root, pluginAlias, pluginGithubUrl, pluginVersion s return err } + content := renderedContent.String() + + // If this is go.mod, append any replace directives + if strings.HasSuffix(targetFilePath, "go.mod") && replaces != "" { + content += replaces + } + if err := os.MkdirAll(filepath.Dir(targetFilePath), 0755); err != nil { fmt.Printf("Error creating directory: %v\n", err) return err } // write the rendered content to the target file - if err := os.WriteFile(targetFilePath, []byte(renderedContent.String()), 0644); err != nil { + if err := os.WriteFile(targetFilePath, []byte(content), 0644); err != nil { fmt.Printf("Error writing to target file: %v\n", err) return err } @@ -100,35 +380,23 @@ func RenderDir(templatePath, root, pluginAlias, pluginGithubUrl, pluginVersion s func main() { // Check if the correct number of command-line arguments are provided if len(os.Args) < 4 { - fmt.Println("Usage: go run generator.go [plugin_version] [pluginGithubUrl]") + fmt.Println("Usage: go run generator.go [pluginGithubUrl]") return } templatePath := os.Args[1] root := os.Args[2] plugin := os.Args[3] - var pluginVersion string var pluginGithubUrl string - // Check if pluginVersion is provided as a command-line argument - if len(os.Args) >= 5 { - pluginVersion = os.Args[4] - } - // Check if PluginGithubUrl is provided as a command-line argument - if len(os.Args) >= 6 { - pluginGithubUrl = os.Args[5] + if len(os.Args) >= 5 { + pluginGithubUrl = os.Args[4] } else { // If PluginGithubUrl is not provided, generate it based on PluginAlias pluginGithubUrl = "github.com/turbot/steampipe-plugin-" + plugin } - // If pluginVersion is provided but pluginGithubUrl is not, error out - if pluginVersion != "" && pluginGithubUrl == "" { - fmt.Println("Error: plugin_github_url is required when plugin_version is specified") - return - } - // Convert relative paths to absolute paths absTemplatePath, err := filepath.Abs(templatePath) if err != nil { @@ -142,5 +410,5 @@ func main() { return } - RenderDir(absTemplatePath, absRoot, plugin, pluginGithubUrl, pluginVersion) + RenderDir(absTemplatePath, absRoot, plugin, pluginGithubUrl) }