From 55c50346df1f0a1ef7a1c0a344fc6afc5910fb95 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Wed, 16 Jul 2025 17:36:09 -0300 Subject: [PATCH 01/32] Test list_robot_accounts --- reconcile/test/utils/test_quay_api.py | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index 819467d76b..13ce5e8dd2 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -94,3 +94,41 @@ def test_list_team_members_raises_other_status_codes( with pytest.raises(HTTPError): quay_api.list_team_members(TEAM_NAME) + + +@responses.activate +def test_list_robot_accounts(quay_api): + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", + status=200, + json=[ + { + "name": "robot1", + "description": "robot1 description", + "created": "2021-01-01T00:00:00Z", + "last_accessed": None, + }, + { + "name": "robot2", + "description": "robot2 description", + "created": "2021-01-01T00:00:00Z", + "last_accessed": None, + }, + ], + ) + + assert quay_api.list_robot_accounts() == [ + { + "name": "robot1", + "description": "robot1 description", + "created": "2021-01-01T00:00:00Z", + "last_accessed": None, + }, + { + "name": "robot2", + "description": "robot2 description", + "created": "2021-01-01T00:00:00Z", + "last_accessed": None, + }, + ] From 41ab5ff585ec6d12f95eb6050392f097ffe04b87 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 17 Jul 2025 15:27:06 -0300 Subject: [PATCH 02/32] Test list_robot_accounts raises for ther status --- reconcile/test/utils/test_quay_api.py | 42 ++++++++++++++++++--------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index 13ce5e8dd2..ca9d23a7e9 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -102,20 +102,22 @@ def test_list_robot_accounts(quay_api): responses.GET, f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", status=200, - json=[ - { - "name": "robot1", - "description": "robot1 description", - "created": "2021-01-01T00:00:00Z", - "last_accessed": None, - }, - { - "name": "robot2", - "description": "robot2 description", - "created": "2021-01-01T00:00:00Z", - "last_accessed": None, - }, - ], + json={ + "robots": [ + { + "name": "robot1", + "description": "robot1 description", + "created": "2021-01-01T00:00:00Z", + "last_accessed": None, + }, + { + "name": "robot2", + "description": "robot2 description", + "created": "2021-01-01T00:00:00Z", + "last_accessed": None, + }, + ] + }, ) assert quay_api.list_robot_accounts() == [ @@ -132,3 +134,15 @@ def test_list_robot_accounts(quay_api): "last_accessed": None, }, ] + +@responses.activate +def test_list_robot_accounts_raises_other_status_codes(quay_api): + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", + status=400, + ) + + with pytest.raises(HTTPError): + quay_api.list_robot_accounts() + From 679f209e3b74191e616a07f4ded5d1c3536747d6 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 17 Jul 2025 15:27:21 -0300 Subject: [PATCH 03/32] Test create_robot_account --- reconcile/test/utils/test_quay_api.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index ca9d23a7e9..6d2e5371b5 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -146,3 +146,16 @@ def test_list_robot_accounts_raises_other_status_codes(quay_api): with pytest.raises(HTTPError): quay_api.list_robot_accounts() +@responses.activate +def test_create_robot_account(quay_api): + responses.add( + responses.PUT, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", + status=200, + json={"name": "robot1", "description": "robot1 description"}, + ) + + quay_api.create_robot_account("robot1", "robot1 description") + + assert responses.calls[0].request.body == b'{"description": "robot1 description"}' + From 9325d7437fd10f9199d3aefc55b0ca8c8b3addc3 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 17 Jul 2025 15:27:47 -0300 Subject: [PATCH 04/32] Test creat_robot_account raises for other status --- reconcile/test/utils/test_quay_api.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index 6d2e5371b5..1c07ac7302 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -159,3 +159,14 @@ def test_create_robot_account(quay_api): assert responses.calls[0].request.body == b'{"description": "robot1 description"}' +@responses.activate +def test_create_robot_account_raises_other_status_codes(quay_api): + responses.add( + responses.PUT, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", + status=400, + ) + + with pytest.raises(HTTPError): + quay_api.create_robot_account("robot1", "robot1 description") + From e386e464c8e403feacb400312ffacccfeb9303e8 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 17 Jul 2025 15:27:59 -0300 Subject: [PATCH 05/32] Test delete robot_account --- reconcile/test/utils/test_quay_api.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index 1c07ac7302..adc967f6fd 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -170,3 +170,15 @@ def test_create_robot_account_raises_other_status_codes(quay_api): with pytest.raises(HTTPError): quay_api.create_robot_account("robot1", "robot1 description") +@responses.activate +def test_delete_robot_account(quay_api): + responses.add( + responses.DELETE, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", + status=200, + ) + + quay_api.delete_robot_account("robot1") + assert responses.calls[0].request.method == "DELETE" + assert responses.calls[0].request.url == f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1" + From 5d3ede35d9009bd0689871714699e4a29a3101e8 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 17 Jul 2025 15:28:15 -0300 Subject: [PATCH 06/32] Test delete robot account raises for other status --- reconcile/test/utils/test_quay_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index adc967f6fd..ef84b1bfe3 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -182,3 +182,13 @@ def test_delete_robot_account(quay_api): assert responses.calls[0].request.method == "DELETE" assert responses.calls[0].request.url == f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1" +@responses.activate +def test_delete_robot_account_raises_other_status_codes(quay_api): + responses.add( + responses.DELETE, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", + status=400, + ) + + with pytest.raises(HTTPError): + quay_api.delete_robot_account("robot1") \ No newline at end of file From 684d9fe674095b29b7b90a6859c6509a17029e8a Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 17 Jul 2025 15:29:55 -0300 Subject: [PATCH 07/32] Format --- reconcile/test/utils/test_quay_api.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index ef84b1bfe3..53bd5e4165 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -135,6 +135,7 @@ def test_list_robot_accounts(quay_api): }, ] + @responses.activate def test_list_robot_accounts_raises_other_status_codes(quay_api): responses.add( @@ -146,6 +147,7 @@ def test_list_robot_accounts_raises_other_status_codes(quay_api): with pytest.raises(HTTPError): quay_api.list_robot_accounts() + @responses.activate def test_create_robot_account(quay_api): responses.add( @@ -159,6 +161,7 @@ def test_create_robot_account(quay_api): assert responses.calls[0].request.body == b'{"description": "robot1 description"}' + @responses.activate def test_create_robot_account_raises_other_status_codes(quay_api): responses.add( @@ -170,6 +173,7 @@ def test_create_robot_account_raises_other_status_codes(quay_api): with pytest.raises(HTTPError): quay_api.create_robot_account("robot1", "robot1 description") + @responses.activate def test_delete_robot_account(quay_api): responses.add( @@ -180,7 +184,11 @@ def test_delete_robot_account(quay_api): quay_api.delete_robot_account("robot1") assert responses.calls[0].request.method == "DELETE" - assert responses.calls[0].request.url == f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1" + assert ( + responses.calls[0].request.url + == f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1" + ) + @responses.activate def test_delete_robot_account_raises_other_status_codes(quay_api): @@ -191,4 +199,4 @@ def test_delete_robot_account_raises_other_status_codes(quay_api): ) with pytest.raises(HTTPError): - quay_api.delete_robot_account("robot1") \ No newline at end of file + quay_api.delete_robot_account("robot1") From 7c0d4a2399bfb897d2aef4b96da7b98c926ea5de Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 31 Jul 2025 09:40:49 -0300 Subject: [PATCH 08/32] Add tests for repo permissions methods --- reconcile/test/utils/test_quay_api.py | 202 ++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index 53bd5e4165..2da0d415d8 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -200,3 +200,205 @@ def test_delete_robot_account_raises_other_status_codes(quay_api): with pytest.raises(HTTPError): quay_api.delete_robot_account("robot1") + + +@responses.activate +def test_get_repo_robot_permissions(quay_api): + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + status=200, + json={"role": "write"}, + ) + + result = quay_api.get_repo_robot_permissions("some-repo", "robot1") + assert result == "write" + + +@responses.activate +def test_get_repo_robot_permissions_no_permission(quay_api): + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + status=400, + json={"message": "User does not have permission for repo."}, + ) + + result = quay_api.get_repo_robot_permissions("some-repo", "robot1") + assert result is None + + +@responses.activate +def test_get_repo_robot_permissions_raises_other_status_codes(quay_api): + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + status=500, + ) + + with pytest.raises(HTTPError): + quay_api.get_repo_robot_permissions("some-repo", "robot1") + + +@responses.activate +def test_set_repo_robot_permissions(quay_api): + responses.add( + responses.PUT, + f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + status=200, + ) + + quay_api.set_repo_robot_permissions("some-repo", "robot1", "admin") + + assert responses.calls[0].request.body == b'{"role": "admin"}' + + +@responses.activate +def test_set_repo_robot_permissions_raises_other_status_codes(quay_api): + responses.add( + responses.PUT, + f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + status=400, + ) + + with pytest.raises(HTTPError): + quay_api.set_repo_robot_permissions("some-repo", "robot1", "admin") + + +@responses.activate +def test_delete_repo_robot_permissions(quay_api): + responses.add( + responses.DELETE, + f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + status=200, + ) + + quay_api.delete_repo_robot_permissions("some-repo", "robot1") + + assert responses.calls[0].request.method == "DELETE" + assert ( + responses.calls[0].request.url + == f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1" + ) + + +@responses.activate +def test_delete_repo_robot_permissions_raises_other_status_codes(quay_api): + responses.add( + responses.DELETE, + f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + status=400, + ) + + with pytest.raises(HTTPError): + quay_api.delete_repo_robot_permissions("some-repo", "robot1") + + +@responses.activate +def test_get_robot_account_details_success(quay_api: QuayApi) -> None: + robot_data = {"name": "test-robot", "description": "Test robot account"} + permissions_data = { + "permissions": [ + {"role": "team", "team": {"name": "test-team"}}, + {"role": "read", "repository": {"name": "test-repo"}}, + ] + } + + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/test-robot", + json=robot_data, + status=200, + ) + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/test-robot/permissions", + json=permissions_data, + status=200, + ) + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/test-robot/permissions", + json=permissions_data, + status=200, + ) + + result = quay_api.get_robot_account_details("test-robot") + + assert result is not None + assert result["name"] == "test-robot" + assert result["description"] == "Test robot account" + assert len(result["teams"]) == 1 + assert result["teams"][0]["name"] == "test-team" + assert len(result["repositories"]) == 1 + assert result["repositories"][0]["name"] == "test-repo" + assert result["repositories"][0]["role"] == "read" + + +@responses.activate +def test_get_robot_account_details_not_found(quay_api: QuayApi) -> None: + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/test-robot", + status=404, + ) + + result = quay_api.get_robot_account_details("test-robot") + assert result is None + + +@responses.activate +def test_list_robot_accounts_detailed(quay_api: QuayApi) -> None: + robots_data = {"robots": [{"name": "robot1"}, {"name": "robot2"}]} + robot1_details = {"name": "robot1", "description": "Robot 1"} + robot2_details = {"name": "robot2", "description": "Robot 2"} + permissions_data = {"permissions": []} + + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", + json=robots_data, + status=200, + ) + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", + json=robot1_details, + status=200, + ) + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1/permissions", + json=permissions_data, + status=200, + ) + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1/permissions", + json=permissions_data, + status=200, + ) + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot2", + json=robot2_details, + status=200, + ) + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot2/permissions", + json=permissions_data, + status=200, + ) + responses.add( + responses.GET, + f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot2/permissions", + json=permissions_data, + status=200, + ) + + result = quay_api.list_robot_accounts_detailed() + + assert len(result) == 2 + assert result[0]["name"] == "robot1" + assert result[1]["name"] == "robot2" From edc1accfe0b21c847998265cebf45e0a3332b348 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 31 Jul 2025 09:41:04 -0300 Subject: [PATCH 09/32] Add robot-accounts query --- reconcile/gql_definitions/introspection.json | 642 ++++++++++++++++++ .../quay_robot_accounts/__init__.py | 0 .../quay_robot_accounts.gql | 25 + .../quay_robot_accounts.py | 104 +++ 4 files changed, 771 insertions(+) create mode 100644 reconcile/gql_definitions/quay_robot_accounts/__init__.py create mode 100644 reconcile/gql_definitions/quay_robot_accounts/quay_robot_accounts.gql create mode 100644 reconcile/gql_definitions/quay_robot_accounts/quay_robot_accounts.py diff --git a/reconcile/gql_definitions/introspection.json b/reconcile/gql_definitions/introspection.json index 0942f90334..17563ef1f9 100644 --- a/reconcile/gql_definitions/introspection.json +++ b/reconcile/gql_definitions/introspection.json @@ -350,6 +350,47 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "quay_robots_v1", + "description": null, + "args": [ + { + "name": "path", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "filter", + "description": null, + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "QuayRobot_v1", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "roles_v1", "description": null, @@ -11856,6 +11897,30 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "quay_robots", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "QuayRobot_v1", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -14372,6 +14437,583 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "QuayRobot_v1", + "description": null, + "fields": [ + { + "name": "schema", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "labels", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quay_username", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quay_org", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "QuayOrg_v1", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "repositories", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "QuayRepository_v1", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "teams", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "QuayOrg_v1", + "description": null, + "fields": [ + { + "name": "schema", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "labels", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mirror", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "QuayOrg_v1", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mirrorFilters", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "QuayOrgMirrorFilter_v1", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "managedRepos", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "instance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "QuayInstance_v1", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "serverUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "managedTeams", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "automationToken", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "VaultSecret_v1", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pushCredentials", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "VaultSecret_v1", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "DatafileObject_v1", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "QuayOrgMirrorFilter_v1", + "description": null, + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tags", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tagsExclude", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "QuayInstance_v1", + "description": null, + "fields": [ + { + "name": "schema", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "labels", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "url", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "QuayRepository_v1", + "description": null, + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permission", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "CredentialsRequest_v1", diff --git a/reconcile/gql_definitions/quay_robot_accounts/__init__.py b/reconcile/gql_definitions/quay_robot_accounts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/reconcile/gql_definitions/quay_robot_accounts/quay_robot_accounts.gql b/reconcile/gql_definitions/quay_robot_accounts/quay_robot_accounts.gql new file mode 100644 index 0000000000..f6fa2db189 --- /dev/null +++ b/reconcile/gql_definitions/quay_robot_accounts/quay_robot_accounts.gql @@ -0,0 +1,25 @@ +# qenerate: plugin=pydantic_v1 + +query QuayRobotAccounts { + robot_accounts: quay_robots_v1 { + name + description + quay_org { + name + instance { + name + url + } + automationToken { + path + field + version + } + } + teams + repositories { + name + permission + } + } +} \ No newline at end of file diff --git a/reconcile/gql_definitions/quay_robot_accounts/quay_robot_accounts.py b/reconcile/gql_definitions/quay_robot_accounts/quay_robot_accounts.py new file mode 100644 index 0000000000..2858f79176 --- /dev/null +++ b/reconcile/gql_definitions/quay_robot_accounts/quay_robot_accounts.py @@ -0,0 +1,104 @@ +""" +Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY! +""" +from collections.abc import Callable # noqa: F401 # pylint: disable=W0611 +from datetime import datetime # noqa: F401 # pylint: disable=W0611 +from enum import Enum # noqa: F401 # pylint: disable=W0611 +from typing import ( # noqa: F401 # pylint: disable=W0611 + Any, + Optional, + Union, +) + +from pydantic import ( # noqa: F401 # pylint: disable=W0611 + BaseModel, + Extra, + Field, + Json, +) + + +DEFINITION = """ +query QuayRobotAccounts { + robot_accounts: quay_robots_v1 { + name + description + quay_org { + name + instance { + name + url + } + automationToken { + path + field + version + } + } + teams + repositories { + name + permission + } + } +} +""" + + +class ConfiguredBaseModel(BaseModel): + class Config: + smart_union=True + extra=Extra.forbid + + +class QuayInstanceV1(ConfiguredBaseModel): + name: str = Field(..., alias="name") + url: str = Field(..., alias="url") + + +class VaultSecretV1(ConfiguredBaseModel): + path: str = Field(..., alias="path") + field: str = Field(..., alias="field") + version: Optional[int] = Field(..., alias="version") + + +class QuayOrgV1(ConfiguredBaseModel): + name: str = Field(..., alias="name") + instance: QuayInstanceV1 = Field(..., alias="instance") + automation_token: Optional[VaultSecretV1] = Field(..., alias="automationToken") + + +class QuayRepositoryV1(ConfiguredBaseModel): + name: str = Field(..., alias="name") + permission: str = Field(..., alias="permission") + + +class QuayRobotV1(ConfiguredBaseModel): + name: str = Field(..., alias="name") + description: Optional[str] = Field(..., alias="description") + quay_org: Optional[QuayOrgV1] = Field(..., alias="quay_org") + teams: Optional[list[str]] = Field(..., alias="teams") + repositories: Optional[list[QuayRepositoryV1]] = Field(..., alias="repositories") + + +class QuayRobotAccountsQueryData(ConfiguredBaseModel): + robot_accounts: Optional[list[QuayRobotV1]] = Field(..., alias="robot_accounts") + + +def query(query_func: Callable, **kwargs: Any) -> QuayRobotAccountsQueryData: + """ + This is a convenience function which queries and parses the data into + concrete types. It should be compatible with most GQL clients. + You do not have to use it to consume the generated data classes. + Alternatively, you can also mime and alternate the behavior + of this function in the caller. + + Parameters: + query_func (Callable): Function which queries your GQL Server + kwargs: optional arguments that will be passed to the query function + + Returns: + QuayRobotAccountsQueryData: queried data parsed into generated classes + """ + raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs) + return QuayRobotAccountsQueryData(**raw_data) From c6bab81fb71eb07bbf4d1fe10c9e11b69a5bd4ba Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 31 Jul 2025 09:41:14 -0300 Subject: [PATCH 10/32] Add robot accounts integration file --- reconcile/quay_robot_accounts.py | 381 +++++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 reconcile/quay_robot_accounts.py diff --git a/reconcile/quay_robot_accounts.py b/reconcile/quay_robot_accounts.py new file mode 100644 index 0000000000..1a8fee8f20 --- /dev/null +++ b/reconcile/quay_robot_accounts.py @@ -0,0 +1,381 @@ +import logging +import sys +from dataclasses import dataclass +from typing import Any + +from reconcile.gql_definitions.quay_robot_accounts.quay_robot_accounts import ( + QuayRobotV1, + query, +) +from reconcile.quay_base import QuayApiStore, get_quay_api_store +from reconcile.status import ExitCodes +from reconcile.utils import gql + +QONTRACT_INTEGRATION = "quay-robot-accounts" + + +@dataclass +class RobotAccountState: + """Represents the state of a robot account""" + + name: str + description: str | None + org_name: str + instance_name: str + teams: set[str] + repositories: dict[str, str] # repo_name -> permission + + +@dataclass +class RobotAccountAction: + """Represents an action to be performed on a robot account""" + + action: str # 'create', 'delete', 'add_team', 'remove_team', 'set_repo_permission', 'remove_repo_permission' + robot_name: str + org_name: str + instance_name: str + team: str | None = None + repo: str | None = None + permission: str | None = None + + +def get_robot_accounts_from_gql() -> list[QuayRobotV1]: + """Fetch robot account definitions from GraphQL""" + query_data = query(query_func=gql.get_api().query) + return list(query_data.robot_accounts or []) + + +def get_current_robot_accounts( + quay_api_store: QuayApiStore, +) -> dict[tuple[str, str], list[dict[str, Any]]]: + """Fetch current robot accounts from Quay API for all organizations""" + current_state = {} + + for org_key, org_info in quay_api_store.items(): + try: + robots = org_info["api"].list_robot_accounts_detailed() + current_state[org_key.instance, org_key.org_name] = robots or [] + except Exception as e: + logging.error( + f"Failed to fetch robot accounts for {org_key.instance}/{org_key.org_name}: {e}" + ) + current_state[org_key.instance, org_key.org_name] = [] + + return current_state + + +def build_desired_state( + robot_accounts: list[QuayRobotV1], +) -> dict[tuple[str, str, str], RobotAccountState]: + """Build desired state from GraphQL definitions""" + desired_state = {} + + for robot in robot_accounts: + if not robot.quay_org: + continue + + instance_name = robot.quay_org.instance.name + org_name = robot.quay_org.name + robot_name = robot.name + + teams = set(robot.teams or []) + repositories = {} + + if robot.repositories: + for repo in robot.repositories: + repositories[repo.name] = repo.permission + + state = RobotAccountState( + name=robot_name, + description=robot.description, + org_name=org_name, + instance_name=instance_name, + teams=teams, + repositories=repositories, + ) + + desired_state[instance_name, org_name, robot_name] = state + + return desired_state + + +def build_current_state( + current_robots: dict[tuple[str, str], list[dict[str, Any]]], + quay_api_store: QuayApiStore, +) -> dict[tuple[str, str, str], RobotAccountState]: + """Build current state from Quay API data""" + current_state = {} + + for (instance_name, org_name), robots in current_robots.items(): + org_key = next( + ( + k + for k in quay_api_store + if k.instance == instance_name and k.org_name == org_name + ), + None, + ) + + if not org_key: + continue + + for robot_data in robots: + robot_name = robot_data["name"] + description = robot_data.get("description") + + # Get team memberships + teams = set() + team_permissions = robot_data.get("teams", []) + teams.update(team_perm["name"] for team_perm in team_permissions) + + # Get repository permissions + repositories = {} + repo_permissions = robot_data.get("repositories", []) + for repo_perm in repo_permissions: + repositories[repo_perm["name"]] = repo_perm["role"] + + state = RobotAccountState( + name=robot_name, + description=description, + org_name=org_name, + instance_name=instance_name, + teams=teams, + repositories=repositories, + ) + + current_state[instance_name, org_name, robot_name] = state + + return current_state + + +def calculate_diff( + desired_state: dict[tuple[str, str, str], RobotAccountState], + current_state: dict[tuple[str, str, str], RobotAccountState], +) -> list[RobotAccountAction]: + """Calculate the differences between desired and current state""" + actions = [] + + # Find robots to create + for key, desired in desired_state.items(): + if key not in current_state: + actions.append( + RobotAccountAction( + action="create", + robot_name=desired.name, + org_name=desired.org_name, + instance_name=desired.instance_name, + ) + ) + + # Add team assignments for new robot + actions.extend([ + RobotAccountAction( + action="add_team", + robot_name=desired.name, + org_name=desired.org_name, + instance_name=desired.instance_name, + team=team, + ) + for team in desired.teams + ]) + + # Add repository permissions for new robot + actions.extend([ + RobotAccountAction( + action="set_repo_permission", + robot_name=desired.name, + org_name=desired.org_name, + instance_name=desired.instance_name, + repo=repo, + permission=permission, + ) + for repo, permission in desired.repositories.items() + ]) + else: + current = current_state[key] + + # Check team differences + teams_to_add = desired.teams - current.teams + teams_to_remove = current.teams - desired.teams + + actions.extend([ + RobotAccountAction( + action="add_team", + robot_name=desired.name, + org_name=desired.org_name, + instance_name=desired.instance_name, + team=team, + ) + for team in teams_to_add + ]) + + actions.extend([ + RobotAccountAction( + action="remove_team", + robot_name=desired.name, + org_name=desired.org_name, + instance_name=desired.instance_name, + team=team, + ) + for team in teams_to_remove + ]) + + # Check repository permission differences + desired_repos = set(desired.repositories.keys()) + current_repos = set(current.repositories.keys()) + + # Repositories to add or update permissions + actions.extend( + RobotAccountAction( + action="set_repo_permission", + robot_name=desired.name, + org_name=desired.org_name, + instance_name=desired.instance_name, + repo=repo, + permission=desired.repositories[repo], + ) + for repo in desired_repos + if repo not in current_repos + or desired.repositories[repo] != current.repositories.get(repo) + ) + + # Repositories to remove permissions from + repos_to_remove = current_repos - desired_repos + actions.extend([ + RobotAccountAction( + action="remove_repo_permission", + robot_name=desired.name, + org_name=desired.org_name, + instance_name=desired.instance_name, + repo=repo, + ) + for repo in repos_to_remove + ]) + + # Find robots to delete (robots in current state but not in desired state) + for key, current in current_state.items(): + if key not in desired_state: + actions.append( + RobotAccountAction( + action="delete", + robot_name=current.name, + org_name=current.org_name, + instance_name=current.instance_name, + ) + ) + + return actions + + +def apply_action( + action: RobotAccountAction, quay_api_store: QuayApiStore, dry_run: bool = False +) -> None: + """Apply a single action to Quay""" + org_key = next( + ( + k + for k in quay_api_store + if k.instance == action.instance_name and k.org_name == action.org_name + ), + None, + ) + + if not org_key: + logging.error(f"No API found for {action.instance_name}/{action.org_name}") + return + + quay_api = quay_api_store[org_key]["api"] + + if dry_run: + logging.info(f"[DRY RUN] Would perform: {action}") + return + + try: + if action.action == "create": + logging.info( + f"Creating robot account {action.robot_name} in {action.org_name}" + ) + quay_api.create_robot_account(action.robot_name, "") + + elif action.action == "delete": + logging.info( + f"Deleting robot account {action.robot_name} from {action.org_name}" + ) + quay_api.delete_robot_account(action.robot_name) + + elif action.action == "add_team": + logging.info( + f"Adding robot {action.robot_name} to team {action.team} in {action.org_name}" + ) + quay_api.add_user_to_team( + f"{action.org_name}+{action.robot_name}", action.team + ) + + elif action.action == "remove_team": + logging.info( + f"Removing robot {action.robot_name} from team {action.team} in {action.org_name}" + ) + quay_api.remove_user_from_team( + f"{action.org_name}+{action.robot_name}", action.team + ) + + elif action.action == "set_repo_permission": + logging.info( + f"Setting {action.permission} permission for robot {action.robot_name} on repo {action.repo}" + ) + quay_api.set_repo_robot_permissions( + action.repo, action.robot_name, action.permission + ) + + elif action.action == "remove_repo_permission": + logging.info( + f"Removing permissions for robot {action.robot_name} from repo {action.repo}" + ) + quay_api.delete_repo_robot_permissions(action.repo, action.robot_name) + + except Exception as e: + logging.error(f"Failed to apply action {action}: {e}") + raise + + +def run(dry_run: bool = False) -> None: + """Main function to run the integration""" + try: + # Get GraphQL data + robot_accounts = get_robot_accounts_from_gql() + logging.info(f"Found {len(robot_accounts)} robot account definitions") + + # Get Quay API store + quay_api_store = get_quay_api_store() + + # Get current state from Quay + current_robots = get_current_robot_accounts(quay_api_store) + + # Build states + desired_state = build_desired_state(robot_accounts) + current_state = build_current_state(current_robots, quay_api_store) + + logging.info(f"Desired robots: {len(desired_state)}") + logging.info(f"Current robots: {len(current_state)}") + + # Calculate diff + actions = calculate_diff(desired_state, current_state) + + if not actions: + logging.info("No actions needed") + return + + logging.info(f"Found {len(actions)} actions to perform") + + if dry_run: + logging.info("Running in dry-run mode - no changes will be made") + + # Apply actions + for action in actions: + apply_action(action, quay_api_store, dry_run) + + logging.info("Integration completed successfully") + + except Exception as e: + logging.error(f"Integration failed: {e}") + sys.exit(ExitCodes.ERROR) From c696f2880ae86570310105df2929732dbc241484 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 31 Jul 2025 09:41:22 -0300 Subject: [PATCH 11/32] Add robot accounts integration tests --- reconcile/test/test_quay_robot_accounts.py | 460 +++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 reconcile/test/test_quay_robot_accounts.py diff --git a/reconcile/test/test_quay_robot_accounts.py b/reconcile/test/test_quay_robot_accounts.py new file mode 100644 index 0000000000..a55f1307a4 --- /dev/null +++ b/reconcile/test/test_quay_robot_accounts.py @@ -0,0 +1,460 @@ +from unittest.mock import Mock + +import pytest + +from reconcile.gql_definitions.quay_robot_accounts.quay_robot_accounts import ( + QuayInstanceV1, + QuayOrgV1, + QuayRepositoryV1, + QuayRobotV1, + VaultSecretV1, +) +from reconcile.quay_base import OrgKey +from reconcile.quay_robot_accounts import ( + RobotAccountAction, + RobotAccountState, + apply_action, + build_current_state, + build_desired_state, + calculate_diff, + get_current_robot_accounts, +) + + +@pytest.fixture +def mock_robot_gql(): + """Mock robot account from GraphQL""" + return QuayRobotV1( + name="test-robot", + description="Test robot account", + quay_org=QuayOrgV1( + name="test-org", + instance=QuayInstanceV1(name="quay-instance", url="quay.io"), + automationToken=VaultSecretV1(path="path", field="field", version=1), + ), + teams=["team1", "team2"], + repositories=[ + QuayRepositoryV1(name="repo1", permission="read"), + QuayRepositoryV1(name="repo2", permission="write"), + ], + ) + + +@pytest.fixture +def mock_current_robot(): + """Mock current robot account from Quay API""" + return { + "name": "existing-robot", + "description": "Existing robot", + "teams": [{"name": "team1"}], + "repositories": [{"name": "repo1", "role": "read"}], + } + + +@pytest.fixture +def mock_quay_api_store(): + """Mock QuayApiStore""" + mock_api = Mock() + mock_api.list_robot_accounts_detailed.return_value = [] + mock_api.create_robot_account.return_value = None + mock_api.delete_robot_account.return_value = None + mock_api.add_user_to_team.return_value = None + mock_api.remove_user_from_team.return_value = None + mock_api.set_repo_robot_permissions.return_value = None + mock_api.delete_repo_robot_permissions.return_value = None + + org_key = OrgKey("quay-instance", "test-org") + return {org_key: {"api": mock_api}} + + +class TestBuildDesiredState: + def test_build_desired_state_single_robot(self, mock_robot_gql): + """Test building desired state with a single robot""" + robots = [mock_robot_gql] + desired_state = build_desired_state(robots) + + assert len(desired_state) == 1 + key = ("quay-instance", "test-org", "test-robot") + assert key in desired_state + + state = desired_state[key] + assert state.name == "test-robot" + assert state.description == "Test robot account" + assert state.org_name == "test-org" + assert state.instance_name == "quay-instance" + assert state.teams == {"team1", "team2"} + assert state.repositories == {"repo1": "read", "repo2": "write"} + + def test_build_desired_state_no_quay_org(self): + """Test building desired state with robot without quay_org""" + robot = QuayRobotV1( + name="test-robot", + description="Test robot", + quay_org=None, + teams=[], + repositories=[], + ) + + desired_state = build_desired_state([robot]) + assert len(desired_state) == 0 + + def test_build_desired_state_empty_teams_repos(self): + """Test building desired state with empty teams and repositories""" + robot = QuayRobotV1( + name="test-robot", + description="Test robot", + quay_org=QuayOrgV1( + name="test-org", + instance=QuayInstanceV1(name="quay-instance", url="quay.io"), + automationToken=None, + ), + teams=None, + repositories=None, + ) + + desired_state = build_desired_state([robot]) + key = ("quay-instance", "test-org", "test-robot") + state = desired_state[key] + + assert state.teams == set() + assert state.repositories == {} + + +class TestBuildCurrentState: + def test_build_current_state_single_robot( + self, mock_current_robot, mock_quay_api_store + ): + """Test building current state with a single robot""" + current_robots = {("quay-instance", "test-org"): [mock_current_robot]} + + current_state = build_current_state(current_robots, mock_quay_api_store) + + assert len(current_state) == 1 + key = ("quay-instance", "test-org", "existing-robot") + assert key in current_state + + state = current_state[key] + assert state.name == "existing-robot" + assert state.description == "Existing robot" + assert state.teams == {"team1"} + assert state.repositories == {"repo1": "read"} + + def test_build_current_state_no_org_key(self, mock_current_robot): + """Test building current state with no matching org key""" + current_robots = {("unknown-instance", "unknown-org"): [mock_current_robot]} + quay_api_store = {} + + current_state = build_current_state(current_robots, quay_api_store) + assert len(current_state) == 0 + + def test_build_current_state_empty_robots(self, mock_quay_api_store): + """Test building current state with empty robot list""" + current_robots = {("quay-instance", "test-org"): []} + + current_state = build_current_state(current_robots, mock_quay_api_store) + assert len(current_state) == 0 + + +class TestCalculateDiff: + def test_calculate_diff_create_robot(self): + """Test calculating diff when robot needs to be created""" + desired_state = { + ("instance", "org", "new-robot"): RobotAccountState( + name="new-robot", + description="New robot", + org_name="org", + instance_name="instance", + teams={"team1"}, + repositories={"repo1": "read"}, + ) + } + current_state = {} + + actions = calculate_diff(desired_state, current_state) + + assert len(actions) == 3 # create, add_team, set_repo_permission + + create_action = next(a for a in actions if a.action == "create") + assert create_action.robot_name == "new-robot" + assert create_action.org_name == "org" + + team_action = next(a for a in actions if a.action == "add_team") + assert team_action.team == "team1" + + repo_action = next(a for a in actions if a.action == "set_repo_permission") + assert repo_action.repo == "repo1" + assert repo_action.permission == "read" + + def test_calculate_diff_delete_robot(self): + """Test calculating diff when robot needs to be deleted""" + desired_state = {} + current_state = { + ("instance", "org", "old-robot"): RobotAccountState( + name="old-robot", + description="Old robot", + org_name="org", + instance_name="instance", + teams=set(), + repositories={}, + ) + } + + actions = calculate_diff(desired_state, current_state) + + assert len(actions) == 1 + assert actions[0].action == "delete" + assert actions[0].robot_name == "old-robot" + + def test_calculate_diff_team_changes(self): + """Test calculating diff for team membership changes""" + desired_state = { + ("instance", "org", "robot"): RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams={"team1", "team3"}, # remove team2, add team3 + repositories={}, + ) + } + current_state = { + ("instance", "org", "robot"): RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams={"team1", "team2"}, # has team2, missing team3 + repositories={}, + ) + } + + actions = calculate_diff(desired_state, current_state) + + action_types = [a.action for a in actions] + assert "add_team" in action_types + assert "remove_team" in action_types + + add_action = next(a for a in actions if a.action == "add_team") + assert add_action.team == "team3" + + remove_action = next(a for a in actions if a.action == "remove_team") + assert remove_action.team == "team2" + + def test_calculate_diff_repository_changes(self): + """Test calculating diff for repository permission changes""" + desired_state = { + ("instance", "org", "robot"): RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams=set(), + repositories={ + "repo1": "write", + "repo3": "read", + }, # change repo1, add repo3, remove repo2 + ) + } + current_state = { + ("instance", "org", "robot"): RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams=set(), + repositories={ + "repo1": "read", + "repo2": "write", + }, # repo1 has different permission, repo2 should be removed + ) + } + + actions = calculate_diff(desired_state, current_state) + + action_types = [a.action for a in actions] + assert "set_repo_permission" in action_types + assert "remove_repo_permission" in action_types + + set_actions = [a for a in actions if a.action == "set_repo_permission"] + assert len(set_actions) == 2 # repo1 permission change, repo3 new + + remove_action = next(a for a in actions if a.action == "remove_repo_permission") + assert remove_action.repo == "repo2" + + def test_calculate_diff_no_changes(self): + """Test calculating diff when no changes are needed""" + state = RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams={"team1"}, + repositories={"repo1": "read"}, + ) + desired_state = {("instance", "org", "robot"): state} + current_state = {("instance", "org", "robot"): state} + + actions = calculate_diff(desired_state, current_state) + assert len(actions) == 0 + + +class TestGetCurrentRobotAccounts: + def test_get_current_robot_accounts_success(self, mock_quay_api_store): + """Test successful fetching of current robot accounts""" + mock_robots = [{"name": "robot1"}, {"name": "robot2"}] + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.list_robot_accounts_detailed.return_value = mock_robots + + result = get_current_robot_accounts(mock_quay_api_store) + + assert len(result) == 1 + assert ("quay-instance", "test-org") in result + assert result["quay-instance", "test-org"] == mock_robots + + def test_get_current_robot_accounts_exception(self, mock_quay_api_store): + """Test handling of exceptions when fetching robot accounts""" + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.list_robot_accounts_detailed.side_effect = Exception("API Error") + + result = get_current_robot_accounts(mock_quay_api_store) + + assert len(result) == 1 + assert result["quay-instance", "test-org"] == [] + + +class TestApplyAction: + def test_apply_action_create_robot(self, mock_quay_api_store): + """Test applying create robot action""" + action = RobotAccountAction( + action="create", + robot_name="new-robot", + org_name="test-org", + instance_name="quay-instance", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.create_robot_account.assert_called_once_with("new-robot", "") + + def test_apply_action_delete_robot(self, mock_quay_api_store): + """Test applying delete robot action""" + action = RobotAccountAction( + action="delete", + robot_name="old-robot", + org_name="test-org", + instance_name="quay-instance", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.delete_robot_account.assert_called_once_with("old-robot") + + def test_apply_action_add_team(self, mock_quay_api_store): + """Test applying add team action""" + action = RobotAccountAction( + action="add_team", + robot_name="robot", + org_name="test-org", + instance_name="quay-instance", + team="new-team", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.add_user_to_team.assert_called_once_with("test-org+robot", "new-team") + + def test_apply_action_remove_team(self, mock_quay_api_store): + """Test applying remove team action""" + action = RobotAccountAction( + action="remove_team", + robot_name="robot", + org_name="test-org", + instance_name="quay-instance", + team="old-team", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.remove_user_from_team.assert_called_once_with( + "test-org+robot", "old-team" + ) + + def test_apply_action_set_repo_permission(self, mock_quay_api_store): + """Test applying set repository permission action""" + action = RobotAccountAction( + action="set_repo_permission", + robot_name="robot", + org_name="test-org", + instance_name="quay-instance", + repo="repo1", + permission="write", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.set_repo_robot_permissions.assert_called_once_with( + "repo1", "robot", "write" + ) + + def test_apply_action_remove_repo_permission(self, mock_quay_api_store): + """Test applying remove repository permission action""" + action = RobotAccountAction( + action="remove_repo_permission", + robot_name="robot", + org_name="test-org", + instance_name="quay-instance", + repo="repo1", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.delete_repo_robot_permissions.assert_called_once_with("repo1", "robot") + + def test_apply_action_dry_run(self, mock_quay_api_store): + """Test applying action in dry run mode""" + action = RobotAccountAction( + action="create", + robot_name="new-robot", + org_name="test-org", + instance_name="quay-instance", + ) + + apply_action(action, mock_quay_api_store, dry_run=True) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.create_robot_account.assert_not_called() + + def test_apply_action_no_org_key(self, mock_quay_api_store): + """Test applying action when org key is not found""" + action = RobotAccountAction( + action="create", + robot_name="new-robot", + org_name="unknown-org", + instance_name="unknown-instance", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.create_robot_account.assert_not_called() + + def test_apply_action_exception_handling(self, mock_quay_api_store): + """Test exception handling in apply_action""" + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.create_robot_account.side_effect = Exception("API Error") + + action = RobotAccountAction( + action="create", + robot_name="new-robot", + org_name="test-org", + instance_name="quay-instance", + ) + + with pytest.raises(Exception, match="API Error"): + apply_action(action, mock_quay_api_store, dry_run=False) From 8808c3209c1da83c3eebac86ddde9b5f7fce4b78 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 31 Jul 2025 09:41:34 -0300 Subject: [PATCH 12/32] Add quay robot accounts to cli --- reconcile/cli.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/reconcile/cli.py b/reconcile/cli.py index 84ec4037ff..dd62ccd309 100644 --- a/reconcile/cli.py +++ b/reconcile/cli.py @@ -1962,6 +1962,14 @@ def quay_membership(ctx: click.Context) -> None: run_integration(reconcile.quay_membership, ctx) +@integration.command(short_help="Manages robot accounts in Quay organizations.") +@click.pass_context +def quay_robot_accounts(ctx: click.Context) -> None: + import reconcile.quay_robot_accounts + + run_integration(reconcile.quay_robot_accounts, ctx) + + @integration.command(short_help="Mirrors external images into GCP Artifact Registry.") @click.pass_context @binary(["skopeo"]) From 5421d01a5c3505e20f6ef54c5f62075271c8a98f Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 21 Aug 2025 18:08:43 -0300 Subject: [PATCH 13/32] Fix typing for quay_robot_accounts --- reconcile/quay_robot_accounts.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/reconcile/quay_robot_accounts.py b/reconcile/quay_robot_accounts.py index 1a8fee8f20..bcc42e3916 100644 --- a/reconcile/quay_robot_accounts.py +++ b/reconcile/quay_robot_accounts.py @@ -124,7 +124,7 @@ def build_current_state( description = robot_data.get("description") # Get team memberships - teams = set() + teams: set[str] = set() team_permissions = robot_data.get("teams", []) teams.update(team_perm["name"] for team_perm in team_permissions) @@ -307,6 +307,8 @@ def apply_action( logging.info( f"Adding robot {action.robot_name} to team {action.team} in {action.org_name}" ) + if not action.team: + raise ValueError(f"Team is required for add_team action: {action}") quay_api.add_user_to_team( f"{action.org_name}+{action.robot_name}", action.team ) @@ -315,6 +317,8 @@ def apply_action( logging.info( f"Removing robot {action.robot_name} from team {action.team} in {action.org_name}" ) + if not action.team: + raise ValueError(f"Team is required for remove_team action: {action}") quay_api.remove_user_from_team( f"{action.org_name}+{action.robot_name}", action.team ) @@ -323,6 +327,14 @@ def apply_action( logging.info( f"Setting {action.permission} permission for robot {action.robot_name} on repo {action.repo}" ) + if not action.repo: + raise ValueError( + f"Repo is required for set_repo_permission action: {action}" + ) + if not action.permission: + raise ValueError( + f"Permission is required for set_repo_permission action: {action}" + ) quay_api.set_repo_robot_permissions( action.repo, action.robot_name, action.permission ) @@ -331,6 +343,10 @@ def apply_action( logging.info( f"Removing permissions for robot {action.robot_name} from repo {action.repo}" ) + if not action.repo: + raise ValueError( + f"Repo is required for set_repo_permissions action: {action}" + ) quay_api.delete_repo_robot_permissions(action.repo, action.robot_name) except Exception as e: From 20fcbdd6e7da90a0cbc8c28456924cd4d9829ae4 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 21 Aug 2025 18:08:55 -0300 Subject: [PATCH 14/32] Fix typting for quay_api tests --- reconcile/test/utils/test_quay_api.py | 34 ++++++++++++++++----------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index 2da0d415d8..0e52bbacbd 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -97,7 +97,7 @@ def test_list_team_members_raises_other_status_codes( @responses.activate -def test_list_robot_accounts(quay_api): +def test_list_robot_accounts(quay_api: QuayApi) -> None: responses.add( responses.GET, f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", @@ -137,7 +137,7 @@ def test_list_robot_accounts(quay_api): @responses.activate -def test_list_robot_accounts_raises_other_status_codes(quay_api): +def test_list_robot_accounts_raises_other_status_codes(quay_api: QuayApi) -> None: responses.add( responses.GET, f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", @@ -149,7 +149,7 @@ def test_list_robot_accounts_raises_other_status_codes(quay_api): @responses.activate -def test_create_robot_account(quay_api): +def test_create_robot_account(quay_api: QuayApi) -> None: responses.add( responses.PUT, f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", @@ -163,7 +163,7 @@ def test_create_robot_account(quay_api): @responses.activate -def test_create_robot_account_raises_other_status_codes(quay_api): +def test_create_robot_account_raises_other_status_codes(quay_api: QuayApi) -> None: responses.add( responses.PUT, f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", @@ -175,7 +175,7 @@ def test_create_robot_account_raises_other_status_codes(quay_api): @responses.activate -def test_delete_robot_account(quay_api): +def test_delete_robot_account(quay_api: QuayApi) -> None: responses.add( responses.DELETE, f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", @@ -191,7 +191,7 @@ def test_delete_robot_account(quay_api): @responses.activate -def test_delete_robot_account_raises_other_status_codes(quay_api): +def test_delete_robot_account_raises_other_status_codes(quay_api: QuayApi) -> None: responses.add( responses.DELETE, f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", @@ -203,7 +203,7 @@ def test_delete_robot_account_raises_other_status_codes(quay_api): @responses.activate -def test_get_repo_robot_permissions(quay_api): +def test_get_repo_robot_permissions(quay_api: QuayApi) -> None: responses.add( responses.GET, f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", @@ -216,7 +216,7 @@ def test_get_repo_robot_permissions(quay_api): @responses.activate -def test_get_repo_robot_permissions_no_permission(quay_api): +def test_get_repo_robot_permissions_no_permission(quay_api: QuayApi) -> None: responses.add( responses.GET, f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", @@ -229,7 +229,9 @@ def test_get_repo_robot_permissions_no_permission(quay_api): @responses.activate -def test_get_repo_robot_permissions_raises_other_status_codes(quay_api): +def test_get_repo_robot_permissions_raises_other_status_codes( + quay_api: QuayApi, +) -> None: responses.add( responses.GET, f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", @@ -241,7 +243,7 @@ def test_get_repo_robot_permissions_raises_other_status_codes(quay_api): @responses.activate -def test_set_repo_robot_permissions(quay_api): +def test_set_repo_robot_permissions(quay_api: QuayApi) -> None: responses.add( responses.PUT, f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", @@ -254,7 +256,9 @@ def test_set_repo_robot_permissions(quay_api): @responses.activate -def test_set_repo_robot_permissions_raises_other_status_codes(quay_api): +def test_set_repo_robot_permissions_raises_other_status_codes( + quay_api: QuayApi, +) -> None: responses.add( responses.PUT, f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", @@ -266,7 +270,7 @@ def test_set_repo_robot_permissions_raises_other_status_codes(quay_api): @responses.activate -def test_delete_repo_robot_permissions(quay_api): +def test_delete_repo_robot_permissions(quay_api: QuayApi) -> None: responses.add( responses.DELETE, f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", @@ -283,7 +287,9 @@ def test_delete_repo_robot_permissions(quay_api): @responses.activate -def test_delete_repo_robot_permissions_raises_other_status_codes(quay_api): +def test_delete_repo_robot_permissions_raises_other_status_codes( + quay_api: QuayApi, +) -> None: responses.add( responses.DELETE, f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", @@ -352,7 +358,7 @@ def test_list_robot_accounts_detailed(quay_api: QuayApi) -> None: robots_data = {"robots": [{"name": "robot1"}, {"name": "robot2"}]} robot1_details = {"name": "robot1", "description": "Robot 1"} robot2_details = {"name": "robot2", "description": "Robot 2"} - permissions_data = {"permissions": []} + permissions_data: dict[str, list[dict[str, str]]] = {"permissions": []} responses.add( responses.GET, From fd2e204c923518bafa06a76559ce37304a083d90 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 21 Aug 2025 18:09:05 -0300 Subject: [PATCH 15/32] Fix typing for quay_robot_accounts tests --- reconcile/test/test_quay_robot_accounts.py | 737 +++++++++++---------- 1 file changed, 389 insertions(+), 348 deletions(-) diff --git a/reconcile/test/test_quay_robot_accounts.py b/reconcile/test/test_quay_robot_accounts.py index a55f1307a4..593d25d48d 100644 --- a/reconcile/test/test_quay_robot_accounts.py +++ b/reconcile/test/test_quay_robot_accounts.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import Mock import pytest @@ -9,7 +10,7 @@ QuayRobotV1, VaultSecretV1, ) -from reconcile.quay_base import OrgKey +from reconcile.quay_base import OrgKey, QuayApiStore from reconcile.quay_robot_accounts import ( RobotAccountAction, RobotAccountState, @@ -22,7 +23,7 @@ @pytest.fixture -def mock_robot_gql(): +def mock_robot_gql() -> QuayRobotV1: """Mock robot account from GraphQL""" return QuayRobotV1( name="test-robot", @@ -41,7 +42,7 @@ def mock_robot_gql(): @pytest.fixture -def mock_current_robot(): +def mock_current_robot() -> dict[str, Any]: """Mock current robot account from Quay API""" return { "name": "existing-robot", @@ -52,7 +53,7 @@ def mock_current_robot(): @pytest.fixture -def mock_quay_api_store(): +def mock_quay_api_store() -> QuayApiStore: """Mock QuayApiStore""" mock_api = Mock() mock_api.list_robot_accounts_detailed.return_value = [] @@ -64,397 +65,437 @@ def mock_quay_api_store(): mock_api.delete_repo_robot_permissions.return_value = None org_key = OrgKey("quay-instance", "test-org") - return {org_key: {"api": mock_api}} - - -class TestBuildDesiredState: - def test_build_desired_state_single_robot(self, mock_robot_gql): - """Test building desired state with a single robot""" - robots = [mock_robot_gql] - desired_state = build_desired_state(robots) - - assert len(desired_state) == 1 - key = ("quay-instance", "test-org", "test-robot") - assert key in desired_state - - state = desired_state[key] - assert state.name == "test-robot" - assert state.description == "Test robot account" - assert state.org_name == "test-org" - assert state.instance_name == "quay-instance" - assert state.teams == {"team1", "team2"} - assert state.repositories == {"repo1": "read", "repo2": "write"} - - def test_build_desired_state_no_quay_org(self): - """Test building desired state with robot without quay_org""" - robot = QuayRobotV1( - name="test-robot", - description="Test robot", - quay_org=None, - teams=[], - repositories=[], - ) + return { + org_key: { + "api": mock_api, + "push_token": None, + "teams": [], + "managedRepos": False, + "mirror": None, + "mirror_filters": {}, + "url": "quay.io", + } + } - desired_state = build_desired_state([robot]) - assert len(desired_state) == 0 - - def test_build_desired_state_empty_teams_repos(self): - """Test building desired state with empty teams and repositories""" - robot = QuayRobotV1( - name="test-robot", - description="Test robot", - quay_org=QuayOrgV1( - name="test-org", - instance=QuayInstanceV1(name="quay-instance", url="quay.io"), - automationToken=None, - ), - teams=None, - repositories=None, - ) - desired_state = build_desired_state([robot]) - key = ("quay-instance", "test-org", "test-robot") - state = desired_state[key] - - assert state.teams == set() - assert state.repositories == {} - - -class TestBuildCurrentState: - def test_build_current_state_single_robot( - self, mock_current_robot, mock_quay_api_store - ): - """Test building current state with a single robot""" - current_robots = {("quay-instance", "test-org"): [mock_current_robot]} - - current_state = build_current_state(current_robots, mock_quay_api_store) - - assert len(current_state) == 1 - key = ("quay-instance", "test-org", "existing-robot") - assert key in current_state - - state = current_state[key] - assert state.name == "existing-robot" - assert state.description == "Existing robot" - assert state.teams == {"team1"} - assert state.repositories == {"repo1": "read"} - - def test_build_current_state_no_org_key(self, mock_current_robot): - """Test building current state with no matching org key""" - current_robots = {("unknown-instance", "unknown-org"): [mock_current_robot]} - quay_api_store = {} - - current_state = build_current_state(current_robots, quay_api_store) - assert len(current_state) == 0 - - def test_build_current_state_empty_robots(self, mock_quay_api_store): - """Test building current state with empty robot list""" - current_robots = {("quay-instance", "test-org"): []} - - current_state = build_current_state(current_robots, mock_quay_api_store) - assert len(current_state) == 0 - - -class TestCalculateDiff: - def test_calculate_diff_create_robot(self): - """Test calculating diff when robot needs to be created""" - desired_state = { - ("instance", "org", "new-robot"): RobotAccountState( - name="new-robot", - description="New robot", - org_name="org", - instance_name="instance", - teams={"team1"}, - repositories={"repo1": "read"}, - ) - } - current_state = {} - - actions = calculate_diff(desired_state, current_state) - - assert len(actions) == 3 # create, add_team, set_repo_permission - - create_action = next(a for a in actions if a.action == "create") - assert create_action.robot_name == "new-robot" - assert create_action.org_name == "org" - - team_action = next(a for a in actions if a.action == "add_team") - assert team_action.team == "team1" - - repo_action = next(a for a in actions if a.action == "set_repo_permission") - assert repo_action.repo == "repo1" - assert repo_action.permission == "read" - - def test_calculate_diff_delete_robot(self): - """Test calculating diff when robot needs to be deleted""" - desired_state = {} - current_state = { - ("instance", "org", "old-robot"): RobotAccountState( - name="old-robot", - description="Old robot", - org_name="org", - instance_name="instance", - teams=set(), - repositories={}, - ) - } +def test_build_desired_state_single_robot(mock_robot_gql: QuayRobotV1) -> None: + """Test building desired state with a single robot""" + robots = [mock_robot_gql] + desired_state: dict[tuple[str, str, str], RobotAccountState] = build_desired_state( + robots + ) - actions = calculate_diff(desired_state, current_state) - - assert len(actions) == 1 - assert actions[0].action == "delete" - assert actions[0].robot_name == "old-robot" - - def test_calculate_diff_team_changes(self): - """Test calculating diff for team membership changes""" - desired_state = { - ("instance", "org", "robot"): RobotAccountState( - name="robot", - description="Robot", - org_name="org", - instance_name="instance", - teams={"team1", "team3"}, # remove team2, add team3 - repositories={}, - ) - } - current_state = { - ("instance", "org", "robot"): RobotAccountState( - name="robot", - description="Robot", - org_name="org", - instance_name="instance", - teams={"team1", "team2"}, # has team2, missing team3 - repositories={}, - ) - } + assert len(desired_state) == 1 + key = ("quay-instance", "test-org", "test-robot") + assert key in desired_state - actions = calculate_diff(desired_state, current_state) - - action_types = [a.action for a in actions] - assert "add_team" in action_types - assert "remove_team" in action_types - - add_action = next(a for a in actions if a.action == "add_team") - assert add_action.team == "team3" - - remove_action = next(a for a in actions if a.action == "remove_team") - assert remove_action.team == "team2" - - def test_calculate_diff_repository_changes(self): - """Test calculating diff for repository permission changes""" - desired_state = { - ("instance", "org", "robot"): RobotAccountState( - name="robot", - description="Robot", - org_name="org", - instance_name="instance", - teams=set(), - repositories={ - "repo1": "write", - "repo3": "read", - }, # change repo1, add repo3, remove repo2 - ) - } - current_state = { - ("instance", "org", "robot"): RobotAccountState( - name="robot", - description="Robot", - org_name="org", - instance_name="instance", - teams=set(), - repositories={ - "repo1": "read", - "repo2": "write", - }, # repo1 has different permission, repo2 should be removed - ) - } + state = desired_state[key] + assert state.name == "test-robot" + assert state.description == "Test robot account" + assert state.org_name == "test-org" + assert state.instance_name == "quay-instance" + assert state.teams == {"team1", "team2"} + assert state.repositories == {"repo1": "read", "repo2": "write"} - actions = calculate_diff(desired_state, current_state) - action_types = [a.action for a in actions] - assert "set_repo_permission" in action_types - assert "remove_repo_permission" in action_types +def test_build_desired_state_no_quay_org(mock_robot_gql: QuayRobotV1) -> None: + """Test building desired state with robot without quay_org""" + robot = QuayRobotV1( + name="test-robot", + description="Test robot", + quay_org=None, + teams=[], + repositories=[], + ) - set_actions = [a for a in actions if a.action == "set_repo_permission"] - assert len(set_actions) == 2 # repo1 permission change, repo3 new + desired_state: dict[tuple[str, str, str], RobotAccountState] = build_desired_state([ + robot + ]) + assert len(desired_state) == 0 - remove_action = next(a for a in actions if a.action == "remove_repo_permission") - assert remove_action.repo == "repo2" - def test_calculate_diff_no_changes(self): - """Test calculating diff when no changes are needed""" - state = RobotAccountState( - name="robot", - description="Robot", +def test_build_desired_state_empty_teams_repos(mock_robot_gql: QuayRobotV1) -> None: + """Test building desired state with empty teams and repositories""" + robot = QuayRobotV1( + name="test-robot", + description="Test robot", + quay_org=QuayOrgV1( + name="test-org", + instance=QuayInstanceV1(name="quay-instance", url="quay.io"), + automationToken=None, + ), + teams=None, + repositories=None, + ) + + desired_state: dict[tuple[str, str, str], RobotAccountState] = build_desired_state([ + robot + ]) + key = ("quay-instance", "test-org", "test-robot") + state = desired_state[key] + + assert state.teams == set() + assert state.repositories == {} + + +def test_build_current_state_single_robot( + mock_current_robot: dict[str, Any], mock_quay_api_store: QuayApiStore +) -> None: + """Test building current state with a single robot""" + current_robots = {("quay-instance", "test-org"): [mock_current_robot]} + + current_state: dict[tuple[str, str, str], RobotAccountState] = build_current_state( + current_robots, mock_quay_api_store + ) + + assert len(current_state) == 1 + key = ("quay-instance", "test-org", "existing-robot") + assert key in current_state + + state = current_state[key] + assert state.name == "existing-robot" + assert state.description == "Existing robot" + assert state.teams == {"team1"} + assert state.repositories == {"repo1": "read"} + + +def test_build_current_state_no_org_key(mock_current_robot: dict[str, Any]) -> None: + """Test building current state with no matching org key""" + current_robots = {("unknown-instance", "unknown-org"): [mock_current_robot]} + quay_api_store: QuayApiStore = {} + + current_state: dict[tuple[str, str, str], RobotAccountState] = build_current_state( + current_robots, quay_api_store + ) + assert len(current_state) == 0 + + +def test_build_current_state_empty_robots(mock_quay_api_store: QuayApiStore) -> None: + """Test building current state with empty robot list""" + current_robots: dict[tuple[str, str], list[dict[str, Any]]] = { + ("quay-instance", "test-org"): [] + } + + current_state: dict[tuple[str, str, str], RobotAccountState] = build_current_state( + current_robots, mock_quay_api_store + ) + assert len(current_state) == 0 + + +def test_calculate_diff_create_robot() -> None: + """Test calculating diff when robot needs to be created""" + desired_state: dict[tuple[str, str, str], RobotAccountState] = { + ("instance", "org", "new-robot"): RobotAccountState( + name="new-robot", + description="New robot", org_name="org", instance_name="instance", teams={"team1"}, repositories={"repo1": "read"}, ) - desired_state = {("instance", "org", "robot"): state} - current_state = {("instance", "org", "robot"): state} + } + current_state: dict[tuple[str, str, str], RobotAccountState] = {} - actions = calculate_diff(desired_state, current_state) - assert len(actions) == 0 + actions = calculate_diff(desired_state, current_state) + assert len(actions) == 3 # create, add_team, set_repo_permission -class TestGetCurrentRobotAccounts: - def test_get_current_robot_accounts_success(self, mock_quay_api_store): - """Test successful fetching of current robot accounts""" - mock_robots = [{"name": "robot1"}, {"name": "robot2"}] - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.list_robot_accounts_detailed.return_value = mock_robots + create_action = next(a for a in actions if a.action == "create") + assert create_action.robot_name == "new-robot" + assert create_action.org_name == "org" - result = get_current_robot_accounts(mock_quay_api_store) + team_action = next(a for a in actions if a.action == "add_team") + assert team_action.team == "team1" - assert len(result) == 1 - assert ("quay-instance", "test-org") in result - assert result["quay-instance", "test-org"] == mock_robots + repo_action = next(a for a in actions if a.action == "set_repo_permission") + assert repo_action.repo == "repo1" + assert repo_action.permission == "read" - def test_get_current_robot_accounts_exception(self, mock_quay_api_store): - """Test handling of exceptions when fetching robot accounts""" - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.list_robot_accounts_detailed.side_effect = Exception("API Error") - result = get_current_robot_accounts(mock_quay_api_store) +def test_calculate_diff_delete_robot() -> None: + """Test calculating diff when robot needs to be deleted""" + desired_state: dict[tuple[str, str, str], RobotAccountState] = {} + current_state: dict[tuple[str, str, str], RobotAccountState] = { + ("instance", "org", "old-robot"): RobotAccountState( + name="old-robot", + description="Old robot", + org_name="org", + instance_name="instance", + teams=set(), + repositories={}, + ) + } - assert len(result) == 1 - assert result["quay-instance", "test-org"] == [] + actions: list[RobotAccountAction] = calculate_diff(desired_state, current_state) + assert len(actions) == 1 + assert actions[0].action == "delete" + assert actions[0].robot_name == "old-robot" -class TestApplyAction: - def test_apply_action_create_robot(self, mock_quay_api_store): - """Test applying create robot action""" - action = RobotAccountAction( - action="create", - robot_name="new-robot", - org_name="test-org", - instance_name="quay-instance", + +def test_calculate_diff_team_changes() -> None: + """Test calculating diff for team membership changes""" + desired_state: dict[tuple[str, str, str], RobotAccountState] = { + ("instance", "org", "robot"): RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams={"team1", "team3"}, # remove team2, add team3 + repositories={}, + ) + } + current_state: dict[tuple[str, str, str], RobotAccountState] = { + ("instance", "org", "robot"): RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams={"team1", "team2"}, # has team2, missing team3 + repositories={}, ) + } - apply_action(action, mock_quay_api_store, dry_run=False) + actions: list[RobotAccountAction] = calculate_diff(desired_state, current_state) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.create_robot_account.assert_called_once_with("new-robot", "") + action_types = [a.action for a in actions] + assert "add_team" in action_types + assert "remove_team" in action_types - def test_apply_action_delete_robot(self, mock_quay_api_store): - """Test applying delete robot action""" - action = RobotAccountAction( - action="delete", - robot_name="old-robot", - org_name="test-org", - instance_name="quay-instance", - ) + add_action = next(a for a in actions if a.action == "add_team") + assert add_action.team == "team3" + + remove_action = next(a for a in actions if a.action == "remove_team") + assert remove_action.team == "team2" - apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.delete_robot_account.assert_called_once_with("old-robot") - - def test_apply_action_add_team(self, mock_quay_api_store): - """Test applying add team action""" - action = RobotAccountAction( - action="add_team", - robot_name="robot", - org_name="test-org", - instance_name="quay-instance", - team="new-team", +def test_calculate_diff_repository_changes() -> None: + """Test calculating diff for repository permission changes""" + desired_state: dict[tuple[str, str, str], RobotAccountState] = { + ("instance", "org", "robot"): RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams=set(), + repositories={ + "repo1": "write", + "repo3": "read", + }, # change repo1, add repo3, remove repo2 ) + } + current_state: dict[tuple[str, str, str], RobotAccountState] = { + ("instance", "org", "robot"): RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams=set(), + repositories={ + "repo1": "read", + "repo2": "write", + }, # repo1 has different permission, repo2 should be removed + ) + } - apply_action(action, mock_quay_api_store, dry_run=False) + actions: list[RobotAccountAction] = calculate_diff(desired_state, current_state) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.add_user_to_team.assert_called_once_with("test-org+robot", "new-team") - - def test_apply_action_remove_team(self, mock_quay_api_store): - """Test applying remove team action""" - action = RobotAccountAction( - action="remove_team", - robot_name="robot", - org_name="test-org", - instance_name="quay-instance", - team="old-team", - ) + action_types = [a.action for a in actions] + assert "set_repo_permission" in action_types + assert "remove_repo_permission" in action_types - apply_action(action, mock_quay_api_store, dry_run=False) + set_actions = [a for a in actions if a.action == "set_repo_permission"] + assert len(set_actions) == 2 # repo1 permission change, repo3 new - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.remove_user_from_team.assert_called_once_with( - "test-org+robot", "old-team" - ) + remove_action = next(a for a in actions if a.action == "remove_repo_permission") + assert remove_action.repo == "repo2" - def test_apply_action_set_repo_permission(self, mock_quay_api_store): - """Test applying set repository permission action""" - action = RobotAccountAction( - action="set_repo_permission", - robot_name="robot", - org_name="test-org", - instance_name="quay-instance", - repo="repo1", - permission="write", - ) - apply_action(action, mock_quay_api_store, dry_run=False) +def test_calculate_diff_no_changes() -> None: + """Test calculating diff when no changes are needed""" + state: RobotAccountState = RobotAccountState( + name="robot", + description="Robot", + org_name="org", + instance_name="instance", + teams={"team1"}, + repositories={"repo1": "read"}, + ) + desired_state = {("instance", "org", "robot"): state} + current_state = {("instance", "org", "robot"): state} - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.set_repo_robot_permissions.assert_called_once_with( - "repo1", "robot", "write" - ) + actions: list[RobotAccountAction] = calculate_diff(desired_state, current_state) + assert len(actions) == 0 - def test_apply_action_remove_repo_permission(self, mock_quay_api_store): - """Test applying remove repository permission action""" - action = RobotAccountAction( - action="remove_repo_permission", - robot_name="robot", - org_name="test-org", - instance_name="quay-instance", - repo="repo1", - ) - apply_action(action, mock_quay_api_store, dry_run=False) +def test_get_current_robot_accounts_success(mock_quay_api_store: QuayApiStore) -> None: + """Test successful fetching of current robot accounts""" + mock_robots: list[dict[str, Any]] = [{"name": "robot1"}, {"name": "robot2"}] + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.list_robot_accounts_detailed.return_value = mock_robots # type: ignore - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.delete_repo_robot_permissions.assert_called_once_with("repo1", "robot") + result: dict[tuple[str, str], list[dict[str, Any]]] = get_current_robot_accounts( + mock_quay_api_store + ) - def test_apply_action_dry_run(self, mock_quay_api_store): - """Test applying action in dry run mode""" - action = RobotAccountAction( - action="create", - robot_name="new-robot", - org_name="test-org", - instance_name="quay-instance", - ) + assert len(result) == 1 + assert ("quay-instance", "test-org") in result + assert result["quay-instance", "test-org"] == mock_robots - apply_action(action, mock_quay_api_store, dry_run=True) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.create_robot_account.assert_not_called() +def test_get_current_robot_accounts_exception( + mock_quay_api_store: QuayApiStore, +) -> None: + """Test handling of exceptions when fetching robot accounts""" + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.list_robot_accounts_detailed.side_effect = Exception("API Error") # type: ignore - def test_apply_action_no_org_key(self, mock_quay_api_store): - """Test applying action when org key is not found""" - action = RobotAccountAction( - action="create", - robot_name="new-robot", - org_name="unknown-org", - instance_name="unknown-instance", - ) + result: dict[tuple[str, str], list[dict[str, Any]]] = get_current_robot_accounts( + mock_quay_api_store + ) + + assert len(result) == 1 + assert result["quay-instance", "test-org"] == [] - apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.create_robot_account.assert_not_called() +def test_apply_action_create_robot(mock_quay_api_store: QuayApiStore) -> None: + """Test applying create robot action""" + action = RobotAccountAction( + action="create", + robot_name="new-robot", + org_name="test-org", + instance_name="quay-instance", + ) - def test_apply_action_exception_handling(self, mock_quay_api_store): - """Test exception handling in apply_action""" - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.create_robot_account.side_effect = Exception("API Error") + apply_action(action, mock_quay_api_store, dry_run=False) - action = RobotAccountAction( - action="create", - robot_name="new-robot", - org_name="test-org", - instance_name="quay-instance", - ) + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.create_robot_account.assert_called_once_with("new-robot", "") # type: ignore + + +def test_apply_action_delete_robot(mock_quay_api_store: QuayApiStore) -> None: + """Test applying delete robot action""" + action = RobotAccountAction( + action="delete", + robot_name="old-robot", + org_name="test-org", + instance_name="quay-instance", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.delete_robot_account.assert_called_once_with("old-robot") # type: ignore + + +def test_apply_action_add_team(mock_quay_api_store: QuayApiStore) -> None: + """Test applying add team action""" + action = RobotAccountAction( + action="add_team", + robot_name="robot", + org_name="test-org", + instance_name="quay-instance", + team="new-team", + ) - with pytest.raises(Exception, match="API Error"): - apply_action(action, mock_quay_api_store, dry_run=False) + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.add_user_to_team.assert_called_once_with("test-org+robot", "new-team") # type: ignore + + +def test_apply_action_remove_team(mock_quay_api_store: QuayApiStore) -> None: + """Test applying remove team action""" + action = RobotAccountAction( + action="remove_team", + robot_name="robot", + org_name="test-org", + instance_name="quay-instance", + team="old-team", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.remove_user_from_team.assert_called_once_with("test-org+robot", "old-team") # type: ignore + + +def test_apply_action_set_repo_permission(mock_quay_api_store: QuayApiStore) -> None: + """Test applying set repository permission action""" + action = RobotAccountAction( + action="set_repo_permission", + robot_name="robot", + org_name="test-org", + instance_name="quay-instance", + repo="repo1", + permission="write", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.set_repo_robot_permissions.assert_called_once_with( # type: ignore[attr-defined] + "repo1", "robot", "write" + ) + + +def test_apply_action_remove_repo_permission(mock_quay_api_store: QuayApiStore) -> None: + """Test applying remove repository permission action""" + action = RobotAccountAction( + action="remove_repo_permission", + robot_name="robot", + org_name="test-org", + instance_name="quay-instance", + repo="repo1", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.delete_repo_robot_permissions.assert_called_once_with("repo1", "robot") # type: ignore + + +def test_apply_action_dry_run(mock_quay_api_store: QuayApiStore) -> None: + """Test applying action in dry run mode""" + action = RobotAccountAction( + action="create", + robot_name="new-robot", + org_name="test-org", + instance_name="quay-instance", + ) + + apply_action(action, mock_quay_api_store, dry_run=True) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.create_robot_account.assert_not_called() # type: ignore + + +def test_apply_action_no_org_key(mock_quay_api_store: QuayApiStore) -> None: + """Test applying action when org key is not found""" + action = RobotAccountAction( + action="create", + robot_name="new-robot", + org_name="unknown-org", + instance_name="unknown-instance", + ) + + apply_action(action, mock_quay_api_store, dry_run=False) + + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.create_robot_account.assert_not_called() # type: ignore + + +def test_apply_action_exception_handling(mock_quay_api_store: QuayApiStore) -> None: + """Test exception handling in apply_action""" + mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] + mock_api.create_robot_account.side_effect = Exception("API Error") # type: ignore + + action = RobotAccountAction( + action="create", + robot_name="new-robot", + org_name="test-org", + instance_name="quay-instance", + ) + + with pytest.raises(Exception, match="API Error"): + apply_action(action, mock_quay_api_store, dry_run=False) From 8da2f4c57d7893b8c0a433d7c4870d802a6d629f Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Mon, 25 Aug 2025 11:31:50 -0300 Subject: [PATCH 16/32] Remove test for message checking --- reconcile/test/utils/test_quay_api.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index 0e52bbacbd..88dc8c33d1 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -215,19 +215,6 @@ def test_get_repo_robot_permissions(quay_api: QuayApi) -> None: assert result == "write" -@responses.activate -def test_get_repo_robot_permissions_no_permission(quay_api: QuayApi) -> None: - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", - status=400, - json={"message": "User does not have permission for repo."}, - ) - - result = quay_api.get_repo_robot_permissions("some-repo", "robot1") - assert result is None - - @responses.activate def test_get_repo_robot_permissions_raises_other_status_codes( quay_api: QuayApi, From b79e278f40f70fc6d3c6721da56a0528f7f9b397 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Tue, 26 Aug 2025 09:42:13 -0300 Subject: [PATCH 17/32] Use robot short name to get robot account details --- reconcile/test/utils/test_quay_api.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index 88dc8c33d1..b0ecabfb88 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -289,7 +289,7 @@ def test_delete_repo_robot_permissions_raises_other_status_codes( @responses.activate def test_get_robot_account_details_success(quay_api: QuayApi) -> None: - robot_data = {"name": "test-robot", "description": "Test robot account"} + robot_data = {"name": f"{ORG}+test-robot", "description": "Test robot account"} permissions_data = { "permissions": [ {"role": "team", "team": {"name": "test-team"}}, @@ -316,10 +316,10 @@ def test_get_robot_account_details_success(quay_api: QuayApi) -> None: status=200, ) - result = quay_api.get_robot_account_details("test-robot") + result = quay_api.get_robot_account_details(f"{ORG}+test-robot") assert result is not None - assert result["name"] == "test-robot" + assert result["name"] == f"{ORG}+test-robot" assert result["description"] == "Test robot account" assert len(result["teams"]) == 1 assert result["teams"][0]["name"] == "test-team" @@ -336,15 +336,15 @@ def test_get_robot_account_details_not_found(quay_api: QuayApi) -> None: status=404, ) - result = quay_api.get_robot_account_details("test-robot") + result = quay_api.get_robot_account_details(f"{ORG}+test-robot") assert result is None @responses.activate def test_list_robot_accounts_detailed(quay_api: QuayApi) -> None: - robots_data = {"robots": [{"name": "robot1"}, {"name": "robot2"}]} - robot1_details = {"name": "robot1", "description": "Robot 1"} - robot2_details = {"name": "robot2", "description": "Robot 2"} + robots_data = {"robots": [{"name": f"{ORG}+robot1"}, {"name": f"{ORG}+robot2"}]} + robot1_details = {"name": f"{ORG}+robot1", "description": "Robot 1"} + robot2_details = {"name": f"{ORG}+robot2", "description": "Robot 2"} permissions_data: dict[str, list[dict[str, str]]] = {"permissions": []} responses.add( @@ -393,5 +393,5 @@ def test_list_robot_accounts_detailed(quay_api: QuayApi) -> None: result = quay_api.list_robot_accounts_detailed() assert len(result) == 2 - assert result[0]["name"] == "robot1" - assert result[1]["name"] == "robot2" + assert result[0]["name"] == f"{ORG}+robot1" + assert result[1]["name"] == f"{ORG}+robot2" From e3b5d5d108bb69c7fa4df60929e82736d15f2de6 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Wed, 8 Oct 2025 13:29:59 -0300 Subject: [PATCH 18/32] make gql-introspection --- reconcile/gql_definitions/introspection.json | 1077 ++++-------------- 1 file changed, 235 insertions(+), 842 deletions(-) diff --git a/reconcile/gql_definitions/introspection.json b/reconcile/gql_definitions/introspection.json index 17563ef1f9..7c8d6da28b 100644 --- a/reconcile/gql_definitions/introspection.json +++ b/reconcile/gql_definitions/introspection.json @@ -4528,6 +4528,16 @@ "name": "ExternalUser_v1", "ofType": null }, + { + "kind": "OBJECT", + "name": "QuayOrg_v1", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "QuayInstance_v1", + "ofType": null + }, { "kind": "OBJECT", "name": "CredentialsRequest_v1", @@ -4598,31 +4608,11 @@ "name": "AppQuayRepos_v1", "ofType": null }, - { - "kind": "OBJECT", - "name": "QuayOrg_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "QuayInstance_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PermissionQuayOrgTeam_v1", - "ofType": null - }, { "kind": "OBJECT", "name": "AppEscalationPolicy_v1", "ofType": null }, - { - "kind": "OBJECT", - "name": "PermissionSlackUsergroup_v1", - "ofType": null - }, { "kind": "OBJECT", "name": "SlackWorkspace_v1", @@ -4738,11 +4728,6 @@ "name": "AutomatedActionsInstance_v1", "ofType": null }, - { - "kind": "OBJECT", - "name": "PermissionAutomatedActions_v1", - "ofType": null - }, { "kind": "OBJECT", "name": "AWSVPC_v1", @@ -4773,11 +4758,6 @@ "name": "VaultAuth_v1", "ofType": null }, - { - "kind": "OBJECT", - "name": "PermissionGithubOrgTeam_v1", - "ofType": null - }, { "kind": "OBJECT", "name": "VaultPolicy_v1", @@ -4833,11 +4813,6 @@ "name": "UnleashProject_v1", "ofType": null }, - { - "kind": "OBJECT", - "name": "FeatureToggleUnleash_v1", - "ofType": null - }, { "kind": "OBJECT", "name": "ResourceTemplateTest_v1", @@ -4923,95 +4898,10 @@ "name": "SaasResourceTemplateTargetReference_v2", "ofType": null }, - { - "kind": "OBJECT", - "name": "PipelinesProviderTekton_v1", - "ofType": null - }, { "kind": "OBJECT", "name": "PipelinesProviderTektonProviderDefaults_v1", "ofType": null - }, - { - "kind": "OBJECT", - "name": "OidcPermissionVault_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "OidcPermissionAcs_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PermissionGithubOrg_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PermissionJenkinsRole_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PermissionGitlabGroupMembership_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "EndpointMonitoringProviderBlackboxExporter_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "EndpointMonitoringProviderSignalFx_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionActionList_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionCreateToken_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionExternalResourceFlushElastiCache_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionExternalResourceRdsReboot_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionExternalResourceRdsSnapshot_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionNoOp_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionOpenshiftTriggerCronjob_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionOpenshiftWorkloadDelete_v1", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AutomatedActionOpenshiftWorkloadRestart_v1", - "ofType": null } ] }, @@ -14967,7 +14857,13 @@ } ], "inputFields": null, - "interfaces": [], + "interfaces": [ + { + "kind": "INTERFACE", + "name": "DatafileObject_v1", + "ofType": null + } + ], "enumValues": null, "possibleTypes": null }, @@ -18621,399 +18517,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "OBJECT", - "name": "QuayOrg_v1", - "description": null, - "fields": [ - { - "name": "schema", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "path", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "labels", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "JSON", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mirror", - "description": null, - "args": [], - "type": { - "kind": "OBJECT", - "name": "QuayOrg_v1", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mirrorFilters", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "QuayOrgMirrorFilter_v1", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "managedRepos", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "instance", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "QuayInstance_v1", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "serverUrl", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "managedTeams", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "automationToken", - "description": null, - "args": [], - "type": { - "kind": "OBJECT", - "name": "VaultSecret_v1", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pushCredentials", - "description": null, - "args": [], - "type": { - "kind": "OBJECT", - "name": "VaultSecret_v1", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "QuayOrgMirrorFilter_v1", - "description": null, - "fields": [ - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tags", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tagsExclude", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "QuayInstance_v1", - "description": null, - "fields": [ - { - "name": "schema", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "path", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "labels", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "JSON", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "url", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, { "kind": "OBJECT", "name": "AppQuayReposTeams_v1", @@ -19225,11 +18728,6 @@ "kind": "INTERFACE", "name": "Permission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -19826,11 +19324,6 @@ "kind": "INTERFACE", "name": "Permission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -28627,11 +28120,6 @@ "kind": "INTERFACE", "name": "Permission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -30193,11 +29681,6 @@ "kind": "INTERFACE", "name": "Permission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -32866,11 +32349,6 @@ "kind": "INTERFACE", "name": "FeatureToggle_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -51805,11 +51283,6 @@ "kind": "INTERFACE", "name": "PipelinesProvider_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -52545,11 +52018,6 @@ "kind": "INTERFACE", "name": "OidcPermission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -52715,11 +52183,6 @@ "kind": "INTERFACE", "name": "OidcPermission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -52857,11 +52320,6 @@ "kind": "INTERFACE", "name": "Permission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -53019,11 +52477,6 @@ "kind": "INTERFACE", "name": "Permission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -53205,11 +52658,6 @@ "kind": "INTERFACE", "name": "Permission_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -54271,232 +53719,7 @@ "deprecationReason": null }, { - "name": "blackboxExporter", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "EndpointMonitoringProviderBlackboxExporterSettings_v1", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "EndpointMonitoringProvider_v1", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "EndpointMonitoringProviderBlackboxExporterSettings_v1", - "description": null, - "fields": [ - { - "name": "module", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "namespace", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Namespace_v1", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "exporterUrl", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "EndpointMonitoringProviderSignalFx_v1", - "description": null, - "fields": [ - { - "name": "schema", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "path", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "labels", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "JSON", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "provider", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "metricLabels", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "JSON", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timeout", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "checkInterval", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "signalFx", + "name": "blackboxExporter", "description": null, "args": [], "type": { @@ -54504,7 +53727,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "EndpointMonitoringProviderSignalFxSettings_v1", + "name": "EndpointMonitoringProviderBlackboxExporterSettings_v1", "ofType": null } }, @@ -54518,10 +53741,225 @@ "kind": "INTERFACE", "name": "EndpointMonitoringProvider_v1", "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EndpointMonitoringProviderBlackboxExporterSettings_v1", + "description": null, + "fields": [ + { + "name": "module", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "namespace", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Namespace_v1", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "exporterUrl", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EndpointMonitoringProviderSignalFx_v1", + "description": null, + "fields": [ + { + "name": "schema", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "labels", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "provider", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metricLabels", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeout", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "checkInterval", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null }, + { + "name": "signalFx", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "EndpointMonitoringProviderSignalFxSettings_v1", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ { "kind": "INTERFACE", - "name": "DatafileObject_v1", + "name": "EndpointMonitoringProvider_v1", "ofType": null } ], @@ -55411,11 +54849,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -55588,11 +55021,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -55754,11 +55182,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -55963,11 +55386,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -56129,11 +55547,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -56271,11 +55684,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -56437,11 +55845,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -56646,11 +56049,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, @@ -56883,11 +56281,6 @@ "kind": "INTERFACE", "name": "AutomatedAction_v1", "ofType": null - }, - { - "kind": "INTERFACE", - "name": "DatafileObject_v1", - "ofType": null } ], "enumValues": null, From a54e19b7414c1e5559cd172f661f5ec48c3d4713 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 18 Dec 2025 15:39:48 -0300 Subject: [PATCH 19/32] remove duplicate get response --- reconcile/test/utils/test_quay_api.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index b0ecabfb88..f67064a34c 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -309,12 +309,6 @@ def test_get_robot_account_details_success(quay_api: QuayApi) -> None: json=permissions_data, status=200, ) - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/test-robot/permissions", - json=permissions_data, - status=200, - ) result = quay_api.get_robot_account_details(f"{ORG}+test-robot") From b29b1e377227b7fda1b6927ce491d38532773822 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 18 Dec 2025 15:42:55 -0300 Subject: [PATCH 20/32] Use create autospect --- reconcile/test/test_quay_robot_accounts.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/reconcile/test/test_quay_robot_accounts.py b/reconcile/test/test_quay_robot_accounts.py index 593d25d48d..9e00cff005 100644 --- a/reconcile/test/test_quay_robot_accounts.py +++ b/reconcile/test/test_quay_robot_accounts.py @@ -1,5 +1,5 @@ from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, create_autospec import pytest @@ -20,6 +20,7 @@ calculate_diff, get_current_robot_accounts, ) +from reconcile.utils.quay_api import QuayApi @pytest.fixture @@ -55,14 +56,8 @@ def mock_current_robot() -> dict[str, Any]: @pytest.fixture def mock_quay_api_store() -> QuayApiStore: """Mock QuayApiStore""" - mock_api = Mock() + mock_api = create_autospec(QuayApi) mock_api.list_robot_accounts_detailed.return_value = [] - mock_api.create_robot_account.return_value = None - mock_api.delete_robot_account.return_value = None - mock_api.add_user_to_team.return_value = None - mock_api.remove_user_from_team.return_value = None - mock_api.set_repo_robot_permissions.return_value = None - mock_api.delete_repo_robot_permissions.return_value = None org_key = OrgKey("quay-instance", "test-org") return { From 632ea561f66f49bb38efe35ad15d6e3fe049939f Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Thu, 18 Dec 2025 16:31:29 -0300 Subject: [PATCH 21/32] Add RobotAccountDetails type --- reconcile/quay_robot_accounts.py | 6 +- reconcile/test/test_quay_robot_accounts.py | 70 ++++++++++++---------- reconcile/utils/quay_api.py | 8 ++- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/reconcile/quay_robot_accounts.py b/reconcile/quay_robot_accounts.py index bcc42e3916..0641b7147d 100644 --- a/reconcile/quay_robot_accounts.py +++ b/reconcile/quay_robot_accounts.py @@ -1,7 +1,6 @@ import logging import sys from dataclasses import dataclass -from typing import Any from reconcile.gql_definitions.quay_robot_accounts.quay_robot_accounts import ( QuayRobotV1, @@ -10,6 +9,7 @@ from reconcile.quay_base import QuayApiStore, get_quay_api_store from reconcile.status import ExitCodes from reconcile.utils import gql +from reconcile.utils.quay_api import RobotAccountDetails QONTRACT_INTEGRATION = "quay-robot-accounts" @@ -47,7 +47,7 @@ def get_robot_accounts_from_gql() -> list[QuayRobotV1]: def get_current_robot_accounts( quay_api_store: QuayApiStore, -) -> dict[tuple[str, str], list[dict[str, Any]]]: +) -> dict[tuple[str, str], list[RobotAccountDetails]]: """Fetch current robot accounts from Quay API for all organizations""" current_state = {} @@ -100,7 +100,7 @@ def build_desired_state( def build_current_state( - current_robots: dict[tuple[str, str], list[dict[str, Any]]], + current_robots: dict[tuple[str, str], list[RobotAccountDetails]], quay_api_store: QuayApiStore, ) -> dict[tuple[str, str, str], RobotAccountState]: """Build current state from Quay API data""" diff --git a/reconcile/test/test_quay_robot_accounts.py b/reconcile/test/test_quay_robot_accounts.py index 9e00cff005..9957abd7bc 100644 --- a/reconcile/test/test_quay_robot_accounts.py +++ b/reconcile/test/test_quay_robot_accounts.py @@ -1,5 +1,4 @@ -from typing import Any -from unittest.mock import Mock, create_autospec +from unittest.mock import create_autospec import pytest @@ -20,7 +19,7 @@ calculate_diff, get_current_robot_accounts, ) -from reconcile.utils.quay_api import QuayApi +from reconcile.utils.quay_api import QuayApi, RobotAccountDetails @pytest.fixture @@ -43,14 +42,14 @@ def mock_robot_gql() -> QuayRobotV1: @pytest.fixture -def mock_current_robot() -> dict[str, Any]: +def mock_current_robot() -> RobotAccountDetails: """Mock current robot account from Quay API""" - return { - "name": "existing-robot", - "description": "Existing robot", - "teams": [{"name": "team1"}], - "repositories": [{"name": "repo1", "role": "read"}], - } + return RobotAccountDetails( + name="existing-robot", + description="Existing robot", + teams=[{"name": "team1"}], + repositories=[{"name": "repo1", "role": "read"}], + ) @pytest.fixture @@ -134,7 +133,7 @@ def test_build_desired_state_empty_teams_repos(mock_robot_gql: QuayRobotV1) -> N def test_build_current_state_single_robot( - mock_current_robot: dict[str, Any], mock_quay_api_store: QuayApiStore + mock_current_robot: RobotAccountDetails, mock_quay_api_store: QuayApiStore ) -> None: """Test building current state with a single robot""" current_robots = {("quay-instance", "test-org"): [mock_current_robot]} @@ -154,26 +153,24 @@ def test_build_current_state_single_robot( assert state.repositories == {"repo1": "read"} -def test_build_current_state_no_org_key(mock_current_robot: dict[str, Any]) -> None: +def test_build_current_state_no_org_key( + mock_current_robot: RobotAccountDetails, +) -> None: """Test building current state with no matching org key""" current_robots = {("unknown-instance", "unknown-org"): [mock_current_robot]} quay_api_store: QuayApiStore = {} - current_state: dict[tuple[str, str, str], RobotAccountState] = build_current_state( - current_robots, quay_api_store - ) + current_state = build_current_state(current_robots, quay_api_store) assert len(current_state) == 0 def test_build_current_state_empty_robots(mock_quay_api_store: QuayApiStore) -> None: """Test building current state with empty robot list""" - current_robots: dict[tuple[str, str], list[dict[str, Any]]] = { + current_robots: dict[tuple[str, str], list[RobotAccountDetails]] = { ("quay-instance", "test-org"): [] } - current_state: dict[tuple[str, str, str], RobotAccountState] = build_current_state( - current_robots, mock_quay_api_store - ) + current_state = build_current_state(current_robots, mock_quay_api_store) assert len(current_state) == 0 @@ -210,7 +207,7 @@ def test_calculate_diff_create_robot() -> None: def test_calculate_diff_delete_robot() -> None: """Test calculating diff when robot needs to be deleted""" desired_state: dict[tuple[str, str, str], RobotAccountState] = {} - current_state: dict[tuple[str, str, str], RobotAccountState] = { + current_state = { ("instance", "org", "old-robot"): RobotAccountState( name="old-robot", description="Old robot", @@ -221,7 +218,7 @@ def test_calculate_diff_delete_robot() -> None: ) } - actions: list[RobotAccountAction] = calculate_diff(desired_state, current_state) + actions = calculate_diff(desired_state, current_state) assert len(actions) == 1 assert actions[0].action == "delete" @@ -251,7 +248,7 @@ def test_calculate_diff_team_changes() -> None: ) } - actions: list[RobotAccountAction] = calculate_diff(desired_state, current_state) + actions = calculate_diff(desired_state, current_state) action_types = [a.action for a in actions] assert "add_team" in action_types @@ -293,7 +290,7 @@ def test_calculate_diff_repository_changes() -> None: ) } - actions: list[RobotAccountAction] = calculate_diff(desired_state, current_state) + actions = calculate_diff(desired_state, current_state) action_types = [a.action for a in actions] assert "set_repo_permission" in action_types @@ -308,7 +305,7 @@ def test_calculate_diff_repository_changes() -> None: def test_calculate_diff_no_changes() -> None: """Test calculating diff when no changes are needed""" - state: RobotAccountState = RobotAccountState( + state = RobotAccountState( name="robot", description="Robot", org_name="org", @@ -319,19 +316,30 @@ def test_calculate_diff_no_changes() -> None: desired_state = {("instance", "org", "robot"): state} current_state = {("instance", "org", "robot"): state} - actions: list[RobotAccountAction] = calculate_diff(desired_state, current_state) + actions = calculate_diff(desired_state, current_state) assert len(actions) == 0 def test_get_current_robot_accounts_success(mock_quay_api_store: QuayApiStore) -> None: """Test successful fetching of current robot accounts""" - mock_robots: list[dict[str, Any]] = [{"name": "robot1"}, {"name": "robot2"}] + mock_robots = [ + RobotAccountDetails( + name="robot1", + description="Robot 1", + teams=[{"name": "team1"}], + repositories=[{"name": "repo1", "role": "read"}], + ), + RobotAccountDetails( + name="robot2", + description="Robot 2", + teams=[{"name": "team2"}], + repositories=[{"name": "repo2", "role": "write"}], + ), + ] mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] mock_api.list_robot_accounts_detailed.return_value = mock_robots # type: ignore - result: dict[tuple[str, str], list[dict[str, Any]]] = get_current_robot_accounts( - mock_quay_api_store - ) + result = get_current_robot_accounts(mock_quay_api_store) assert len(result) == 1 assert ("quay-instance", "test-org") in result @@ -345,9 +353,7 @@ def test_get_current_robot_accounts_exception( mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] mock_api.list_robot_accounts_detailed.side_effect = Exception("API Error") # type: ignore - result: dict[tuple[str, str], list[dict[str, Any]]] = get_current_robot_accounts( - mock_quay_api_store - ) + result = get_current_robot_accounts(mock_quay_api_store) assert len(result) == 1 assert result["quay-instance", "test-org"] == [] diff --git a/reconcile/utils/quay_api.py b/reconcile/utils/quay_api.py index f929a71fbe..4638b7b517 100644 --- a/reconcile/utils/quay_api.py +++ b/reconcile/utils/quay_api.py @@ -1,5 +1,5 @@ import contextlib -from typing import Any +from typing import Any, TypedDict import requests @@ -9,6 +9,12 @@ class QuayTeamNotFoundError(Exception): pass +class RobotAccountDetails(TypedDict): + name: str + description: str | None + teams: list[dict[str, str]] + repositories: list[dict[str, str]] + class QuayApi(ApiBase): LIMIT_FOLLOWS = 15 From 1e7f726ba7de90fffa3ecb67f9ba2f615cf5c61a Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Fri, 16 Jan 2026 16:32:05 -0300 Subject: [PATCH 22/32] Adapt quay api tests to be session based --- reconcile/test/utils/test_quay_api.py | 307 ++++++++------------------ 1 file changed, 91 insertions(+), 216 deletions(-) diff --git a/reconcile/test/utils/test_quay_api.py b/reconcile/test/utils/test_quay_api.py index f67064a34c..154864c15d 100644 --- a/reconcile/test/utils/test_quay_api.py +++ b/reconcile/test/utils/test_quay_api.py @@ -96,13 +96,12 @@ def test_list_team_members_raises_other_status_codes( quay_api.list_team_members(TEAM_NAME) -@responses.activate -def test_list_robot_accounts(quay_api: QuayApi) -> None: - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", - status=200, - json={ +def test_list_robot_accounts(quay_api: QuayApi, httpserver: HTTPServer) -> None: + httpserver.expect_request( + f"/api/v1/organization/{ORG}/robots", + method="GET", + ).respond_with_json( + { "robots": [ { "name": "robot1", @@ -118,274 +117,150 @@ def test_list_robot_accounts(quay_api: QuayApi) -> None: }, ] }, + status=200, ) assert quay_api.list_robot_accounts() == [ { "name": "robot1", "description": "robot1 description", - "created": "2021-01-01T00:00:00Z", - "last_accessed": None, + "teams": [], + "repositories": [], }, { "name": "robot2", "description": "robot2 description", - "created": "2021-01-01T00:00:00Z", - "last_accessed": None, + "teams": [], + "repositories": [], }, ] -@responses.activate -def test_list_robot_accounts_raises_other_status_codes(quay_api: QuayApi) -> None: - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", - status=400, - ) +def test_list_robot_accounts_raises_other_status_codes( + quay_api: QuayApi, httpserver: HTTPServer +) -> None: + httpserver.expect_request( + f"/api/v1/organization/{ORG}/robots", + method="GET", + ).respond_with_json({"error": "Bad request"}, status=400) with pytest.raises(HTTPError): quay_api.list_robot_accounts() -@responses.activate -def test_create_robot_account(quay_api: QuayApi) -> None: - responses.add( - responses.PUT, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", - status=200, - json={"name": "robot1", "description": "robot1 description"}, +def test_create_robot_account(quay_api: QuayApi, httpserver: HTTPServer) -> None: + httpserver.expect_request( + f"/api/v1/organization/{ORG}/robots/robot1", + method="PUT", + ).respond_with_json( + {"name": "robot1", "description": "robot1 description"}, status=200 ) quay_api.create_robot_account("robot1", "robot1 description") - assert responses.calls[0].request.body == b'{"description": "robot1 description"}' - - -@responses.activate -def test_create_robot_account_raises_other_status_codes(quay_api: QuayApi) -> None: - responses.add( - responses.PUT, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", - status=400, - ) - - with pytest.raises(HTTPError): - quay_api.create_robot_account("robot1", "robot1 description") + assert len(httpserver.log) == 1 + request = httpserver.log[0][0] + assert request.method == "PUT" + assert json.loads(request.get_data()) == {"description": "robot1 description"} -@responses.activate -def test_delete_robot_account(quay_api: QuayApi) -> None: - responses.add( - responses.DELETE, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", - status=200, - ) +def test_delete_robot_account(quay_api: QuayApi, httpserver: HTTPServer) -> None: + httpserver.expect_request( + f"/api/v1/organization/{ORG}/robots/robot1", + method="DELETE", + ).respond_with_json({}, status=200) quay_api.delete_robot_account("robot1") - assert responses.calls[0].request.method == "DELETE" - assert ( - responses.calls[0].request.url - == f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1" - ) + assert len(httpserver.log) == 1 + request = httpserver.log[0][0] + assert request.method == "DELETE" -@responses.activate -def test_delete_robot_account_raises_other_status_codes(quay_api: QuayApi) -> None: - responses.add( - responses.DELETE, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", - status=400, - ) + +def test_delete_robot_account_raises_other_status_codes( + quay_api: QuayApi, httpserver: HTTPServer +) -> None: + httpserver.expect_request( + f"/api/v1/organization/{ORG}/robots/robot1", + method="DELETE", + ).respond_with_json({"error": "Bad request"}, status=400) with pytest.raises(HTTPError): quay_api.delete_robot_account("robot1") -@responses.activate -def test_get_repo_robot_permissions(quay_api: QuayApi) -> None: - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", - status=200, - json={"role": "write"}, - ) +def test_get_repo_robot_account_permissions( + quay_api: QuayApi, httpserver: HTTPServer +) -> None: + httpserver.expect_request( + f"/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + method="GET", + ).respond_with_json({"role": "write"}, status=200) - result = quay_api.get_repo_robot_permissions("some-repo", "robot1") + result = quay_api.get_repo_robot_account_permissions("some-repo", "robot1") assert result == "write" -@responses.activate def test_get_repo_robot_permissions_raises_other_status_codes( - quay_api: QuayApi, + quay_api: QuayApi, httpserver: HTTPServer ) -> None: - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", - status=500, - ) + httpserver.expect_request( + f"/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + method="GET", + ).respond_with_json({"error": "Bad request"}, status=400) with pytest.raises(HTTPError): - quay_api.get_repo_robot_permissions("some-repo", "robot1") + quay_api.get_repo_robot_account_permissions("some-repo", "robot1") -@responses.activate -def test_set_repo_robot_permissions(quay_api: QuayApi) -> None: - responses.add( - responses.PUT, - f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", - status=200, - ) +def test_set_repo_robot_permissions(quay_api: QuayApi, httpserver: HTTPServer) -> None: + httpserver.expect_request( + f"/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + method="PUT", + ).respond_with_json({}, status=200) - quay_api.set_repo_robot_permissions("some-repo", "robot1", "admin") + quay_api.set_repo_robot_account_permissions("some-repo", "robot1", "admin") - assert responses.calls[0].request.body == b'{"role": "admin"}' + assert len(httpserver.log) == 1 + request = httpserver.log[0][0] + assert request.method == "PUT" + assert json.loads(request.get_data()) == {"role": "admin"} -@responses.activate def test_set_repo_robot_permissions_raises_other_status_codes( - quay_api: QuayApi, + quay_api: QuayApi, httpserver: HTTPServer ) -> None: - responses.add( - responses.PUT, - f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", - status=400, - ) + httpserver.expect_request( + f"/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + method="PUT", + ).respond_with_json({"error": "Bad request"}, status=400) with pytest.raises(HTTPError): - quay_api.set_repo_robot_permissions("some-repo", "robot1", "admin") + quay_api.set_repo_robot_account_permissions("some-repo", "robot1", "admin") -@responses.activate -def test_delete_repo_robot_permissions(quay_api: QuayApi) -> None: - responses.add( - responses.DELETE, - f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", - status=200, - ) +def test_delete_repo_robot_permissions( + quay_api: QuayApi, httpserver: HTTPServer +) -> None: + httpserver.expect_request( + f"/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + method="DELETE", + ).respond_with_json({}, status=200) - quay_api.delete_repo_robot_permissions("some-repo", "robot1") + quay_api.delete_repo_robot_account_permissions("some-repo", "robot1") - assert responses.calls[0].request.method == "DELETE" - assert ( - responses.calls[0].request.url - == f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1" - ) + assert len(httpserver.log) == 1 + request = httpserver.log[0][0] + assert request.method == "DELETE" -@responses.activate def test_delete_repo_robot_permissions_raises_other_status_codes( - quay_api: QuayApi, + quay_api: QuayApi, httpserver: HTTPServer ) -> None: - responses.add( - responses.DELETE, - f"https://{BASE_URL}/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", - status=400, - ) + httpserver.expect_request( + f"/api/v1/repository/{ORG}/some-repo/permissions/user/{ORG}+robot1", + method="DELETE", + ).respond_with_json({"error": "Bad request"}, status=400) with pytest.raises(HTTPError): - quay_api.delete_repo_robot_permissions("some-repo", "robot1") - - -@responses.activate -def test_get_robot_account_details_success(quay_api: QuayApi) -> None: - robot_data = {"name": f"{ORG}+test-robot", "description": "Test robot account"} - permissions_data = { - "permissions": [ - {"role": "team", "team": {"name": "test-team"}}, - {"role": "read", "repository": {"name": "test-repo"}}, - ] - } - - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/test-robot", - json=robot_data, - status=200, - ) - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/test-robot/permissions", - json=permissions_data, - status=200, - ) - - result = quay_api.get_robot_account_details(f"{ORG}+test-robot") - - assert result is not None - assert result["name"] == f"{ORG}+test-robot" - assert result["description"] == "Test robot account" - assert len(result["teams"]) == 1 - assert result["teams"][0]["name"] == "test-team" - assert len(result["repositories"]) == 1 - assert result["repositories"][0]["name"] == "test-repo" - assert result["repositories"][0]["role"] == "read" - - -@responses.activate -def test_get_robot_account_details_not_found(quay_api: QuayApi) -> None: - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/test-robot", - status=404, - ) - - result = quay_api.get_robot_account_details(f"{ORG}+test-robot") - assert result is None - - -@responses.activate -def test_list_robot_accounts_detailed(quay_api: QuayApi) -> None: - robots_data = {"robots": [{"name": f"{ORG}+robot1"}, {"name": f"{ORG}+robot2"}]} - robot1_details = {"name": f"{ORG}+robot1", "description": "Robot 1"} - robot2_details = {"name": f"{ORG}+robot2", "description": "Robot 2"} - permissions_data: dict[str, list[dict[str, str]]] = {"permissions": []} - - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots", - json=robots_data, - status=200, - ) - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1", - json=robot1_details, - status=200, - ) - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1/permissions", - json=permissions_data, - status=200, - ) - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot1/permissions", - json=permissions_data, - status=200, - ) - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot2", - json=robot2_details, - status=200, - ) - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot2/permissions", - json=permissions_data, - status=200, - ) - responses.add( - responses.GET, - f"https://{BASE_URL}/api/v1/organization/{ORG}/robots/robot2/permissions", - json=permissions_data, - status=200, - ) - - result = quay_api.list_robot_accounts_detailed() - - assert len(result) == 2 - assert result[0]["name"] == f"{ORG}+robot1" - assert result[1]["name"] == f"{ORG}+robot2" + quay_api.delete_repo_robot_account_permissions("some-repo", "robot1") From 1b0f6df3962c386fd7b1b249f6901296c74e774c Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Fri, 16 Jan 2026 16:32:29 -0300 Subject: [PATCH 23/32] fix quay_mirror account that depend on QuayApiStore --- reconcile/test/test_quay_mirror_org.py | 30 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/reconcile/test/test_quay_mirror_org.py b/reconcile/test/test_quay_mirror_org.py index 395e9d3b92..45fc1531f2 100644 --- a/reconcile/test/test_quay_mirror_org.py +++ b/reconcile/test/test_quay_mirror_org.py @@ -22,15 +22,16 @@ @patch("reconcile.utils.gql.get_api", autospec=True) @patch("reconcile.queries.get_app_interface_settings", return_value={}) +@patch("reconcile.quay_base.get_quay_api_store", return_value={}) class TestControlFile: def test_control_file_dir_does_not_exist( - self, mock_gql: Mock, mock_settings: Mock + self, mock_gql: Mock, mock_settings: Mock, mock_quay_api_store: Mock ) -> None: with pytest.raises(FileNotFoundError): QuayMirrorOrg(control_file_dir="/no-such-dir") def test_control_file_path_from_given_dir( - self, mock_gql: Mock, mock_settings: Mock + self, mock_gql: Mock, mock_settings: Mock, mock_quay_api_store: Mock ) -> None: with tempfile.TemporaryDirectory() as tmp_dir_name: qm = QuayMirrorOrg(control_file_dir=tmp_dir_name) @@ -39,6 +40,7 @@ def test_control_file_path_from_given_dir( @patch("reconcile.utils.gql.get_api", autospec=True) @patch("reconcile.queries.get_app_interface_settings", return_value={}) +@patch("reconcile.quay_base.get_quay_api_store", return_value={}) @patch("time.time", return_value=NOW) class TestIsCompareTags: def setup_method(self) -> None: @@ -53,14 +55,22 @@ def teardown_method(self) -> None: # Last run was in NOW - 100s, we run compare tags every 10s. def test_is_compare_tags_elapsed( - self, mock_gql: Mock, mock_settings: Mock, mock_time: Mock + self, + mock_gql: Mock, + mock_settings: Mock, + mock_time: Mock, + mock_quay_api_store: Mock, ) -> None: qm = QuayMirrorOrg(control_file_dir=self.tmp_dir.name, compare_tags_interval=10) assert qm.is_compare_tags # Same as before, but now we force no compare with the option. def test_is_compare_tags_force_no_compare( - self, mock_gql: Mock, mock_settings: Mock, mock_time: Mock + self, + mock_gql: Mock, + mock_settings: Mock, + mock_time: Mock, + mock_quay_api_store: Mock, ) -> None: qm = QuayMirrorOrg( control_file_dir=self.tmp_dir.name, @@ -71,7 +81,11 @@ def test_is_compare_tags_force_no_compare( # Last run was in NOW - 100s, we run compare tags every 1000s. def test_is_compare_tags_not_elapsed( - self, mock_gql: Mock, mock_settings: Mock, mock_time: Mock + self, + mock_gql: Mock, + mock_settings: Mock, + mock_time: Mock, + mock_quay_api_store: Mock, ) -> None: qm = QuayMirrorOrg( control_file_dir=self.tmp_dir.name, compare_tags_interval=1000 @@ -80,7 +94,11 @@ def test_is_compare_tags_not_elapsed( # Same as before, but now we force compare with the option. def test_is_compare_tags_force_compare( - self, mock_gql: Mock, mock_settings: Mock, mock_time: Mock + self, + mock_gql: Mock, + mock_settings: Mock, + mock_time: Mock, + mock_quay_api_store: Mock, ) -> None: qm = QuayMirrorOrg( control_file_dir=self.tmp_dir.name, From 4409d5d635a2f79c73fff379602fdac8c12434be Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Fri, 16 Jan 2026 16:32:56 -0300 Subject: [PATCH 24/32] Update test_quay_robot_accounts to use QuayApiStore --- reconcile/test/test_quay_robot_accounts.py | 41 ++++++++++------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/reconcile/test/test_quay_robot_accounts.py b/reconcile/test/test_quay_robot_accounts.py index 9957abd7bc..5aeec7481a 100644 --- a/reconcile/test/test_quay_robot_accounts.py +++ b/reconcile/test/test_quay_robot_accounts.py @@ -9,7 +9,7 @@ QuayRobotV1, VaultSecretV1, ) -from reconcile.quay_base import OrgKey, QuayApiStore +from reconcile.quay_base import OrgInfo, OrgKey, QuayApiStore from reconcile.quay_robot_accounts import ( RobotAccountAction, RobotAccountState, @@ -53,22 +53,18 @@ def mock_current_robot() -> RobotAccountDetails: @pytest.fixture -def mock_quay_api_store() -> QuayApiStore: +def mock_quay_api_store() -> dict[OrgKey, OrgInfo]: """Mock QuayApiStore""" - mock_api = create_autospec(QuayApi) - mock_api.list_robot_accounts_detailed.return_value = [] - - org_key = OrgKey("quay-instance", "test-org") return { - org_key: { - "api": mock_api, - "push_token": None, - "teams": [], - "managedRepos": False, - "mirror": None, - "mirror_filters": {}, - "url": "quay.io", - } + OrgKey("quay-instance", "test-org"): OrgInfo( + url="quay.io", + push_token=None, + teams=[], + managedRepos=False, + mirror=None, + mirror_filters={}, + api=create_autospec(QuayApi), + ) } @@ -154,13 +150,12 @@ def test_build_current_state_single_robot( def test_build_current_state_no_org_key( - mock_current_robot: RobotAccountDetails, + mock_current_robot: RobotAccountDetails, mock_quay_api_store: QuayApiStore ) -> None: """Test building current state with no matching org key""" current_robots = {("unknown-instance", "unknown-org"): [mock_current_robot]} - quay_api_store: QuayApiStore = {} - current_state = build_current_state(current_robots, quay_api_store) + current_state = build_current_state(current_robots, mock_quay_api_store) assert len(current_state) == 0 @@ -337,7 +332,7 @@ def test_get_current_robot_accounts_success(mock_quay_api_store: QuayApiStore) - ), ] mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.list_robot_accounts_detailed.return_value = mock_robots # type: ignore + mock_api.list_robot_accounts.return_value = mock_robots # type: ignore result = get_current_robot_accounts(mock_quay_api_store) @@ -351,7 +346,7 @@ def test_get_current_robot_accounts_exception( ) -> None: """Test handling of exceptions when fetching robot accounts""" mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.list_robot_accounts_detailed.side_effect = Exception("API Error") # type: ignore + mock_api.list_robot_accounts.side_effect = Exception("API Error") # type: ignore result = get_current_robot_accounts(mock_quay_api_store) @@ -435,7 +430,7 @@ def test_apply_action_set_repo_permission(mock_quay_api_store: QuayApiStore) -> apply_action(action, mock_quay_api_store, dry_run=False) mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.set_repo_robot_permissions.assert_called_once_with( # type: ignore[attr-defined] + mock_api.set_repo_robot_account_permissions.assert_called_once_with( # type: ignore "repo1", "robot", "write" ) @@ -453,7 +448,9 @@ def test_apply_action_remove_repo_permission(mock_quay_api_store: QuayApiStore) apply_action(action, mock_quay_api_store, dry_run=False) mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.delete_repo_robot_permissions.assert_called_once_with("repo1", "robot") # type: ignore + mock_api.delete_repo_robot_account_permissions.assert_called_once_with( # type: ignore + "repo1", "robot" + ) def test_apply_action_dry_run(mock_quay_api_store: QuayApiStore) -> None: From 50c8d3313354249207d9f58d2f1010d1300333fa Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Fri, 16 Jan 2026 16:33:22 -0300 Subject: [PATCH 25/32] Adds robot account methods based on new Api class --- reconcile/utils/quay_api.py | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/reconcile/utils/quay_api.py b/reconcile/utils/quay_api.py index 4638b7b517..8f943313e9 100644 --- a/reconcile/utils/quay_api.py +++ b/reconcile/utils/quay_api.py @@ -9,6 +9,7 @@ class QuayTeamNotFoundError(Exception): pass + class RobotAccountDetails(TypedDict): name: str description: str | None @@ -246,3 +247,61 @@ def set_repo_team_permissions(self, repo_name: str, team: str, role: str) -> Non ) body = {"role": role} self._put(url, data=body) + + def list_robot_accounts(self) -> list[RobotAccountDetails]: + url = f"/api/v1/organization/{self.organization}/robots" + body = self._get(url) + return [ + RobotAccountDetails( + name=robot["name"], + description=robot["description"], + teams=[], + repositories=[], + ) + for robot in body["robots"] + ] + + def create_robot_account(self, name: str, description: str) -> None: + url = f"/api/v1/organization/{self.organization}/robots/{name}" + body = {"description": description} + self._put(url, data=body) + + def delete_robot_account(self, name: str) -> None: + url = f"/api/v1/organization/{self.organization}/robots/{name}" + self._delete(url) + + def get_robot_account_permissions(self, name: str) -> list[dict[str, Any]]: + url = f"/api/v1/organization/{self.organization}/robots/{name}/permissions" + body = self._get(url) + return body["permissions"] + + def set_robot_account_permissions( + self, name: str, permissions: list[dict[str, Any]] + ) -> None: + url = f"/api/v1/organization/{self.organization}/robots/{name}/permissions" + body = {"permissions": permissions} + self._put(url, data=body) + + def delete_robot_account_permissions(self, name: str) -> None: + url = f"/api/v1/organization/{self.organization}/robots/{name}/permissions" + self._delete(url) + + def get_repo_robot_account_permissions( + self, repo_name: str, robot_name: str + ) -> str | None: + url = f"/api/v1/repository/{self.organization}/{repo_name}/permissions/user/{self.organization}+{robot_name}" + body = self._get(url) + return body.get("role") or None + + def set_repo_robot_account_permissions( + self, repo_name: str, robot_name: str, role: str + ) -> None: + url = f"/api/v1/repository/{self.organization}/{repo_name}/permissions/user/{self.organization}+{robot_name}" + body = {"role": role} + self._put(url, data=body) + + def delete_repo_robot_account_permissions( + self, repo_name: str, robot_name: str + ) -> None: + url = f"/api/v1/repository/{self.organization}/{repo_name}/permissions/user/{self.organization}+{robot_name}" + self._delete(url) From a9c77b441062386d6bb69389d64c35ae659fe813 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Fri, 16 Jan 2026 16:33:39 -0300 Subject: [PATCH 26/32] Fix get_quay_api_store return type --- reconcile/quay_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reconcile/quay_base.py b/reconcile/quay_base.py index eaafa06079..499ca6941e 100644 --- a/reconcile/quay_base.py +++ b/reconcile/quay_base.py @@ -34,7 +34,7 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.cleanup() -def get_quay_api_store() -> dict[OrgKey, OrgInfo]: +def get_quay_api_store() -> QuayApiStore: """ Returns a dictionary with a key for each Quay organization managed in app-interface. @@ -42,7 +42,7 @@ def get_quay_api_store() -> dict[OrgKey, OrgInfo]: quay_orgs = queries.get_quay_orgs() settings = queries.get_app_interface_settings() secret_reader = SecretReader(settings=settings) - store = {} + store = QuayApiStore() for org_data in quay_orgs: instance_name = org_data["instance"]["name"] org_name = org_data["name"] From ea1aae05b7c66b0c0b64a9edb321620d6c6220d4 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Fri, 16 Jan 2026 16:33:54 -0300 Subject: [PATCH 27/32] Fix integration to use QuayApiStore --- reconcile/quay_robot_accounts.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/reconcile/quay_robot_accounts.py b/reconcile/quay_robot_accounts.py index 0641b7147d..45a492c674 100644 --- a/reconcile/quay_robot_accounts.py +++ b/reconcile/quay_robot_accounts.py @@ -53,7 +53,7 @@ def get_current_robot_accounts( for org_key, org_info in quay_api_store.items(): try: - robots = org_info["api"].list_robot_accounts_detailed() + robots = org_info["api"].list_robot_accounts() current_state[org_key.instance, org_key.org_name] = robots or [] except Exception as e: logging.error( @@ -268,7 +268,9 @@ def calculate_diff( def apply_action( - action: RobotAccountAction, quay_api_store: QuayApiStore, dry_run: bool = False + action: RobotAccountAction, + quay_api_store: QuayApiStore, + dry_run: bool = False, ) -> None: """Apply a single action to Quay""" org_key = next( @@ -335,7 +337,7 @@ def apply_action( raise ValueError( f"Permission is required for set_repo_permission action: {action}" ) - quay_api.set_repo_robot_permissions( + quay_api.set_repo_robot_account_permissions( action.repo, action.robot_name, action.permission ) @@ -347,7 +349,9 @@ def apply_action( raise ValueError( f"Repo is required for set_repo_permissions action: {action}" ) - quay_api.delete_repo_robot_permissions(action.repo, action.robot_name) + quay_api.delete_repo_robot_account_permissions( + action.repo, action.robot_name + ) except Exception as e: logging.error(f"Failed to apply action {action}: {e}") From c35f2174878d4deb9ca1323f1917509be05781f6 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Fri, 16 Jan 2026 16:51:37 -0300 Subject: [PATCH 28/32] Fix: change logging messages to debug --- reconcile/quay_robot_accounts.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/reconcile/quay_robot_accounts.py b/reconcile/quay_robot_accounts.py index 45a492c674..1cf19fcb3b 100644 --- a/reconcile/quay_robot_accounts.py +++ b/reconcile/quay_robot_accounts.py @@ -363,7 +363,7 @@ def run(dry_run: bool = False) -> None: try: # Get GraphQL data robot_accounts = get_robot_accounts_from_gql() - logging.info(f"Found {len(robot_accounts)} robot account definitions") + logging.debug(f"Found {len(robot_accounts)} robot account definitions") # Get Quay API store quay_api_store = get_quay_api_store() @@ -375,26 +375,26 @@ def run(dry_run: bool = False) -> None: desired_state = build_desired_state(robot_accounts) current_state = build_current_state(current_robots, quay_api_store) - logging.info(f"Desired robots: {len(desired_state)}") - logging.info(f"Current robots: {len(current_state)}") + logging.debug(f"Desired robots: {len(desired_state)}") + logging.debug(f"Current robots: {len(current_state)}") # Calculate diff actions = calculate_diff(desired_state, current_state) if not actions: - logging.info("No actions needed") + logging.debug("No actions needed") return - logging.info(f"Found {len(actions)} actions to perform") + logging.debug(f"Found {len(actions)} actions to perform") if dry_run: - logging.info("Running in dry-run mode - no changes will be made") + logging.debug("Running in dry-run mode - no changes will be made") # Apply actions for action in actions: apply_action(action, quay_api_store, dry_run) - logging.info("Integration completed successfully") + logging.debug("Integration completed successfully") except Exception as e: logging.error(f"Integration failed: {e}") From 06a37531613270de15828b445107b1677a105031 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Fri, 16 Jan 2026 16:54:19 -0300 Subject: [PATCH 29/32] Fix: remove unwanted expection handling --- reconcile/quay_robot_accounts.py | 55 ++++++++++++++------------------ 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/reconcile/quay_robot_accounts.py b/reconcile/quay_robot_accounts.py index 1cf19fcb3b..b63e69816c 100644 --- a/reconcile/quay_robot_accounts.py +++ b/reconcile/quay_robot_accounts.py @@ -1,5 +1,4 @@ import logging -import sys from dataclasses import dataclass from reconcile.gql_definitions.quay_robot_accounts.quay_robot_accounts import ( @@ -7,7 +6,6 @@ query, ) from reconcile.quay_base import QuayApiStore, get_quay_api_store -from reconcile.status import ExitCodes from reconcile.utils import gql from reconcile.utils.quay_api import RobotAccountDetails @@ -360,42 +358,37 @@ def apply_action( def run(dry_run: bool = False) -> None: """Main function to run the integration""" - try: - # Get GraphQL data - robot_accounts = get_robot_accounts_from_gql() - logging.debug(f"Found {len(robot_accounts)} robot account definitions") - - # Get Quay API store - quay_api_store = get_quay_api_store() + # Get GraphQL data + robot_accounts = get_robot_accounts_from_gql() + logging.debug(f"Found {len(robot_accounts)} robot account definitions") - # Get current state from Quay - current_robots = get_current_robot_accounts(quay_api_store) + # Get Quay API store + quay_api_store = get_quay_api_store() - # Build states - desired_state = build_desired_state(robot_accounts) - current_state = build_current_state(current_robots, quay_api_store) + # Get current state from Quay + current_robots = get_current_robot_accounts(quay_api_store) - logging.debug(f"Desired robots: {len(desired_state)}") - logging.debug(f"Current robots: {len(current_state)}") + # Build states + desired_state = build_desired_state(robot_accounts) + current_state = build_current_state(current_robots, quay_api_store) - # Calculate diff - actions = calculate_diff(desired_state, current_state) + logging.debug(f"Desired robots: {len(desired_state)}") + logging.debug(f"Current robots: {len(current_state)}") - if not actions: - logging.debug("No actions needed") - return + # Calculate diff + actions = calculate_diff(desired_state, current_state) - logging.debug(f"Found {len(actions)} actions to perform") + if not actions: + logging.debug("No actions needed") + return - if dry_run: - logging.debug("Running in dry-run mode - no changes will be made") + logging.debug(f"Found {len(actions)} actions to perform") - # Apply actions - for action in actions: - apply_action(action, quay_api_store, dry_run) + if dry_run: + logging.debug("Running in dry-run mode - no changes will be made") - logging.debug("Integration completed successfully") + # Apply actions + for action in actions: + apply_action(action, quay_api_store, dry_run) - except Exception as e: - logging.error(f"Integration failed: {e}") - sys.exit(ExitCodes.ERROR) + logging.debug("Integration completed successfully") From e4f0d94a4807825d33fba42365cad300fc315c30 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Mon, 19 Jan 2026 11:57:24 -0300 Subject: [PATCH 30/32] Fix: removes unecessarie type definitions --- reconcile/quay_robot_accounts.py | 3 +-- reconcile/test/test_quay_robot_accounts.py | 26 ++++++++-------------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/reconcile/quay_robot_accounts.py b/reconcile/quay_robot_accounts.py index b63e69816c..950776d6d5 100644 --- a/reconcile/quay_robot_accounts.py +++ b/reconcile/quay_robot_accounts.py @@ -122,9 +122,8 @@ def build_current_state( description = robot_data.get("description") # Get team memberships - teams: set[str] = set() team_permissions = robot_data.get("teams", []) - teams.update(team_perm["name"] for team_perm in team_permissions) + teams = {team_perm["name"] for team_perm in team_permissions} # Get repository permissions repositories = {} diff --git a/reconcile/test/test_quay_robot_accounts.py b/reconcile/test/test_quay_robot_accounts.py index 5aeec7481a..1679ceed35 100644 --- a/reconcile/test/test_quay_robot_accounts.py +++ b/reconcile/test/test_quay_robot_accounts.py @@ -71,9 +71,7 @@ def mock_quay_api_store() -> dict[OrgKey, OrgInfo]: def test_build_desired_state_single_robot(mock_robot_gql: QuayRobotV1) -> None: """Test building desired state with a single robot""" robots = [mock_robot_gql] - desired_state: dict[tuple[str, str, str], RobotAccountState] = build_desired_state( - robots - ) + desired_state = build_desired_state(robots) assert len(desired_state) == 1 key = ("quay-instance", "test-org", "test-robot") @@ -98,9 +96,7 @@ def test_build_desired_state_no_quay_org(mock_robot_gql: QuayRobotV1) -> None: repositories=[], ) - desired_state: dict[tuple[str, str, str], RobotAccountState] = build_desired_state([ - robot - ]) + desired_state = build_desired_state([robot]) assert len(desired_state) == 0 @@ -118,9 +114,7 @@ def test_build_desired_state_empty_teams_repos(mock_robot_gql: QuayRobotV1) -> N repositories=None, ) - desired_state: dict[tuple[str, str, str], RobotAccountState] = build_desired_state([ - robot - ]) + desired_state = build_desired_state([robot]) key = ("quay-instance", "test-org", "test-robot") state = desired_state[key] @@ -134,9 +128,7 @@ def test_build_current_state_single_robot( """Test building current state with a single robot""" current_robots = {("quay-instance", "test-org"): [mock_current_robot]} - current_state: dict[tuple[str, str, str], RobotAccountState] = build_current_state( - current_robots, mock_quay_api_store - ) + current_state = build_current_state(current_robots, mock_quay_api_store) assert len(current_state) == 1 key = ("quay-instance", "test-org", "existing-robot") @@ -171,7 +163,7 @@ def test_build_current_state_empty_robots(mock_quay_api_store: QuayApiStore) -> def test_calculate_diff_create_robot() -> None: """Test calculating diff when robot needs to be created""" - desired_state: dict[tuple[str, str, str], RobotAccountState] = { + desired_state = { ("instance", "org", "new-robot"): RobotAccountState( name="new-robot", description="New robot", @@ -222,7 +214,7 @@ def test_calculate_diff_delete_robot() -> None: def test_calculate_diff_team_changes() -> None: """Test calculating diff for team membership changes""" - desired_state: dict[tuple[str, str, str], RobotAccountState] = { + desired_state = { ("instance", "org", "robot"): RobotAccountState( name="robot", description="Robot", @@ -232,7 +224,7 @@ def test_calculate_diff_team_changes() -> None: repositories={}, ) } - current_state: dict[tuple[str, str, str], RobotAccountState] = { + current_state = { ("instance", "org", "robot"): RobotAccountState( name="robot", description="Robot", @@ -258,7 +250,7 @@ def test_calculate_diff_team_changes() -> None: def test_calculate_diff_repository_changes() -> None: """Test calculating diff for repository permission changes""" - desired_state: dict[tuple[str, str, str], RobotAccountState] = { + desired_state = { ("instance", "org", "robot"): RobotAccountState( name="robot", description="Robot", @@ -271,7 +263,7 @@ def test_calculate_diff_repository_changes() -> None: }, # change repo1, add repo3, remove repo2 ) } - current_state: dict[tuple[str, str, str], RobotAccountState] = { + current_state = { ("instance", "org", "robot"): RobotAccountState( name="robot", description="Robot", From 0c21c94fde3bc4db9ff0d9f9568566b6693f40eb Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Mon, 19 Jan 2026 15:17:18 -0300 Subject: [PATCH 31/32] Fix: use enum on RobotAccountActions --- reconcile/quay_robot_accounts.py | 46 ++++++++++-------- reconcile/test/test_quay_robot_accounts.py | 55 +++++++++++++--------- 2 files changed, 60 insertions(+), 41 deletions(-) diff --git a/reconcile/quay_robot_accounts.py b/reconcile/quay_robot_accounts.py index 950776d6d5..6cc0ad6e24 100644 --- a/reconcile/quay_robot_accounts.py +++ b/reconcile/quay_robot_accounts.py @@ -1,5 +1,6 @@ import logging from dataclasses import dataclass +from enum import Enum from reconcile.gql_definitions.quay_robot_accounts.quay_robot_accounts import ( QuayRobotV1, @@ -12,6 +13,15 @@ QONTRACT_INTEGRATION = "quay-robot-accounts" +class RobotAccountActionType(Enum): + CREATE = "create" + DELETE = "delete" + ADD_TEAM = "add_team" + REMOVE_TEAM = "remove_team" + SET_REPO_PERMISSION = "set_repo_permission" + REMOVE_REPO_PERMISSION = "remove_repo_permission" + + @dataclass class RobotAccountState: """Represents the state of a robot account""" @@ -28,7 +38,7 @@ class RobotAccountState: class RobotAccountAction: """Represents an action to be performed on a robot account""" - action: str # 'create', 'delete', 'add_team', 'remove_team', 'set_repo_permission', 'remove_repo_permission' + action: RobotAccountActionType robot_name: str org_name: str instance_name: str @@ -157,7 +167,7 @@ def calculate_diff( if key not in current_state: actions.append( RobotAccountAction( - action="create", + action=RobotAccountActionType.CREATE, robot_name=desired.name, org_name=desired.org_name, instance_name=desired.instance_name, @@ -167,7 +177,7 @@ def calculate_diff( # Add team assignments for new robot actions.extend([ RobotAccountAction( - action="add_team", + action=RobotAccountActionType.ADD_TEAM, robot_name=desired.name, org_name=desired.org_name, instance_name=desired.instance_name, @@ -179,7 +189,7 @@ def calculate_diff( # Add repository permissions for new robot actions.extend([ RobotAccountAction( - action="set_repo_permission", + action=RobotAccountActionType.SET_REPO_PERMISSION, robot_name=desired.name, org_name=desired.org_name, instance_name=desired.instance_name, @@ -197,7 +207,7 @@ def calculate_diff( actions.extend([ RobotAccountAction( - action="add_team", + action=RobotAccountActionType.ADD_TEAM, robot_name=desired.name, org_name=desired.org_name, instance_name=desired.instance_name, @@ -208,7 +218,7 @@ def calculate_diff( actions.extend([ RobotAccountAction( - action="remove_team", + action=RobotAccountActionType.REMOVE_TEAM, robot_name=desired.name, org_name=desired.org_name, instance_name=desired.instance_name, @@ -224,7 +234,7 @@ def calculate_diff( # Repositories to add or update permissions actions.extend( RobotAccountAction( - action="set_repo_permission", + action=RobotAccountActionType.SET_REPO_PERMISSION, robot_name=desired.name, org_name=desired.org_name, instance_name=desired.instance_name, @@ -240,7 +250,7 @@ def calculate_diff( repos_to_remove = current_repos - desired_repos actions.extend([ RobotAccountAction( - action="remove_repo_permission", + action=RobotAccountActionType.REMOVE_REPO_PERMISSION, robot_name=desired.name, org_name=desired.org_name, instance_name=desired.instance_name, @@ -254,7 +264,7 @@ def calculate_diff( if key not in desired_state: actions.append( RobotAccountAction( - action="delete", + action=RobotAccountActionType.DELETE, robot_name=current.name, org_name=current.org_name, instance_name=current.instance_name, @@ -289,20 +299,20 @@ def apply_action( logging.info(f"[DRY RUN] Would perform: {action}") return - try: - if action.action == "create": + match action.action: + case RobotAccountActionType.CREATE: logging.info( f"Creating robot account {action.robot_name} in {action.org_name}" ) quay_api.create_robot_account(action.robot_name, "") - elif action.action == "delete": + case RobotAccountActionType.DELETE: logging.info( f"Deleting robot account {action.robot_name} from {action.org_name}" ) quay_api.delete_robot_account(action.robot_name) - elif action.action == "add_team": + case RobotAccountActionType.ADD_TEAM: logging.info( f"Adding robot {action.robot_name} to team {action.team} in {action.org_name}" ) @@ -312,7 +322,7 @@ def apply_action( f"{action.org_name}+{action.robot_name}", action.team ) - elif action.action == "remove_team": + case RobotAccountActionType.REMOVE_TEAM: logging.info( f"Removing robot {action.robot_name} from team {action.team} in {action.org_name}" ) @@ -322,7 +332,7 @@ def apply_action( f"{action.org_name}+{action.robot_name}", action.team ) - elif action.action == "set_repo_permission": + case RobotAccountActionType.SET_REPO_PERMISSION: logging.info( f"Setting {action.permission} permission for robot {action.robot_name} on repo {action.repo}" ) @@ -338,7 +348,7 @@ def apply_action( action.repo, action.robot_name, action.permission ) - elif action.action == "remove_repo_permission": + case RobotAccountActionType.REMOVE_REPO_PERMISSION: logging.info( f"Removing permissions for robot {action.robot_name} from repo {action.repo}" ) @@ -350,10 +360,6 @@ def apply_action( action.repo, action.robot_name ) - except Exception as e: - logging.error(f"Failed to apply action {action}: {e}") - raise - def run(dry_run: bool = False) -> None: """Main function to run the integration""" diff --git a/reconcile/test/test_quay_robot_accounts.py b/reconcile/test/test_quay_robot_accounts.py index 1679ceed35..a605581b18 100644 --- a/reconcile/test/test_quay_robot_accounts.py +++ b/reconcile/test/test_quay_robot_accounts.py @@ -12,6 +12,7 @@ from reconcile.quay_base import OrgInfo, OrgKey, QuayApiStore from reconcile.quay_robot_accounts import ( RobotAccountAction, + RobotAccountActionType, RobotAccountState, apply_action, build_current_state, @@ -179,14 +180,20 @@ def test_calculate_diff_create_robot() -> None: assert len(actions) == 3 # create, add_team, set_repo_permission - create_action = next(a for a in actions if a.action == "create") + create_action = next( + a for a in actions if a.action == RobotAccountActionType.CREATE + ) assert create_action.robot_name == "new-robot" assert create_action.org_name == "org" - team_action = next(a for a in actions if a.action == "add_team") + team_action = next( + a for a in actions if a.action == RobotAccountActionType.ADD_TEAM + ) assert team_action.team == "team1" - repo_action = next(a for a in actions if a.action == "set_repo_permission") + repo_action = next( + a for a in actions if a.action == RobotAccountActionType.SET_REPO_PERMISSION + ) assert repo_action.repo == "repo1" assert repo_action.permission == "read" @@ -208,7 +215,7 @@ def test_calculate_diff_delete_robot() -> None: actions = calculate_diff(desired_state, current_state) assert len(actions) == 1 - assert actions[0].action == "delete" + assert actions[0].action == RobotAccountActionType.DELETE assert actions[0].robot_name == "old-robot" @@ -238,13 +245,15 @@ def test_calculate_diff_team_changes() -> None: actions = calculate_diff(desired_state, current_state) action_types = [a.action for a in actions] - assert "add_team" in action_types - assert "remove_team" in action_types + assert RobotAccountActionType.ADD_TEAM in action_types + assert RobotAccountActionType.REMOVE_TEAM in action_types - add_action = next(a for a in actions if a.action == "add_team") + add_action = next(a for a in actions if a.action == RobotAccountActionType.ADD_TEAM) assert add_action.team == "team3" - remove_action = next(a for a in actions if a.action == "remove_team") + remove_action = next( + a for a in actions if a.action == RobotAccountActionType.REMOVE_TEAM + ) assert remove_action.team == "team2" @@ -280,13 +289,17 @@ def test_calculate_diff_repository_changes() -> None: actions = calculate_diff(desired_state, current_state) action_types = [a.action for a in actions] - assert "set_repo_permission" in action_types - assert "remove_repo_permission" in action_types + assert RobotAccountActionType.SET_REPO_PERMISSION in action_types + assert RobotAccountActionType.REMOVE_REPO_PERMISSION in action_types - set_actions = [a for a in actions if a.action == "set_repo_permission"] + set_actions = [ + a for a in actions if a.action == RobotAccountActionType.SET_REPO_PERMISSION + ] assert len(set_actions) == 2 # repo1 permission change, repo3 new - remove_action = next(a for a in actions if a.action == "remove_repo_permission") + remove_action = next( + a for a in actions if a.action == RobotAccountActionType.REMOVE_REPO_PERMISSION + ) assert remove_action.repo == "repo2" @@ -349,7 +362,7 @@ def test_get_current_robot_accounts_exception( def test_apply_action_create_robot(mock_quay_api_store: QuayApiStore) -> None: """Test applying create robot action""" action = RobotAccountAction( - action="create", + action=RobotAccountActionType.CREATE, robot_name="new-robot", org_name="test-org", instance_name="quay-instance", @@ -364,7 +377,7 @@ def test_apply_action_create_robot(mock_quay_api_store: QuayApiStore) -> None: def test_apply_action_delete_robot(mock_quay_api_store: QuayApiStore) -> None: """Test applying delete robot action""" action = RobotAccountAction( - action="delete", + action=RobotAccountActionType.DELETE, robot_name="old-robot", org_name="test-org", instance_name="quay-instance", @@ -379,7 +392,7 @@ def test_apply_action_delete_robot(mock_quay_api_store: QuayApiStore) -> None: def test_apply_action_add_team(mock_quay_api_store: QuayApiStore) -> None: """Test applying add team action""" action = RobotAccountAction( - action="add_team", + action=RobotAccountActionType.ADD_TEAM, robot_name="robot", org_name="test-org", instance_name="quay-instance", @@ -395,7 +408,7 @@ def test_apply_action_add_team(mock_quay_api_store: QuayApiStore) -> None: def test_apply_action_remove_team(mock_quay_api_store: QuayApiStore) -> None: """Test applying remove team action""" action = RobotAccountAction( - action="remove_team", + action=RobotAccountActionType.REMOVE_TEAM, robot_name="robot", org_name="test-org", instance_name="quay-instance", @@ -411,7 +424,7 @@ def test_apply_action_remove_team(mock_quay_api_store: QuayApiStore) -> None: def test_apply_action_set_repo_permission(mock_quay_api_store: QuayApiStore) -> None: """Test applying set repository permission action""" action = RobotAccountAction( - action="set_repo_permission", + action=RobotAccountActionType.SET_REPO_PERMISSION, robot_name="robot", org_name="test-org", instance_name="quay-instance", @@ -430,7 +443,7 @@ def test_apply_action_set_repo_permission(mock_quay_api_store: QuayApiStore) -> def test_apply_action_remove_repo_permission(mock_quay_api_store: QuayApiStore) -> None: """Test applying remove repository permission action""" action = RobotAccountAction( - action="remove_repo_permission", + action=RobotAccountActionType.REMOVE_REPO_PERMISSION, robot_name="robot", org_name="test-org", instance_name="quay-instance", @@ -448,7 +461,7 @@ def test_apply_action_remove_repo_permission(mock_quay_api_store: QuayApiStore) def test_apply_action_dry_run(mock_quay_api_store: QuayApiStore) -> None: """Test applying action in dry run mode""" action = RobotAccountAction( - action="create", + action=RobotAccountActionType.CREATE, robot_name="new-robot", org_name="test-org", instance_name="quay-instance", @@ -463,7 +476,7 @@ def test_apply_action_dry_run(mock_quay_api_store: QuayApiStore) -> None: def test_apply_action_no_org_key(mock_quay_api_store: QuayApiStore) -> None: """Test applying action when org key is not found""" action = RobotAccountAction( - action="create", + action=RobotAccountActionType.CREATE, robot_name="new-robot", org_name="unknown-org", instance_name="unknown-instance", @@ -481,7 +494,7 @@ def test_apply_action_exception_handling(mock_quay_api_store: QuayApiStore) -> N mock_api.create_robot_account.side_effect = Exception("API Error") # type: ignore action = RobotAccountAction( - action="create", + action=RobotAccountActionType.CREATE, robot_name="new-robot", org_name="test-org", instance_name="quay-instance", From 457ed709242fbd24228c64aacb8ba93c211710b5 Mon Sep 17 00:00:00 2001 From: Esron Silva Date: Mon, 19 Jan 2026 17:44:53 -0300 Subject: [PATCH 32/32] Fix: use mock_quay_api as a fixture to improve tests --- reconcile/test/test_quay_robot_accounts.py | 80 +++++++++++++--------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/reconcile/test/test_quay_robot_accounts.py b/reconcile/test/test_quay_robot_accounts.py index a605581b18..f26d05b475 100644 --- a/reconcile/test/test_quay_robot_accounts.py +++ b/reconcile/test/test_quay_robot_accounts.py @@ -54,7 +54,13 @@ def mock_current_robot() -> RobotAccountDetails: @pytest.fixture -def mock_quay_api_store() -> dict[OrgKey, OrgInfo]: +def mock_quay_api() -> QuayApi: + """Mock QuayApi""" + return create_autospec(QuayApi) + + +@pytest.fixture +def mock_quay_api_store(mock_quay_api: QuayApi) -> dict[OrgKey, OrgInfo]: """Mock QuayApiStore""" return { OrgKey("quay-instance", "test-org"): OrgInfo( @@ -64,7 +70,7 @@ def mock_quay_api_store() -> dict[OrgKey, OrgInfo]: managedRepos=False, mirror=None, mirror_filters={}, - api=create_autospec(QuayApi), + api=mock_quay_api, ) } @@ -320,7 +326,9 @@ def test_calculate_diff_no_changes() -> None: assert len(actions) == 0 -def test_get_current_robot_accounts_success(mock_quay_api_store: QuayApiStore) -> None: +def test_get_current_robot_accounts_success( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test successful fetching of current robot accounts""" mock_robots = [ RobotAccountDetails( @@ -336,8 +344,8 @@ def test_get_current_robot_accounts_success(mock_quay_api_store: QuayApiStore) - repositories=[{"name": "repo2", "role": "write"}], ), ] - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.list_robot_accounts.return_value = mock_robots # type: ignore + + mock_quay_api.list_robot_accounts.return_value = mock_robots # type: ignore result = get_current_robot_accounts(mock_quay_api_store) @@ -347,11 +355,11 @@ def test_get_current_robot_accounts_success(mock_quay_api_store: QuayApiStore) - def test_get_current_robot_accounts_exception( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore, ) -> None: """Test handling of exceptions when fetching robot accounts""" - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.list_robot_accounts.side_effect = Exception("API Error") # type: ignore + mock_quay_api.list_robot_accounts.side_effect = Exception("API Error") # type: ignore result = get_current_robot_accounts(mock_quay_api_store) @@ -359,7 +367,9 @@ def test_get_current_robot_accounts_exception( assert result["quay-instance", "test-org"] == [] -def test_apply_action_create_robot(mock_quay_api_store: QuayApiStore) -> None: +def test_apply_action_create_robot( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test applying create robot action""" action = RobotAccountAction( action=RobotAccountActionType.CREATE, @@ -370,11 +380,12 @@ def test_apply_action_create_robot(mock_quay_api_store: QuayApiStore) -> None: apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.create_robot_account.assert_called_once_with("new-robot", "") # type: ignore + mock_quay_api.create_robot_account.assert_called_once_with("new-robot", "") # type: ignore -def test_apply_action_delete_robot(mock_quay_api_store: QuayApiStore) -> None: +def test_apply_action_delete_robot( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test applying delete robot action""" action = RobotAccountAction( action=RobotAccountActionType.DELETE, @@ -385,11 +396,12 @@ def test_apply_action_delete_robot(mock_quay_api_store: QuayApiStore) -> None: apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.delete_robot_account.assert_called_once_with("old-robot") # type: ignore + mock_quay_api.delete_robot_account.assert_called_once_with("old-robot") # type: ignore -def test_apply_action_add_team(mock_quay_api_store: QuayApiStore) -> None: +def test_apply_action_add_team( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test applying add team action""" action = RobotAccountAction( action=RobotAccountActionType.ADD_TEAM, @@ -401,11 +413,12 @@ def test_apply_action_add_team(mock_quay_api_store: QuayApiStore) -> None: apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.add_user_to_team.assert_called_once_with("test-org+robot", "new-team") # type: ignore + mock_quay_api.add_user_to_team.assert_called_once_with("test-org+robot", "new-team") # type: ignore -def test_apply_action_remove_team(mock_quay_api_store: QuayApiStore) -> None: +def test_apply_action_remove_team( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test applying remove team action""" action = RobotAccountAction( action=RobotAccountActionType.REMOVE_TEAM, @@ -417,11 +430,14 @@ def test_apply_action_remove_team(mock_quay_api_store: QuayApiStore) -> None: apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.remove_user_from_team.assert_called_once_with("test-org+robot", "old-team") # type: ignore + mock_quay_api.remove_user_from_team.assert_called_once_with( # type: ignore + "test-org+robot", "old-team" + ) -def test_apply_action_set_repo_permission(mock_quay_api_store: QuayApiStore) -> None: +def test_apply_action_set_repo_permission( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test applying set repository permission action""" action = RobotAccountAction( action=RobotAccountActionType.SET_REPO_PERMISSION, @@ -434,13 +450,14 @@ def test_apply_action_set_repo_permission(mock_quay_api_store: QuayApiStore) -> apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.set_repo_robot_account_permissions.assert_called_once_with( # type: ignore + mock_quay_api.set_repo_robot_account_permissions.assert_called_once_with( # type: ignore "repo1", "robot", "write" ) -def test_apply_action_remove_repo_permission(mock_quay_api_store: QuayApiStore) -> None: +def test_apply_action_remove_repo_permission( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test applying remove repository permission action""" action = RobotAccountAction( action=RobotAccountActionType.REMOVE_REPO_PERMISSION, @@ -452,13 +469,14 @@ def test_apply_action_remove_repo_permission(mock_quay_api_store: QuayApiStore) apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.delete_repo_robot_account_permissions.assert_called_once_with( # type: ignore + mock_quay_api.delete_repo_robot_account_permissions.assert_called_once_with( # type: ignore "repo1", "robot" ) -def test_apply_action_dry_run(mock_quay_api_store: QuayApiStore) -> None: +def test_apply_action_dry_run( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test applying action in dry run mode""" action = RobotAccountAction( action=RobotAccountActionType.CREATE, @@ -469,11 +487,12 @@ def test_apply_action_dry_run(mock_quay_api_store: QuayApiStore) -> None: apply_action(action, mock_quay_api_store, dry_run=True) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.create_robot_account.assert_not_called() # type: ignore + mock_quay_api.create_robot_account.assert_not_called() # type: ignore -def test_apply_action_no_org_key(mock_quay_api_store: QuayApiStore) -> None: +def test_apply_action_no_org_key( + mock_quay_api: QuayApi, mock_quay_api_store: QuayApiStore +) -> None: """Test applying action when org key is not found""" action = RobotAccountAction( action=RobotAccountActionType.CREATE, @@ -484,8 +503,7 @@ def test_apply_action_no_org_key(mock_quay_api_store: QuayApiStore) -> None: apply_action(action, mock_quay_api_store, dry_run=False) - mock_api = mock_quay_api_store[next(iter(mock_quay_api_store.keys()))]["api"] - mock_api.create_robot_account.assert_not_called() # type: ignore + mock_quay_api.create_robot_account.assert_not_called() # type: ignore def test_apply_action_exception_handling(mock_quay_api_store: QuayApiStore) -> None: