Skip to content

Commit 6a21293

Browse files
authored
Merge pull request #2 from epinio/chore/cleanup-and-error-handling
chore(Added some error handling and clean job logic):
2 parents 5942ba4 + 1e90bf0 commit 6a21293

3 files changed

Lines changed: 172 additions & 17 deletions

File tree

internal/controller/installepinio_controller.go

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"errors"
2222
"fmt"
23+
"sort"
2324
"strings"
2425
"time"
2526

@@ -262,10 +263,7 @@ func (r *InstallEpinioReconciler) Reconcile(ctx context.Context, req ctrl.Reques
262263
if jobTerminalFailed(job.Status) {
263264
failed := job.Status.Failed
264265
log.Info("Install Job reported failure", "job", jobName, "namespace", ctrlNs, "failed", failed)
265-
failMsg := fmt.Sprintf("Install Job failed (%d failed)", failed)
266-
if failed == 0 {
267-
failMsg = "Install Job failed"
268-
}
266+
failMsg := r.jobFailureMessage(ctx, ctrlNs, jobName)
269267
setCondition(&inst, metav1.Condition{
270268
Type: conditionDegraded,
271269
Status: metav1.ConditionTrue,
@@ -412,13 +410,35 @@ func (r *InstallEpinioReconciler) reconcileDelete(ctx context.Context, inst *epi
412410
return ctrl.Result{}, err
413411
}
414412

413+
// Check if this is the last InstallEpinio in the namespace before removing the finalizer.
414+
var remaining epiniov1alpha1.InstallEpinioList
415+
if err := r.List(ctx, &remaining, client.InNamespace(ctrlNs)); err != nil {
416+
log.Error(err, "failed to list remaining InstallEpinio resources")
417+
return ctrl.Result{}, err
418+
}
419+
// The current resource is still present (finalizer not yet removed), so count > 1 means others exist.
420+
lastCR := len(remaining.Items) <= 1
421+
422+
// Remove finalizer first so the CR can be fully deleted even if the
423+
// subsequent namespace deletion terminates the operator pod.
415424
controllerutil.RemoveFinalizer(inst, installCleanupFinalizer)
416425
log.Info("Removing cleanup finalizer")
417426
if err := r.Update(ctx, inst); err != nil {
418427
log.Error(err, "failed to remove cleanup finalizer")
419428
return ctrl.Result{}, err
420429
}
421430

431+
// Delete the operator namespace after the finalizer is gone so that
432+
// namespace termination is not blocked by the CR.
433+
if lastCR {
434+
ns := &corev1.Namespace{}
435+
ns.Name = ctrlNs
436+
if err := r.deleteIfExists(ctx, ns, "Namespace"); err != nil {
437+
return ctrl.Result{}, err
438+
}
439+
log.Info("Deleted operator namespace", "namespace", ctrlNs)
440+
}
441+
422442
return ctrl.Result{}, nil
423443
}
424444

@@ -526,6 +546,59 @@ func installJobName(namespace, name string) string {
526546
return jobNamePrefix + namespace + "-" + name
527547
}
528548

549+
// jobFailureMessage inspects the pods belonging to a failed Job and extracts
550+
// a human-readable error from init or main container termination details.
551+
func (r *InstallEpinioReconciler) jobFailureMessage(ctx context.Context, namespace, jobName string) string {
552+
fallback := fmt.Sprintf("Install Job %q failed", jobName)
553+
554+
var podList corev1.PodList
555+
if err := r.List(ctx, &podList,
556+
client.InNamespace(namespace),
557+
client.MatchingLabels{"job-name": jobName},
558+
); err != nil || len(podList.Items) == 0 {
559+
return fallback
560+
}
561+
562+
pods := append([]corev1.Pod(nil), podList.Items...)
563+
sort.Slice(pods, func(i, j int) bool {
564+
return pods[i].CreationTimestamp.After(pods[j].CreationTimestamp.Time)
565+
})
566+
567+
for i := range pods {
568+
if msg := installFailureMessageFromPod(&pods[i]); msg != "" {
569+
return msg
570+
}
571+
}
572+
573+
return fallback
574+
}
575+
576+
func installFailureMessageFromPod(pod *corev1.Pod) string {
577+
if msg := installFailureMessageFromContainerStatuses(pod.Status.InitContainerStatuses); msg != "" {
578+
return msg
579+
}
580+
return installFailureMessageFromContainerStatuses(pod.Status.ContainerStatuses)
581+
}
582+
583+
func installFailureMessageFromContainerStatuses(statuses []corev1.ContainerStatus) string {
584+
for _, cs := range statuses {
585+
if cs.State.Terminated == nil {
586+
continue
587+
}
588+
term := cs.State.Terminated
589+
if term.Message != "" {
590+
return fmt.Sprintf("Install failed: %s", term.Message)
591+
}
592+
if term.Reason != "" {
593+
return fmt.Sprintf("Install failed: %s (exit code %d)", term.Reason, term.ExitCode)
594+
}
595+
if term.ExitCode != 0 {
596+
return fmt.Sprintf("Install failed with exit code %d", term.ExitCode)
597+
}
598+
}
599+
return ""
600+
}
601+
529602
func jobTerminalFailed(status batchv1.JobStatus) bool {
530603
for i := range status.Conditions {
531604
if status.Conditions[i].Type == batchv1.JobFailed && status.Conditions[i].Status == corev1.ConditionTrue {

internal/controller/installepinio_controller_test.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package controller
1818

1919
import (
2020
"context"
21+
"fmt"
2122

2223
. "github.com/onsi/ginkgo/v2"
2324
. "github.com/onsi/gomega"
@@ -33,28 +34,32 @@ import (
3334
epiniov1alpha1 "github.com/epinio/install-operator/api/v1alpha1"
3435
)
3536

37+
var testNsCounter int
38+
3639
var _ = Describe("InstallEpinio Controller", func() {
3740
Context("When reconciling a resource", func() {
3841
const resourceName = "test-resource"
3942

4043
ctx := context.Background()
4144

42-
typeNamespacedName := types.NamespacedName{
43-
Name: resourceName,
44-
Namespace: "system",
45-
}
45+
var testNs string
46+
var typeNamespacedName types.NamespacedName
4647
installepinio := &epiniov1alpha1.InstallEpinio{}
4748
var reconciler *InstallEpinioReconciler
4849

4950
BeforeEach(func() {
50-
Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "system"}})).To(SatisfyAny(
51-
Succeed(),
52-
WithTransform(errors.IsAlreadyExists, BeTrue()),
53-
))
51+
testNsCounter++
52+
testNs = fmt.Sprintf("test-system-%d", testNsCounter)
53+
typeNamespacedName = types.NamespacedName{
54+
Name: resourceName,
55+
Namespace: testNs,
56+
}
57+
58+
Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNs}})).To(Succeed())
5459
reconciler = &InstallEpinioReconciler{
5560
Client: k8sClient,
5661
Scheme: k8sClient.Scheme(),
57-
ControllerNamespace: "system",
62+
ControllerNamespace: testNs,
5863
InstallerServiceAccount: "installer-sa",
5964
InstallerHelmImage: "example.com/helm:test",
6065
}
@@ -65,7 +70,7 @@ var _ = Describe("InstallEpinio Controller", func() {
6570
resource := &epiniov1alpha1.InstallEpinio{
6671
ObjectMeta: metav1.ObjectMeta{
6772
Name: resourceName,
68-
Namespace: "system",
73+
Namespace: testNs,
6974
},
7075
Spec: epiniov1alpha1.InstallEpinioSpec{
7176
Domain: "demo.example.test",
@@ -110,7 +115,7 @@ var _ = Describe("InstallEpinio Controller", func() {
110115
Expect(err).NotTo(HaveOccurred())
111116

112117
job := &batchv1.Job{}
113-
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "epinio-installer-system-test-resource", Namespace: "system"}, job)).To(Succeed())
118+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("epinio-installer-%s-test-resource", testNs), Namespace: testNs}, job)).To(Succeed())
114119
Expect(job.Spec.Template.Spec.Containers).To(HaveLen(1))
115120
Expect(job.Spec.Template.Spec.Containers[0].Env).To(ContainElement(corev1.EnvVar{Name: "EPINIO_VERSION", Value: "1.0.0"}))
116121
Expect(job.Spec.Template.Spec.ServiceAccountName).To(Equal("installer-sa"))
@@ -119,7 +124,7 @@ var _ = Describe("InstallEpinio Controller", func() {
119124
Expect(job.OwnerReferences[0].Name).To(Equal(resourceName))
120125

121126
configMap := &corev1.ConfigMap{}
122-
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "epinio-install-script-system-test-resource", Namespace: "system"}, configMap)).To(Succeed())
127+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("epinio-install-script-%s-test-resource", testNs), Namespace: testNs}, configMap)).To(Succeed())
123128
Expect(configMap.OwnerReferences).To(HaveLen(1))
124129
Expect(configMap.OwnerReferences[0].Name).To(Equal(resourceName))
125130

@@ -136,7 +141,7 @@ var _ = Describe("InstallEpinio Controller", func() {
136141
Expect(err).NotTo(HaveOccurred())
137142

138143
job := &batchv1.Job{}
139-
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "epinio-installer-system-test-resource", Namespace: "system"}, job)).To(Succeed())
144+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("epinio-installer-%s-test-resource", testNs), Namespace: testNs}, job)).To(Succeed())
140145
job.Status.Succeeded = 1
141146
Expect(k8sClient.Status().Update(ctx, job)).To(Succeed())
142147

internal/controller/installepinio_controller_unit_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,83 @@ import (
1919
epiniov1alpha1 "github.com/epinio/install-operator/api/v1alpha1"
2020
)
2121

22+
func TestInstallFailureMessageFromContainerStatuses(t *testing.T) {
23+
t.Parallel()
24+
25+
if got := installFailureMessageFromContainerStatuses(nil); got != "" {
26+
t.Fatalf("empty statuses: got %q", got)
27+
}
28+
msg := installFailureMessageFromContainerStatuses([]corev1.ContainerStatus{
29+
{Name: "a", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: "helm oops"}}},
30+
})
31+
if msg != "Install failed: helm oops" {
32+
t.Fatalf("message: got %q", msg)
33+
}
34+
}
35+
36+
func TestInstallFailureMessageFromPodInitBeforeMain(t *testing.T) {
37+
t.Parallel()
38+
39+
pod := &corev1.Pod{
40+
Status: corev1.PodStatus{
41+
InitContainerStatuses: []corev1.ContainerStatus{
42+
{Name: "init", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: "init pull failed"}}},
43+
},
44+
ContainerStatuses: []corev1.ContainerStatus{
45+
{Name: "main", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: "main never ran"}}},
46+
},
47+
},
48+
}
49+
if got := installFailureMessageFromPod(pod); got != "Install failed: init pull failed" {
50+
t.Fatalf("expected init message first, got %q", got)
51+
}
52+
}
53+
54+
func TestJobFailureMessagePrefersNewestPod(t *testing.T) {
55+
t.Parallel()
56+
57+
ctx := context.Background()
58+
scheme := runtime.NewScheme()
59+
if err := clientgoscheme.AddToScheme(scheme); err != nil {
60+
t.Fatalf("scheme: %v", err)
61+
}
62+
63+
oldTime := metav1.NewTime(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))
64+
newTime := metav1.NewTime(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
65+
66+
objs := []client.Object{
67+
&corev1.Pod{
68+
ObjectMeta: metav1.ObjectMeta{
69+
Name: "old", Namespace: "system", Labels: map[string]string{"job-name": "thejob"},
70+
CreationTimestamp: oldTime,
71+
},
72+
Status: corev1.PodStatus{
73+
ContainerStatuses: []corev1.ContainerStatus{
74+
{State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: "from older pod"}}},
75+
},
76+
},
77+
},
78+
&corev1.Pod{
79+
ObjectMeta: metav1.ObjectMeta{
80+
Name: "new", Namespace: "system", Labels: map[string]string{"job-name": "thejob"},
81+
CreationTimestamp: newTime,
82+
},
83+
Status: corev1.PodStatus{
84+
ContainerStatuses: []corev1.ContainerStatus{
85+
{State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: "from newer pod"}}},
86+
},
87+
},
88+
},
89+
}
90+
91+
r := &InstallEpinioReconciler{
92+
Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build(),
93+
}
94+
if got := r.jobFailureMessage(ctx, "system", "thejob"); got != "Install failed: from newer pod" {
95+
t.Fatalf("jobFailureMessage: got %q", got)
96+
}
97+
}
98+
2299
func TestJobTerminalHelpers(t *testing.T) {
23100
t.Parallel()
24101

0 commit comments

Comments
 (0)