Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions controllers/cloud.redhat.com/clowdjobinvocation_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package controllers
import (
"context"
"fmt"
"strings"
"time"

rc "github.com/RedHatInsights/rhc-osdk-utils/resourceCache"
Expand All @@ -45,6 +46,10 @@ import (
"github.com/RedHatInsights/rhc-osdk-utils/utils"
)

// ExpectedImageTagAnnotation is the annotation key used on ClowdJobInvocations
// to specify the expected image tag that the ClowdApp must have before the job runs.
const ExpectedImageTagAnnotation = "clowder.redhat.com/expected-image-tag"

// ClowdJobInvocationReconciler reconciles a ClowdJobInvocation object
type ClowdJobInvocationReconciler struct {
client.Client
Expand Down Expand Up @@ -171,6 +176,31 @@ func (r *ClowdJobInvocationReconciler) Reconcile(ctx context.Context, req ctrl.R
return ctrl.Result{Requeue: true}, reconcileErr
}

// If the CJI carries an expected-image-tag annotation, verify the
// ClowdApp's job images contain that tag before proceeding. This
// handles the case where the CJI is applied before the ClowdApp has
// been updated with the new image by the deployment pipeline.
if expectedTag, ok := cji.Annotations[ExpectedImageTagAnnotation]; ok && expectedTag != "" {
if !appJobImagesContainTag(&app, cji.Spec.Jobs, expectedTag) {
r.Log.Info("ClowdApp job images do not yet contain expected tag, requeue",
"jobinvocation", cji.Name,
"expectedTag", expectedTag,
"appName", cji.Spec.AppName,
)
r.Recorder.Eventf(&cji, "Warning", "ImageTagMismatch",
"ClowdApp [%s] does not yet have expected image tag [%s]; requeueing",
cji.Spec.AppName, expectedTag)
imageErr := errors.NewClowderError(fmt.Sprintf(
"ClowdApp [%s] job images do not contain expected tag [%s]",
cji.Spec.AppName, expectedTag,
))
if condErr := SetClowdJobInvocationConditions(ctx, r.Client, &cji, crd.ReconciliationFailed, imageErr); condErr != nil {
return ctrl.Result{}, condErr
}
return ctrl.Result{Requeue: true}, imageErr
}
}

// Determine if the ClowdApp containing the Job is ready
notReadyError := r.HandleNotReady(ctx, app, cji)
if notReadyError != nil {
Expand Down Expand Up @@ -337,6 +367,23 @@ func getJobFromName(jobName string, app *crd.ClowdApp) (job crd.Job, err error)
return crd.Job{}, errors.NewClowderError(fmt.Sprintf("No such job %s", jobName))
}

// appJobImagesContainTag checks whether the ClowdApp's job images for the
// requested jobs contain the expected image tag suffix (e.g., ":a634e3a").
func appJobImagesContainTag(app *crd.ClowdApp, jobNames []string, expectedTag string) bool {
suffix := ":" + expectedTag
for _, jobName := range jobNames {
for _, j := range app.Spec.Jobs {
if j.Name == jobName {
if !strings.HasSuffix(j.PodSpec.Image, suffix) {
return false
}
break
}
}
}
return true
}

// SetupWithManager registers the CJI with the main manager process
func (r *ClowdJobInvocationReconciler) SetupWithManager(mgr ctrl.Manager) error {
r.Recorder = mgr.GetEventRecorderFor("clowdjobinvocation")
Expand Down
219 changes: 219 additions & 0 deletions controllers/cloud.redhat.com/clowdjobinvocation_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,222 @@ func TestCJIExistingJobMapSkipsReconciliation(t *testing.T) {
assert.NoError(t, err)
assert.Zero(t, result.RequeueAfter)
}

func TestCJIExpectedImageTagMismatchRequeues(t *testing.T) {
ns := "test-image-tag"

cji := &crd.ClowdJobInvocation{
ObjectMeta: metav1.ObjectMeta{
Name: "run-db-migrations-newsha",
Namespace: ns,
Annotations: map[string]string{
ExpectedImageTagAnnotation: "newsha",
},
},
Spec: crd.ClowdJobInvocationSpec{
AppName: "test-app",
RunOnNotReady: true,
Jobs: []string{"run-db-migrations"},
},
}

app := &crd.ClowdApp{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Namespace: ns,
Generation: 5,
},
Spec: crd.ClowdAppSpec{
EnvName: "test-env",
Jobs: []crd.Job{
{
Name: "run-db-migrations",
PodSpec: crd.PodSpec{
Image: "quay.io/myorg/myapp:oldsha",
},
},
},
},
Status: crd.ClowdAppStatus{
Generation: 5,
},
}

cachedClient := fake.NewClientBuilder().
WithScheme(Scheme).
WithObjects(cji).
WithStatusSubresource(&crd.ClowdJobInvocation{}).
Build()

apiReader := fake.NewClientBuilder().
WithScheme(Scheme).
WithObjects(app).
Build()

recorder := record.NewFakeRecorder(100)

reconciler := &ClowdJobInvocationReconciler{
Client: cachedClient,
APIReader: apiReader,
Log: ctrl.Log.WithName("test"),
Scheme: Scheme,
Recorder: recorder,
}

_, err := reconciler.Reconcile(context.Background(), ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "run-db-migrations-newsha",
Namespace: ns,
},
})

assert.Error(t, err)
assert.Contains(t, err.Error(), "do not contain expected tag")
assert.Contains(t, err.Error(), "newsha")
}

