Add Burrow forge infrastructure and tailnet control plane
This commit is contained in:
parent
d1ed826389
commit
de25f240d5
51 changed files with 9058 additions and 0 deletions
253
services/forgejo-nsc/internal/app/service.go
Normal file
253
services/forgejo-nsc/internal/app/service.go
Normal 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
|
||||
}
|
||||
160
services/forgejo-nsc/internal/app/service_test.go
Normal file
160
services/forgejo-nsc/internal/app/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue