All checks were successful
docker-build / Build image (push) Successful in 9s
433 lines
13 KiB
Go
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)
|
|
}
|