feat: bootstrap official runner through SPIFFE facade
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// Package runnerbootstrap configures an unmodified, one-shot Gitea Runner to
|
||||
// consume exactly the task assigned by the controller facade.
|
||||
package runnerbootstrap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/runnerfacade"
|
||||
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvAssignmentID = "CI_ASSIGNMENT_ID"
|
||||
EnvCapability = "CI_RUNNER_CAPABILITY"
|
||||
EnvFacadeURL = "CI_RUNNER_FACADE_URL"
|
||||
EnvFacadeID = "CI_RUNNER_FACADE_SPIFFE_ID"
|
||||
EnvSPIFFEID = "CI_SPIFFE_ID"
|
||||
EnvBackend = "CI_RUNNER_BACKEND"
|
||||
)
|
||||
|
||||
// Bootstrap emits assignment-scoped launch configuration. FacadeURL is the
|
||||
// controller endpoint reached by the local SPIFFE proxy, not by Runner itself.
|
||||
type Bootstrap struct {
|
||||
Capabilities runnerfacade.Capabilities
|
||||
FacadeURL string
|
||||
FacadeSPIFFEID string
|
||||
}
|
||||
|
||||
func (b Bootstrap) Environment(assignment taskassignment.Assignment) (map[string]string, error) {
|
||||
if assignment.ID == "" || assignment.Identity.SPIFFEID == "" || b.FacadeSPIFFEID == "" {
|
||||
return nil, errors.New("assignment ID and SPIFFE ID are required")
|
||||
}
|
||||
parsed, err := url.Parse(b.FacadeURL)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("runner facade URL must be an absolute https URL")
|
||||
}
|
||||
capability := b.Capabilities.Issue(assignment.ID)
|
||||
if capability == "" {
|
||||
return nil, errors.New("runner capability issuer is not configured")
|
||||
}
|
||||
return map[string]string{
|
||||
EnvAssignmentID: assignment.ID,
|
||||
EnvCapability: capability,
|
||||
EnvFacadeURL: b.FacadeURL,
|
||||
EnvFacadeID: b.FacadeSPIFFEID,
|
||||
EnvSPIFFEID: assignment.Identity.SPIFFEID,
|
||||
EnvBackend: string(assignment.Backend),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Registration mirrors Gitea Runner v3.5.0's registration file schema. ID is
|
||||
// intentionally zero: the facade authenticates UUID and token and never uses
|
||||
// the server-issued runner database ID.
|
||||
type Registration struct {
|
||||
Warning string `json:"WARNING"`
|
||||
ID int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Address string `json:"address"`
|
||||
Labels []string `json:"labels"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
}
|
||||
|
||||
func RegistrationJSON(assignmentID, capability, localProxyURL string, backend taskassignment.Backend) ([]byte, error) {
|
||||
if assignmentID == "" || capability == "" {
|
||||
return nil, errors.New("assignment ID and runner capability are required")
|
||||
}
|
||||
parsed, err := url.Parse(localProxyURL)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.Host == "" {
|
||||
return nil, errors.New("local runner proxy URL must be an absolute http URL")
|
||||
}
|
||||
if backend != taskassignment.BackendPod && backend != taskassignment.BackendVM {
|
||||
return nil, fmt.Errorf("unsupported runner backend %q", backend)
|
||||
}
|
||||
registration := Registration{
|
||||
Warning: "Generated for one preassigned task by gitea-dynamic-runner.",
|
||||
UUID: assignmentID, Name: assignmentID, Token: capability,
|
||||
Address: localProxyURL, Labels: []string{"self-hosted", string(backend)}, Ephemeral: true,
|
||||
}
|
||||
data, err := json.MarshalIndent(registration, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package runnerbootstrap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/runnerfacade"
|
||||
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment"
|
||||
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskidentity"
|
||||
)
|
||||
|
||||
func testBootstrap(t *testing.T) Bootstrap {
|
||||
t.Helper()
|
||||
capabilities, err := runnerfacade.NewCapabilities([]byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Bootstrap{
|
||||
Capabilities: capabilities,
|
||||
FacadeURL: "https://runner-facade.gitea-actions.svc:8443",
|
||||
FacadeSPIFFEID: "spiffe://ddupan.top/ns/gitea-actions/sa/gitea-dynamic-runner",
|
||||
}
|
||||
}
|
||||
|
||||
func testAssignment() taskassignment.Assignment {
|
||||
return taskassignment.Assignment{
|
||||
ID: "gitea-task-42", Backend: taskassignment.BackendPod,
|
||||
Identity: taskidentity.Identity{SPIFFEID: "spiffe://ddupan.top/ci/owner/repo/publish"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentIsDeterministicAndAssignmentScoped(t *testing.T) {
|
||||
bootstrap := testBootstrap(t)
|
||||
first, err := bootstrap.Environment(testAssignment())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := bootstrap.Environment(testAssignment())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first[EnvCapability] == "" || first[EnvCapability] != second[EnvCapability] {
|
||||
t.Fatalf("capabilities = %q, %q", first[EnvCapability], second[EnvCapability])
|
||||
}
|
||||
if first[EnvAssignmentID] != "gitea-task-42" || first[EnvSPIFFEID] != testAssignment().Identity.SPIFFEID {
|
||||
t.Fatalf("environment = %#v", first)
|
||||
}
|
||||
if first[EnvBackend] != "pod" || first[EnvFacadeID] == "" {
|
||||
t.Fatalf("environment = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationMatchesOfficialRunnerSchema(t *testing.T) {
|
||||
data, err := RegistrationJSON("gitea-task-42", "capability", "http://127.0.0.1:8080", taskassignment.BackendVM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var registration Registration
|
||||
if err := json.Unmarshal(data, ®istration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registration.UUID != "gitea-task-42" || registration.Token != "capability" || registration.Address != "http://127.0.0.1:8080" || !registration.Ephemeral {
|
||||
t.Fatalf("registration = %#v", registration)
|
||||
}
|
||||
if len(registration.Labels) != 2 || registration.Labels[0] != "self-hosted" || registration.Labels[1] != "vm" {
|
||||
t.Fatalf("labels = %#v", registration.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationRejectsNonLocalTLSAddress(t *testing.T) {
|
||||
if _, err := RegistrationJSON("id", "capability", "https://facade.example", taskassignment.BackendPod); err == nil {
|
||||
t.Fatal("expected local proxy URL validation error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package runnerbootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment"
|
||||
)
|
||||
|
||||
type ExecutorConfig struct {
|
||||
AssignmentID string
|
||||
Capability string
|
||||
Backend taskassignment.Backend
|
||||
FacadeURL string
|
||||
FacadeSPIFFEID string
|
||||
WorkloadAPIAddr string
|
||||
RunnerBinary string
|
||||
ListenAddress string
|
||||
WorkDir string
|
||||
Stdout *os.File
|
||||
Stderr *os.File
|
||||
}
|
||||
|
||||
// RunExecutor runs the SPIFFE proxy and one unmodified official Runner process.
|
||||
// The generated registration file exists only in the executor's temporary
|
||||
// work directory and the runner exits after its preassigned task.
|
||||
func RunExecutor(ctx context.Context, config ExecutorConfig) error {
|
||||
if config.RunnerBinary == "" {
|
||||
config.RunnerBinary = "gitea-runner"
|
||||
}
|
||||
if config.ListenAddress == "" {
|
||||
config.ListenAddress = "127.0.0.1:0"
|
||||
}
|
||||
listener, err := net.Listen("tcp", config.ListenAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen for local runner proxy: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
address, ok := listener.Addr().(*net.TCPAddr)
|
||||
if !ok || !address.IP.IsLoopback() {
|
||||
return errors.New("runner proxy must listen on a loopback address")
|
||||
}
|
||||
|
||||
proxy, err := NewProxy(ctx, config.FacadeURL, config.FacadeSPIFFEID, config.WorkloadAPIAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer proxy.Close()
|
||||
|
||||
workDir := config.WorkDir
|
||||
removeWorkDir := false
|
||||
if workDir == "" {
|
||||
workDir, err = os.MkdirTemp("", "gitea-task-runner-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create runner work directory: %w", err)
|
||||
}
|
||||
removeWorkDir = true
|
||||
}
|
||||
if removeWorkDir {
|
||||
defer os.RemoveAll(workDir)
|
||||
}
|
||||
registration, err := RegistrationJSON(
|
||||
config.AssignmentID, config.Capability, "http://"+listener.Addr().String(), config.Backend,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, ".runner"), registration, 0o600); err != nil {
|
||||
return fmt.Errorf("write one-shot runner registration: %w", err)
|
||||
}
|
||||
|
||||
server := &http.Server{Handler: proxy.Handler, ReadHeaderTimeout: 10 * time.Second}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() { serverErrors <- server.Serve(listener) }()
|
||||
|
||||
command := exec.CommandContext(ctx, config.RunnerBinary, "daemon", "--once")
|
||||
command.Dir = workDir
|
||||
command.Stdout = config.Stdout
|
||||
command.Stderr = config.Stderr
|
||||
runnerErr := command.Run()
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
shutdownErr := server.Shutdown(shutdownContext)
|
||||
cancel()
|
||||
serverErr := <-serverErrors
|
||||
if errors.Is(serverErr, http.ErrServerClosed) {
|
||||
serverErr = nil
|
||||
}
|
||||
return errors.Join(runnerErr, shutdownErr, serverErr)
|
||||
}
|
||||
|
||||
// ExecutorConfigFromEnvironment reads the non-secret image configuration and
|
||||
// the assignment-scoped values injected by the backend. The Workload API
|
||||
// address follows SPIFFE_ENDPOINT_SOCKET through go-spiffe when not set here.
|
||||
func ExecutorConfigFromEnvironment() (ExecutorConfig, error) {
|
||||
backend := taskassignment.Backend(os.Getenv(EnvBackend))
|
||||
if backend != taskassignment.BackendPod && backend != taskassignment.BackendVM {
|
||||
return ExecutorConfig{}, fmt.Errorf("invalid %s %q", EnvBackend, backend)
|
||||
}
|
||||
config := ExecutorConfig{
|
||||
AssignmentID: os.Getenv(EnvAssignmentID), Capability: os.Getenv(EnvCapability),
|
||||
Backend: backend, FacadeURL: os.Getenv(EnvFacadeURL), FacadeSPIFFEID: os.Getenv(EnvFacadeID),
|
||||
RunnerBinary: os.Getenv("GITEA_RUNNER_BINARY"), ListenAddress: "127.0.0.1:0",
|
||||
Stdout: os.Stdout, Stderr: os.Stderr,
|
||||
}
|
||||
if config.AssignmentID == "" || config.Capability == "" || config.FacadeURL == "" || config.FacadeSPIFFEID == "" {
|
||||
return ExecutorConfig{}, errors.New("complete runner assignment and facade environment is required")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package runnerbootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
|
||||
"github.com/spiffe/go-spiffe/v2/spiffeid"
|
||||
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
|
||||
"github.com/spiffe/go-spiffe/v2/workloadapi"
|
||||
)
|
||||
|
||||
type Proxy struct {
|
||||
Handler http.Handler
|
||||
source *workloadapi.X509Source
|
||||
}
|
||||
|
||||
// NewProxy obtains rotating X509-SVIDs from the Workload API and authorizes
|
||||
// one exact controller identity. The official Runner talks plain HTTP only to
|
||||
// this executor-local handler.
|
||||
func NewProxy(ctx context.Context, facadeURL, facadeSPIFFEID, workloadAPIAddr string) (*Proxy, error) {
|
||||
target, err := url.Parse(facadeURL)
|
||||
if err != nil || target.Scheme != "https" || target.Host == "" {
|
||||
return nil, fmt.Errorf("runner facade URL must be an absolute https URL")
|
||||
}
|
||||
serverID, err := spiffeid.FromString(facadeSPIFFEID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse runner facade SPIFFE ID: %w", err)
|
||||
}
|
||||
options := []workloadapi.X509SourceOption{}
|
||||
if workloadAPIAddr != "" {
|
||||
options = append(options, workloadapi.WithClientOptions(workloadapi.WithAddr(workloadAPIAddr)))
|
||||
}
|
||||
source, err := workloadapi.NewX509Source(ctx, options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open SPIFFE Workload API X509 source: %w", err)
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = tlsconfig.MTLSClientConfig(source, source, tlsconfig.AuthorizeID(serverID))
|
||||
return &Proxy{Handler: NewProxyHandler(target, transport), source: source}, nil
|
||||
}
|
||||
|
||||
func NewProxyHandler(target *url.URL, transport http.RoundTripper) http.Handler {
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
proxy.Transport = transport
|
||||
return proxy
|
||||
}
|
||||
|
||||
func (p *Proxy) Close() error {
|
||||
if p == nil || p.source == nil {
|
||||
return nil
|
||||
}
|
||||
return p.source.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user