// Package podbackend implements the native homelab Kubernetes executor backend. package podbackend import ( "context" "errors" "fmt" "net/url" "strings" "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment" "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskidentity" "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskworker" ) const assignmentLabel = "ci.ddupan.top/assignment-id" // Pod is the backend state required by the reconciler, not an in-memory lifecycle record. type Pod struct { Name string Namespace string UID string Phase string Labels map[string]string Annotations map[string]string } // PodManifest leaves task execution wiring to the executor image while fixing // the metadata contract needed for recovery. type PodManifest struct { Name string Namespace string Labels map[string]string Annotations map[string]string Image string ServiceAccount string Args []string } // IdentityEntry is a ClusterStaticEntry pinned to one concrete Pod UID. type IdentityEntry struct { Name string Labels map[string]string ClassName string ParentID string SPIFFEID string Selectors []string } // API is the narrow Kubernetes boundary used by the backend adapter. type API interface { ListPods(context.Context, string, string) ([]Pod, error) CreatePod(context.Context, PodManifest) (Pod, error) DeletePod(context.Context, string, string) error EnsureIdentityEntry(context.Context, IdentityEntry) error DeleteIdentityEntry(context.Context, string) error } type Config struct { Namespace string Image string ServiceAccount string ExecutorArgs []string TrustDomain string SPIRECluster string SPIREClass string ExecutorUID int } type Backend struct { API API Config Config } func (b Backend) Find(ctx context.Context, assignmentID string) (*taskworker.Executor, error) { if err := b.validate(); err != nil { return nil, err } pods, err := b.API.ListPods(ctx, b.Config.Namespace, assignmentLabel+"="+assignmentID) if err != nil { return nil, fmt.Errorf("list assignment Pods: %w", err) } if len(pods) > 1 { return nil, fmt.Errorf("assignment %s owns %d Pods", assignmentID, len(pods)) } if len(pods) == 0 { return nil, nil } return executor(pods[0]), nil } func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignment, metadata taskworker.Metadata) (*taskworker.Executor, error) { if err := b.validate(); err != nil { return nil, err } if assignment.Backend != taskassignment.BackendPod { return nil, fmt.Errorf("Pod backend cannot create %q assignment", assignment.Backend) } labels := clone(metadata.Labels) labels["app.kubernetes.io/name"] = "gitea-dynamic-runner" labels["app.kubernetes.io/component"] = "executor" pod, err := b.API.CreatePod(ctx, PodManifest{ Name: assignment.ID, Namespace: b.Config.Namespace, Labels: labels, Annotations: clone(metadata.Annotations), Image: b.Config.Image, ServiceAccount: b.Config.ServiceAccount, Args: append(append([]string{}, b.Config.ExecutorArgs...), assignment.ID), }) if err != nil { return nil, fmt.Errorf("create assignment Pod: %w", err) } return executor(pod), nil } func (b Backend) BindIdentity(ctx context.Context, executor *taskworker.Executor, identity taskidentity.Identity) error { if err := b.validate(); err != nil { return err } if executor == nil || executor.Name == "" || executor.IdentityTarget == "" { return errors.New("Pod name and UID are required for identity binding") } if _, err := identityPath(identity.SPIFFEID, b.Config.TrustDomain); err != nil { return err } return b.API.EnsureIdentityEntry(ctx, IdentityEntry{ Name: executor.Name, Labels: map[string]string{ "app.kubernetes.io/name": "gitea-dynamic-runner", "app.kubernetes.io/component": "pod-identity", assignmentLabel: executor.Name, }, ClassName: b.Config.SPIREClass, ParentID: fmt.Sprintf( "spiffe://%s/spire/agent/k8s_psat/%s/pod/%s", b.Config.TrustDomain, b.Config.SPIRECluster, executor.IdentityTarget, ), SPIFFEID: identity.SPIFFEID, Selectors: []string{fmt.Sprintf("unix:uid:%d", b.Config.ExecutorUID)}, }) } func identityPath(spiffeID, trustDomain string) (string, error) { parsed, err := url.Parse(spiffeID) if err != nil || parsed.Scheme != "spiffe" || parsed.Host != trustDomain || !strings.HasPrefix(parsed.Path, "/ci/") { return "", fmt.Errorf("invalid CI SPIFFE ID %q", spiffeID) } return strings.TrimPrefix(parsed.Path, "/ci/"), nil } func (b Backend) Delete(ctx context.Context, executor *taskworker.Executor) error { if err := b.validate(); err != nil { return err } if executor == nil || executor.Name == "" { return nil } if err := b.API.DeleteIdentityEntry(ctx, executor.Name); err != nil { return fmt.Errorf("delete Pod identity entry: %w", err) } if err := b.API.DeletePod(ctx, b.Config.Namespace, executor.Name); err != nil { return fmt.Errorf("delete assignment Pod: %w", err) } return nil } func (b Backend) validate() error { if b.API == nil || b.Config.Namespace == "" || b.Config.Image == "" || b.Config.ServiceAccount == "" || b.Config.TrustDomain == "" || b.Config.SPIRECluster == "" || b.Config.SPIREClass == "" || b.Config.ExecutorUID < 1 { return errors.New("Pod API and complete executor/SPIRE configuration are required") } return nil } func executor(pod Pod) *taskworker.Executor { return &taskworker.Executor{ Name: pod.Name, IdentityTarget: pod.UID, Phase: phase(pod.Phase), } } func phase(value string) taskworker.Phase { switch value { case "Succeeded": return taskworker.PhaseSucceeded case "Failed": return taskworker.PhaseFailed case "Running": return taskworker.PhaseRunning default: return taskworker.PhasePending } } func clone(source map[string]string) map[string]string { target := make(map[string]string, len(source)) for key, value := range source { target[key] = value } return target }