Initial scaffold: API service, K8s operator, and CRDs
Forgebot is a K8s operator + API service for dispatching AI agent jobs from git forge commands. Includes: - CRDs: AgentPool, AgentTask, ProviderQueue, RepositoryBinding - API server with webhook handler, task queue, and comment proxy - Operator controllers for task scheduling and job management - Gitea provider with webhook parsing and signature verification - PostgreSQL database with auto-migration - Woodpecker CI pipelines and multi-stage Dockerfiles
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1"
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
type ProviderQueueReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=providerqueues,verbs=get;list;watch;update;patch
|
||||
// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=providerqueues/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agenttasks,verbs=create
|
||||
// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=repositorybindings,verbs=get;list;watch
|
||||
|
||||
func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
var queue forgebotv1alpha1.ProviderQueue
|
||||
if err := r.Get(ctx, req.NamespacedName, &queue); err != nil {
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
|
||||
pollInterval, err := time.ParseDuration(queue.Spec.PollInterval)
|
||||
if err != nil {
|
||||
pollInterval = 30 * time.Second
|
||||
}
|
||||
|
||||
httpClient := r.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
resp, err := httpClient.Get(queue.Spec.Endpoint + "/tasks?status=pending")
|
||||
if err != nil {
|
||||
now := metav1.Now()
|
||||
queue.Status.LastPoll = &now
|
||||
queue.Status.LastError = err.Error()
|
||||
_ = r.Status().Update(ctx, &queue)
|
||||
logger.Error(err, "failed to poll API")
|
||||
return ctrl.Result{RequeueAfter: pollInterval}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ctrl.Result{RequeueAfter: pollInterval}, err
|
||||
}
|
||||
|
||||
var tasks []models.Task
|
||||
if err := json.Unmarshal(body, &tasks); err != nil {
|
||||
return ctrl.Result{RequeueAfter: pollInterval}, err
|
||||
}
|
||||
|
||||
var bindings forgebotv1alpha1.RepositoryBindingList
|
||||
if err := r.List(ctx, &bindings, client.InNamespace(req.Namespace)); err != nil {
|
||||
return ctrl.Result{RequeueAfter: pollInterval}, err
|
||||
}
|
||||
|
||||
bindingMap := map[string]*forgebotv1alpha1.RepositoryBinding{}
|
||||
for i := range bindings.Items {
|
||||
b := &bindings.Items[i]
|
||||
if b.Spec.ProviderQueueRef == queue.Name {
|
||||
bindingMap[b.Spec.Repository] = b
|
||||
}
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
binding, ok := bindingMap[task.Repository]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isAllowed(binding, task.Author, task.Command) {
|
||||
continue
|
||||
}
|
||||
|
||||
agentTask := &forgebotv1alpha1.AgentTask{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("task-%s", task.ID[:8]),
|
||||
Namespace: req.Namespace,
|
||||
},
|
||||
Spec: forgebotv1alpha1.AgentTaskSpec{
|
||||
PoolRef: binding.Spec.AgentPoolRef,
|
||||
Command: task.Command,
|
||||
Skill: resolveSkill(binding, task.Command),
|
||||
Repository: task.Repository,
|
||||
Ref: task.Ref,
|
||||
Context: forgebotv1alpha1.TaskContext{
|
||||
IssueNumber: task.IssueNumber,
|
||||
PRNumber: task.PRNumber,
|
||||
CommentID: task.CommentID,
|
||||
Body: task.Body,
|
||||
Author: task.Author,
|
||||
},
|
||||
ExtraTools: task.ExtraTools,
|
||||
ParentTaskRef: task.ParentTaskID,
|
||||
},
|
||||
}
|
||||
|
||||
if err := r.Create(ctx, agentTask); err != nil {
|
||||
logger.Error(err, "failed to create AgentTask", "task", task.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
queue.Status.TasksCreated++
|
||||
logger.Info("created AgentTask", "task", agentTask.Name, "command", task.Command)
|
||||
}
|
||||
|
||||
now := metav1.Now()
|
||||
queue.Status.LastPoll = &now
|
||||
queue.Status.LastError = ""
|
||||
_ = r.Status().Update(ctx, &queue)
|
||||
|
||||
return ctrl.Result{RequeueAfter: pollInterval}, nil
|
||||
}
|
||||
|
||||
func isAllowed(binding *forgebotv1alpha1.RepositoryBinding, author, command string) bool {
|
||||
if len(binding.Spec.AllowedUsers) > 0 {
|
||||
found := false
|
||||
for _, u := range binding.Spec.AllowedUsers {
|
||||
if u == author {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(binding.Spec.AllowedCommands) > 0 {
|
||||
for _, c := range binding.Spec.AllowedCommands {
|
||||
if c == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveSkill(binding *forgebotv1alpha1.RepositoryBinding, command string) string {
|
||||
for _, m := range binding.Spec.SkillMapping {
|
||||
if m.Command == command {
|
||||
return m.Skill
|
||||
}
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func (r *ProviderQueueReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&forgebotv1alpha1.ProviderQueue{}).
|
||||
Complete(r)
|
||||
}
|
||||
Reference in New Issue
Block a user