Some checks failed
CI / build-and-test (push) Failing after 2s
Release / build (amd64, darwin) (push) Successful in 6s
Release / build (amd64, linux) (push) Successful in 5s
Release / build (amd64, windows) (push) Successful in 6s
Release / build (arm64, darwin) (push) Successful in 5s
Release / build (arm64, linux) (push) Successful in 5s
Release / release (push) Successful in 11s
- Split detectDiskSpace() into platform-specific files with build tags
- disk_unix.go: Uses unix.Statfs for Linux and macOS
- disk_windows.go: Uses windows.GetDiskFreeSpaceEx for Windows
- Fixes Windows cross-compilation build errors
🤖 Generated with Claude Code
410 lines
10 KiB
Go
410 lines
10 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package envcheck
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/docker/docker/client"
|
|
)
|
|
|
|
// DiskInfo holds disk space information
|
|
type DiskInfo struct {
|
|
Total uint64 `json:"total_bytes"`
|
|
Free uint64 `json:"free_bytes"`
|
|
Used uint64 `json:"used_bytes"`
|
|
UsedPercent float64 `json:"used_percent"`
|
|
}
|
|
|
|
// DistroInfo holds Linux distribution information
|
|
type DistroInfo struct {
|
|
ID string `json:"id,omitempty"` // e.g., "ubuntu", "debian", "fedora"
|
|
VersionID string `json:"version_id,omitempty"` // e.g., "24.04", "12"
|
|
PrettyName string `json:"pretty_name,omitempty"` // e.g., "Ubuntu 24.04 LTS"
|
|
}
|
|
|
|
// RunnerCapabilities represents the capabilities of a runner for AI consumption
|
|
type RunnerCapabilities struct {
|
|
OS string `json:"os"`
|
|
Arch string `json:"arch"`
|
|
Distro *DistroInfo `json:"distro,omitempty"`
|
|
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"`
|
|
Disk *DiskInfo `json:"disk,omitempty"`
|
|
Bandwidth *BandwidthInfo `json:"bandwidth,omitempty"`
|
|
SuggestedLabels []string `json:"suggested_labels,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 Linux distribution
|
|
if runtime.GOOS == "linux" {
|
|
cap.Distro = detectLinuxDistro()
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Detect disk space
|
|
cap.Disk = detectDiskSpace()
|
|
|
|
// Generate suggested labels based on detected capabilities
|
|
cap.SuggestedLabels = generateSuggestedLabels(cap)
|
|
|
|
return cap
|
|
}
|
|
|
|
// detectLinuxDistro reads /etc/os-release to get distribution info
|
|
func detectLinuxDistro() *DistroInfo {
|
|
file, err := os.Open("/etc/os-release")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer file.Close()
|
|
|
|
distro := &DistroInfo{}
|
|
scanner := bufio.NewScanner(file)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "ID=") {
|
|
distro.ID = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
|
} else if strings.HasPrefix(line, "VERSION_ID=") {
|
|
distro.VersionID = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
|
|
} else if strings.HasPrefix(line, "PRETTY_NAME=") {
|
|
distro.PrettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
|
|
}
|
|
}
|
|
|
|
if distro.ID == "" {
|
|
return nil
|
|
}
|
|
|
|
return distro
|
|
}
|
|
|
|
// generateSuggestedLabels creates industry-standard labels based on capabilities
|
|
func generateSuggestedLabels(cap *RunnerCapabilities) []string {
|
|
labels := []string{}
|
|
seen := make(map[string]bool)
|
|
|
|
addLabel := func(label string) {
|
|
if label != "" && !seen[label] {
|
|
seen[label] = true
|
|
labels = append(labels, label)
|
|
}
|
|
}
|
|
|
|
// OS labels
|
|
switch cap.OS {
|
|
case "linux":
|
|
addLabel("linux")
|
|
addLabel("linux-latest")
|
|
case "windows":
|
|
addLabel("windows")
|
|
addLabel("windows-latest")
|
|
case "darwin":
|
|
addLabel("macos")
|
|
addLabel("macos-latest")
|
|
}
|
|
|
|
// Distro labels (Linux only)
|
|
if cap.Distro != nil && cap.Distro.ID != "" {
|
|
distro := strings.ToLower(cap.Distro.ID)
|
|
addLabel(distro)
|
|
addLabel(distro + "-latest")
|
|
}
|
|
|
|
return labels
|
|
}
|
|
|
|
|
|
// 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
|
|
}
|