@@ -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,274 @@ 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 set install.preflight.crdUpgradeSafety.enforcement to "None" because it is
1478+ // a real spec field that the API server will persist (unlike unknown fields, which
1479+ // are pruned by structural schemas). Any persisted spec change bumps
1480+ // .metadata.generation, giving us a reliable synchronization signal.
1481+ func TriggerClusterExtensionReconciliation (ctx context.Context ) error {
1482+ sc := scenarioCtx (ctx )
1483+ payload := `{"spec":{"install":{"preflight":{"crdUpgradeSafety":{"enforcement":"None"}}}}}`
1484+ _ , err := k8sClient ("patch" , "clusterextension" , sc .clusterExtensionName ,
1485+ "--type=merge" ,
1486+ "-p" , payload )
1487+ if err != nil {
1488+ return fmt .Errorf ("failed to trigger reconciliation for ClusterExtension %s: %w; stderr: %s" , sc .clusterExtensionName , err , stderrOutput (err ))
1489+ }
1490+ return nil
1491+ }
1492+
1493+ // DeploymentRolloutIsComplete verifies that a deployment rollout has completed successfully.
1494+ // This ensures the new ReplicaSet is fully scaled up and the old one is scaled down.
1495+ func DeploymentRolloutIsComplete (ctx context.Context , deploymentName string ) error {
1496+ sc := scenarioCtx (ctx )
1497+ deploymentName = substituteScenarioVars (deploymentName , sc )
1498+
1499+ waitFor (ctx , func () bool {
1500+ out , err := k8sClient ("rollout" , "status" , "deployment/" + deploymentName , "-n" , sc .namespace , "--watch=false" )
1501+ if err != nil {
1502+ logger .V (1 ).Info ("Failed to get rollout status" , "deployment" , deploymentName , "error" , err )
1503+ return false
1504+ }
1505+ // Successful rollout shows "successfully rolled out"
1506+ if strings .Contains (out , "successfully rolled out" ) {
1507+ logger .V (1 ).Info ("Rollout completed successfully" , "deployment" , deploymentName )
1508+ return true
1509+ }
1510+ logger .V (1 ).Info ("Rollout not yet complete" , "deployment" , deploymentName , "status" , out )
1511+ return false
1512+ })
1513+ return nil
1514+ }
1515+
1516+ // DeploymentHasReplicaSets verifies that a deployment has the expected number of ReplicaSets
1517+ // and that the latest one is active with pods running.
1518+ func DeploymentHasReplicaSets (ctx context.Context , deploymentName string , expectedCountStr string ) error {
1519+ sc := scenarioCtx (ctx )
1520+ deploymentName = substituteScenarioVars (deploymentName , sc )
1521+
1522+ expectedCount := 2 // Default to 2 (original + restarted)
1523+ if n , err := fmt .Sscanf (expectedCountStr , "%d" , & expectedCount ); err != nil || n != 1 {
1524+ logger .V (1 ).Info ("Failed to parse expected count, using default" , "input" , expectedCountStr , "default" , 2 )
1525+ expectedCount = 2
1526+ }
1527+
1528+ waitFor (ctx , func () bool {
1529+ // First, get the deployment to find its selector labels
1530+ deploymentOut , err := k8sClient ("get" , "deployment" , deploymentName , "-n" , sc .namespace , "-o" , "json" )
1531+ if err != nil {
1532+ logger .V (1 ).Info ("Failed to get deployment" , "deployment" , deploymentName , "error" , err )
1533+ return false
1534+ }
1535+
1536+ var deployment appsv1.Deployment
1537+ if err := json .Unmarshal ([]byte (deploymentOut ), & deployment ); err != nil {
1538+ logger .V (1 ).Info ("Failed to parse deployment" , "error" , err )
1539+ return false
1540+ }
1541+
1542+ // Get all ReplicaSets owned by this deployment using ownerReferences
1543+ out , err := k8sClient ("get" , "rs" , "-n" , sc .namespace , "-o" , "json" )
1544+ if err != nil {
1545+ logger .V (1 ).Info ("Failed to get ReplicaSets" , "deployment" , deploymentName , "error" , err )
1546+ return false
1547+ }
1548+
1549+ var allRsList struct {
1550+ Items []appsv1.ReplicaSet `json:"items"`
1551+ }
1552+ if err := json .Unmarshal ([]byte (out ), & allRsList ); err != nil {
1553+ logger .V (1 ).Info ("Failed to parse ReplicaSets" , "error" , err )
1554+ return false
1555+ }
1556+
1557+ // Filter ReplicaSets owned by this deployment
1558+ var rsList []appsv1.ReplicaSet
1559+ for _ , rs := range allRsList .Items {
1560+ for _ , owner := range rs .OwnerReferences {
1561+ if owner .Kind == "Deployment" && owner .Name == deploymentName {
1562+ rsList = append (rsList , rs )
1563+ break
1564+ }
1565+ }
1566+ }
1567+
1568+ if len (rsList ) < expectedCount {
1569+ logger .V (1 ).Info ("Not enough ReplicaSets yet" , "deployment" , deploymentName , "current" , len (rsList ), "expected" , expectedCount )
1570+ return false
1571+ }
1572+
1573+ // Verify at least one ReplicaSet has active replicas
1574+ hasActiveRS := false
1575+ for _ , rs := range rsList {
1576+ if rs .Status .Replicas > 0 && rs .Status .ReadyReplicas > 0 {
1577+ hasActiveRS = true
1578+ logger .V (1 ).Info ("Found active ReplicaSet" , "name" , rs .Name , "replicas" , rs .Status .Replicas , "ready" , rs .Status .ReadyReplicas )
1579+ }
1580+ }
1581+
1582+ if ! hasActiveRS {
1583+ logger .V (1 ).Info ("No active ReplicaSet found yet" , "deployment" , deploymentName )
1584+ return false
1585+ }
1586+
1587+ logger .V (1 ).Info ("ReplicaSet verification passed" , "deployment" , deploymentName , "count" , len (rsList ))
1588+ return true
1589+ })
1590+ return nil
1591+ }
0 commit comments