feat: bootstrap official runner through SPIFFE facade
This commit is contained in:
@@ -61,7 +61,7 @@ func (b Backend) Find(ctx context.Context, assignmentID string) (*taskworker.Exe
|
||||
return executor(result.Items[0]), nil
|
||||
}
|
||||
|
||||
func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignment, metadata taskworker.Metadata) (*taskworker.Executor, error) {
|
||||
func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignment, launch taskworker.LaunchSpec) (*taskworker.Executor, error) {
|
||||
if err := b.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -69,10 +69,11 @@ func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignmen
|
||||
return nil, fmt.Errorf("OpenSandbox backend cannot create %q assignment", assignment.Backend)
|
||||
}
|
||||
environment := clone(b.Config.Env)
|
||||
environment["CI_ASSIGNMENT_ID"] = assignment.ID
|
||||
environment["CI_SPIFFE_ID"] = assignment.Identity.SPIFFEID
|
||||
sandboxMetadata := clone(metadata.Annotations)
|
||||
for key, value := range metadata.Labels {
|
||||
for key, value := range launch.Environment {
|
||||
environment[key] = value
|
||||
}
|
||||
sandboxMetadata := clone(launch.Metadata.Annotations)
|
||||
for key, value := range launch.Metadata.Labels {
|
||||
sandboxMetadata[key] = value
|
||||
}
|
||||
request := opensandbox.CreateSandboxRequest{
|
||||
|
||||
@@ -62,13 +62,22 @@ func TestFindRecoversSandboxByMetadata(t *testing.T) {
|
||||
func TestCreateUsesPoolAndPersistsRecoveryMetadata(t *testing.T) {
|
||||
lifecycle := &fakeLifecycle{}
|
||||
metadata := taskworker.BackendMetadata(assignment())
|
||||
executor, err := backend(lifecycle).Create(context.Background(), assignment(), metadata)
|
||||
executor, err := backend(lifecycle).Create(context.Background(), assignment(), taskworker.LaunchSpec{
|
||||
Metadata: metadata,
|
||||
Environment: map[string]string{
|
||||
"CI_SPIFFE_ID": assignment().Identity.SPIFFEID,
|
||||
"CI_RUNNER_CAPABILITY": "capability",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lifecycle.created.Extensions["poolRef"] != "ci-vm" || lifecycle.created.Metadata[assignmentMetadata] != assignment().ID || lifecycle.created.Env["CI_SPIFFE_ID"] != assignment().Identity.SPIFFEID || executor.Name != "sandbox-42" {
|
||||
t.Fatalf("request=%#v executor=%#v", lifecycle.created, executor)
|
||||
}
|
||||
if lifecycle.created.Env["CI_RUNNER_CAPABILITY"] != "capability" {
|
||||
t.Fatalf("environment = %#v", lifecycle.created.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindIdentityVerifiesPersistedMetadata(t *testing.T) {
|
||||
|
||||
@@ -35,6 +35,7 @@ type PodManifest struct {
|
||||
Image string
|
||||
ServiceAccount string
|
||||
Args []string
|
||||
Environment map[string]string
|
||||
}
|
||||
|
||||
// IdentityEntry is a ClusterStaticEntry pinned to one concrete Pod UID.
|
||||
@@ -89,24 +90,25 @@ func (b Backend) Find(ctx context.Context, assignmentID string) (*taskworker.Exe
|
||||
return executor(pods[0]), nil
|
||||
}
|
||||
|
||||
func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignment, metadata taskworker.Metadata) (*taskworker.Executor, error) {
|
||||
func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignment, launch taskworker.LaunchSpec) (*taskworker.Executor, error) {
|
||||
if err := b.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assignment.Backend != taskassignment.BackendPod {
|
||||
return nil, fmt.Errorf("Pod backend cannot create %q assignment", assignment.Backend)
|
||||
}
|
||||
labels := clone(metadata.Labels)
|
||||
labels := clone(launch.Metadata.Labels)
|
||||
labels["app.kubernetes.io/name"] = "gitea-dynamic-runner"
|
||||
labels["app.kubernetes.io/component"] = "executor"
|
||||
pod, err := b.API.CreatePod(ctx, PodManifest{
|
||||
Name: assignment.ID,
|
||||
Namespace: b.Config.Namespace,
|
||||
Labels: labels,
|
||||
Annotations: clone(metadata.Annotations),
|
||||
Annotations: clone(launch.Metadata.Annotations),
|
||||
Image: b.Config.Image,
|
||||
ServiceAccount: b.Config.ServiceAccount,
|
||||
Args: append(append([]string{}, b.Config.ExecutorArgs...), assignment.ID),
|
||||
Environment: clone(launch.Environment),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create assignment Pod: %w", err)
|
||||
|
||||
@@ -75,7 +75,9 @@ func TestFindRecoversPodByAssignmentLabel(t *testing.T) {
|
||||
func TestCreateUsesDeterministicNameAndRecoveryMetadata(t *testing.T) {
|
||||
api := &fakeAPI{}
|
||||
metadata := taskworker.BackendMetadata(assignment())
|
||||
executor, err := backend(api).Create(context.Background(), assignment(), metadata)
|
||||
executor, err := backend(api).Create(context.Background(), assignment(), taskworker.LaunchSpec{
|
||||
Metadata: metadata, Environment: map[string]string{"CI_RUNNER_CAPABILITY": "capability"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -85,6 +87,9 @@ func TestCreateUsesDeterministicNameAndRecoveryMetadata(t *testing.T) {
|
||||
if api.created.Annotations["ci.ddupan.top/spiffe-id"] != assignment().Identity.SPIFFEID {
|
||||
t.Fatalf("annotations = %#v", api.created.Annotations)
|
||||
}
|
||||
if api.created.Environment["CI_RUNNER_CAPABILITY"] != "capability" {
|
||||
t.Fatalf("environment = %#v", api.created.Environment)
|
||||
}
|
||||
if len(api.created.Args) != 2 || api.created.Args[1] != "gitea-task-42" || executor.IdentityTarget != "pod-uid" {
|
||||
t.Fatalf("args=%v executor=%#v", api.created.Args, executor)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ func (c *Client) ListPods(ctx context.Context, namespace, selector string) ([]Po
|
||||
}
|
||||
|
||||
func (c *Client) CreatePod(ctx context.Context, manifest PodManifest) (Pod, error) {
|
||||
environment := make([]corev1.EnvVar, 0, len(manifest.Environment))
|
||||
for name, value := range manifest.Environment {
|
||||
environment = append(environment, corev1.EnvVar{Name: name, Value: value})
|
||||
}
|
||||
document := &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: manifest.Name, Namespace: manifest.Namespace,
|
||||
@@ -68,7 +72,7 @@ func (c *Client) CreatePod(ctx context.Context, manifest PodManifest) (Pod, erro
|
||||
ServiceAccountName: manifest.ServiceAccount,
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
Containers: []corev1.Container{{
|
||||
Name: "executor", Image: manifest.Image, Args: manifest.Args,
|
||||
Name: "executor", Image: manifest.Image, Args: manifest.Args, Env: environment,
|
||||
SecurityContext: &corev1.SecurityContext{Privileged: boolPointer(true)},
|
||||
VolumeMounts: []corev1.VolumeMount{{
|
||||
Name: "spire-agent-socket", MountPath: "/run/spire/agent-sockets", ReadOnly: true,
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestClientPodLifecycleUsesTypedClient(t *testing.T) {
|
||||
Name: "gitea-task-42", Namespace: "gitea-actions",
|
||||
Labels: map[string]string{assignmentLabel: "gitea-task-42"},
|
||||
Image: "zot/ci-executor:main", ServiceAccount: "gitea-task-executor",
|
||||
Args: []string{"execute", "gitea-task-42"},
|
||||
Args: []string{"execute", "gitea-task-42"}, Environment: map[string]string{"CI_RUNNER_CAPABILITY": "capability"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -34,6 +34,9 @@ func TestClientPodLifecycleUsesTypedClient(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pod.Spec.Containers[0].Env; len(got) != 1 || got[0].Name != "CI_RUNNER_CAPABILITY" || got[0].Value != "capability" {
|
||||
t.Fatalf("environment = %#v", got)
|
||||
}
|
||||
pod.UID = types.UID("pod-uid")
|
||||
pod.Status.Phase = corev1.PodRunning
|
||||
if _, err := client.Kubernetes.CoreV1().Pods("gitea-actions").Update(context.Background(), pod, metav1.UpdateOptions{}); err != nil {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -34,11 +34,26 @@ type Metadata struct {
|
||||
Annotations map[string]string
|
||||
}
|
||||
|
||||
// LaunchSpec contains the durable resource metadata and the short-lived
|
||||
// executor environment. Environment values configure the one-shot runner but
|
||||
// are deliberately excluded from labels and annotations.
|
||||
type LaunchSpec struct {
|
||||
Metadata Metadata
|
||||
Environment map[string]string
|
||||
}
|
||||
|
||||
// Bootstrap produces assignment-scoped executor configuration. Implementations
|
||||
// must be deterministic so a redelivery after controller restart creates the
|
||||
// same credentials without storing another lifecycle record.
|
||||
type Bootstrap interface {
|
||||
Environment(taskassignment.Assignment) (map[string]string, error)
|
||||
}
|
||||
|
||||
// Backend is implemented by the native Pod and OpenSandbox adapters.
|
||||
// Every method must be idempotent.
|
||||
type Backend interface {
|
||||
Find(context.Context, string) (*Executor, error)
|
||||
Create(context.Context, taskassignment.Assignment, Metadata) (*Executor, error)
|
||||
Create(context.Context, taskassignment.Assignment, LaunchSpec) (*Executor, error)
|
||||
BindIdentity(context.Context, *Executor, taskidentity.Identity) error
|
||||
Delete(context.Context, *Executor) error
|
||||
}
|
||||
@@ -53,16 +68,17 @@ type TaskState interface {
|
||||
// Worker has no correctness-critical in-memory state. Handle may be called
|
||||
// again for the same assignment after any operation.
|
||||
type Worker struct {
|
||||
Backend Backend
|
||||
Tasks TaskState
|
||||
Backend Backend
|
||||
Tasks TaskState
|
||||
Bootstrap Bootstrap
|
||||
}
|
||||
|
||||
// Accept completes the durable handoff from JetStream to the backend. Once it
|
||||
// returns true, all recovery information exists in Kubernetes/OpenSandbox and
|
||||
// the assignment message can be acknowledged immediately.
|
||||
func (w Worker) Accept(ctx context.Context, assignment taskassignment.Assignment) (bool, error) {
|
||||
if w.Backend == nil || w.Tasks == nil {
|
||||
return false, errors.New("backend and Gitea task state are required")
|
||||
if w.Backend == nil || w.Tasks == nil || w.Bootstrap == nil {
|
||||
return false, errors.New("backend, Gitea task state, and runner bootstrap are required")
|
||||
}
|
||||
if assignment.ID == "" || assignment.Task == nil {
|
||||
return false, errors.New("valid assignment is required")
|
||||
@@ -84,7 +100,11 @@ func (w Worker) Accept(ctx context.Context, assignment taskassignment.Assignment
|
||||
return true, nil
|
||||
}
|
||||
if executor == nil {
|
||||
executor, err = w.Backend.Create(ctx, assignment, BackendMetadata(assignment))
|
||||
launch, launchErr := w.launchSpec(assignment)
|
||||
if launchErr != nil {
|
||||
return false, launchErr
|
||||
}
|
||||
executor, err = w.Backend.Create(ctx, assignment, launch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -101,8 +121,8 @@ func (w Worker) Accept(ctx context.Context, assignment taskassignment.Assignment
|
||||
// Handle performs one reconciliation. Done means the queue message may be
|
||||
// acknowledged. A false result should remain pending and be reconciled again.
|
||||
func (w Worker) Handle(ctx context.Context, assignment taskassignment.Assignment) (done bool, err error) {
|
||||
if w.Backend == nil || w.Tasks == nil {
|
||||
return false, errors.New("backend and Gitea task state are required")
|
||||
if w.Backend == nil || w.Tasks == nil || w.Bootstrap == nil {
|
||||
return false, errors.New("backend, Gitea task state, and runner bootstrap are required")
|
||||
}
|
||||
if assignment.ID == "" || assignment.Task == nil {
|
||||
return false, errors.New("valid assignment is required")
|
||||
@@ -126,7 +146,11 @@ func (w Worker) Handle(ctx context.Context, assignment taskassignment.Assignment
|
||||
}
|
||||
|
||||
if executor == nil {
|
||||
executor, err = w.Backend.Create(ctx, assignment, BackendMetadata(assignment))
|
||||
launch, launchErr := w.launchSpec(assignment)
|
||||
if launchErr != nil {
|
||||
return false, launchErr
|
||||
}
|
||||
executor, err = w.Backend.Create(ctx, assignment, launch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -154,6 +178,14 @@ func (w Worker) Handle(ctx context.Context, assignment taskassignment.Assignment
|
||||
}
|
||||
}
|
||||
|
||||
func (w Worker) launchSpec(assignment taskassignment.Assignment) (LaunchSpec, error) {
|
||||
environment, err := w.Bootstrap.Environment(assignment)
|
||||
if err != nil {
|
||||
return LaunchSpec{}, err
|
||||
}
|
||||
return LaunchSpec{Metadata: BackendMetadata(assignment), Environment: environment}, nil
|
||||
}
|
||||
|
||||
// BackendMetadata is the shared metadata contract for Pods and OpenSandbox.
|
||||
func BackendMetadata(assignment taskassignment.Assignment) Metadata {
|
||||
return Metadata{
|
||||
|
||||
@@ -17,8 +17,14 @@ type fakeBackend struct {
|
||||
deleted int
|
||||
}
|
||||
|
||||
type fakeBootstrap struct{}
|
||||
|
||||
func (fakeBootstrap) Environment(taskassignment.Assignment) (map[string]string, error) {
|
||||
return map[string]string{"CI_RUNNER_CAPABILITY": "capability"}, nil
|
||||
}
|
||||
|
||||
func (b *fakeBackend) Find(context.Context, string) (*Executor, error) { return b.executor, nil }
|
||||
func (b *fakeBackend) Create(_ context.Context, _ taskassignment.Assignment, _ Metadata) (*Executor, error) {
|
||||
func (b *fakeBackend) Create(_ context.Context, _ taskassignment.Assignment, _ LaunchSpec) (*Executor, error) {
|
||||
b.created++
|
||||
b.executor = &Executor{Name: "executor", IdentityTarget: "pod-uid", Phase: PhaseRunning}
|
||||
return b.executor, nil
|
||||
@@ -60,7 +66,7 @@ func assignment() taskassignment.Assignment {
|
||||
|
||||
func TestHandleRecoversExistingExecutorWithoutCreatingAnother(t *testing.T) {
|
||||
backend := &fakeBackend{executor: &Executor{Name: "existing", IdentityTarget: "uid", Phase: PhaseRunning}}
|
||||
worker := Worker{Backend: backend, Tasks: &fakeTasks{}}
|
||||
worker := Worker{Backend: backend, Tasks: &fakeTasks{}, Bootstrap: fakeBootstrap{}}
|
||||
|
||||
done, err := worker.Handle(context.Background(), assignment())
|
||||
if err != nil || done {
|
||||
@@ -73,7 +79,7 @@ func TestHandleRecoversExistingExecutorWithoutCreatingAnother(t *testing.T) {
|
||||
|
||||
func TestAcceptAcknowledgesAfterBackendAndIdentityAreDurable(t *testing.T) {
|
||||
backend := &fakeBackend{}
|
||||
worker := Worker{Backend: backend, Tasks: &fakeTasks{}}
|
||||
worker := Worker{Backend: backend, Tasks: &fakeTasks{}, Bootstrap: fakeBootstrap{}}
|
||||
|
||||
accepted, err := worker.Accept(context.Background(), assignment())
|
||||
if err != nil || !accepted {
|
||||
@@ -86,7 +92,7 @@ func TestAcceptAcknowledgesAfterBackendAndIdentityAreDurable(t *testing.T) {
|
||||
|
||||
func TestAcceptRetriesWhileBackendIdentityTargetIsUnavailable(t *testing.T) {
|
||||
backend := &fakeBackend{executor: &Executor{Name: "pending", Phase: PhasePending}}
|
||||
worker := Worker{Backend: backend, Tasks: &fakeTasks{}}
|
||||
worker := Worker{Backend: backend, Tasks: &fakeTasks{}, Bootstrap: fakeBootstrap{}}
|
||||
|
||||
accepted, err := worker.Accept(context.Background(), assignment())
|
||||
if err != nil || accepted {
|
||||
@@ -100,7 +106,7 @@ func TestAcceptRetriesWhileBackendIdentityTargetIsUnavailable(t *testing.T) {
|
||||
func TestHandleReportsBeforeCleanupAndBecomesRecoverable(t *testing.T) {
|
||||
backend := &fakeBackend{executor: &Executor{Name: "finished", IdentityTarget: "uid", Phase: PhaseSucceeded}}
|
||||
tasks := &fakeTasks{}
|
||||
worker := Worker{Backend: backend, Tasks: tasks}
|
||||
worker := Worker{Backend: backend, Tasks: tasks, Bootstrap: fakeBootstrap{}}
|
||||
|
||||
done, err := worker.Handle(context.Background(), assignment())
|
||||
if err != nil || !done {
|
||||
|
||||
Reference in New Issue
Block a user