Files
gitlab-job-runner/internal/controller/gitlabjobrunner_controller.go
Vassiliy Yegorov 489db3b8a6
All checks were successful
docker-build / Build image (push) Successful in 9s
init
2025-12-07 20:18:17 +07:00

433 lines
13 KiB
Go

/*
Copyright 2025.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package controller
import (
"context"
"fmt"
"time"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"
batchv1alpha1 "github.com/rml/gitlab-job-runner/api/v1alpha1"
)
// GitlabJobRunnerReconciler reconciles a GitlabJobRunner object
type GitlabJobRunnerReconciler struct {
client.Client
Scheme *runtime.Scheme
}
const (
finalizerName = "batch.rml.ru/finalizer"
defaultImage = "bitnami/git:latest"
defaultBranch = "main"
)
// +kubebuilder:rbac:groups=batch.rml.ru,resources=gitlabjobrunners,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=batch.rml.ru,resources=gitlabjobrunners/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=batch.rml.ru,resources=gitlabjobrunners/finalizers,verbs=update
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
func (r *GitlabJobRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
// Fetch the GitlabJobRunner instance
runner := &batchv1alpha1.GitlabJobRunner{}
if err := r.Get(ctx, req.NamespacedName, runner); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
log.Error(err, "unable to fetch GitlabJobRunner")
return ctrl.Result{}, err
}
// Handle finalizer
if runner.ObjectMeta.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(runner, finalizerName) {
controllerutil.AddFinalizer(runner, finalizerName)
if err := r.Update(ctx, runner); err != nil {
return ctrl.Result{}, err
}
}
} else {
if controllerutil.ContainsFinalizer(runner, finalizerName) {
if err := r.cleanupResources(ctx, runner); err != nil {
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(runner, finalizerName)
if err := r.Update(ctx, runner); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// Verify GitLab secret exists
secret := &corev1.Secret{}
if err := r.Get(ctx, types.NamespacedName{
Name: runner.Spec.GitlabSecretRef,
Namespace: runner.Namespace,
}, secret); err != nil {
log.Error(err, "unable to fetch GitLab secret")
r.updateStatus(ctx, runner, "Degraded", metav1.ConditionFalse, "SecretNotFound", "GitLab secret not found")
return ctrl.Result{RequeueAfter: time.Minute}, err
}
// Determine if this should be a Job or CronJob
if runner.Spec.Schedule != "" {
return r.reconcileCronJob(ctx, runner)
}
return r.reconcileJob(ctx, runner)
}
// reconcileJob handles one-time Job creation
func (r *GitlabJobRunnerReconciler) reconcileJob(ctx context.Context, runner *batchv1alpha1.GitlabJobRunner) (ctrl.Result, error) {
log := logf.FromContext(ctx)
jobName := runner.Name
job := &batchv1.Job{}
err := r.Get(ctx, types.NamespacedName{Name: jobName, Namespace: runner.Namespace}, job)
if err != nil && errors.IsNotFound(err) {
job = r.constructJob(runner)
if err := controllerutil.SetControllerReference(runner, job, r.Scheme); err != nil {
return ctrl.Result{}, err
}
if err := r.Create(ctx, job); err != nil {
log.Error(err, "unable to create Job")
r.updateStatus(ctx, runner, "Degraded", metav1.ConditionFalse, "JobCreationFailed", err.Error())
return ctrl.Result{}, err
}
log.Info("created Job", "job", jobName)
r.updateStatus(ctx, runner, "Progressing", metav1.ConditionTrue, "JobCreated", "Job created successfully")
return ctrl.Result{}, nil
} else if err != nil {
return ctrl.Result{}, err
}
// Job exists, update status based on its state
if job.Status.Succeeded > 0 {
r.updateStatus(ctx, runner, "Available", metav1.ConditionTrue, "JobCompleted", "Job completed successfully")
} else if job.Status.Failed > 0 {
r.updateStatus(ctx, runner, "Degraded", metav1.ConditionFalse, "JobFailed", "Job failed")
} else {
r.updateStatus(ctx, runner, "Progressing", metav1.ConditionTrue, "JobRunning", "Job is running")
runner.Status.ActiveJobName = jobName
r.Status().Update(ctx, runner)
}
return ctrl.Result{}, nil
}
// reconcileCronJob handles CronJob creation and management
func (r *GitlabJobRunnerReconciler) reconcileCronJob(ctx context.Context, runner *batchv1alpha1.GitlabJobRunner) (ctrl.Result, error) {
log := logf.FromContext(ctx)
cronJobName := runner.Name
cronJob := &batchv1.CronJob{}
err := r.Get(ctx, types.NamespacedName{Name: cronJobName, Namespace: runner.Namespace}, cronJob)
if err != nil && errors.IsNotFound(err) {
cronJob = r.constructCronJob(runner)
if err := controllerutil.SetControllerReference(runner, cronJob, r.Scheme); err != nil {
return ctrl.Result{}, err
}
if err := r.Create(ctx, cronJob); err != nil {
log.Error(err, "unable to create CronJob")
r.updateStatus(ctx, runner, "Degraded", metav1.ConditionFalse, "CronJobCreationFailed", err.Error())
return ctrl.Result{}, err
}
log.Info("created CronJob", "cronjob", cronJobName)
r.updateStatus(ctx, runner, "Available", metav1.ConditionTrue, "CronJobCreated", "CronJob created successfully")
return ctrl.Result{}, nil
} else if err != nil {
return ctrl.Result{}, err
}
// CronJob exists, check if update is needed
needsUpdate := false
if cronJob.Spec.Schedule != runner.Spec.Schedule {
cronJob.Spec.Schedule = runner.Spec.Schedule
needsUpdate = true
}
if cronJob.Spec.Suspend != nil && *cronJob.Spec.Suspend != runner.Spec.Suspend {
cronJob.Spec.Suspend = &runner.Spec.Suspend
needsUpdate = true
}
if needsUpdate {
if err := r.Update(ctx, cronJob); err != nil {
log.Error(err, "unable to update CronJob")
return ctrl.Result{}, err
}
log.Info("updated CronJob", "cronjob", cronJobName)
}
// Update status with last schedule time
if cronJob.Status.LastScheduleTime != nil {
runner.Status.LastScheduleTime = cronJob.Status.LastScheduleTime
r.Status().Update(ctx, runner)
}
r.updateStatus(ctx, runner, "Available", metav1.ConditionTrue, "CronJobActive", "CronJob is active")
return ctrl.Result{}, nil
}
// constructJob creates a Job specification
func (r *GitlabJobRunnerReconciler) constructJob(runner *batchv1alpha1.GitlabJobRunner) *batchv1.Job {
image := runner.Spec.Image
if image == "" {
image = defaultImage
}
branch := runner.Spec.Branch
if branch == "" {
branch = defaultBranch
}
backoffLimit := int32(3)
ttlSecondsAfterFinished := int32(3600)
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: runner.Name,
Namespace: runner.Namespace,
Labels: map[string]string{
"app.kubernetes.io/name": "gitlabjobrunner",
"app.kubernetes.io/instance": runner.Name,
"app.kubernetes.io/managed-by": "gitlabjobrunner-controller",
},
},
Spec: batchv1.JobSpec{
BackoffLimit: &backoffLimit,
TTLSecondsAfterFinished: &ttlSecondsAfterFinished,
Template: corev1.PodTemplateSpec{
Spec: r.constructPodSpec(runner, image, branch),
},
},
}
return job
}
// constructCronJob creates a CronJob specification
func (r *GitlabJobRunnerReconciler) constructCronJob(runner *batchv1alpha1.GitlabJobRunner) *batchv1.CronJob {
image := runner.Spec.Image
if image == "" {
image = defaultImage
}
branch := runner.Spec.Branch
if branch == "" {
branch = defaultBranch
}
concurrencyPolicy := batchv1.ForbidConcurrent
if runner.Spec.ConcurrencyPolicy != "" {
switch runner.Spec.ConcurrencyPolicy {
case "Allow":
concurrencyPolicy = batchv1.AllowConcurrent
case "Replace":
concurrencyPolicy = batchv1.ReplaceConcurrent
}
}
successfulJobsHistoryLimit := int32(3)
failedJobsHistoryLimit := int32(1)
cronJob := &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: runner.Name,
Namespace: runner.Namespace,
Labels: map[string]string{
"app.kubernetes.io/name": "gitlabjobrunner",
"app.kubernetes.io/instance": runner.Name,
"app.kubernetes.io/managed-by": "gitlabjobrunner-controller",
},
},
Spec: batchv1.CronJobSpec{
Schedule: runner.Spec.Schedule,
ConcurrencyPolicy: concurrencyPolicy,
Suspend: &runner.Spec.Suspend,
SuccessfulJobsHistoryLimit: &successfulJobsHistoryLimit,
FailedJobsHistoryLimit: &failedJobsHistoryLimit,
JobTemplate: batchv1.JobTemplateSpec{
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: r.constructPodSpec(runner, image, branch),
},
},
},
},
}
return cronJob
}
// constructPodSpec creates the pod specification for Job/CronJob
func (r *GitlabJobRunnerReconciler) constructPodSpec(runner *batchv1alpha1.GitlabJobRunner, image, branch string) corev1.PodSpec {
serviceAccount := runner.Spec.ServiceAccountName
if serviceAccount == "" {
serviceAccount = "default"
}
return corev1.PodSpec{
ServiceAccountName: serviceAccount,
RestartPolicy: corev1.RestartPolicyOnFailure,
InitContainers: []corev1.Container{
{
Name: "git-clone",
Image: image,
Command: []string{
"sh",
"-c",
fmt.Sprintf(`
git clone -b %s --depth 1 https://oauth2:$GITLAB_TOKEN@$(echo $GITLAB_URL | sed 's|https://||')/%s /workspace
`, branch, runner.Spec.RepositoryURL),
},
Env: []corev1.EnvVar{
{
Name: "GITLAB_URL",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: runner.Spec.GitlabSecretRef,
},
Key: "GITLAB_URL",
},
},
},
{
Name: "GITLAB_TOKEN",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: runner.Spec.GitlabSecretRef,
},
Key: "GITLAB_TOKEN",
},
},
},
},
VolumeMounts: []corev1.VolumeMount{
{
Name: "workspace",
MountPath: "/workspace",
},
},
},
},
Containers: []corev1.Container{
{
Name: "executor",
Image: "bitnami/kubectl:latest",
Command: []string{
"sh",
"-c",
fmt.Sprintf("cd /workspace && sh %s", runner.Spec.ScriptPath),
},
VolumeMounts: []corev1.VolumeMount{
{
Name: "workspace",
MountPath: "/workspace",
},
},
},
},
Volumes: []corev1.Volume{
{
Name: "workspace",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
},
}
}
// updateStatus updates the status conditions
func (r *GitlabJobRunnerReconciler) updateStatus(ctx context.Context, runner *batchv1alpha1.GitlabJobRunner, condType string, status metav1.ConditionStatus, reason, message string) {
condition := metav1.Condition{
Type: condType,
Status: status,
ObservedGeneration: runner.Generation,
LastTransitionTime: metav1.Now(),
Reason: reason,
Message: message,
}
// Remove existing condition of the same type
for i, c := range runner.Status.Conditions {
if c.Type == condType {
runner.Status.Conditions = append(runner.Status.Conditions[:i], runner.Status.Conditions[i+1:]...)
break
}
}
runner.Status.Conditions = append(runner.Status.Conditions, condition)
r.Status().Update(ctx, runner)
}
// cleanupResources handles cleanup when GitlabJobRunner is deleted
func (r *GitlabJobRunnerReconciler) cleanupResources(ctx context.Context, runner *batchv1alpha1.GitlabJobRunner) error {
log := logf.FromContext(ctx)
// Delete associated Job if exists
job := &batchv1.Job{}
if err := r.Get(ctx, types.NamespacedName{Name: runner.Name, Namespace: runner.Namespace}, job); err == nil {
if err := r.Delete(ctx, job); err != nil {
log.Error(err, "unable to delete Job")
return err
}
}
// Delete associated CronJob if exists
cronJob := &batchv1.CronJob{}
if err := r.Get(ctx, types.NamespacedName{Name: runner.Name, Namespace: runner.Namespace}, cronJob); err == nil {
if err := r.Delete(ctx, cronJob); err != nil {
log.Error(err, "unable to delete CronJob")
return err
}
}
return nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *GitlabJobRunnerReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&batchv1alpha1.GitlabJobRunner{}).
Owns(&batchv1.Job{}).
Owns(&batchv1.CronJob{}).
Named("gitlabjobrunner").
Complete(r)
}