Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b6b8038e
|
||
|
|
70c5ff422f | ||
|
|
906e6a2e18
|
||
|
|
ace84373f6 | ||
|
|
87cf359ef6
|
||
|
|
ad3934e7e1 | ||
|
|
d82687382a
|
||
|
|
7f52cf393f | ||
|
|
8a186dcd86 | ||
|
|
327c73e744 |
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
kind:
|
||||
runs-on: [self-hosted, vm]
|
||||
runs-on: [self-hosted, vm-dev]
|
||||
steps:
|
||||
- name: Verify Docker
|
||||
run: docker info
|
||||
|
||||
@@ -15,6 +15,10 @@ runs-on: [self-hosted, vm]
|
||||
只执行一个 job,并在 job 结束后连同本地状态一起销毁。完整的设计约束见
|
||||
[`docs/design-principles.md`](docs/design-principles.md)。
|
||||
|
||||
集成期间可将 `VM_RUNNER_LABEL=vm-dev`,只接取显式使用
|
||||
`runs-on: [self-hosted, vm-dev]` 的测试任务;生产 `vm` job 将保持在 Gitea pending,
|
||||
不会在 backend 修复过程中继续涌入。
|
||||
|
||||
目标 Go controller 组件:
|
||||
|
||||
- `scheduler`:以常驻 Gitea RunnerService 身份直接领取 task,并把完整 assignment
|
||||
|
||||
@@ -49,7 +49,7 @@ type controllerConfig struct {
|
||||
PodNamespace, PodImage, PodServiceAccount, SPIRECluster, SPIREClass string
|
||||
SPIREAgentID string
|
||||
PodExecutorUID, PodCapacity int
|
||||
OpenSandboxURL, OpenSandboxAPIKey, OpenSandboxPool string
|
||||
OpenSandboxURL, OpenSandboxAPIKey, OpenSandboxPool, VMRunnerLabel string
|
||||
VMTimeout, VMCapacity int
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func runController(ctx context.Context) error {
|
||||
labels = append(labels, string(taskassignment.BackendPod))
|
||||
}
|
||||
if slices.Contains(config.Components, controller.VMWorker) {
|
||||
labels = append(labels, string(taskassignment.BackendVM))
|
||||
labels = append(labels, config.VMRunnerLabel)
|
||||
}
|
||||
poller := taskscheduler.Poller{
|
||||
Client: giteaClient,
|
||||
@@ -345,7 +345,7 @@ func loadControllerConfig() (controllerConfig, error) {
|
||||
FacadeListen: env("RUNNER_FACADE_LISTEN", ":8443"), FacadeURL: os.Getenv("RUNNER_FACADE_URL"), FacadeSPIFFEID: os.Getenv("RUNNER_FACADE_SPIFFE_ID"), CapabilityKey: []byte(capabilityKey),
|
||||
PodNamespace: env("POD_NAMESPACE", "gitea-actions"), PodImage: os.Getenv("POD_EXECUTOR_IMAGE"), PodServiceAccount: env("POD_SERVICE_ACCOUNT", "gitea-task-executor"),
|
||||
SPIRECluster: env("SPIRE_CLUSTER", "homelab"), SPIREClass: env("SPIRE_CLASS", "spire-mgmt-spire"), SPIREAgentID: os.Getenv("SPIRE_AGENT_ID"), PodExecutorUID: envInt("POD_EXECUTOR_UID", 2000), PodCapacity: envInt("POD_CAPACITY", 4),
|
||||
OpenSandboxURL: os.Getenv("OPENSANDBOX_API"), OpenSandboxPool: env("OPENSANDBOX_POOL", "ci-vm"), VMTimeout: envInt("VM_TIMEOUT_SECONDS", 14400), VMCapacity: envInt("VM_CAPACITY", 1),
|
||||
OpenSandboxURL: os.Getenv("OPENSANDBOX_API"), OpenSandboxPool: env("OPENSANDBOX_POOL", "ci-vm"), VMRunnerLabel: env("VM_RUNNER_LABEL", "vm"), VMTimeout: envInt("VM_TIMEOUT_SECONDS", 14400), VMCapacity: envInt("VM_CAPACITY", 1),
|
||||
}
|
||||
if config.WorkloadAPIAddr == "" || config.FacadeURL == "" || config.FacadeSPIFFEID == "" {
|
||||
return controllerConfig{}, errors.New("SPIFFE_ENDPOINT_SOCKET, RUNNER_FACADE_URL, and RUNNER_FACADE_SPIFFE_ID are required")
|
||||
@@ -357,6 +357,9 @@ func loadControllerConfig() (controllerConfig, error) {
|
||||
return controllerConfig{}, errors.New("SPIRE_AGENT_ID is required for pod-worker")
|
||||
}
|
||||
if slices.Contains(selection, controller.VMWorker) {
|
||||
if config.VMRunnerLabel != "vm" && config.VMRunnerLabel != "vm-dev" {
|
||||
return controllerConfig{}, errors.New("VM_RUNNER_LABEL must be vm or vm-dev")
|
||||
}
|
||||
if config.OpenSandboxURL == "" {
|
||||
return controllerConfig{}, errors.New("OPENSANDBOX_API is required for vm-worker")
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func TestLoadControllerConfigRequiresOpenSandboxSecretOnlyForVM(t *testing.T) {
|
||||
t.Setenv("RUNNER_FACADE_URL", "https://facade:8443")
|
||||
t.Setenv("RUNNER_FACADE_SPIFFE_ID", "spiffe://ddupan.top/controller")
|
||||
t.Setenv("OPENSANDBOX_API", "http://opensandbox.internal")
|
||||
t.Setenv("VM_RUNNER_LABEL", "vm-dev")
|
||||
if _, err := loadControllerConfig(); err == nil {
|
||||
t.Fatal("expected missing OpenSandbox API key file error")
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ func OpenConsumer(ctx context.Context, manager consumerManager, stream, subjectB
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
AckWait: 5 * time.Minute,
|
||||
MaxAckPending: capacity,
|
||||
MaxDeliver: 20,
|
||||
MaxDeliver: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s assignment consumer: %w", backend, err)
|
||||
|
||||
@@ -205,7 +205,7 @@ func TestOpenConsumerUsesIndependentDurablePerBackend(t *testing.T) {
|
||||
if _, err := OpenConsumer(context.Background(), manager, "CI_RUNNER", "ci.assignment", taskassignment.BackendPod, 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manager.config.Durable != "pod" || manager.config.FilterSubject != "ci.assignment.pod" || manager.config.AckPolicy != jetstream.AckExplicitPolicy || manager.config.MaxAckPending != 4 {
|
||||
if manager.config.Durable != "pod" || manager.config.FilterSubject != "ci.assignment.pod" || manager.config.AckPolicy != jetstream.AckExplicitPolicy || manager.config.MaxAckPending != 4 || manager.config.MaxDeliver != 1000 {
|
||||
t.Fatalf("config = %#v", manager.config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ package opensandboxbackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
opensandbox "github.com/alibaba/OpenSandbox/sdks/sandbox/go"
|
||||
@@ -17,6 +21,8 @@ import (
|
||||
|
||||
const assignmentMetadata = "ci.ddupan.top/assignment-id"
|
||||
const terminalMetadata = "ci.ddupan.top/terminal"
|
||||
const annotationsMetadataPrefix = "ci.ddupan.top/annotations-"
|
||||
const metadataValueLimit = 63
|
||||
|
||||
type Lifecycle interface {
|
||||
ListSandboxes(context.Context, opensandbox.ListOptions) (*opensandbox.ListSandboxesResponse, error)
|
||||
@@ -123,7 +129,11 @@ func (b Backend) RecoverAssignments(ctx context.Context, trustDomain string) ([]
|
||||
if sandbox.Metadata[terminalMetadata] == "true" {
|
||||
continue
|
||||
}
|
||||
assignment, err := taskassignment.FromMetadata(sandbox.Metadata, sandbox.Metadata, trustDomain)
|
||||
annotations, err := decodeAnnotations(sandbox.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode sandbox %s annotations: %w", sandbox.ID, err)
|
||||
}
|
||||
assignment, err := taskassignment.FromMetadata(sandbox.Metadata, annotations, trustDomain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recover sandbox %s: %w", sandbox.ID, err)
|
||||
}
|
||||
@@ -169,8 +179,12 @@ func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignmen
|
||||
for key, value := range launch.Environment {
|
||||
environment[key] = value
|
||||
}
|
||||
sandboxMetadata := clone(launch.Metadata.Annotations)
|
||||
for key, value := range launch.Metadata.Labels {
|
||||
sandboxMetadata := clone(launch.Metadata.Labels)
|
||||
annotations, err := encodeAnnotations(launch.Metadata.Annotations)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode sandbox annotations: %w", err)
|
||||
}
|
||||
for key, value := range annotations {
|
||||
sandboxMetadata[key] = value
|
||||
}
|
||||
request := opensandbox.CreateSandboxRequest{
|
||||
@@ -194,7 +208,11 @@ func (b Backend) BindIdentity(ctx context.Context, executor *taskworker.Executor
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify sandbox identity metadata: %w", err)
|
||||
}
|
||||
if sandbox.Metadata["ci.ddupan.top/spiffe-id"] != identity.SPIFFEID {
|
||||
annotations, err := decodeAnnotations(sandbox.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode sandbox identity metadata: %w", err)
|
||||
}
|
||||
if annotations["ci.ddupan.top/spiffe-id"] != identity.SPIFFEID {
|
||||
return fmt.Errorf("sandbox %s has inconsistent SPIFFE identity metadata", executor.Name)
|
||||
}
|
||||
return nil
|
||||
@@ -243,3 +261,50 @@ func clone(source map[string]string) map[string]string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// OpenSandbox metadata follows Kubernetes label-value constraints, unlike Pod
|
||||
// annotations. Store the annotation map as deterministic URL-safe base64
|
||||
// chunks so repository paths and SPIFFE IDs remain lossless and recoverable.
|
||||
func encodeAnnotations(annotations map[string]string) (map[string]string, error) {
|
||||
data, err := json.Marshal(annotations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(data)
|
||||
result := make(map[string]string, (len(encoded)+metadataValueLimit-1)/metadataValueLimit)
|
||||
for index := 0; len(encoded) > 0; index++ {
|
||||
length := min(metadataValueLimit, len(encoded))
|
||||
result[fmt.Sprintf("%s%03d", annotationsMetadataPrefix, index)] = encoded[:length]
|
||||
encoded = encoded[length:]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeAnnotations(metadata map[string]string) (map[string]string, error) {
|
||||
keys := make([]string, 0)
|
||||
for key := range metadata {
|
||||
if strings.HasPrefix(key, annotationsMetadataPrefix) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, errors.New("sandbox annotation metadata is missing")
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var encoded strings.Builder
|
||||
for index, key := range keys {
|
||||
if key != fmt.Sprintf("%s%03d", annotationsMetadataPrefix, index) {
|
||||
return nil, errors.New("sandbox annotation metadata chunks are incomplete")
|
||||
}
|
||||
encoded.WriteString(metadata[key])
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(encoded.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var annotations map[string]string
|
||||
if err := json.Unmarshal(data, &annotations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return annotations, nil
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ func (f *fakeLifecycle) GetSandbox(_ context.Context, id string) (*opensandbox.S
|
||||
return &item, nil
|
||||
}
|
||||
}
|
||||
return &opensandbox.SandboxInfo{ID: id, Metadata: map[string]string{"ci.ddupan.top/spiffe-id": assignment().Identity.SPIFFEID}}, nil
|
||||
metadata, _ := encodeAnnotations(map[string]string{"ci.ddupan.top/spiffe-id": assignment().Identity.SPIFFEID})
|
||||
return &opensandbox.SandboxInfo{ID: id, Metadata: metadata}, nil
|
||||
}
|
||||
func (f *fakeLifecycle) PatchSandboxMetadata(_ context.Context, id string, patch opensandbox.MetadataPatch) (*opensandbox.SandboxInfo, error) {
|
||||
for index := range f.items {
|
||||
@@ -89,12 +90,38 @@ func TestCreateUsesPoolAndPersistsRecoveryMetadata(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lifecycle.created.Extensions["poolRef"] != "ci-vm" || lifecycle.created.Metadata[assignmentMetadata] != assignment().ID || lifecycle.created.Env["CI_SPIFFE_ID"] != assignment().Identity.SPIFFEID || executor.Name != "sandbox-42" {
|
||||
if lifecycle.created.Extensions["poolRef"] != "ci-vm" || lifecycle.created.Metadata[assignmentMetadata] != assignment().ID || lifecycle.created.Metadata["ci.ddupan.top/runner"] != "true" || lifecycle.created.Env["CI_SPIFFE_ID"] != assignment().Identity.SPIFFEID || executor.Name != "sandbox-42" {
|
||||
t.Fatalf("request=%#v executor=%#v", lifecycle.created, executor)
|
||||
}
|
||||
if lifecycle.created.Env["CI_RUNNER_CAPABILITY"] != "capability" {
|
||||
t.Fatalf("environment = %#v", lifecycle.created.Env)
|
||||
}
|
||||
annotations, err := decodeAnnotations(lifecycle.created.Metadata)
|
||||
if err != nil || annotations["ci.ddupan.top/repository"] != "owner/repo" || annotations["ci.ddupan.top/spiffe-id"] != assignment().Identity.SPIFFEID {
|
||||
t.Fatalf("annotations=%#v err=%v", annotations, err)
|
||||
}
|
||||
for _, value := range lifecycle.created.Metadata {
|
||||
if len(value) > metadataValueLimit {
|
||||
t.Fatalf("metadata value exceeds %d characters: %q", metadataValueLimit, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotationMetadataRoundTripPreservesSlashValues(t *testing.T) {
|
||||
want := taskworker.BackendMetadata(assignment()).Annotations
|
||||
encoded, err := encodeAnnotations(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := decodeAnnotations(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range want {
|
||||
if got[key] != value {
|
||||
t.Fatalf("%s=%q, want %q", key, got[key], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindIdentityVerifiesPersistedMetadata(t *testing.T) {
|
||||
|
||||
@@ -102,7 +102,7 @@ func backendFromTask(task *runnerv1.Task) (Backend, error) {
|
||||
return "", fmt.Errorf("task runs-on labels must include self-hosted: %v", labels)
|
||||
}
|
||||
hasPod := slices.Contains(labels, string(BackendPod))
|
||||
hasVM := slices.Contains(labels, string(BackendVM))
|
||||
hasVM := slices.Contains(labels, string(BackendVM)) || slices.Contains(labels, "vm-dev")
|
||||
if hasPod == hasVM {
|
||||
return "", fmt.Errorf("task runs-on labels must select exactly one of pod or vm: %v", labels)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestNewSelectsBackendFromRunsOn(t *testing.T) {
|
||||
}{
|
||||
{"[self-hosted, pod]", BackendPod},
|
||||
{"[self-hosted, vm]", BackendVM},
|
||||
{"[self-hosted, vm-dev]", BackendVM},
|
||||
} {
|
||||
assignment, err := New(task(t, test.labels), "ddupan.top")
|
||||
if err != nil {
|
||||
|
||||
@@ -178,6 +178,7 @@ func (w Worker) launchSpec(assignment taskassignment.Assignment) (LaunchSpec, er
|
||||
func BackendMetadata(assignment taskassignment.Assignment) Metadata {
|
||||
return Metadata{
|
||||
Labels: map[string]string{
|
||||
"ci.ddupan.top/runner": "true",
|
||||
"ci.ddupan.top/assignment-id": assignment.ID,
|
||||
"ci.ddupan.top/task-id": strconv.FormatInt(assignment.Task.GetId(), 10),
|
||||
"ci.ddupan.top/backend": string(assignment.Backend),
|
||||
|
||||
Reference in New Issue
Block a user