feat: run official executor behind SPIFFE mTLS
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/runnerbootstrap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
if len(os.Args) != 2 {
|
||||||
|
return errors.New("usage: gitea-dynamic-runner executor")
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "executor":
|
||||||
|
config, err := runnerbootstrap.ExecutorConfigFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runnerbootstrap.RunExecutor(ctx, config)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown command %q", os.Args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,14 @@ FROM ghcr.io/spiffe/spire-agent:1.15.3@sha256:41b0dcd8b258a69db9e2768292a060766f
|
|||||||
|
|
||||||
FROM docker.io/gitea/runner:3.5.0@sha256:66b7da94dc7dcadb2e076bec6928221336a9a637196399281c4b766fe1288242 AS runner
|
FROM docker.io/gitea/runner:3.5.0@sha256:66b7da94dc7dcadb2e076bec6928221336a9a637196399281c4b766fe1288242 AS runner
|
||||||
|
|
||||||
|
FROM docker.io/library/golang:1.27-alpine@sha256:4cb7ac979db5fcc41cae44b2227ba5ab8a51e8807f40d9ba4dee20a0ad960b5b AS controller
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-dynamic-runner ./cmd/gitea-dynamic-runner
|
||||||
|
|
||||||
# The runner daemon image is intentionally minimal and does not contain the
|
# The runner daemon image is intentionally minimal and does not contain the
|
||||||
# Node.js runtime required by JavaScript actions such as actions/checkout.
|
# Node.js runtime required by JavaScript actions such as actions/checkout.
|
||||||
# Run the daemon in Gitea's Ubuntu workflow image so host-mode jobs and their
|
# Run the daemon in Gitea's Ubuntu workflow image so host-mode jobs and their
|
||||||
@@ -9,13 +17,22 @@ FROM docker.io/gitea/runner:3.5.0@sha256:66b7da94dc7dcadb2e076bec6928221336a9a63
|
|||||||
FROM docker.io/gitea/runner-images:ubuntu-latest@sha256:fd911d7417bfbf0f454530e447da95b58001e1df41bbc5e1a8dd35d432575aae
|
FROM docker.io/gitea/runner-images:ubuntu-latest@sha256:fd911d7417bfbf0f454530e447da95b58001e1df41bbc5e1a8dd35d432575aae
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
RUN groupadd --gid 2000 runner \
|
||||||
|
&& useradd --uid 2000 --gid 2000 --groups docker --create-home --shell /bin/bash runner \
|
||||||
|
&& printf 'runner ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/runner \
|
||||||
|
&& chmod 0440 /etc/sudoers.d/runner \
|
||||||
|
&& install -d -o 2000 -g 2000 /data
|
||||||
|
|
||||||
COPY --from=runner /usr/local/bin/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=runner /usr/local/bin/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY --from=runner /usr/local/bin/run.sh /usr/local/bin/run.sh
|
COPY --from=controller /out/gitea-dynamic-runner /usr/local/bin/gitea-dynamic-runner
|
||||||
COPY --from=spire /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent
|
COPY --from=spire /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent
|
||||||
COPY config/runner.yaml /etc/gitea-runner/config.yaml
|
COPY config/runner.yaml /etc/gitea-runner/config.yaml
|
||||||
COPY --chmod=0755 scripts/gitea-job-started /usr/local/libexec/gitea-job-started
|
COPY --chmod=0755 scripts/gitea-job-started /usr/local/libexec/gitea-job-started
|
||||||
COPY --chmod=0755 scripts/gitea-opensandbox-runner /usr/local/libexec/gitea-opensandbox-runner
|
COPY --chmod=0755 scripts/gitea-opensandbox-runner /usr/local/libexec/gitea-opensandbox-runner
|
||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
WORKDIR /
|
ENV HOME=/home/runner
|
||||||
ENTRYPOINT ["/usr/local/bin/run.sh"]
|
USER 2000:2000
|
||||||
|
WORKDIR /home/runner
|
||||||
|
ENTRYPOINT ["/usr/local/bin/gitea-dynamic-runner"]
|
||||||
|
CMD ["executor"]
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignmen
|
|||||||
Annotations: clone(launch.Metadata.Annotations),
|
Annotations: clone(launch.Metadata.Annotations),
|
||||||
Image: b.Config.Image,
|
Image: b.Config.Image,
|
||||||
ServiceAccount: b.Config.ServiceAccount,
|
ServiceAccount: b.Config.ServiceAccount,
|
||||||
Args: append(append([]string{}, b.Config.ExecutorArgs...), assignment.ID),
|
Args: append([]string{}, b.Config.ExecutorArgs...),
|
||||||
Environment: clone(launch.Environment),
|
Environment: clone(launch.Environment),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (a *fakeAPI) DeleteIdentityEntry(_ context.Context, name string) error {
|
|||||||
func backend(api API) Backend {
|
func backend(api API) Backend {
|
||||||
return Backend{API: api, Config: Config{
|
return Backend{API: api, Config: Config{
|
||||||
Namespace: "gitea-actions", Image: "zot/ci-executor:main",
|
Namespace: "gitea-actions", Image: "zot/ci-executor:main",
|
||||||
ServiceAccount: "gitea-task-executor", ExecutorArgs: []string{"execute"},
|
ServiceAccount: "gitea-task-executor", ExecutorArgs: []string{"executor"},
|
||||||
TrustDomain: "ddupan.top", SPIRECluster: "homelab",
|
TrustDomain: "ddupan.top", SPIRECluster: "homelab",
|
||||||
SPIREClass: "spire-mgmt-spire", ExecutorUID: 2000,
|
SPIREClass: "spire-mgmt-spire", ExecutorUID: 2000,
|
||||||
}}
|
}}
|
||||||
@@ -90,7 +90,7 @@ func TestCreateUsesDeterministicNameAndRecoveryMetadata(t *testing.T) {
|
|||||||
if api.created.Environment["CI_RUNNER_CAPABILITY"] != "capability" {
|
if api.created.Environment["CI_RUNNER_CAPABILITY"] != "capability" {
|
||||||
t.Fatalf("environment = %#v", api.created.Environment)
|
t.Fatalf("environment = %#v", api.created.Environment)
|
||||||
}
|
}
|
||||||
if len(api.created.Args) != 2 || api.created.Args[1] != "gitea-task-42" || executor.IdentityTarget != "pod-uid" {
|
if len(api.created.Args) != 1 || api.created.Args[0] != "executor" || executor.IdentityTarget != "pod-uid" {
|
||||||
t.Fatalf("args=%v executor=%#v", api.created.Args, executor)
|
t.Fatalf("args=%v executor=%#v", api.created.Args, executor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,10 +68,14 @@ func (f *Facade) Register(context.Context, *connect.Request[runnerv1.RegisterReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *Facade) Declare(ctx context.Context, request *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) {
|
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 {
|
assignmentID, _, err := f.authenticate(ctx, request)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return connect.NewResponse(&runnerv1.DeclareResponse{}), nil
|
return connect.NewResponse(&runnerv1.DeclareResponse{Runner: &runnerv1.Runner{
|
||||||
|
Uuid: assignmentID, Name: assignmentID, Status: runnerv1.RunnerStatus_RUNNER_STATUS_IDLE,
|
||||||
|
Version: request.Msg.GetVersion(), Labels: append([]string(nil), request.Msg.GetLabels()...), Ephemeral: true,
|
||||||
|
}}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Facade) FetchTask(ctx context.Context, request *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) {
|
func (f *Facade) FetchTask(ctx context.Context, request *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) {
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ package runnerfacade
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"gitea.dev/actionslib/pkg/protocol"
|
"gitea.dev/actionslib/pkg/protocol"
|
||||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
|
"gitea.dev/actionslib/runner/v1/runnerv1connect"
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
|
||||||
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment"
|
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment"
|
||||||
@@ -81,6 +87,49 @@ func TestFacadeReturnsOnlyPreassignedTaskAndSignalsClaim(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFacadeDeclareReturnsOfficialRunnerMetadata(t *testing.T) {
|
||||||
|
facade, assignment, token := testFacade(t)
|
||||||
|
ctx := WithSPIFFEID(context.Background(), assignment.Identity.SPIFFEID)
|
||||||
|
response, err := facade.Declare(ctx, authenticatedRequest(&runnerv1.DeclareRequest{
|
||||||
|
Version: "v3.5.0", Labels: []string{"self-hosted", "pod"},
|
||||||
|
}, assignment.ID, token))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runner := response.Msg.GetRunner()
|
||||||
|
if runner.GetUuid() != assignment.ID || runner.GetName() != assignment.ID || runner.GetVersion() != "v3.5.0" || !runner.GetEphemeral() {
|
||||||
|
t.Fatalf("runner = %#v", runner)
|
||||||
|
}
|
||||||
|
if len(runner.GetLabels()) != 2 || runner.GetLabels()[0] != "self-hosted" || runner.GetLabels()[1] != "pod" {
|
||||||
|
t.Fatalf("labels = %#v", runner.GetLabels())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIHandlerMatchesOfficialRunnerBasePath(t *testing.T) {
|
||||||
|
facade, assignment, token := testFacade(t)
|
||||||
|
identityURL, err := url.Parse(assignment.Identity.SPIFFEID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
handler := APIHandler(facade)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
request.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{URIs: []*url.URL{identityURL}}}}
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := runnerv1connect.NewRunnerServiceClient(server.Client(), server.URL+APIBasePath)
|
||||||
|
request := authenticatedRequest(&runnerv1.DeclareRequest{
|
||||||
|
Version: "v3.5.0", Labels: []string{"self-hosted", "pod"},
|
||||||
|
}, assignment.ID, token)
|
||||||
|
response, err := client.Declare(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.Msg.GetRunner().GetUuid() != assignment.ID {
|
||||||
|
t.Fatalf("runner = %#v", response.Msg.GetRunner())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFacadeRejectsWrongIdentityOrCapability(t *testing.T) {
|
func TestFacadeRejectsWrongIdentityOrCapability(t *testing.T) {
|
||||||
facade, assignment, token := testFacade(t)
|
facade, assignment, token := testFacade(t)
|
||||||
wrongIdentity := WithSPIFFEID(context.Background(), "spiffe://ddupan.top/ci/owner/repo/other")
|
wrongIdentity := WithSPIFFEID(context.Background(), "spiffe://ddupan.top/ci/owner/repo/other")
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package runnerfacade
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spiffe/go-spiffe/v2/spiffeid"
|
||||||
|
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
|
||||||
|
"github.com/spiffe/go-spiffe/v2/workloadapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const APIBasePath = "/api/actions"
|
||||||
|
|
||||||
|
// APIHandler exposes the facade at the base path used by the official Runner.
|
||||||
|
// SPIFFE middleware runs after the TLS listener has authenticated the peer.
|
||||||
|
func APIHandler(facade *Facade) http.Handler {
|
||||||
|
path, handler := facade.Handler()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle(APIBasePath+path, http.StripPrefix(APIBasePath, SPIFFEMiddleware(handler)))
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Facade *Facade
|
||||||
|
ListenAddress string
|
||||||
|
TrustDomain string
|
||||||
|
WorkloadAPIAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run serves the RunnerService facade with workload-to-workload mTLS. Any
|
||||||
|
// identity in the local trust domain may complete TLS; the facade then requires
|
||||||
|
// the exact logical task identity stored in its assignment registry.
|
||||||
|
func (s Server) Run(ctx context.Context) error {
|
||||||
|
if s.Facade == nil || s.ListenAddress == "" || s.TrustDomain == "" {
|
||||||
|
return errors.New("runner facade, listen address, and trust domain are required")
|
||||||
|
}
|
||||||
|
trustDomain, err := spiffeid.TrustDomainFromString(s.TrustDomain)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse facade trust domain: %w", err)
|
||||||
|
}
|
||||||
|
options := []workloadapi.X509SourceOption{}
|
||||||
|
if s.WorkloadAPIAddr != "" {
|
||||||
|
options = append(options, workloadapi.WithClientOptions(workloadapi.WithAddr(s.WorkloadAPIAddr)))
|
||||||
|
}
|
||||||
|
source, err := workloadapi.NewX509Source(ctx, options...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open facade SPIFFE Workload API X509 source: %w", err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", s.ListenAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen for runner facade: %w", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
tlsListener := tls.NewListener(listener, tlsconfig.MTLSServerConfig(
|
||||||
|
source, source, tlsconfig.AuthorizeMemberOf(trustDomain),
|
||||||
|
))
|
||||||
|
httpServer := &http.Server{Handler: APIHandler(s.Facade), ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
serverErrors := make(chan error, 1)
|
||||||
|
go func() { serverErrors <- httpServer.Serve(tlsListener) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
shutdownErr := httpServer.Shutdown(shutdownContext)
|
||||||
|
cancel()
|
||||||
|
serverErr := <-serverErrors
|
||||||
|
if errors.Is(serverErr, http.ErrServerClosed) {
|
||||||
|
serverErr = nil
|
||||||
|
}
|
||||||
|
return errors.Join(shutdownErr, serverErr)
|
||||||
|
case err := <-serverErrors:
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("serve runner facade: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user