实现预分配 RunnerService facade

This commit is contained in:
2026-09-20 19:51:32 +00:00
parent b77245e4b9
commit 5fdd39f7ff
9 changed files with 481 additions and 7 deletions
+36
View File
@@ -0,0 +1,36 @@
package runnerfacade
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
)
// Capabilities are deterministic per assignment so controller restarts do not
// require a per-task token database.
type Capabilities struct{ key []byte }
func NewCapabilities(key []byte) (Capabilities, error) {
if len(key) < 32 {
return Capabilities{}, errors.New("runner facade capability key must be at least 32 bytes")
}
return Capabilities{key: append([]byte(nil), key...)}, nil
}
func (c Capabilities) Issue(assignmentID string) string {
if len(c.key) < 32 || assignmentID == "" {
return ""
}
mac := hmac.New(sha256.New, c.key)
_, _ = mac.Write([]byte("gitea-runner-assignment\x00" + assignmentID))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (c Capabilities) Verify(assignmentID, token string) bool {
want := c.Issue(assignmentID)
if want == "" || token == "" {
return false
}
return hmac.Equal([]byte(want), []byte(token))
}
+134
View File
@@ -0,0 +1,134 @@
// Package runnerfacade presents pre-assigned tasks to unmodified Gitea Runner binaries.
package runnerfacade
import (
"context"
"errors"
"net/http"
"connectrpc.com/connect"
"gitea.dev/actionslib/pkg/protocol"
runnerv1 "gitea.dev/actionslib/runner/v1"
"gitea.dev/actionslib/runner/v1/runnerv1connect"
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment"
)
type identityKey struct{}
func WithSPIFFEID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, identityKey{}, id)
}
// SPIFFEMiddleware extracts the authenticated workload identity from the mTLS
// peer certificate. TLS verification itself is configured by the server with
// go-spiffe; this layer only passes the verified ID into Connect handlers.
func SPIFFEMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.TLS == nil || len(request.TLS.PeerCertificates) == 0 {
http.Error(response, "client SPIFFE identity required", http.StatusUnauthorized)
return
}
var spiffeID string
for _, uri := range request.TLS.PeerCertificates[0].URIs {
if uri.Scheme == "spiffe" {
if spiffeID != "" {
http.Error(response, "multiple client SPIFFE identities", http.StatusUnauthorized)
return
}
spiffeID = uri.String()
}
}
if spiffeID == "" {
http.Error(response, "client SPIFFE identity required", http.StatusUnauthorized)
return
}
next.ServeHTTP(response, request.WithContext(WithSPIFFEID(request.Context(), spiffeID)))
})
}
type Upstream interface {
UpdateTask(context.Context, *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error)
UpdateLog(context.Context, *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error)
}
type Facade struct {
runnerv1connect.UnimplementedRunnerServiceHandler
Registry *Registry
Capabilities Capabilities
Upstream Upstream
}
func (f *Facade) Handler() (string, http.Handler) {
return runnerv1connect.NewRunnerServiceHandler(f)
}
func (f *Facade) Register(context.Context, *connect.Request[runnerv1.RegisterRequest]) (*connect.Response[runnerv1.RegisterResponse], error) {
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("executor registration is disabled"))
}
func (f *Facade) Declare(ctx context.Context, request *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) {
if _, _, err := f.authenticate(ctx, request); err != nil {
return nil, err
}
return connect.NewResponse(&runnerv1.DeclareResponse{}), nil
}
func (f *Facade) FetchTask(ctx context.Context, request *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) {
assignmentID, spiffeID, err := f.authenticate(ctx, request)
if err != nil {
return nil, err
}
assignment, err := f.Registry.Claim(assignmentID, spiffeID)
if err != nil {
return nil, connect.NewError(connect.CodeFailedPrecondition, err)
}
return connect.NewResponse(&runnerv1.FetchTaskResponse{Task: assignment.Task}), nil
}
func (f *Facade) UpdateTask(ctx context.Context, request *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
assignment, err := f.authorizeClaimed(ctx, request)
if err != nil {
return nil, err
}
if request.Msg.GetState().GetId() != assignment.Task.GetId() {
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("task update does not match assignment"))
}
return f.Upstream.UpdateTask(ctx, connect.NewRequest(request.Msg))
}
func (f *Facade) UpdateLog(ctx context.Context, request *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
assignment, err := f.authorizeClaimed(ctx, request)
if err != nil {
return nil, err
}
if request.Msg.GetTaskId() != assignment.Task.GetId() {
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("log update does not match assignment"))
}
return f.Upstream.UpdateLog(ctx, connect.NewRequest(request.Msg))
}
func (f *Facade) authorizeClaimed(ctx context.Context, request connect.AnyRequest) (taskassignment.Assignment, error) {
assignmentID, spiffeID, err := f.authenticate(ctx, request)
if err != nil {
return taskassignment.Assignment{}, err
}
assignment, err := f.Registry.Resolve(assignmentID, spiffeID)
if err != nil {
return taskassignment.Assignment{}, connect.NewError(connect.CodePermissionDenied, err)
}
return assignment, nil
}
func (f *Facade) authenticate(ctx context.Context, request connect.AnyRequest) (string, string, error) {
if f.Registry == nil || f.Upstream == nil {
return "", "", connect.NewError(connect.CodeInternal, errors.New("runner facade is not configured"))
}
assignmentID := request.Header().Get(protocol.UUIDHeader)
token := request.Header().Get(protocol.TokenHeader)
spiffeID, _ := ctx.Value(identityKey{}).(string)
if assignmentID == "" || spiffeID == "" || !f.Capabilities.Verify(assignmentID, token) {
return "", "", connect.NewError(connect.CodeUnauthenticated, errors.New("invalid executor identity or capability"))
}
return assignmentID, spiffeID, nil
}
+126
View File
@@ -0,0 +1,126 @@
package runnerfacade
import (
"context"
"testing"
"connectrpc.com/connect"
"gitea.dev/actionslib/pkg/protocol"
runnerv1 "gitea.dev/actionslib/runner/v1"
"google.golang.org/protobuf/types/known/structpb"
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment"
)
type fakeUpstream struct {
taskUpdates int
logUpdates int
}
func (u *fakeUpstream) UpdateTask(_ context.Context, request *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
u.taskUpdates++
return connect.NewResponse(&runnerv1.UpdateTaskResponse{State: request.Msg.State}), nil
}
func (u *fakeUpstream) UpdateLog(_ context.Context, request *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
u.logUpdates++
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: request.Msg.Index + int64(len(request.Msg.Rows))}), nil
}
func facadeAssignment(t *testing.T) taskassignment.Assignment {
t.Helper()
fields, err := structpb.NewStruct(map[string]any{"repository": "owner/repo"})
if err != nil {
t.Fatal(err)
}
assignment, err := taskassignment.New(&runnerv1.Task{
Id: 42, Context: fields,
WorkflowPayload: []byte("jobs:\n publish:\n runs-on: [self-hosted, pod]\n steps: []\n"),
}, "ddupan.top")
if err != nil {
t.Fatal(err)
}
return assignment
}
func testFacade(t *testing.T) (*Facade, taskassignment.Assignment, string) {
t.Helper()
capabilities, err := NewCapabilities([]byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
assignment := facadeAssignment(t)
registry := NewRegistry()
if _, err := registry.Offer(assignment); err != nil {
t.Fatal(err)
}
return &Facade{Registry: registry, Capabilities: capabilities, Upstream: &fakeUpstream{}}, assignment, capabilities.Issue(assignment.ID)
}
func authenticatedRequest[T any](message *T, assignmentID, token string) *connect.Request[T] {
request := connect.NewRequest(message)
request.Header().Set(protocol.UUIDHeader, assignmentID)
request.Header().Set(protocol.TokenHeader, token)
return request
}
func TestFacadeReturnsOnlyPreassignedTaskAndSignalsClaim(t *testing.T) {
facade, assignment, token := testFacade(t)
ctx := WithSPIFFEID(context.Background(), assignment.Identity.SPIFFEID)
response, err := facade.FetchTask(ctx, authenticatedRequest(&runnerv1.FetchTaskRequest{}, assignment.ID, token))
if err != nil {
t.Fatal(err)
}
if response.Msg.GetTask().GetId() != assignment.Task.GetId() {
t.Fatalf("task = %#v", response.Msg.GetTask())
}
if err := facade.Registry.WaitClaimed(context.Background(), assignment.ID); err != nil {
t.Fatal(err)
}
if _, err := facade.FetchTask(ctx, authenticatedRequest(&runnerv1.FetchTaskRequest{}, assignment.ID, token)); connect.CodeOf(err) != connect.CodeFailedPrecondition {
t.Fatalf("second FetchTask error = %v", err)
}
}
func TestFacadeRejectsWrongIdentityOrCapability(t *testing.T) {
facade, assignment, token := testFacade(t)
wrongIdentity := WithSPIFFEID(context.Background(), "spiffe://ddupan.top/ci/owner/repo/other")
if _, err := facade.FetchTask(wrongIdentity, authenticatedRequest(&runnerv1.FetchTaskRequest{}, assignment.ID, token)); connect.CodeOf(err) != connect.CodeFailedPrecondition {
t.Fatalf("wrong identity error = %v", err)
}
ctx := WithSPIFFEID(context.Background(), assignment.Identity.SPIFFEID)
if _, err := facade.FetchTask(ctx, authenticatedRequest(&runnerv1.FetchTaskRequest{}, assignment.ID, "wrong")); connect.CodeOf(err) != connect.CodeUnauthenticated {
t.Fatalf("wrong capability error = %v", err)
}
}
func TestFacadeForwardsOnlyMatchingTaskAndLogUpdates(t *testing.T) {
facade, assignment, token := testFacade(t)
ctx := WithSPIFFEID(context.Background(), assignment.Identity.SPIFFEID)
if _, err := facade.FetchTask(ctx, authenticatedRequest(&runnerv1.FetchTaskRequest{}, assignment.ID, token)); err != nil {
t.Fatal(err)
}
if _, err := facade.UpdateTask(ctx, authenticatedRequest(&runnerv1.UpdateTaskRequest{State: &runnerv1.TaskState{Id: 42}}, assignment.ID, token)); err != nil {
t.Fatal(err)
}
if _, err := facade.UpdateLog(ctx, authenticatedRequest(&runnerv1.UpdateLogRequest{TaskId: 42}, assignment.ID, token)); err != nil {
t.Fatal(err)
}
upstream := facade.Upstream.(*fakeUpstream)
if upstream.taskUpdates != 1 || upstream.logUpdates != 1 {
t.Fatalf("task updates=%d log updates=%d", upstream.taskUpdates, upstream.logUpdates)
}
if _, err := facade.UpdateLog(ctx, authenticatedRequest(&runnerv1.UpdateLogRequest{TaskId: 99}, assignment.ID, token)); connect.CodeOf(err) != connect.CodePermissionDenied {
t.Fatalf("mismatched log error = %v", err)
}
}
func TestCapabilitiesAreDeterministicAndAssignmentScoped(t *testing.T) {
capabilities, err := NewCapabilities([]byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
token := capabilities.Issue("gitea-task-42")
if !capabilities.Verify("gitea-task-42", token) || capabilities.Verify("gitea-task-43", token) {
t.Fatal("capability scope is invalid")
}
}
+92
View File
@@ -0,0 +1,92 @@
package runnerfacade
import (
"context"
"errors"
"fmt"
"sync"
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment"
)
type claim struct {
assignment taskassignment.Assignment
claimed bool
ready chan struct{}
}
// Registry holds pending task payloads and an active authorization cache.
// Pending entries are rebuilt by JetStream redelivery; active authorization is
// reconstructable from backend metadata and is not an independent state store.
type Registry struct {
mu sync.Mutex
claims map[string]*claim
}
func NewRegistry() *Registry { return &Registry{claims: make(map[string]*claim)} }
func (r *Registry) Offer(assignment taskassignment.Assignment) (<-chan struct{}, error) {
r.mu.Lock()
defer r.mu.Unlock()
if existing := r.claims[assignment.ID]; existing != nil {
if existing.assignment.Identity != assignment.Identity || existing.assignment.Task.GetId() != assignment.Task.GetId() {
return nil, fmt.Errorf("assignment %s was offered with different task data", assignment.ID)
}
return existing.ready, nil
}
entry := &claim{assignment: assignment, ready: make(chan struct{})}
r.claims[assignment.ID] = entry
return entry.ready, nil
}
func (r *Registry) Claim(assignmentID, spiffeID string) (taskassignment.Assignment, error) {
r.mu.Lock()
defer r.mu.Unlock()
entry := r.claims[assignmentID]
if entry == nil {
return taskassignment.Assignment{}, errors.New("assignment is not pending")
}
if entry.assignment.Identity.SPIFFEID != spiffeID {
return taskassignment.Assignment{}, errors.New("executor SPIFFE ID does not match assignment")
}
if entry.claimed {
return taskassignment.Assignment{}, errors.New("assignment was already claimed")
}
entry.claimed = true
close(entry.ready)
return entry.assignment, nil
}
func (r *Registry) Resolve(assignmentID, spiffeID string) (taskassignment.Assignment, error) {
r.mu.Lock()
defer r.mu.Unlock()
entry := r.claims[assignmentID]
if entry == nil || !entry.claimed {
return taskassignment.Assignment{}, errors.New("assignment is not claimed")
}
if entry.assignment.Identity.SPIFFEID != spiffeID {
return taskassignment.Assignment{}, errors.New("executor SPIFFE ID does not match assignment")
}
return entry.assignment, nil
}
func (r *Registry) WaitClaimed(ctx context.Context, assignmentID string) error {
r.mu.Lock()
entry := r.claims[assignmentID]
r.mu.Unlock()
if entry == nil {
return errors.New("assignment is not pending")
}
select {
case <-entry.ready:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (r *Registry) Remove(assignmentID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.claims, assignmentID)
}