Skip to content

Commit cb5ea10

Browse files
jongioCopilot
andauthored
refactor: split god modules into focused files (#287)
Split 3 large files (>500 lines) into smaller, single-responsibility modules: rpc/logs.go (833 -> 398 lines): - logs_store.go: store interfaces + LogsStoreFuncs adapter (123 lines) - logs_ring.go: localLogRing buffer implementation (85 lines) - logs_proto.go: proto conversion helpers (170 lines) commands/reqs.go (885 -> 260 lines): - reqs_checker.go: PrerequisiteChecker + command validation (303 lines) - reqs_version.go: version parsing/comparison utilities (126 lines) - reqs_fix.go: fix runner flow (213 lines) commands/run.go (769 -> 328 lines): - run_orchestration.go: service execution/monitoring lifecycle (347 lines) - run_hooks.go: hook execution/conversion (112 lines) All files now under 500 lines. No behavioral changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d770082 commit cb5ea10

11 files changed

Lines changed: 1686 additions & 1623 deletions

File tree

cli/src/cmd/app/commands/reqs.go

Lines changed: 2 additions & 717 deletions
Large diffs are not rendered by default.
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
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

Comments
 (0)