Skip to content

Commit d1f0bcd

Browse files
chore(ci): Add test to check if user changes are preserved
Add a test to ensure that OLM is not reverting user changes like kubectl rollout restart. Assisted-by: Cursor/Claude
1 parent 147cfa8 commit d1f0bcd

2 files changed

Lines changed: 383 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
@BoxcutterRuntime
2+
Feature: Preserve user-managed fields on deployed resources
3+
OLM uses Server-Side Apply with specific field ownership. Fields that OLM does
4+
not declare ownership of (e.g. user-applied annotations and labels) belong to
5+
other managers and must be preserved across reconciliation cycles.
6+
Related: https://github.com/operator-framework/operator-lifecycle-manager/issues/3392
7+
8+
Background:
9+
Given OLM is available
10+
And ClusterCatalog "test" serves bundles
11+
And ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE}
12+
13+
Scenario: User-added annotations and labels coexist with bundle-defined labels after reconciliation
14+
When ClusterExtension is applied
15+
"""
16+
apiVersion: olm.operatorframework.io/v1
17+
kind: ClusterExtension
18+
metadata:
19+
name: ${NAME}
20+
spec:
21+
namespace: ${TEST_NAMESPACE}
22+
serviceAccount:
23+
name: olm-sa
24+
source:
25+
sourceType: Catalog
26+
catalog:
27+
packageName: test
28+
selector:
29+
matchLabels:
30+
"olm.operatorframework.io/metadata.name": test-catalog
31+
"""
32+
Then ClusterExtension is rolled out
33+
And ClusterExtension is available
34+
And resource "deployment/test-operator" is available
35+
# The bundle defines labels on the deployment via the CSV spec; verify they are present
36+
And resource "deployment/test-operator" has label "app.kubernetes.io/name" with value "test-operator"
37+
When user adds annotation "example.com/custom-annotation=my-value" to "deployment/test-operator"
38+
And user adds label "example.com/custom-label=my-value" to "deployment/test-operator"
39+
Then resource "deployment/test-operator" has annotation "example.com/custom-annotation" with value "my-value"
40+
And resource "deployment/test-operator" has label "example.com/custom-label" with value "my-value"
41+
When ClusterExtension reconciliation is triggered
42+
And ClusterExtension has been reconciled the latest generation
43+
Then resource "deployment/test-operator" has annotation "example.com/custom-annotation" with value "my-value"
44+
And resource "deployment/test-operator" has label "example.com/custom-label" with value "my-value"
45+
# Bundle-defined labels must still be intact after reconciliation
46+
And resource "deployment/test-operator" has label "app.kubernetes.io/name" with value "test-operator"
47+
48+
Scenario: Deployment rollout restart persists after OLM reconciliation
49+
When ClusterExtension is applied
50+
"""
51+
apiVersion: olm.operatorframework.io/v1
52+
kind: ClusterExtension
53+
metadata:
54+
name: ${NAME}
55+
spec:
56+
namespace: ${TEST_NAMESPACE}
57+
serviceAccount:
58+
name: olm-sa
59+
source:
60+
sourceType: Catalog
61+
catalog:
62+
packageName: test
63+
selector:
64+
matchLabels:
65+
"olm.operatorframework.io/metadata.name": test-catalog
66+
"""
67+
Then ClusterExtension is rolled out
68+
And ClusterExtension is available
69+
And resource "deployment/test-operator" is available
70+
When user performs rollout restart on "deployment/test-operator"
71+
Then deployment "test-operator" has restart annotation
72+
And deployment "test-operator" rollout is complete
73+
And deployment "test-operator" has 2 replica sets
74+
When ClusterExtension reconciliation is triggered
75+
And ClusterExtension has been reconciled the latest generation
76+
Then deployment "test-operator" has restart annotation
77+
And deployment "test-operator" rollout is complete

