|
| 1 | +package commands |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "os/exec" |
| 7 | + "path/filepath" |
| 8 | + "strings" |
| 9 | + |
| 10 | + "github.com/jongio/azd-core/cliout" |
| 11 | +) |
| 12 | + |
| 13 | +var allowedCustomPrereqCommands = map[string]struct{}{ |
| 14 | + "air": {}, |
| 15 | + "aspire": {}, |
| 16 | + "az": {}, |
| 17 | + "azd": {}, |
| 18 | + "cargo": {}, |
| 19 | + toolDocker: {}, |
| 20 | + "dotnet": {}, |
| 21 | + "func": {}, |
| 22 | + "git": {}, |
| 23 | + "go": {}, |
| 24 | + "gradle": {}, |
| 25 | + "java": {}, |
| 26 | + "mvn": {}, |
| 27 | + "node": {}, |
| 28 | + "npm": {}, |
| 29 | + "pip": {}, |
| 30 | + "pipenv": {}, |
| 31 | + "pnpm": {}, |
| 32 | + "poetry": {}, |
| 33 | + "python": {}, |
| 34 | + "uv": {}, |
| 35 | + "yarn": {}, |
| 36 | +} |
| 37 | + |
| 38 | +func normalizeAllowedCommandName(command string) string { |
| 39 | + trimmed := strings.TrimSpace(command) |
| 40 | + if trimmed == "" || strings.ContainsAny(trimmed, `\\/`) { |
| 41 | + return "" |
| 42 | + } |
| 43 | + |
| 44 | + name := strings.ToLower(filepath.Base(trimmed)) |
| 45 | + return strings.TrimSuffix(name, ".exe") |
| 46 | +} |
| 47 | + |
| 48 | +func isAllowedCustomPrereqCommand(command string) bool { |
| 49 | + name := normalizeAllowedCommandName(command) |
| 50 | + if name == "" { |
| 51 | + return false |
| 52 | + } |
| 53 | + _, allowed := allowedCustomPrereqCommands[name] |
| 54 | + return allowed |
| 55 | +} |
| 56 | + |
| 57 | +func validateCustomPrereqCommand(fieldName, command string) error { |
| 58 | + if strings.TrimSpace(command) == "" { |
| 59 | + return nil |
| 60 | + } |
| 61 | + if !isAllowedCustomPrereqCommand(command) { |
| 62 | + return fmt.Errorf("invalid %s %q: only allowlisted tool commands are permitted", fieldName, command) |
| 63 | + } |
| 64 | + return nil |
| 65 | +} |
| 66 | + |
| 67 | +// installURLRegistry maps tool names to their installation page URLs. |
| 68 | +var installURLRegistry = map[string]string{ |
| 69 | + "node": "https://nodejs.org/", |
| 70 | + "npm": "https://nodejs.org/", |
| 71 | + "pnpm": "https://pnpm.io/installation", |
| 72 | + "yarn": "https://yarnpkg.com/getting-started/install", |
| 73 | + "python": "https://www.python.org/downloads/", |
| 74 | + "pip": "https://www.python.org/downloads/", |
| 75 | + "poetry": "https://python-poetry.org/docs/#installation", |
| 76 | + "uv": "https://docs.astral.sh/uv/getting-started/installation/", |
| 77 | + "pipenv": "https://pipenv.pypa.io/en/latest/installation.html", |
| 78 | + "dotnet": "https://dotnet.microsoft.com/download", |
| 79 | + "aspire": "https://learn.microsoft.com/dotnet/aspire/fundamentals/setup-tooling", |
| 80 | + toolDocker: "https://www.docker.com/products/docker-desktop", |
| 81 | + "git": "https://git-scm.com/downloads", |
| 82 | + "go": "https://go.dev/dl/", |
| 83 | + "azd": "https://aka.ms/install-azd", |
| 84 | + "az": "https://aka.ms/installazurecli", |
| 85 | + "air": "https://github.com/air-verse/air#installation", |
| 86 | + "func": "https://learn.microsoft.com/azure/azure-functions/functions-run-local#install-the-azure-functions-core-tools", |
| 87 | + "java": "https://adoptium.net/", |
| 88 | + "mvn": "https://maven.apache.org/install.html", |
| 89 | + "gradle": "https://gradle.org/install/", |
| 90 | + "gh": "https://cli.github.com/", |
| 91 | +} |
| 92 | + |
| 93 | +// PrerequisiteChecker handles checking of prerequisites. |
| 94 | +type PrerequisiteChecker struct { |
| 95 | + registry map[string]ToolConfig |
| 96 | + aliases map[string]string |
| 97 | +} |
| 98 | + |
| 99 | +// NewPrerequisiteChecker creates a new prerequisite checker. |
| 100 | +func NewPrerequisiteChecker() *PrerequisiteChecker { |
| 101 | + return &PrerequisiteChecker{ |
| 102 | + registry: toolRegistry, |
| 103 | + aliases: toolAliases, |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +// Check checks a prerequisite and returns structured result. |
| 108 | +func (pc *PrerequisiteChecker) Check(prereq Prerequisite) ReqResult { |
| 109 | + // Resolve install URL (custom overrides built-in) |
| 110 | + installURL := pc.getInstallURL(prereq) |
| 111 | + |
| 112 | + result := ReqResult{ |
| 113 | + Name: prereq.Name, |
| 114 | + Required: prereq.MinVersion, |
| 115 | + Satisfied: false, |
| 116 | + InstallURL: installURL, |
| 117 | + } |
| 118 | + |
| 119 | + if err := validateCustomPrereqCommand("command", prereq.Command); err != nil { |
| 120 | + result.Message = err.Error() |
| 121 | + return result |
| 122 | + } |
| 123 | + if prereq.CheckRunning { |
| 124 | + if err := validateCustomPrereqCommand("runningCheckCommand", prereq.RunningCheckCommand); err != nil { |
| 125 | + result.Message = err.Error() |
| 126 | + return result |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + installed, version, isPodman := pc.getInstalledVersion(prereq) |
| 131 | + result.Installed = installed |
| 132 | + result.Version = version |
| 133 | + result.IsPodman = isPodman |
| 134 | + |
| 135 | + if !installed { |
| 136 | + result.Message = "Not installed" |
| 137 | + if !cliout.IsJSON() { |
| 138 | + cliout.ItemError("%s: NOT INSTALLED (required: %s)", prereq.Name, prereq.MinVersion) |
| 139 | + if installURL != "" { |
| 140 | + cliout.Item(" Install: %s", installURL) |
| 141 | + } |
| 142 | + } |
| 143 | + return result |
| 144 | + } |
| 145 | + |
| 146 | + // When Podman is aliased to Docker, skip version comparison since version schemes differ. |
| 147 | + // Podman uses its own versioning (e.g., 5.7.0) which is not comparable to Docker versions (e.g., 20.10.0). |
| 148 | + if isPodman && prereq.Name == toolDocker { |
| 149 | + result.Message = "Podman detected (version check skipped)" |
| 150 | + if !cliout.IsJSON() { |
| 151 | + cliout.ItemSuccess("%s: %s via Podman (version check skipped)", prereq.Name, version) |
| 152 | + } |
| 153 | + // Continue to check if running if needed, otherwise mark satisfied |
| 154 | + if !prereq.CheckRunning { |
| 155 | + result.Satisfied = true |
| 156 | + return result |
| 157 | + } |
| 158 | + } else if version == "" { |
| 159 | + result.Message = "Version unknown" |
| 160 | + if !cliout.IsJSON() { |
| 161 | + cliout.ItemWarning("%s: INSTALLED (version unknown, required: %s)", prereq.Name, prereq.MinVersion) |
| 162 | + } |
| 163 | + // Continue to check if it's running if needed |
| 164 | + } else { |
| 165 | + versionOk := compareVersions(version, prereq.MinVersion) |
| 166 | + if !versionOk { |
| 167 | + result.Message = fmt.Sprintf("Version %s does not meet minimum %s", version, prereq.MinVersion) |
| 168 | + if !cliout.IsJSON() { |
| 169 | + cliout.ItemError("%s: %s (required: %s)", prereq.Name, version, prereq.MinVersion) |
| 170 | + if installURL != "" { |
| 171 | + cliout.Item(" Install: %s", installURL) |
| 172 | + } |
| 173 | + } |
| 174 | + return result |
| 175 | + } |
| 176 | + if !cliout.IsJSON() { |
| 177 | + cliout.ItemSuccess("%s: %s (required: %s)", prereq.Name, version, prereq.MinVersion) |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + // Check if the tool is running (if configured) |
| 182 | + if prereq.CheckRunning { |
| 183 | + result.CheckedRun = true |
| 184 | + isRunning := pc.checkIsRunning(prereq) |
| 185 | + result.Running = isRunning |
| 186 | + if !isRunning { |
| 187 | + result.Message = "Not running" |
| 188 | + if !cliout.IsJSON() { |
| 189 | + cliout.Item("- %s✗%s NOT RUNNING", cliout.Red, cliout.Reset) |
| 190 | + } |
| 191 | + return result |
| 192 | + } |
| 193 | + result.Satisfied = true |
| 194 | + result.Message = "Running" |
| 195 | + if !cliout.IsJSON() { |
| 196 | + cliout.Item("- %s✓%s RUNNING", cliout.Green, cliout.Reset) |
| 197 | + } |
| 198 | + return result |
| 199 | + } |
| 200 | + |
| 201 | + if version != "" { |
| 202 | + result.Satisfied = true |
| 203 | + result.Message = "Satisfied" |
| 204 | + } |
| 205 | + return result |
| 206 | +} |
| 207 | + |
| 208 | +// getInstallURL returns the install URL for a prerequisite. |
| 209 | +// Custom InstallURL in prerequisite takes precedence over built-in registry. |
| 210 | +func (pc *PrerequisiteChecker) getInstallURL(prereq Prerequisite) string { |
| 211 | + // Custom URL takes precedence |
| 212 | + if prereq.InstallURL != "" { |
| 213 | + return prereq.InstallURL |
| 214 | + } |
| 215 | + |
| 216 | + // Resolve aliases to canonical name |
| 217 | + tool := prereq.Name |
| 218 | + if canonical, isAlias := pc.aliases[tool]; isAlias { |
| 219 | + tool = canonical |
| 220 | + } |
| 221 | + |
| 222 | + // Look up in registry |
| 223 | + if url, found := installURLRegistry[tool]; found { |
| 224 | + return url |
| 225 | + } |
| 226 | + |
| 227 | + return "" |
| 228 | +} |
| 229 | + |
| 230 | +// getInstalledVersion gets the installed version of a prerequisite. |
| 231 | +// Returns isPodman=true when Podman is detected aliased to Docker. |
| 232 | +func (pc *PrerequisiteChecker) getInstalledVersion(prereq Prerequisite) (installed bool, version string, isPodman bool) { |
| 233 | + config := pc.getToolConfig(prereq) |
| 234 | + |
| 235 | + // #nosec G204 -- Command and args come from toolRegistry or validated azure.yaml prerequisite configuration |
| 236 | + cmd := exec.CommandContext(context.Background(), config.Command, config.Args...) |
| 237 | + output, err := cmd.CombinedOutput() |
| 238 | + if err != nil { |
| 239 | + return false, "", false |
| 240 | + } |
| 241 | + |
| 242 | + outputStr := strings.TrimSpace(string(output)) |
| 243 | + |
| 244 | + // Detect Podman aliased to Docker |
| 245 | + isPodman = strings.Contains(outputStr, "Podman Engine") |
| 246 | + |
| 247 | + version = extractVersion(config, outputStr) |
| 248 | + |
| 249 | + return true, version, isPodman |
| 250 | +} |
| 251 | + |
| 252 | +// getToolConfig gets the tool configuration for a prerequisite. |
| 253 | +func (pc *PrerequisiteChecker) getToolConfig(prereq Prerequisite) ToolConfig { |
| 254 | + // Check if custom configuration is provided in prerequisite |
| 255 | + if prereq.Command != "" { |
| 256 | + return ToolConfig{ |
| 257 | + Command: normalizeAllowedCommandName(prereq.Command), |
| 258 | + Args: prereq.Args, |
| 259 | + VersionPrefix: prereq.VersionPrefix, |
| 260 | + VersionField: prereq.VersionField, |
| 261 | + } |
| 262 | + } |
| 263 | + |
| 264 | + // Use registry-based configuration |
| 265 | + tool := prereq.Name |
| 266 | + |
| 267 | + // Resolve aliases to canonical name |
| 268 | + if canonical, isAlias := pc.aliases[tool]; isAlias { |
| 269 | + tool = canonical |
| 270 | + } |
| 271 | + |
| 272 | + // Look up tool configuration |
| 273 | + if config, found := pc.registry[tool]; found { |
| 274 | + return config |
| 275 | + } |
| 276 | + |
| 277 | + // Fallback: try generic --version with tool ID as command |
| 278 | + return ToolConfig{ |
| 279 | + Command: prereq.Name, |
| 280 | + Args: []string{"--version"}, |
| 281 | + } |
| 282 | +} |
| 283 | + |
| 284 | +// checkIsRunning checks if a prerequisite tool is currently running. |
| 285 | +func (pc *PrerequisiteChecker) checkIsRunning(prereq Prerequisite) bool { |
| 286 | + // If no custom running check is configured, use defaults based on tool ID |
| 287 | + command := prereq.RunningCheckCommand |
| 288 | + args := prereq.RunningCheckArgs |
| 289 | + expectedExitCode := 0 |
| 290 | + if prereq.RunningCheckExitCode != nil { |
| 291 | + expectedExitCode = *prereq.RunningCheckExitCode |
| 292 | + } |
| 293 | + |
| 294 | + // Default checks for known tools |
| 295 | + if command == "" { |
| 296 | + switch prereq.Name { |
| 297 | + case toolDocker: |
| 298 | + command = toolDocker |
| 299 | + args = []string{"ps"} |
| 300 | + default: |
| 301 | + // No default running check for this tool |
| 302 | + // Return false to indicate check is not configured properly |
| 303 | + // Users should provide RunningCheckCommand if checkRunning is true |
| 304 | + return false |
| 305 | + } |
| 306 | + } |
| 307 | + |
| 308 | + if prereq.RunningCheckCommand != "" { |
| 309 | + command = normalizeAllowedCommandName(command) |
| 310 | + } |
| 311 | + |
| 312 | + // #nosec G204 -- Command and args are validated against an allowlist or come from the built-in Docker check |
| 313 | + cmd := exec.CommandContext(context.Background(), command, args...) |
| 314 | + output, err := cmd.CombinedOutput() |
| 315 | + |
| 316 | + // Check exit code |
| 317 | + exitCode := 0 |
| 318 | + if err != nil { |
| 319 | + if exitErr, ok := err.(*exec.ExitError); ok { |
| 320 | + exitCode = exitErr.ExitCode() |
| 321 | + } else { |
| 322 | + // Command failed to execute |
| 323 | + return false |
| 324 | + } |
| 325 | + } |
| 326 | + |
| 327 | + if exitCode != expectedExitCode { |
| 328 | + return false |
| 329 | + } |
| 330 | + |
| 331 | + // If an expected substring is configured, check for it in the output |
| 332 | + if prereq.RunningCheckExpected != "" { |
| 333 | + outputStr := strings.TrimSpace(string(output)) |
| 334 | + return strings.Contains(outputStr, prereq.RunningCheckExpected) |
| 335 | + } |
| 336 | + |
| 337 | + return true |
| 338 | +} |
| 339 | + |
| 340 | +// Deprecated: Use PrerequisiteChecker.Check instead |
| 341 | +func checkPrerequisite(prereq Prerequisite) bool { |
| 342 | + checker := NewPrerequisiteChecker() |
| 343 | + result := checker.Check(prereq) |
| 344 | + return result.Satisfied |
| 345 | +} |
0 commit comments