// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package envcheck import ( "context" "encoding/json" "os/exec" "runtime" "strings" "time" "github.com/docker/docker/client" ) // RunnerCapabilities represents the capabilities of a runner for AI consumption type RunnerCapabilities struct { OS string `json:"os"` Arch string `json:"arch"` Docker bool `json:"docker"` DockerCompose bool `json:"docker_compose"` ContainerRuntime string `json:"container_runtime,omitempty"` Shell []string `json:"shell,omitempty"` Tools map[string][]string `json:"tools,omitempty"` Features *CapabilityFeatures `json:"features,omitempty"` Limitations []string `json:"limitations,omitempty"` } // CapabilityFeatures represents feature support flags type CapabilityFeatures struct { ArtifactsV4 bool `json:"artifacts_v4"` Cache bool `json:"cache"` Services bool `json:"services"` CompositeActions bool `json:"composite_actions"` } // DetectCapabilities detects the runner's capabilities func DetectCapabilities(ctx context.Context, dockerHost string) *RunnerCapabilities { cap := &RunnerCapabilities{ OS: runtime.GOOS, Arch: runtime.GOARCH, Tools: make(map[string][]string), Shell: detectShells(), Features: &CapabilityFeatures{ ArtifactsV4: false, // Gitea doesn't support v4 artifacts Cache: true, Services: true, CompositeActions: true, }, Limitations: []string{ "actions/upload-artifact@v4 not supported (use v3 or direct API upload)", "actions/download-artifact@v4 not supported (use v3)", }, } // Detect Docker cap.Docker, cap.ContainerRuntime = detectDocker(ctx, dockerHost) if cap.Docker { cap.DockerCompose = detectDockerCompose(ctx) cap.Features.Services = true } // Detect common tools detectTools(ctx, cap) return cap } // ToJSON converts capabilities to JSON string for transmission func (c *RunnerCapabilities) ToJSON() string { data, err := json.Marshal(c) if err != nil { return "{}" } return string(data) } func detectShells() []string { shells := []string{} switch runtime.GOOS { case "windows": if _, err := exec.LookPath("pwsh"); err == nil { shells = append(shells, "pwsh") } if _, err := exec.LookPath("powershell"); err == nil { shells = append(shells, "powershell") } shells = append(shells, "cmd") case "darwin": if _, err := exec.LookPath("zsh"); err == nil { shells = append(shells, "zsh") } if _, err := exec.LookPath("bash"); err == nil { shells = append(shells, "bash") } shells = append(shells, "sh") default: // linux and others if _, err := exec.LookPath("bash"); err == nil { shells = append(shells, "bash") } shells = append(shells, "sh") } return shells } func detectDocker(ctx context.Context, dockerHost string) (bool, string) { opts := []client.Opt{client.FromEnv} if dockerHost != "" { opts = append(opts, client.WithHost(dockerHost)) } cli, err := client.NewClientWithOpts(opts...) if err != nil { return false, "" } defer cli.Close() timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() _, err = cli.Ping(timeoutCtx) if err != nil { return false, "" } // Check if it's podman or docker info, err := cli.Info(timeoutCtx) if err == nil { if strings.Contains(strings.ToLower(info.Name), "podman") { return true, "podman" } } return true, "docker" } func detectDockerCompose(ctx context.Context) bool { // Check for docker compose v2 (docker compose) cmd := exec.CommandContext(ctx, "docker", "compose", "version") if err := cmd.Run(); err == nil { return true } // Check for docker-compose v1 if _, err := exec.LookPath("docker-compose"); err == nil { return true } return false } func detectTools(ctx context.Context, cap *RunnerCapabilities) { toolDetectors := map[string]func(context.Context) []string{ "node": detectNodeVersions, "go": detectGoVersions, "python": detectPythonVersions, "java": detectJavaVersions, "dotnet": detectDotnetVersions, "rust": detectRustVersions, } for tool, detector := range toolDetectors { if versions := detector(ctx); len(versions) > 0 { cap.Tools[tool] = versions } } } func detectNodeVersions(ctx context.Context) []string { return detectToolVersion(ctx, "node", "--version", "v") } func detectGoVersions(ctx context.Context) []string { return detectToolVersion(ctx, "go", "version", "go") } func detectPythonVersions(ctx context.Context) []string { versions := []string{} // Try python3 first if v := detectToolVersion(ctx, "python3", "--version", "Python "); len(v) > 0 { versions = append(versions, v...) } // Also try python if v := detectToolVersion(ctx, "python", "--version", "Python "); len(v) > 0 { // Avoid duplicates for _, ver := range v { found := false for _, existing := range versions { if existing == ver { found = true break } } if !found { versions = append(versions, ver) } } } return versions } func detectJavaVersions(ctx context.Context) []string { cmd := exec.CommandContext(ctx, "java", "-version") output, err := cmd.CombinedOutput() if err != nil { return nil } // Java version output goes to stderr and looks like: openjdk version "17.0.1" or java version "1.8.0_301" lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.Contains(line, "version") { // Extract version from quotes start := strings.Index(line, "\"") end := strings.LastIndex(line, "\"") if start != -1 && end > start { version := line[start+1 : end] // Simplify version (e.g., "17.0.1" -> "17") parts := strings.Split(version, ".") if len(parts) > 0 { if parts[0] == "1" && len(parts) > 1 { return []string{parts[1]} // Java 8 style: 1.8 -> 8 } return []string{parts[0]} } } } } return nil } func detectDotnetVersions(ctx context.Context) []string { cmd := exec.CommandContext(ctx, "dotnet", "--list-sdks") output, err := cmd.Output() if err != nil { return nil } versions := []string{} lines := strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Format: "8.0.100 [/path/to/sdk]" parts := strings.Split(line, " ") if len(parts) > 0 { version := parts[0] // Simplify to major version major := strings.Split(version, ".")[0] // Avoid duplicates found := false for _, v := range versions { if v == major { found = true break } } if !found { versions = append(versions, major) } } } return versions } func detectRustVersions(ctx context.Context) []string { return detectToolVersion(ctx, "rustc", "--version", "rustc ") } func detectToolVersion(ctx context.Context, cmd string, args string, prefix string) []string { timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() c := exec.CommandContext(timeoutCtx, cmd, args) output, err := c.Output() if err != nil { return nil } line := strings.TrimSpace(string(output)) if prefix != "" { if idx := strings.Index(line, prefix); idx != -1 { line = line[idx+len(prefix):] } } // Get just the version number parts := strings.Fields(line) if len(parts) > 0 { version := parts[0] // Clean up version string version = strings.TrimPrefix(version, "v") // Return major.minor or just major vparts := strings.Split(version, ".") if len(vparts) >= 2 { return []string{vparts[0] + "." + vparts[1]} } return []string{vparts[0]} } return nil }