@@ -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