func TestCJIExpectedImageTagMatchProceeds(t *testing.T) {
ns := "test-image-tag-match"

cji := &crd.ClowdJobInvocation{
ObjectMeta: metav1.ObjectMeta{
Name: "run-db-migrations-newsha",
Namespace: ns,
Annotations: map[string]string{
ExpectedImageTagAnnotation: "newsha",
},
},
Spec: crd.ClowdJobInvocationSpec{
AppName: "test-app",
RunOnNotReady: true,
Jobs: []string{"run-db-migrations"},
},
}

app := &crd.ClowdApp{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Namespace: ns,
Generation: 5,
},
Spec: crd.ClowdAppSpec{
EnvName: "test-env",
Jobs: []crd.Job{
{
Name: "run-db-migrations",
PodSpec: crd.PodSpec{
Image: "quay.io/myorg/myapp:newsha",
},
},
},
},
Status: crd.ClowdAppStatus{
Generation: 5,
},
}

cachedClient := fake.NewClientBuilder().
WithScheme(Scheme).
WithObjects(cji).
WithStatusSubresource(&crd.ClowdJobInvocation{}).
Build()

apiReader := fake.NewClientBuilder().
WithScheme(Scheme).
WithObjects(app).
Build()

recorder := record.NewFakeRecorder(100)

reconciler := &ClowdJobInvocationReconciler{
Client: cachedClient,
APIReader: apiReader,
Log: ctrl.Log.WithName("test"),
Scheme: Scheme,
Recorder: recorder,
}

_, err := reconciler.Reconcile(context.Background(), ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "run-db-migrations-newsha",
Namespace: ns,
},
})

// Should pass the image tag check and fail later (missing env, etc.)
// but NOT with the "do not contain expected tag" error
if err != nil {
assert.NotContains(t, err.Error(), "do not contain expected tag")
}
}

func TestCJINoAnnotationSkipsImageCheck(t *testing.T) {
ns := "test-no-annotation"

cji := &crd.ClowdJobInvocation{
ObjectMeta: metav1.ObjectMeta{
Name: "run-db-migrations-oldsha",
Namespace: ns,
},
Spec: crd.ClowdJobInvocationSpec{
AppName: "test-app",
RunOnNotReady: true,
Jobs: []string{"run-db-migrations"},
},
}

app := &crd.ClowdApp{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Namespace: ns,
Generation: 5,
},
Spec: crd.ClowdAppSpec{
EnvName: "test-env",
Jobs: []crd.Job{
{
Name: "run-db-migrations",
PodSpec: crd.PodSpec{
Image: "quay.io/myorg/myapp:oldsha",
},
},
},
},
Status: crd.ClowdAppStatus{
Generation: 5,
},
}

cachedClient := fake.NewClientBuilder().
WithScheme(Scheme).
WithObjects(cji).
WithStatusSubresource(&crd.ClowdJobInvocation{}).
Build()

apiReader := fake.NewClientBuilder().
WithScheme(Scheme).
WithObjects(app).
Build()

recorder := record.NewFakeRecorder(100)

reconciler := &ClowdJobInvocationReconciler{
Client: cachedClient,
APIReader: apiReader,
Log: ctrl.Log.WithName("test"),
Scheme: Scheme,
Recorder: recorder,
}

_, err := reconciler.Reconcile(context.Background(), ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "run-db-migrations-oldsha",
Namespace: ns,
},
})

// Should NOT hit the image tag check at all
if err != nil {
assert.NotContains(t, err.Error(), "do not contain expected tag")
}
}
Loading