test/e2e/steps/steps.go

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ func RegisterSteps(sc *godog.ScenarioContext) {
9090
sc.Step(`^(?i)resource apply fails with error msg containing "([^"]+)"$`, ResourceApplyFails)
9191
sc.Step(`^(?i)resource "([^"]+)" is eventually restored$`, ResourceRestored)
9292
sc.Step(`^(?i)resource "([^"]+)" matches$`, ResourceMatches)
93+
sc.Step(`^(?i)user performs rollout restart on "([^"]+)"$`, UserPerformsRolloutRestart)
94+
sc.Step(`^(?i)user adds annotation "([^"]+)" to "([^"]+)"$`, UserAddsAnnotation)
95+
sc.Step(`^(?i)user adds label "([^"]+)" to "([^"]+)"$`, UserAddsLabel)
96+
sc.Step(`^(?i)resource "([^"]+)" has annotation "([^"]+)" with value "([^"]+)"$`, ResourceHasAnnotation)
97+
sc.Step(`^(?i)resource "([^"]+)" has label "([^"]+)" with value "([^"]+)"$`, ResourceHasLabel)
98+
sc.Step(`^(?i)deployment "([^"]+)" has restart annotation$`, DeploymentHasRestartAnnotation)
99+
sc.Step(`^(?i)deployment "([^"]+)" rollout is complete$`, DeploymentRolloutIsComplete)
100+
sc.Step(`^(?i)deployment "([^"]+)" has (\d+) replica sets?$`, DeploymentHasReplicaSets)
101+
sc.Step(`^(?i)ClusterExtension reconciliation is triggered$`, TriggerClusterExtensionReconciliation)
93102

94103
sc.Step(`^(?i)ServiceAccount "([^"]*)" with needed permissions is available in test namespace$`, ServiceAccountWithNeededPermissionsIsAvailableInNamespace)
95104
sc.Step(`^(?i)ServiceAccount "([^"]*)" with needed permissions is available in \${TEST_NAMESPACE}$`, ServiceAccountWithNeededPermissionsIsAvailableInNamespace)
@@ -1309,3 +1318,300 @@ func latestActiveRevisionForExtension(extName string) (*ocv1.ClusterExtensionRev
13091318

13101319
return latest, nil
13111320
}
1321+
1322+
// UserAddsAnnotation adds a custom annotation to a resource using kubectl annotate.
1323+
func UserAddsAnnotation(ctx context.Context, annotation, resourceName string) error {
1324+
sc := scenarioCtx(ctx)
1325+
resourceName = substituteScenarioVars(resourceName, sc)
1326+
1327+
kind, name, ok := strings.Cut(resourceName, "/")
1328+
if !ok {
1329+
return fmt.Errorf("invalid resource name format: %q (expected kind/name)", resourceName)
1330+
}
1331+
1332+
out, err := k8sClient("annotate", kind, name, annotation, "--overwrite", "-n", sc.namespace)
1333+
if err != nil {
1334+
return fmt.Errorf("failed to annotate %s: %w; stderr: %s", resourceName, err, stderrOutput(err))
1335+
}
1336+
logger.V(1).Info("Annotation added", "resource", resourceName, "annotation", annotation, "output", out)
1337+
return nil
1338+
}
1339+
1340+
// UserAddsLabel adds a custom label to a resource using kubectl label.
1341+
func UserAddsLabel(ctx context.Context, label, resourceName string) error {
1342+
sc := scenarioCtx(ctx)
1343+
resourceName = substituteScenarioVars(resourceName, sc)
1344+
1345+
kind, name, ok := strings.Cut(resourceName, "/")
1346+
if !ok {
1347+
return fmt.Errorf("invalid resource name format: %q (expected kind/name)", resourceName)
1348+
}
1349+
1350+
out, err := k8sClient("label", kind, name, label, "--overwrite", "-n", sc.namespace)
1351+
if err != nil {
1352+
return fmt.Errorf("failed to label %s: %w; stderr: %s", resourceName, err, stderrOutput(err))
1353+
}
1354+
logger.V(1).Info("Label added", "resource", resourceName, "label", label, "output", out)
1355+
return nil
1356+
}
1357+
1358+
// ResourceHasAnnotation waits for a resource to have the given annotation key with the expected value.
1359+
func ResourceHasAnnotation(ctx context.Context, resourceName, annotationKey, expectedValue string) error {
1360+
sc := scenarioCtx(ctx)
1361+
resourceName = substituteScenarioVars(resourceName, sc)
1362+
1363+
kind, name, ok := strings.Cut(resourceName, "/")
1364+
if !ok {
1365+
return fmt.Errorf("invalid resource name format: %q (expected kind/name)", resourceName)
1366+
}
1367+
1368+
waitFor(ctx, func() bool {
1369+
out, err := k8sClient("get", kind, name, "-n", sc.namespace, "-o", "json")
1370+
if err != nil {
1371+
return false
1372+
}
1373+
var obj unstructured.Unstructured
1374+
if err := json.Unmarshal([]byte(out), &obj); err != nil {
1375+
return false
1376+
}
1377+
annotations := obj.GetAnnotations()
1378+
if v, found := annotations[annotationKey]; found && v == expectedValue {
1379+
logger.V(1).Info("Annotation found", "resource", resourceName, "key", annotationKey, "value", v)
1380+
return true
1381+
}
1382+
logger.V(1).Info("Annotation not yet present or value mismatch", "resource", resourceName, "key", annotationKey, "annotations", annotations)
1383+
return false
1384+
})
1385+
return nil
1386+
}
1387+
1388+
// ResourceHasLabel waits for a resource to have the given label key with the expected value.
1389+
func ResourceHasLabel(ctx context.Context, resourceName, labelKey, expectedValue string) error {
1390+
sc := scenarioCtx(ctx)
1391+
resourceName = substituteScenarioVars(resourceName, sc)
1392+
1393+
kind, name, ok := strings.Cut(resourceName, "/")
1394+
if !ok {
1395+
return fmt.Errorf("invalid resource name format: %q (expected kind/name)", resourceName)
1396+
}
1397+
1398+
waitFor(ctx, func() bool {
1399+
out, err := k8sClient("get", kind, name, "-n", sc.namespace, "-o", "json")
1400+
if err != nil {
1401+
return false
1402+
}
1403+
var obj unstructured.Unstructured
1404+
if err := json.Unmarshal([]byte(out), &obj); err != nil {
1405+
return false
1406+
}
1407+
labels := obj.GetLabels()
1408+
if v, found := labels[labelKey]; found && v == expectedValue {
1409+
logger.V(1).Info("Label found", "resource", resourceName, "key", labelKey, "value", v)
1410+
return true
1411+
}
1412+
logger.V(1).Info("Label not yet present or value mismatch", "resource", resourceName, "key", labelKey, "labels", labels)
1413+
return false
1414+
})
1415+
return nil
1416+
}
1417+
1418+
// UserPerformsRolloutRestart simulates a user running "kubectl rollout restart deployment/<name>".
1419+
// See: https://github.com/operator-framework/operator-lifecycle-manager/issues/3392
1420+
func UserPerformsRolloutRestart(ctx context.Context, resourceName string) error {
1421+
sc := scenarioCtx(ctx)
1422+
resourceName = substituteScenarioVars(resourceName, sc)
1423+
1424+
kind, deploymentName, ok := strings.Cut(resourceName, "/")
1425+
if !ok {
1426+
return fmt.Errorf("invalid resource name format: %q (expected kind/name)", resourceName)
1427+
}
1428+
1429+
if kind != "deployment" {
1430+
return fmt.Errorf("only deployment resources are supported for restart annotation, got: %q", kind)
1431+
}
1432+
1433+
// Run kubectl rollout restart to add the restart annotation.
1434+
// This is the real command users run, so we test actual user behavior.
1435+
out, err := k8sClient("rollout", "restart", resourceName, "-n", sc.namespace)
1436+
if err != nil {
1437+
return fmt.Errorf("failed to rollout restart %s: %w; stderr: %s", resourceName, err, stderrOutput(err))
1438+
}
1439+
1440+
logger.V(1).Info("Rollout restart initiated", "deployment", deploymentName, "output", out)
1441+
1442+
return nil
1443+
}
1444+
1445+
// DeploymentHasRestartAnnotation waits for the deployment's pod template to have
1446+
// the kubectl.kubernetes.io/restartedAt annotation. Uses JSON parsing to avoid
1447+
// JSONPath issues with dots in annotation keys. Polls with timeout.
1448+
func DeploymentHasRestartAnnotation(ctx context.Context, deploymentName string) error {
1449+
sc := scenarioCtx(ctx)
1450+
deploymentName = substituteScenarioVars(deploymentName, sc)
1451+
1452+
restartAnnotationKey := "kubectl.kubernetes.io/restartedAt"
1453+
waitFor(ctx, func() bool {
1454+
out, err := k8sClient("get", "deployment", deploymentName, "-n", sc.namespace, "-o", "json")
1455+
if err != nil {
1456+
return false
1457+
}
1458+
var d appsv1.Deployment
1459+
if err := json.Unmarshal([]byte(out), &d); err != nil {
1460+
return false
1461+
}
1462+
if v, found := d.Spec.Template.Annotations[restartAnnotationKey]; found {
1463+
logger.V(1).Info("Restart annotation found", "deployment", deploymentName, "restartedAt", v)
1464+
return true
1465+
}
1466+
logger.V(1).Info("Restart annotation not yet present", "deployment", deploymentName, "annotations", d.Spec.Template.Annotations)
1467+
return false
1468+
})
1469+
return nil
1470+
}
1471+
1472+
// TriggerClusterExtensionReconciliation patches the ClusterExtension spec to bump
1473+
// its metadata generation, forcing the controller to run a full reconciliation loop.
1474+
// Use with "ClusterExtension has been reconciled the latest generation" to confirm
1475+
// the controller processed the change before asserting on the cluster state.
1476+
//
1477+
// We flip install.preflight.crdUpgradeSafety.enforcement between "None" and "Strict"
1478+
// because it is a real spec field that the API server will persist (unlike unknown
1479+
// fields, which are pruned by structural schemas). Toggling ensures that each call
1480+
// results in a spec change, reliably bumping .metadata.generation.
1481+
func TriggerClusterExtensionReconciliation(ctx context.Context) error {
1482+
sc := scenarioCtx(ctx)
1483+
1484+
out, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, "-o", "json")
1485+
if err != nil {
1486+
return fmt.Errorf("failed to get ClusterExtension %s: %w; stderr: %s", sc.clusterExtensionName, err, stderrOutput(err))
1487+
}
1488+
1489+
var obj map[string]interface{}
1490+
if err := json.Unmarshal([]byte(out), &obj); err != nil {
1491+
return fmt.Errorf("failed to unmarshal ClusterExtension %s JSON: %w", sc.clusterExtensionName, err)
1492+
}
1493+
1494+
currentEnforcement := ""
1495+
if spec, ok := obj["spec"].(map[string]interface{}); ok {
1496+
if install, ok := spec["install"].(map[string]interface{}); ok {
1497+
if preflight, ok := install["preflight"].(map[string]interface{}); ok {
1498+
if crdUpgradeSafety, ok := preflight["crdUpgradeSafety"].(map[string]interface{}); ok {
1499+
if v, ok := crdUpgradeSafety["enforcement"].(string); ok {
1500+
currentEnforcement = v
1501+
}
1502+
}
1503+
}
1504+
}
1505+
}
1506+
1507+
newEnforcement := "None"
1508+
if currentEnforcement == "None" {
1509+
newEnforcement = "Strict"
1510+
}
1511+
1512+
payload := fmt.Sprintf(`{"spec":{"install":{"preflight":{"crdUpgradeSafety":{"enforcement":%q}}}}}`, newEnforcement)
1513+
_, err = k8sClient("patch", "clusterextension", sc.clusterExtensionName,
1514+
"--type=merge",
1515+
"-p", payload)
1516+
if err != nil {
1517+
return fmt.Errorf("failed to trigger reconciliation for ClusterExtension %s: %w; stderr: %s", sc.clusterExtensionName, err, stderrOutput(err))
1518+
}
1519+
return nil
1520+
}
1521+
1522+
// DeploymentRolloutIsComplete verifies that a deployment rollout has completed successfully.
1523+
// This ensures the new ReplicaSet is fully scaled up and the old one is scaled down.
1524+
func DeploymentRolloutIsComplete(ctx context.Context, deploymentName string) error {
1525+
sc := scenarioCtx(ctx)
1526+
deploymentName = substituteScenarioVars(deploymentName, sc)
1527+
1528+
waitFor(ctx, func() bool {
1529+
out, err := k8sClient("rollout", "status", "deployment/"+deploymentName, "-n", sc.namespace, "--watch=false")
1530+
if err != nil {
1531+
logger.V(1).Info("Failed to get rollout status", "deployment", deploymentName, "error", err)
1532+
return false
1533+
}
1534+
// Successful rollout shows "successfully rolled out"
1535+
if strings.Contains(out, "successfully rolled out") {
1536+
logger.V(1).Info("Rollout completed successfully", "deployment", deploymentName)
1537+
return true
1538+
}
1539+
logger.V(1).Info("Rollout not yet complete", "deployment", deploymentName, "status", out)
1540+
return false
1541+
})
1542+
return nil
1543+
}
1544+
1545+
// DeploymentHasReplicaSets verifies that a deployment has the expected number of ReplicaSets
1546+
// and that at least one owned ReplicaSet is active with pods running.
1547+
func DeploymentHasReplicaSets(ctx context.Context, deploymentName string, expectedCountStr string) error {
1548+
sc := scenarioCtx(ctx)
1549+
deploymentName = substituteScenarioVars(deploymentName, sc)
1550+
1551+
expectedCount := 2 // Default to 2 (original + restarted)
1552+
if n, err := fmt.Sscanf(expectedCountStr, "%d", &expectedCount); err != nil || n != 1 {
1553+
logger.V(1).Info("Failed to parse expected count, using default", "input", expectedCountStr, "default", 2)
1554+
expectedCount = 2
1555+
}
1556+
1557+
waitFor(ctx, func() bool {
1558+
deploymentOut, err := k8sClient("get", "deployment", deploymentName, "-n", sc.namespace, "-o", "json")
1559+
if err != nil {
1560+
logger.V(1).Info("Failed to get deployment", "deployment", deploymentName, "error", err)
1561+
return false
1562+
}
1563+
1564+
var deployment appsv1.Deployment
1565+
if err := json.Unmarshal([]byte(deploymentOut), &deployment); err != nil {
1566+
logger.V(1).Info("Failed to parse deployment", "error", err)
1567+
return false
1568+
}
1569+
1570+
out, err := k8sClient("get", "rs", "-n", sc.namespace, "-o", "json")
1571+
if err != nil {
1572+
logger.V(1).Info("Failed to get ReplicaSets", "deployment", deploymentName, "error", err)
1573+
return false
1574+
}
1575+
1576+
var allRsList struct {
1577+
Items []appsv1.ReplicaSet `json:"items"`
1578+
}
1579+
if err := json.Unmarshal([]byte(out), &allRsList); err != nil {
1580+
logger.V(1).Info("Failed to parse ReplicaSets", "error", err)
1581+
return false
1582+
}
1583+
1584+
var rsList []appsv1.ReplicaSet
1585+
for _, rs := range allRsList.Items {
1586+
for _, owner := range rs.OwnerReferences {
1587+
if owner.Kind == "Deployment" && owner.UID == deployment.UID {
1588+
rsList = append(rsList, rs)
1589+
break
1590+
}
1591+
}
1592+
}
1593+
1594+
if len(rsList) != expectedCount {
1595+
logger.V(1).Info("ReplicaSet count does not match expected value yet", "deployment", deploymentName, "current", len(rsList), "expected", expectedCount)
1596+
return false
1597+
}
1598+
1599+
// Verify at least one ReplicaSet has active replicas
1600+
hasActiveRS := false
1601+
for _, rs := range rsList {
1602+
if rs.Status.Replicas > 0 && rs.Status.ReadyReplicas > 0 {
1603+
hasActiveRS = true
1604+
logger.V(1).Info("Found active ReplicaSet", "name", rs.Name, "replicas", rs.Status.Replicas, "ready", rs.Status.ReadyReplicas)
1605+
}
1606+
}
1607+
1608+
if !hasActiveRS {
1609+
logger.V(1).Info("No active ReplicaSet found yet", "deployment", deploymentName)
1610+
return false
1611+
}
1612+
1613+
logger.V(1).Info("ReplicaSet verification passed", "deployment", deploymentName, "count", len(rsList))
1614+
return true
1615+
})
1616+
return nil
1617+
}

0 commit comments

Comments
 (0)