From a6c08576a9def5b340bfbf78e02247d15761394a Mon Sep 17 00:00:00 2001 From: logikonline Date: Sat, 10 Jan 2026 04:49:54 -0500 Subject: [PATCH] Add runner capability detection and reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 5 +- go.sum | 18 +- internal/app/cmd/daemon.go | 13 +- internal/app/run/runner.go | 7 +- internal/pkg/envcheck/capabilities.go | 312 ++++++++++++++++++++++++++ 5 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 internal/pkg/envcheck/capabilities.go diff --git a/go.mod b/go.mod index 036d395..7042e77 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 toolchain go1.24.11 require ( - code.gitea.io/actions-proto-go v0.4.1 + code.gitea.io/actions-proto-go v0.5.0 code.gitea.io/gitea-vet v0.2.3 connectrpc.com/connect v1.16.2 github.com/avast/retry-go/v4 v4.6.0 @@ -108,3 +108,6 @@ replace github.com/go-git/go-git/v5 => github.com/go-git/go-git/v5 v5.16.2 // Remove after replace github.com/distribution/reference v0.6.0 => github.com/distribution/reference v0.5.0 + +// Use GitCaddy fork with capability support +replace code.gitea.io/actions-proto-go => git.marketally.com/gitcaddy/actions-proto-go v0.5.0 diff --git a/go.sum b/go.sum index 5a0c5c8..49b46b4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls= -code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas= code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= connectrpc.com/connect v1.16.2 h1:ybd6y+ls7GOlb7Bh5C8+ghA6SvCBajHwxssO2CGFjqE= @@ -8,6 +6,8 @@ cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +git.marketally.com/gitcaddy/actions-proto-go v0.5.0 h1:D2loMnqTXiaJL6TMfOOUJz4/3Vpv0AnMDSJVuiqMNrM= +git.marketally.com/gitcaddy/actions-proto-go v0.5.0/go.mod h1:li5RzZsj1sV8a0SXzXWsGNwv0dYw7Wj829AgloZqF5o= gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742 h1:ulcquQluJbmNASkh6ina70LvcHEa9eWYfQ+DeAZ0VEE= gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= @@ -51,8 +51,6 @@ github.com/docker/cli v25.0.3+incompatible h1:KLeNs7zws74oFuVhgZQ5ONGZiXUUdgsdy6 github.com/docker/cli v25.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v25.0.6+incompatible h1:5cPwbwriIcsua2REJe8HqQV+6WlWc1byg2QSXzBxBGg= -github.com/docker/docker v25.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v25.0.13+incompatible h1:YeBrkUd3q0ZoRDNoEzuopwCLU+uD8GZahDHwBdsTnkU= github.com/docker/docker v25.0.13+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= @@ -233,8 +231,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= @@ -248,8 +244,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -271,20 +265,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index 06810b2..9072147 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -136,8 +136,19 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu runner := run.NewRunner(cfg, reg, cli) + // Detect runner capabilities for AI-friendly workflow generation + dockerHost := cfg.Container.DockerHost + if dockerHost == "" { + if dh, err := getDockerSocketPath(""); err == nil { + dockerHost = dh + } + } + capabilities := envcheck.DetectCapabilities(ctx, dockerHost) + capabilitiesJson := capabilities.ToJSON() + log.Infof("detected capabilities: %s", capabilitiesJson) + // declare the labels of the runner before fetching tasks - resp, err := runner.Declare(ctx, ls.Names()) + resp, err := runner.Declare(ctx, ls.Names(), capabilitiesJson) if err != nil && connect.CodeOf(err) == connect.CodeUnimplemented { log.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v1.21 or later") return err diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 4b0f76b..295dfe6 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -249,9 +249,10 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. return execErr } -func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Response[runnerv1.DeclareResponse], error) { +func (r *Runner) Declare(ctx context.Context, labels []string, capabilitiesJson string) (*connect.Response[runnerv1.DeclareResponse], error) { return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{ - Version: ver.Version(), - Labels: labels, + Version: ver.Version(), + Labels: labels, + CapabilitiesJson: capabilitiesJson, })) } diff --git a/internal/pkg/envcheck/capabilities.go b/internal/pkg/envcheck/capabilities.go new file mode 100644 index 0000000..3cfd601 --- /dev/null +++ b/internal/pkg/envcheck/capabilities.go @@ -0,0 +1,312 @@ +// 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 +}