实现预分配 RunnerService facade
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user