diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index de00b68f3b87..863237977040 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -782,10 +782,9 @@ in { networking.scripted = handleTest ./networking/networkd-and-scripted.nix { networkd = false; }; networking.networkd = handleTest ./networking/networkd-and-scripted.nix { networkd = true; }; networking.networkmanager = handleTest ./networking/networkmanager.nix {}; - netbox_3_6 = handleTest ./web-apps/netbox.nix { netbox = pkgs.netbox_3_6; }; - netbox_3_7 = handleTest ./web-apps/netbox.nix { netbox = pkgs.netbox_3_7; }; - netbox_4_1 = handleTest ./web-apps/netbox.nix { netbox = pkgs.netbox_4_1; }; - netbox_4_2 = handleTest ./web-apps/netbox.nix { netbox = pkgs.netbox_4_2; }; + netbox_3_7 = handleTest ./web-apps/netbox/default.nix { netbox = pkgs.netbox_3_7; }; + netbox_4_1 = handleTest ./web-apps/netbox/default.nix { netbox = pkgs.netbox_4_1; }; + netbox_4_2 = handleTest ./web-apps/netbox/default.nix { netbox = pkgs.netbox_4_2; }; netbox-upgrade = handleTest ./web-apps/netbox-upgrade.nix {}; # TODO: put in networking.nix after the test becomes more complete networkingProxy = handleTest ./networking-proxy.nix {}; diff --git a/nixos/tests/web-apps/netbox.nix b/nixos/tests/web-apps/netbox.nix deleted file mode 100644 index bc3fec264543..000000000000 --- a/nixos/tests/web-apps/netbox.nix +++ /dev/null @@ -1,331 +0,0 @@ -let - ldapDomain = "example.org"; - ldapSuffix = "dc=example,dc=org"; - - ldapRootUser = "admin"; - ldapRootPassword = "foobar"; - - testUser = "alice"; - testPassword = "verySecure"; - testGroup = "netbox-users"; -in -import ../make-test-python.nix ( - { - lib, - pkgs, - netbox, - ... - }: - { - name = "netbox"; - - meta = with lib.maintainers; { - maintainers = [ - minijackson - ]; - }; - - nodes.machine = - { config, ... }: - { - virtualisation.memorySize = 2048; - services.netbox = { - enable = true; - package = netbox; - secretKeyFile = pkgs.writeText "secret" '' - abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 - ''; - - enableLdap = true; - ldapConfigPath = pkgs.writeText "ldap_config.py" '' - import ldap - from django_auth_ldap.config import LDAPSearch, PosixGroupType - - AUTH_LDAP_SERVER_URI = "ldap://localhost/" - - AUTH_LDAP_USER_SEARCH = LDAPSearch( - "ou=accounts,ou=posix,${ldapSuffix}", - ldap.SCOPE_SUBTREE, - "(uid=%(user)s)", - ) - - AUTH_LDAP_GROUP_SEARCH = LDAPSearch( - "ou=groups,ou=posix,${ldapSuffix}", - ldap.SCOPE_SUBTREE, - "(objectClass=posixGroup)", - ) - AUTH_LDAP_GROUP_TYPE = PosixGroupType() - - # Mirror LDAP group assignments. - AUTH_LDAP_MIRROR_GROUPS = True - - # For more granular permissions, we can map LDAP groups to Django groups. - AUTH_LDAP_FIND_GROUP_PERMS = True - ''; - }; - - services.nginx = { - enable = true; - - recommendedProxySettings = true; - - virtualHosts.netbox = { - default = true; - locations."/".proxyPass = "http://localhost:${toString config.services.netbox.port}"; - locations."/static/".alias = "/var/lib/netbox/static/"; - }; - }; - - # Adapted from the sssd-ldap NixOS test - services.openldap = { - enable = true; - settings = { - children = { - "cn=schema".includes = [ - "${pkgs.openldap}/etc/schema/core.ldif" - "${pkgs.openldap}/etc/schema/cosine.ldif" - "${pkgs.openldap}/etc/schema/inetorgperson.ldif" - "${pkgs.openldap}/etc/schema/nis.ldif" - ]; - "olcDatabase={1}mdb" = { - attrs = { - objectClass = [ - "olcDatabaseConfig" - "olcMdbConfig" - ]; - olcDatabase = "{1}mdb"; - olcDbDirectory = "/var/lib/openldap/db"; - olcSuffix = ldapSuffix; - olcRootDN = "cn=${ldapRootUser},${ldapSuffix}"; - olcRootPW = ldapRootPassword; - }; - }; - }; - }; - declarativeContents = { - ${ldapSuffix} = '' - dn: ${ldapSuffix} - objectClass: top - objectClass: dcObject - objectClass: organization - o: ${ldapDomain} - - dn: ou=posix,${ldapSuffix} - objectClass: top - objectClass: organizationalUnit - - dn: ou=accounts,ou=posix,${ldapSuffix} - objectClass: top - objectClass: organizationalUnit - - dn: uid=${testUser},ou=accounts,ou=posix,${ldapSuffix} - objectClass: person - objectClass: posixAccount - userPassword: ${testPassword} - homeDirectory: /home/${testUser} - uidNumber: 1234 - gidNumber: 1234 - cn: "" - sn: "" - - dn: ou=groups,ou=posix,${ldapSuffix} - objectClass: top - objectClass: organizationalUnit - - dn: cn=${testGroup},ou=groups,ou=posix,${ldapSuffix} - objectClass: posixGroup - gidNumber: 2345 - memberUid: ${testUser} - ''; - }; - }; - - users.users.nginx.extraGroups = [ "netbox" ]; - - networking.firewall.allowedTCPPorts = [ 80 ]; - }; - - testScript = - let - changePassword = pkgs.writeText "change-password.py" '' - from users.models import User - u = User.objects.get(username='netbox') - u.set_password('netbox') - u.save() - ''; - in - '' - from typing import Any, Dict - import json - - start_all() - machine.wait_for_unit("netbox.target") - machine.wait_until_succeeds("journalctl --since -1m --unit netbox --grep Listening") - - with subtest("Home screen loads"): - machine.succeed( - "curl -sSfL http://[::1]:8001 | grep 'Home | NetBox'" - ) - - with subtest("Staticfiles are generated"): - machine.succeed("test -e /var/lib/netbox/static/netbox.js") - - with subtest("Superuser can be created"): - machine.succeed( - "netbox-manage createsuperuser --noinput --username netbox --email netbox@example.com" - ) - # Django doesn't have a "clean" way of inputting the password from the command line - machine.succeed("cat '${changePassword}' | netbox-manage shell") - - machine.wait_for_unit("network.target") - - with subtest("Home screen loads from nginx"): - machine.succeed( - "curl -sSfL http://localhost | grep 'Home | NetBox'" - ) - - with subtest("Staticfiles can be fetched"): - machine.succeed("curl -sSfL http://localhost/static/netbox.js") - machine.succeed("curl -sSfL http://localhost/static/docs/") - - def login(username: str, password: str): - encoded_data = json.dumps({"username": username, "password": password}) - uri = "/users/tokens/provision/" - result = json.loads( - machine.succeed( - "curl -sSfL " - "-X POST " - "-H 'Accept: application/json' " - "-H 'Content-Type: application/json' " - f"'http://localhost/api{uri}' " - f"--data '{encoded_data}'" - ) - ) - return result["key"] - - with subtest("Can login"): - auth_token = login("netbox", "netbox") - - def get(uri: str): - return json.loads( - machine.succeed( - "curl -sSfL " - "-H 'Accept: application/json' " - f"-H 'Authorization: Token {auth_token}' " - f"'http://localhost/api{uri}'" - ) - ) - - def delete(uri: str): - return machine.succeed( - "curl -sSfL " - f"-X DELETE " - "-H 'Accept: application/json' " - f"-H 'Authorization: Token {auth_token}' " - f"'http://localhost/api{uri}'" - ) - - - def data_request(uri: str, method: str, data: Dict[str, Any]): - encoded_data = json.dumps(data) - return json.loads( - machine.succeed( - "curl -sSfL " - f"-X {method} " - "-H 'Accept: application/json' " - "-H 'Content-Type: application/json' " - f"-H 'Authorization: Token {auth_token}' " - f"'http://localhost/api{uri}' " - f"--data '{encoded_data}'" - ) - ) - - def post(uri: str, data: Dict[str, Any]): - return data_request(uri, "POST", data) - - def patch(uri: str, data: Dict[str, Any]): - return data_request(uri, "PATCH", data) - - with subtest("Can create objects"): - result = post("/dcim/sites/", {"name": "Test site", "slug": "test-site"}) - site_id = result["id"] - - # Example from: - # http://netbox.extra.cea.fr/static/docs/integrations/rest-api/#creating-a-new-object - post("/ipam/prefixes/", {"prefix": "192.0.2.0/24", "site": site_id}) - - result = post( - "/dcim/manufacturers/", - {"name": "Test manufacturer", "slug": "test-manufacturer"} - ) - manufacturer_id = result["id"] - - # Had an issue with device-types before NetBox 3.4.0 - result = post( - "/dcim/device-types/", - { - "model": "Test device type", - "manufacturer": manufacturer_id, - "slug": "test-device-type", - }, - ) - device_type_id = result["id"] - - with subtest("Can list objects"): - result = get("/dcim/sites/") - - assert result["count"] == 1 - assert result["results"][0]["id"] == site_id - assert result["results"][0]["name"] == "Test site" - assert result["results"][0]["description"] == "" - - result = get("/dcim/device-types/") - assert result["count"] == 1 - assert result["results"][0]["id"] == device_type_id - assert result["results"][0]["model"] == "Test device type" - - with subtest("Can update objects"): - new_description = "Test site description" - patch(f"/dcim/sites/{site_id}/", {"description": new_description}) - result = get(f"/dcim/sites/{site_id}/") - assert result["description"] == new_description - - with subtest("Can delete objects"): - # Delete a device-type since no object depends on it - delete(f"/dcim/device-types/{device_type_id}/") - - result = get("/dcim/device-types/") - assert result["count"] == 0 - - with subtest("Can use the GraphQL API"): - encoded_data = json.dumps({ - "query": "query { prefix_list { prefix, site { id, description } } }", - }) - result = json.loads( - machine.succeed( - "curl -sSfL " - "-H 'Accept: application/json' " - "-H 'Content-Type: application/json' " - f"-H 'Authorization: Token {auth_token}' " - "'http://localhost/graphql/' " - f"--data '{encoded_data}'" - ) - ) - - assert len(result["data"]["prefix_list"]) == 1 - assert result["data"]["prefix_list"][0]["prefix"] == "192.0.2.0/24" - assert result["data"]["prefix_list"][0]["site"]["id"] == str(site_id) - assert result["data"]["prefix_list"][0]["site"]["description"] == new_description - - with subtest("Can login with LDAP"): - machine.wait_for_unit("openldap.service") - login("alice", "${testPassword}") - - with subtest("Can associate LDAP groups"): - result = get("/users/users/?username=${testUser}") - - assert result["count"] == 1 - assert any(group["name"] == "${testGroup}" for group in result["results"][0]["groups"]) - ''; - } -) diff --git a/nixos/tests/web-apps/netbox/default.nix b/nixos/tests/web-apps/netbox/default.nix new file mode 100644 index 000000000000..e9636b3c6eea --- /dev/null +++ b/nixos/tests/web-apps/netbox/default.nix @@ -0,0 +1,164 @@ +let + ldapDomain = "example.org"; + ldapSuffix = "dc=example,dc=org"; + + ldapRootUser = "admin"; + ldapRootPassword = "foobar"; + + testUser = "alice"; + testPassword = "verySecure"; + testGroup = "netbox-users"; +in +import ../../make-test-python.nix ( + { + lib, + pkgs, + netbox, + ... + }: + { + name = "netbox"; + + meta = with lib.maintainers; { + maintainers = [ + minijackson + ]; + }; + + skipTypeCheck = true; + + nodes.machine = + { config, ... }: + { + virtualisation.memorySize = 2048; + services.netbox = { + enable = true; + package = netbox; + secretKeyFile = pkgs.writeText "secret" '' + abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 + ''; + + enableLdap = true; + ldapConfigPath = pkgs.writeText "ldap_config.py" '' + import ldap + from django_auth_ldap.config import LDAPSearch, PosixGroupType + + AUTH_LDAP_SERVER_URI = "ldap://localhost/" + + AUTH_LDAP_USER_SEARCH = LDAPSearch( + "ou=accounts,ou=posix,${ldapSuffix}", + ldap.SCOPE_SUBTREE, + "(uid=%(user)s)", + ) + + AUTH_LDAP_GROUP_SEARCH = LDAPSearch( + "ou=groups,ou=posix,${ldapSuffix}", + ldap.SCOPE_SUBTREE, + "(objectClass=posixGroup)", + ) + AUTH_LDAP_GROUP_TYPE = PosixGroupType() + + # Mirror LDAP group assignments. + AUTH_LDAP_MIRROR_GROUPS = True + + # For more granular permissions, we can map LDAP groups to Django groups. + AUTH_LDAP_FIND_GROUP_PERMS = True + ''; + }; + + services.nginx = { + enable = true; + + recommendedProxySettings = true; + + virtualHosts.netbox = { + default = true; + locations."/".proxyPass = "http://localhost:${toString config.services.netbox.port}"; + locations."/static/".alias = "/var/lib/netbox/static/"; + }; + }; + + # Adapted from the sssd-ldap NixOS test + services.openldap = { + enable = true; + settings = { + children = { + "cn=schema".includes = [ + "${pkgs.openldap}/etc/schema/core.ldif" + "${pkgs.openldap}/etc/schema/cosine.ldif" + "${pkgs.openldap}/etc/schema/inetorgperson.ldif" + "${pkgs.openldap}/etc/schema/nis.ldif" + ]; + "olcDatabase={1}mdb" = { + attrs = { + objectClass = [ + "olcDatabaseConfig" + "olcMdbConfig" + ]; + olcDatabase = "{1}mdb"; + olcDbDirectory = "/var/lib/openldap/db"; + olcSuffix = ldapSuffix; + olcRootDN = "cn=${ldapRootUser},${ldapSuffix}"; + olcRootPW = ldapRootPassword; + }; + }; + }; + }; + declarativeContents = { + ${ldapSuffix} = '' + dn: ${ldapSuffix} + objectClass: top + objectClass: dcObject + objectClass: organization + o: ${ldapDomain} + + dn: ou=posix,${ldapSuffix} + objectClass: top + objectClass: organizationalUnit + + dn: ou=accounts,ou=posix,${ldapSuffix} + objectClass: top + objectClass: organizationalUnit + + dn: uid=${testUser},ou=accounts,ou=posix,${ldapSuffix} + objectClass: person + objectClass: posixAccount + userPassword: ${testPassword} + homeDirectory: /home/${testUser} + uidNumber: 1234 + gidNumber: 1234 + cn: "" + sn: "" + + dn: ou=groups,ou=posix,${ldapSuffix} + objectClass: top + objectClass: organizationalUnit + + dn: cn=${testGroup},ou=groups,ou=posix,${ldapSuffix} + objectClass: posixGroup + gidNumber: 2345 + memberUid: ${testUser} + ''; + }; + }; + + users.users.nginx.extraGroups = [ "netbox" ]; + + networking.firewall.allowedTCPPorts = [ 80 ]; + }; + + testScript = + let + changePassword = pkgs.writeText "change-password.py" '' + from users.models import User + u = User.objects.get(username='netbox') + u.set_password('netbox') + u.save() + ''; + in + builtins.replaceStrings + [ "$\{changePassword}" "$\{testUser}" "$\{testPassword}" "$\{testGroup}" ] + [ "${changePassword}" "${testUser}" "${testPassword}" "${testGroup}" ] + (lib.readFile "${./testScript.py}"); + } +) diff --git a/nixos/tests/web-apps/netbox/testScript.py b/nixos/tests/web-apps/netbox/testScript.py new file mode 100644 index 000000000000..7d0f9bff10c7 --- /dev/null +++ b/nixos/tests/web-apps/netbox/testScript.py @@ -0,0 +1,274 @@ +from typing import Any, Dict +import json + +start_all() +machine.wait_for_unit("netbox.target") +machine.wait_until_succeeds("journalctl --since -1m --unit netbox --grep Listening") + +test_objects = { + "sites": { + "test-site": { + "name": "Test site", + "slug": "test-site" + }, + "test-site-two": { + "name": "Test site 2", + "slug": "test-site-second-edition" + } + }, + "prefixes": { + "v4-with-updated-desc": { + "prefix": "192.0.2.0/24", + "class_type": "Prefix", + "family": { "label": "IPv4" }, + "scope": { + "__typename": "SiteType", + "id": "1", + "description": "Test site description" + } + }, + "v6-cidr-32": { + "prefix": "2001:db8::/32", + "class_type": "Prefix", + "family": { "label": "IPv6" }, + "scope": { + "__typename": "SiteType", + "id": "1", + "description": "Test site description" + } + }, + "v6-cidr-48": { + "prefix": "2001:db8:c0fe::/48", + "class_type": "Prefix", + "family": { "label": "IPv6" }, + "scope": { + "__typename": "SiteType", + "id": "1", + "description": "Test site description" + } + } + } +} + +def compare(a: str, b: str): + differences = [(x - y) for (x,y) in list(zip( + list(map(int, a.split('.'))), + list(map(int, b.split('.'))) + ))] + for d in differences: + if d != 0: + return d + return 0 + +with subtest("Home screen loads"): + machine.succeed( + "curl -sSfL http://[::1]:8001 | grep 'Home | NetBox'" + ) + +with subtest("Staticfiles are generated"): + machine.succeed("test -e /var/lib/netbox/static/netbox.js") + +with subtest("Superuser can be created"): + machine.succeed( + "netbox-manage createsuperuser --noinput --username netbox --email netbox@example.com" + ) + # Django doesn't have a "clean" way of inputting the password from the command line + machine.succeed("cat '${changePassword}' | netbox-manage shell") + +machine.wait_for_unit("network.target") + +with subtest("Home screen loads from nginx"): + machine.succeed( + "curl -sSfL http://localhost | grep 'Home | NetBox'" + ) + +with subtest("Staticfiles can be fetched"): + machine.succeed("curl -sSfL http://localhost/static/netbox.js") + machine.succeed("curl -sSfL http://localhost/static/docs/") + +def login(username: str, password: str): + encoded_data = json.dumps({"username": username, "password": password}) + uri = "/users/tokens/provision/" + result = json.loads( + machine.succeed( + "curl -sSfL " + "-X POST " + "-H 'Accept: application/json' " + "-H 'Content-Type: application/json' " + f"'http://localhost/api{uri}' " + f"--data '{encoded_data}'" + ) + ) + return result["key"] + +with subtest("Can login"): + auth_token = login("netbox", "netbox") + +def get(uri: str): + return json.loads( + machine.succeed( + "curl -sSfL " + "-H 'Accept: application/json' " + f"-H 'Authorization: Token {auth_token}' " + f"'http://localhost/api{uri}'" + ) + ) + +def delete(uri: str): + return machine.succeed( + "curl -sSfL " + f"-X DELETE " + "-H 'Accept: application/json' " + f"-H 'Authorization: Token {auth_token}' " + f"'http://localhost/api{uri}'" + ) + + +def data_request(uri: str, method: str, data: Dict[str, Any]): + encoded_data = json.dumps(data) + return json.loads( + machine.succeed( + "curl -sSfL " + f"-X {method} " + "-H 'Accept: application/json' " + "-H 'Content-Type: application/json' " + f"-H 'Authorization: Token {auth_token}' " + f"'http://localhost/api{uri}' " + f"--data '{encoded_data}'" + ) + ) + +def post(uri: str, data: Dict[str, Any]): + return data_request(uri, "POST", data) + +def patch(uri: str, data: Dict[str, Any]): + return data_request(uri, "PATCH", data) + +# Retrieve netbox version +netbox_version = get("/status/")["netbox-version"] + +with subtest("Can create objects"): + result = post("/dcim/sites/", {"name": "Test site", "slug": "test-site"}) + site_id = result["id"] + + + for prefix in test_objects["prefixes"].values(): + if compare(netbox_version, '4.2.0') >= 0: + post("/ipam/prefixes/", { + "prefix": prefix["prefix"], + "scope_id": site_id, + "scope_type": "dcim." + prefix["scope"]["__typename"].replace("Type", "").lower() + }) + prefix["scope"]["id"] = str(site_id) + else: + post("/ipam/prefixes/", { + "prefix": prefix["prefix"], + "site": str(site_id), + }) + + result = post( + "/dcim/manufacturers/", + {"name": "Test manufacturer", "slug": "test-manufacturer"} + ) + manufacturer_id = result["id"] + + # Had an issue with device-types before NetBox 3.4.0 + result = post( + "/dcim/device-types/", + { + "model": "Test device type", + "manufacturer": manufacturer_id, + "slug": "test-device-type", + }, + ) + device_type_id = result["id"] + +with subtest("Can list objects"): + result = get("/dcim/sites/") + + assert result["count"] == 1 + assert result["results"][0]["id"] == site_id + assert result["results"][0]["name"] == "Test site" + assert result["results"][0]["description"] == "" + + result = get("/dcim/device-types/") + assert result["count"] == 1 + assert result["results"][0]["id"] == device_type_id + assert result["results"][0]["model"] == "Test device type" + +with subtest("Can update objects"): + new_description = "Test site description" + patch(f"/dcim/sites/{site_id}/", {"description": new_description}) + result = get(f"/dcim/sites/{site_id}/") + assert result["description"] == new_description + +with subtest("Can delete objects"): + # Delete a device-type since no object depends on it + delete(f"/dcim/device-types/{device_type_id}/") + + result = get("/dcim/device-types/") + assert result["count"] == 0 + +def request_graphql(query: str): + return machine.succeed( + "curl -sSfL " + "-H 'Accept: application/json' " + "-H 'Content-Type: application/json' " + f"-H 'Authorization: Token {auth_token}' " + "'http://localhost/graphql/' " + f"--data '{json.dumps({"query": query})}'" + ) + + +if compare(netbox_version, '4.2.0') >= 0: + with subtest("Can use the GraphQL API (NetBox 4.2.0+)"): + graphql_query = '''query { + prefix_list { + prefix + class_type + family { + label + } + scope { + __typename + ... on SiteType { + id + description + } + } + } + } + ''' + + answer = request_graphql(graphql_query) + result = json.loads(answer) + assert len(result["data"]["prefix_list"]) == 3 + assert test_objects["prefixes"]["v4-with-updated-desc"] in result["data"]["prefix_list"] + assert test_objects["prefixes"]["v6-cidr-32"] in result["data"]["prefix_list"] + assert test_objects["prefixes"]["v6-cidr-48"] in result["data"]["prefix_list"] + +if compare(netbox_version, '4.2.0') < 0: + with subtest("Can use the GraphQL API (Netbox <= 4.2.0)"): + answer = request_graphql('''query { + prefix_list { + prefix + site { + id + } + } + } + ''') + result = json.loads(answer) + print(result["data"]["prefix_list"][0]) + assert result["data"]["prefix_list"][0]["prefix"] == test_objects["prefixes"]["v4-with-updated-desc"]["prefix"] + assert int(result["data"]["prefix_list"][0]["site"]["id"]) == int(test_objects["prefixes"]["v4-with-updated-desc"]["scope"]["id"]) + +with subtest("Can login with LDAP"): + machine.wait_for_unit("openldap.service") + login("alice", "${testPassword}") + +with subtest("Can associate LDAP groups"): + result = get("/users/users/?username=${testUser}") + + assert result["count"] == 1 + assert any(group["name"] == "${testGroup}" for group in result["results"][0]["groups"]) diff --git a/pkgs/by-name/ne/netbox_4_2/package.nix b/pkgs/by-name/ne/netbox_4_2/package.nix index b043bfd1b26f..34f75c1f6bbc 100644 --- a/pkgs/by-name/ne/netbox_4_2/package.nix +++ b/pkgs/by-name/ne/netbox_4_2/package.nix @@ -14,7 +14,7 @@ let in py.pkgs.buildPythonApplication rec { pname = "netbox"; - version = "4.2.3"; + version = "4.2.6"; format = "other"; @@ -22,7 +22,7 @@ py.pkgs.buildPythonApplication rec { owner = "netbox-community"; repo = "netbox"; tag = "v${version}"; - hash = "sha256-vdH/R88Vtu+xRLjETK0h+E4WoYRoseP0r+wROi8nMcM="; + hash = "sha256-SOGVMaqAYc+DeyeF5ZQ4TQr9RIhWH23Lwth3h0Y3Dtg="; }; patches = [