add user scope

This commit is contained in:
2026-01-12 21:07:05 +08:00
parent 6bc93a2476
commit ce5c764402
13 changed files with 291 additions and 222 deletions

View File

@@ -30,18 +30,23 @@ type RunnerGroupScope string
const (
RunnerGroupScopeGlobal RunnerGroupScope = "global"
RunnerGroupScopeOrg RunnerGroupScope = "org"
RunnerGroupScopeUser RunnerGroupScope = "user"
RunnerGroupScopeRepo RunnerGroupScope = "repo"
)
type RunnerGroupSpec struct {
// Scope defines the scope of the runner (global, org, repo)
// +kubebuilder:validation:Enum=global;org;repo
// Scope defines the scope of the runner (global, org, user, repo)
// +kubebuilder:validation:Enum=global;org;user;repo
Scope RunnerScope `json:"scope"`
// Org is required if scope is 'org'
// +optional
Org string `json:"org,omitempty"`
// User is required if scope is 'user'
// +optional
User string `json:"user,omitempty"`
// Repo is required if scope is 'repo'
// +optional
Repo string `json:"repo,omitempty"`
@@ -49,7 +54,8 @@ type RunnerGroupSpec struct {
// GiteaURL is the base URL of the Gitea instance
GiteaURL string `json:"giteaURL"`
// Labels to assign to the runner
// Labels to assign to the runner.
// Defaults (e.g. ubuntu-latest) are merged automatically by the controller.
// +optional
Labels []string `json:"labels,omitempty"`
@@ -79,154 +85,103 @@ type RunnerGroupStatus struct {
## 4. Controller Implementation (`internal/controller/runnergroup_controller.go`)
The controller handles the reconciliation loop.
The controller handles the reconciliation loop and manages the lifecycle of ephemeral runners.
### 4.1 RBAC Permissions
### 4.1 Struct Definition
Add markers to generate RBAC roles:
The reconciler includes a thread-safe map to cache spawned jobs and prevent duplicate scheduling.
```go
// +kubebuilder:rbac:groups=gitea.bpg.pw,resources=runnergroups,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=gitea.bpg.pw,resources=runnergroups/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
type RunnerGroupReconciler struct {
client.Client
Scheme *runtime.Scheme
GiteaClient gitea.Client
SpawnedJobsCache sync.Map // Stores [int64]time.Time (JobID -> SpawnTime)
}
```
### 4.2 Reconcile Logic
The `Reconcile` function should follow this flow:
The `Reconcile` function follows this flow:
1. **Fetch RunnerGroup**: Get the `RunnerGroup` CR instance. If not found, ignore (deleted).
2. **List Jobs**: List all `batchv1.Job` resources in the same namespace that are owned by this RunnerGroup.
- Filter by label `gitea.bpg.pw/runnergroup-name=<runnergroup-name>`.
3. **Update Status**: Update `status.activeRunners` with the count of non-completed jobs.
4. **Capacity Check**:
- If `activeRunners >= spec.maxActiveRunners`, stop and requeue.
5. **Poll Gitea**:
- Retrieve the Auth Token from the Secret referenced in `spec.authToken`.
- Instantiate a Gitea API Client.
- Query for queued workflow runs matching the scope and labels.
6. **Scale Up**:
- Calculate `needed = count(queued_jobs)`.
- Calculate `available_slots = spec.maxActiveRunners - activeRunners`.
- `to_spawn = min(needed, available_slots)`.
- Loop `to_spawn` times:
- Create a new `batchv1.Job`.
7. **Requeue**: Return `ctrl.Result{RequeueAfter: 10 * time.Second}` to ensure continuous polling.
1. **Fetch RunnerGroup**: Get the `RunnerGroup` CR instance.
2. **List Jobs**: List all `batchv1.Job` resources owned by this CR to calculate `activeRunners`.
3. **Update Status**: Update `status.activeRunners`.
4. **Capacity Check**: Stop scaling if `activeRunners >= spec.maxActiveRunners`.
5. **Label Calculation**: Call `getEffectiveLabels` to merge `spec.labels` with hardcoded Gitea defaults (e.g., `ubuntu-latest:docker://node:16-bullseye`).
6. **Poll Gitea**:
- Retrieve Auth Token.
- Call `GiteaClient.GetRunnerStats` with the effective labels.
- This returns a list of `QueuedJobs`.
7. **Scale Up & Deduplication**:
- Iterate through `stats.QueuedJobs`.
- **Check Cache**: If Job ID exists in `SpawnedJobsCache`:
- If TTL (< 5 min) is valid: **Skip** (already handled).
- If TTL expired: **Retry** (assume previous runner failed).
- If Job ID not in cache or expired:
- Check `availableSlots`.
- Retrieve Registration Token (if not yet fetched).
- **Spawn Job**: Create `batchv1.Job`.
- **Update Cache**: Store Job ID in `SpawnedJobsCache`.
- Decrement `availableSlots`.
8. **Cache Cleanup**: Remove IDs from `SpawnedJobsCache` if they are not present in the latest `QueuedJobs` list from Gitea.
9. **Requeue**: Return `ctrl.Result{RequeueAfter: 10 * time.Second}`.
### 4.3 Job Construction
### 4.3 Helper Functions
Helper function to create the Job object:
#### getEffectiveLabels
```go
func (r *RunnerGroupReconciler) constructJobForRunnerGroup(runnerGroup *giteav1alpha1.RunnerGroup, registrationToken string) (*batchv1.Job, error) {
// Generate random suffix for name
name := fmt.Sprintf("%s-%s", runnerGroup.Name, randString(5))
Merges user-defined labels with Gitea defaults. If a user defines `ubuntu-latest`, it overrides the default `ubuntu-latest:docker://...`.
// Construct Env Vars
envVars := []corev1.EnvVar{
{Name: "GITEA_INSTANCE_URL", Value: runnerGroup.Spec.GiteaURL},
{Name: "GITEA_RUNNER_REGISTRATION_TOKEN", Value: registrationToken},
{Name: "GITEA_RUNNER_EPHEMERAL", Value: "true"},
{Name: "DOCKER_HOST", Value: "tcp://localhost:2376"},
// ... other envs from README
}
#### constructJobForRunnerGroup
if len(runnerGroup.Spec.Labels) > 0 {
labelsStr := strings.Join(runnerGroup.Spec.Labels, ",")
envVars = append(envVars, corev1.EnvVar{Name: "GITEA_RUNNER_LABELS", Value: labelsStr})
}
Creates the Job object with:
// Construct Job
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: runnerGroup.Namespace,
Labels: map[string]string{
"app": runnerGroup.Name,
"gitea.bpg.pw/runnergroup-name": runnerGroup.Name,
"gitea.bpg.pw/managed-by": "gitea-runner-operator",
},
},
Spec: batchv1.JobSpec{
TTLSecondsAfterFinished: pointer.Int32(600),
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyOnFailure,
Containers: []corev1.Container{
{
Name: "runner",
Image: "gitea/act_runner:nightly-dind-rootless",
ImagePullPolicy: corev1.PullAlways,
SecurityContext: &corev1.SecurityContext{Privileged: pointer.Bool(true)},
Env: envVars,
VolumeMounts: []corev1.VolumeMount{
{Name: "runner-data", MountPath: "/data"},
},
},
},
Volumes: []corev1.Volume{
{
Name: "runner-data",
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: "act-runner-vol", // Note: Consider making this configurable or EmptyDir
},
},
},
},
},
},
},
}
// Set Controller Reference
if err := ctrl.SetControllerReference(runnerGroup, job, r.Scheme); err != nil {
return nil, err
}
return job, nil
}
```
- **Name**: `{runnergroup-name}-{random-suffix}`
- **Env**:
- `GITEA_RUNNER_NAME`: Set to the Job name.
- `GITEA_RUNNER_LABELS`: Comma-separated effective labels.
- Standard runner envs (`GITEA_INSTANCE_URL`, etc).
## 5. Gitea Client (`internal/gitea/client.go`)
A simple HTTP client wrapper to interact with Gitea.
A specialized client to interact with Gitea's Actions API.
### 5.1 Interface
```go
type RunnerStats struct {
QueuedJobs []ActionWorkflowJob
Running int
}
type Client interface {
GetQueuedRuns(ctx context.Context, scope RunnerGroupScope, owner, repo string, labels []string) (int, error)
GetRunnerStats(ctx context.Context, giteaURL, authToken string, scope RunnerGroupScope, org, repo string, labels []string) (*RunnerStats, error)
}
```
### 5.2 Implementation Details
### 5.2 Logic
- **Endpoint**: `/api/v1/repos/{owner}/{repo}/actions/runs`
- **Query Params**: `status=queued`
- **Filtering**:
- The API might return all queued runs.
- The client must filter these runs locally to ensure they match the `labels` defined in the RunnerGroup CR.
- _Note_: Gitea API might not support filtering by labels directly in the list endpoint, so client-side filtering is necessary.
1. **Endpoints**:
- Repo/Org/Global: Uses `/actions/jobs` endpoints.
- User: Fetches repos via `/users/{user}/repos`, then queries `/actions/jobs` for each repo.
2. **Fetching**:
- Fetches jobs with `status=queued`, `waiting`, `pending`.
- Handles pagination (fetches all pages).
3. **Filtering**:
- Iterates through fetched jobs.
- **Matches Labels**: Checks if the job's required labels are a subset of the runner's supported labels (effective labels).
- Supports exact match (`linux` == `linux`)
- Supports schema match (`ubuntu-latest` matches `ubuntu-latest:docker://...`)
- Returns only matching jobs in `QueuedJobs`.
## 6. Configuration & Deployment
## 6. Testing Strategy
### 6.1 Dockerfile
Standard Operator SDK Dockerfile. Ensure the base image is minimal (e.g., `gcr.io/distroless/static:nonroot`).
### 6.2 Kustomize
Update `config/default/kustomization.yaml` to include the CRD and RBAC configurations.
## 7. Testing Strategy
1. **Unit Tests**:
- Test `constructJobForRunnerGroup` to ensure Env vars and Labels are set correctly.
- Test Gitea Client response parsing.
2. **Integration Tests (EnvTest)**:
- Spin up a local k8s control plane.
- Create a `RunnerGroup` CR.
- Verify the controller creates a `Job` when the mocked Gitea client returns queued jobs.
- Verify the controller respects `MaxActiveRunners`.
1. **Unit Tests (`internal/gitea/client_test.go`)**:
- Mock Gitea API server.
- Verify `GetRunnerStats` correctly parses JSON and handles pagination.
- Verify label matching logic (subset, schema matching).
2. **Controller Tests**:
- Verify `SpawnedJobsCache` prevents double scheduling.
- Verify TTL logic allows retries for stuck jobs.
- Verify `getEffectiveLabels` merging logic.