Add Burrow forge infrastructure and tailnet control plane

This commit is contained in:
Conrad Kramer 2026-03-31 14:53:48 -07:00
parent d1ed826389
commit de25f240d5
51 changed files with 9058 additions and 0 deletions

View file

@ -0,0 +1,253 @@
package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"golang.org/x/sync/errgroup"
"github.com/burrow/forgejo-nsc/internal/forgejo"
"github.com/burrow/forgejo-nsc/internal/nsc"
)
type Dispatcher interface {
LaunchRunner(ctx context.Context, req nsc.LaunchRequest) (string, error)
}
type ForgejoClient interface {
RegistrationToken(ctx context.Context, scope forgejo.Scope) (string, error)
}
type Service struct {
forgejo ForgejoClient
dispatcher Dispatcher
logger *slog.Logger
defaultScope forgejo.Scope
defaultLabels []string
instanceURL string
defaultTTL time.Duration
allowLabels map[string]struct{}
allowScopes map[string]struct{}
}
type Config struct {
DefaultScope forgejo.Scope
DefaultLabels []string
InstanceURL string
DefaultTTL time.Duration
AllowLabels []string
AllowScopes []string
}
func NewService(cfg Config, forgejo ForgejoClient, dispatcher Dispatcher, logger *slog.Logger) *Service {
if logger == nil {
logger = slog.Default()
}
allowLabels := make(map[string]struct{}, len(cfg.AllowLabels))
for _, label := range cfg.AllowLabels {
allowLabels[normalizeLabel(label)] = struct{}{}
}
allowScopes := make(map[string]struct{}, len(cfg.AllowScopes))
for _, scope := range cfg.AllowScopes {
allowScopes[scope] = struct{}{}
}
return &Service{
defaultScope: cfg.DefaultScope,
defaultLabels: cfg.DefaultLabels,
instanceURL: cfg.InstanceURL,
defaultTTL: cfg.DefaultTTL,
forgejo: forgejo,
dispatcher: dispatcher,
logger: logger,
allowLabels: allowLabels,
allowScopes: allowScopes,
}
}
type DispatchRequest struct {
Count int
Labels []string
Scope *Scope
TTL time.Duration
Machine string
Image string
ExtraEnv map[string]string
}
type Scope struct {
Level string
Owner string
Name string
}
type DispatchResponse struct {
Runners []RunnerHandle `json:"runners"`
}
type RunnerHandle struct {
Name string `json:"name"`
}
func (s *Service) Dispatch(ctx context.Context, req DispatchRequest) (DispatchResponse, error) {
count := req.Count
if count <= 0 {
count = 1
}
scope, err := s.mergeScope(req.Scope)
if err != nil {
return DispatchResponse{}, err
}
labels, err := s.mergeLabels(req.Labels)
if err != nil {
return DispatchResponse{}, err
}
if len(labels) == 0 {
return DispatchResponse{}, errors.New("no runner labels resolved")
}
ttl := req.TTL
if ttl == 0 {
ttl = s.defaultTTL
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
res := DispatchResponse{
Runners: make([]RunnerHandle, count),
}
eg, egCtx := errgroup.WithContext(ctx)
for i := 0; i < count; i++ {
index := i
eg.Go(func() error {
token, err := s.forgejo.RegistrationToken(egCtx, scope)
if err != nil {
return fmt.Errorf("fetching registration token: %w", err)
}
name, err := s.dispatcher.LaunchRunner(egCtx, nsc.LaunchRequest{
Token: token,
InstanceURL: s.instanceURL,
Labels: labels,
Duration: ttl,
MachineType: req.Machine,
Image: req.Image,
ExtraEnv: req.ExtraEnv,
})
if err != nil {
return err
}
res.Runners[index] = RunnerHandle{Name: name}
return nil
})
}
if err := eg.Wait(); err != nil {
return DispatchResponse{}, err
}
return res, nil
}
func (s *Service) mergeScope(value *Scope) (forgejo.Scope, error) {
if value == nil {
return s.defaultScope, nil
}
scope := forgejo.Scope{
Level: forgejo.ScopeLevel(value.Level),
Owner: value.Owner,
Name: value.Name,
}
if scope.Level == "" {
return forgejo.Scope{}, errors.New("scope level is required")
}
switch scope.Level {
case forgejo.ScopeInstance:
if !s.scopeAllowed(scope) {
return forgejo.Scope{}, fmt.Errorf("scope %q not allowed", scopeKey(scope))
}
return scope, nil
case forgejo.ScopeOrganization:
if scope.Owner == "" {
return forgejo.Scope{}, errors.New("organization scope requires owner")
}
if !s.scopeAllowed(scope) {
return forgejo.Scope{}, fmt.Errorf("scope %q not allowed", scopeKey(scope))
}
return scope, nil
case forgejo.ScopeRepository:
if scope.Owner == "" || scope.Name == "" {
return forgejo.Scope{}, errors.New("repository scope requires owner and name")
}
if !s.scopeAllowed(scope) {
return forgejo.Scope{}, fmt.Errorf("scope %q not allowed", scopeKey(scope))
}
return scope, nil
default:
return forgejo.Scope{}, fmt.Errorf("unsupported scope %q", scope.Level)
}
}
func (s *Service) mergeLabels(labels []string) ([]string, error) {
var resolved []string
if len(labels) == 0 {
resolved = append([]string{}, s.defaultLabels...)
} else {
resolved = labels
}
if len(s.allowLabels) == 0 {
return resolved, nil
}
for _, label := range resolved {
norm := normalizeLabel(label)
if _, ok := s.allowLabels[norm]; !ok {
return nil, fmt.Errorf("label %q not allowed", label)
}
}
return resolved, nil
}
func normalizeLabel(label string) string {
trimmed := strings.TrimSpace(label)
if trimmed == "" {
return ""
}
// Ignore any explicit executor suffix ("label:host"), since workflows
// and config allowlists typically deal in base label names.
if before, _, ok := strings.Cut(trimmed, ":"); ok {
return before
}
return trimmed
}
func scopeKey(scope forgejo.Scope) string {
switch scope.Level {
case forgejo.ScopeInstance:
return "instance"
case forgejo.ScopeOrganization:
return fmt.Sprintf("organization:%s", scope.Owner)
case forgejo.ScopeRepository:
return fmt.Sprintf("repository:%s/%s", scope.Owner, scope.Name)
default:
return string(scope.Level)
}
}
func (s *Service) scopeAllowed(scope forgejo.Scope) bool {
if len(s.allowScopes) == 0 {
return true
}
_, ok := s.allowScopes[scopeKey(scope)]
return ok
}

View file

@ -0,0 +1,160 @@
package app
import (
"context"
"sync"
"testing"
"time"
"github.com/burrow/forgejo-nsc/internal/forgejo"
"github.com/burrow/forgejo-nsc/internal/nsc"
)
type mockForgejo struct {
mu sync.Mutex
tokens []string
scopes []forgejo.Scope
err error
counter int
}
func (m *mockForgejo) RegistrationToken(ctx context.Context, scope forgejo.Scope) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.scopes = append(m.scopes, scope)
if m.err != nil {
return "", m.err
}
if m.counter >= len(m.tokens) {
return "", context.Canceled
}
tok := m.tokens[m.counter]
m.counter++
return tok, nil
}
type mockDispatcher struct {
mu sync.Mutex
requests []nsc.LaunchRequest
responses []string
err error
}
func (m *mockDispatcher) LaunchRunner(ctx context.Context, req nsc.LaunchRequest) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.err != nil {
return "", m.err
}
m.requests = append(m.requests, req)
idx := len(m.requests) - 1
if idx < len(m.responses) {
return m.responses[idx], nil
}
return "runner", nil
}
func TestServiceDispatchUsesDefaults(t *testing.T) {
forgejoMock := &mockForgejo{tokens: []string{"token"}}
dispatcherMock := &mockDispatcher{responses: []string{"runner-default"}}
cfg := Config{
DefaultScope: forgejo.Scope{Level: forgejo.ScopeInstance},
DefaultLabels: []string{"nscloud"},
InstanceURL: "https://forgejo.example.com",
DefaultTTL: 15 * time.Minute,
}
service := NewService(cfg, forgejoMock, dispatcherMock, nil)
resp, err := service.Dispatch(context.Background(), DispatchRequest{})
if err != nil {
t.Fatalf("Dispatch returned error: %v", err)
}
if len(resp.Runners) != 1 || resp.Runners[0].Name != "runner-default" {
t.Fatalf("unexpected dispatch response: %+v", resp)
}
if len(forgejoMock.scopes) != 1 || forgejoMock.scopes[0].Level != forgejo.ScopeInstance {
t.Fatalf("expected default scope, got %+v", forgejoMock.scopes)
}
if len(dispatcherMock.requests) != 1 {
t.Fatalf("expected one dispatcher call, got %d", len(dispatcherMock.requests))
}
req := dispatcherMock.requests[0]
if req.InstanceURL != cfg.InstanceURL {
t.Fatalf("expected instance URL %s, got %s", cfg.InstanceURL, req.InstanceURL)
}
if got := req.Labels; len(got) != 1 || got[0] != "nscloud" {
t.Fatalf("expected default labels, got %v", got)
}
if req.Duration != cfg.DefaultTTL {
t.Fatalf("expected duration %v, got %v", cfg.DefaultTTL, req.Duration)
}
}
func TestServiceDispatchCustomScopeAndCount(t *testing.T) {
forgejoMock := &mockForgejo{tokens: []string{"token-1", "token-2"}}
dispatcherMock := &mockDispatcher{responses: []string{"runner-1", "runner-2"}}
cfg := Config{
DefaultScope: forgejo.Scope{Level: forgejo.ScopeInstance},
DefaultLabels: []string{"default"},
InstanceURL: "https://forgejo.example.com",
DefaultTTL: 10 * time.Minute,
}
service := NewService(cfg, forgejoMock, dispatcherMock, nil)
reqScope := &Scope{Level: string(forgejo.ScopeRepository), Owner: "acme", Name: "repo"}
res, err := service.Dispatch(context.Background(), DispatchRequest{
Count: 2,
Labels: []string{"custom"},
Scope: reqScope,
TTL: 5 * time.Minute,
Machine: "4x8",
Image: "runner:latest",
ExtraEnv: map[string]string{"FOO": "bar"},
})
if err != nil {
t.Fatalf("Dispatch returned error: %v", err)
}
if len(res.Runners) != 2 {
t.Fatalf("expected two runners, got %+v", res)
}
if len(forgejoMock.scopes) != 2 {
t.Fatalf("expected two scope calls, got %d", len(forgejoMock.scopes))
}
for _, scope := range forgejoMock.scopes {
if scope.Level != forgejo.ScopeRepository || scope.Owner != "acme" || scope.Name != "repo" {
t.Fatalf("unexpected scope: %+v", scope)
}
}
if len(dispatcherMock.requests) != 2 {
t.Fatalf("expected two dispatcher calls, got %d", len(dispatcherMock.requests))
}
for _, call := range dispatcherMock.requests {
if call.MachineType != "4x8" || call.Image != "runner:latest" {
t.Fatalf("unexpected machine/image in %+v", call)
}
if call.Duration != 5*time.Minute {
t.Fatalf("expected TTL to override default, got %v", call.Duration)
}
if call.Labels[0] != "custom" {
t.Fatalf("expected custom labels, got %v", call.Labels)
}
if call.ExtraEnv["FOO"] != "bar" {
t.Fatalf("expected env passthrough, got %v", call.ExtraEnv)
}
}
}
func TestServiceDispatchErrorsWithoutLabels(t *testing.T) {
service := NewService(Config{DefaultScope: forgejo.Scope{Level: forgejo.ScopeInstance}}, &mockForgejo{}, &mockDispatcher{}, nil)
if _, err := service.Dispatch(context.Background(), DispatchRequest{}); err == nil {
t.Fatalf("expected error when no labels are available")
}
}