Some checks failed
release-nightly / release-image (map[tag_suffix: target:basic]) (push) Failing after 0s
release-nightly / release-image (map[tag_suffix:-dind-rootless target:dind-rootless]) (push) Failing after 0s
release-tag / goreleaser (push) Failing after 2s
release-tag / release-image (push) Failing after 0s
checks / check and test (push) Failing after 2s
release-nightly / goreleaser (push) Failing after 3s
release-nightly / release-image (map[tag_suffix:-dind target:dind]) (push) Failing after 0s
This change adds automatic detection of runner capabilities including: - OS and architecture - Docker/Podman availability and version - Docker Compose support - Available shells (bash, sh, pwsh, etc.) - Installed development tools (Node, Go, Python, Java, .NET, Rust) - Feature support flags (cache, services, composite actions) - Known limitations (artifact v4 not supported) Capabilities are detected on startup and sent to Gitea via the CapabilitiesJson field in DeclareRequest. This enables AI tools to query runner capabilities before generating workflows. Uses GitCaddy fork of actions-proto-go with capability support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
313 lines
7.6 KiB
Go
313 lines
7.6 KiB
Go
// 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
|
|
}
|