diff --git a/changes/1632.enhance.md b/changes/1632.enhance.md new file mode 100644 index 0000000000..bfac6f9e7f --- /dev/null +++ b/changes/1632.enhance.md @@ -0,0 +1 @@ +Upgrade Graphene and GraphQL core (v2 -> v3) for better support of Relay, security rules, and other improvements diff --git a/changes/1664.feature.md b/changes/1664.feature.md new file mode 100644 index 0000000000..6fb503aa7e --- /dev/null +++ b/changes/1664.feature.md @@ -0,0 +1 @@ +Add a `allow_app_download_panel` config to webserver to show/hide the webui app download panel on the summary page. diff --git a/changes/1665.fix.md b/changes/1665.fix.md new file mode 100644 index 0000000000..eea9b93206 --- /dev/null +++ b/changes/1665.fix.md @@ -0,0 +1 @@ +Fix symbolic link loop error of vfolder diff --git a/changes/1666.feature.md b/changes/1666.feature.md new file mode 100644 index 0000000000..08ce3aa813 --- /dev/null +++ b/changes/1666.feature.md @@ -0,0 +1 @@ +Add a `allow_custom_resource_allocation` config to webserver to show/hide the custom allocation on the session launcher. diff --git a/changes/1668.fix.md b/changes/1668.fix.md new file mode 100644 index 0000000000..744f014590 --- /dev/null +++ b/changes/1668.fix.md @@ -0,0 +1 @@ +Update the parameter of session-template update API to follow-up change of session-template create API. diff --git a/changes/1674.enhance.md b/changes/1674.enhance.md new file mode 100644 index 0000000000..e52b2edd68 --- /dev/null +++ b/changes/1674.enhance.md @@ -0,0 +1 @@ +Use the explicit `graphql.Undefined` value to fill the unspecified fields of GraphQL mutation input objects diff --git a/changes/1676.misc.md b/changes/1676.misc.md new file mode 100644 index 0000000000..a37a918699 --- /dev/null +++ b/changes/1676.misc.md @@ -0,0 +1 @@ +Include `HOME` env-var when running tests via pants diff --git a/configs/webserver/sample.conf b/configs/webserver/sample.conf index 5b0f5d8c4a..b94bd05591 100644 --- a/configs/webserver/sample.conf +++ b/configs/webserver/sample.conf @@ -52,6 +52,8 @@ mask_user_info = false # hide_agents = true # URL to download the webui electron app. If blank, https://github.com/lablup/backend.ai-webui/releases/download will be used. # app_download_url = "" +# Allow users to see the panel downloading the webui app from the summary page. +# allow_app_download_panel = true # Enable/disable 2-Factor-Authentication (TOTP). enable_2FA = false # Force enable 2-Factor-Authentication (TOTP). @@ -61,6 +63,8 @@ force_2FA = false # system_SSH_image = "" # If true, display the amount of usage per directory such as folder capacity, and number of files and directories. directory_based_usage = false +# If true, display the custom allocation on the session launcher. +# allow_custom_resource_allocation = true [resources] # Display "Open port to public" checkbox in the app launcher. diff --git a/pants.toml b/pants.toml index c69d01edaa..cf2d925f77 100644 --- a/pants.toml +++ b/pants.toml @@ -39,7 +39,7 @@ root_patterns = [ ] [test] -extra_env_vars = ["BACKEND_BUILD_ROOT=%(buildroot)s"] +extra_env_vars = ["BACKEND_BUILD_ROOT=%(buildroot)s", "HOME"] [python] enable_resolves = true diff --git a/python.lock b/python.lock index 6cd49a393f..933de779a1 100644 --- a/python.lock +++ b/python.lock @@ -45,7 +45,7 @@ // "dataclasses-json~=0.5.7", // "etcetra==0.1.17", // "faker~=13.12.0", -// "graphene~=2.1.9", +// "graphene~=3.3.0", // "hiredis>=2.2.3", // "humanize>=3.1.0", // "ifaddr~=0.2", @@ -164,13 +164,13 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "5268c89765a4a17d6875f70686d6ee406a0d4c40f0b65a0a311fb400aecfedf4", - "url": "https://files.pythonhosted.org/packages/61/1c/dc7a63e28cd4497ba05a3ff62684a49fd9d55c30671ca6f3f863ade0427a/aiodns-3.1.0-py3-none-any.whl" + "hash": "a387b63da4ced6aad35b1dda2d09620ad608a1c7c0fb71efa07ebb4cd511928d", + "url": "https://files.pythonhosted.org/packages/1a/74/976abff30200cb0cab0bd076db074b8cdda9236ba885ebe3f4d91c7e074b/aiodns-3.1.1-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "fadb59304f4e38fd23b2f1953107df5cf29121d66c4e9c37a9bb13832c0c0513", - "url": "https://files.pythonhosted.org/packages/e5/70/f9aa5346bb56b9de897ee0ad03e4d94e76731886df59e73218e024c257d6/aiodns-3.1.0.tar.gz" + "hash": "1073eac48185f7a4150cad7f96a5192d6911f12b4fb894de80a088508c9b3a99", + "url": "https://files.pythonhosted.org/packages/fa/10/4de99e6e67703d8f6b10ea92a4d2a6c5b96a9c0708b75389a00203387925/aiodns-3.1.1.tar.gz" } ], "project_name": "aiodns", @@ -178,7 +178,7 @@ "pycares>=4.0.0" ], "requires_python": null, - "version": "3.1.0" + "version": "3.1.1" }, { "artifacts": [ @@ -538,19 +538,26 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "d10a4bf949f619f719b227ef5386e31f49a2b6d453004b21f02661ccc8670c7b", - "url": "https://files.pythonhosted.org/packages/45/a4/b4fcadbdab46c2ec2d2f6f8b4ab3f64fd0040789ac7f065eba82119cd602/aniso8601-7.0.0-py2.py3-none-any.whl" + "hash": "1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f", + "url": "https://files.pythonhosted.org/packages/e3/04/e97c12dc034791d7b504860acfcdd2963fa21ae61eaca1c9d31245f812c3/aniso8601-9.0.1-py2.py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "513d2b6637b7853806ae79ffaca6f3e8754bdd547048f5ccc1420aec4b714f1e", - "url": "https://files.pythonhosted.org/packages/7f/39/0da0982a3a42fd896beaa07425692fb3100a9d0e40723783efc20f1dec7c/aniso8601-7.0.0.tar.gz" + "hash": "72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973", + "url": "https://files.pythonhosted.org/packages/cb/72/be3db445b03944bfbb2b02b82d00cb2a2bcf96275c4543f14bf60fa79e12/aniso8601-9.0.1.tar.gz" } ], "project_name": "aniso8601", - "requires_dists": [], + "requires_dists": [ + "black; extra == \"dev\"", + "coverage; extra == \"dev\"", + "isort; extra == \"dev\"", + "pre-commit; extra == \"dev\"", + "pyenchant; extra == \"dev\"", + "pylint; extra == \"dev\"" + ], "requires_python": null, - "version": "7.0.0" + "version": "9.0.1" }, { "artifacts": [ @@ -864,36 +871,36 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "65d052ec13197460586ee385aa2d6bba0e7378d2d2c7f3e93c044c43ae1ca782", - "url": "https://files.pythonhosted.org/packages/c0/67/4d23a38313d7b37892a6d9c9260809f1a2f5a37feaf6f13da0aa27f57d6d/boto3-1.28.63-py3-none-any.whl" + "hash": "ff3d0116e0ca6c096547652390025780eace3a28f6c04c9ffbf38448f1e5a87b", + "url": "https://files.pythonhosted.org/packages/c7/dd/4fe47b2cec8731ec26d7410e659c4f0c4cd36baa835e2312cb0ec5383b07/boto3-1.28.65-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "94218aba2feb5b404b665b8d76c172dc654f79b4c5fa0e9e92459c098da87bf4", - "url": "https://files.pythonhosted.org/packages/08/93/0f614264bca69210ac6b0ae06bc112a9569a0b849499e6a7884b670d272a/boto3-1.28.63.tar.gz" + "hash": "9d52a1605657aeb5b19b09cfc01d9a92f88a616a5daf5479a59656d6341ea6b3", + "url": "https://files.pythonhosted.org/packages/1b/2f/4ccd05e765a9aa3222125da37ceced40b4133094069c4d011ca7ae37681f/boto3-1.28.65.tar.gz" } ], "project_name": "boto3", "requires_dists": [ - "botocore<1.32.0,>=1.31.63", + "botocore<1.32.0,>=1.31.65", "botocore[crt]<2.0a0,>=1.21.0; extra == \"crt\"", "jmespath<2.0.0,>=0.7.1", "s3transfer<0.8.0,>=0.7.0" ], "requires_python": ">=3.7", - "version": "1.28.63" + "version": "1.28.65" }, { "artifacts": [ { "algorithm": "sha256", - "hash": "cb9db5db5af865b1fc2e1405b967db5d78dd0f4d84e5dc1974e082733c1034b7", - "url": "https://files.pythonhosted.org/packages/24/0e/39117ec73ea22e700503b3af1dbab270563d6d6ca862cf572899824e4212/botocore-1.31.63-py3-none-any.whl" + "hash": "f74e3da98dfcec17bc63ef58f82c643bf5bd7ec6cc11a26ede21cc4cd064917f", + "url": "https://files.pythonhosted.org/packages/63/c6/8e29a2b9dffa188d07c26d19ae578a26d8063834e4d844bf22c2a0028229/botocore-1.31.65-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "6e582c811ea74f25bdb490ac372b2645de4a60286b42ddd8c69f3b6df82b6b12", - "url": "https://files.pythonhosted.org/packages/a6/4f/4c2b7d96dd50aed47db6cccf6c1c089853f6474e1d1da5c5aa320d7d4157/botocore-1.31.63.tar.gz" + "hash": "90716c6f1af97e5c2f516e9a3379767ebdddcc6cbed79b026fa5038ce4e5e43e", + "url": "https://files.pythonhosted.org/packages/42/30/e5e2126eca77baedbf51e48241c898d99784d272bcf2fb47f5a10360e555/botocore-1.31.65.tar.gz" } ], "project_name": "botocore", @@ -905,7 +912,7 @@ "urllib3<2.1,>=1.25.4; python_version >= \"3.10\"" ], "requires_python": ">=3.7", - "version": "1.31.63" + "version": "1.31.65" }, { "artifacts": [ @@ -1511,93 +1518,86 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "3d446eb1237c551052bc31155cf1a3a607053e4f58c9172b83a1b597beaa0868", - "url": "https://files.pythonhosted.org/packages/ef/a2/b3e68706bf45abc2f9d70f099a4b4ca6305779577f4a03458d78fb39cd42/graphene-2.1.9-py2.py3-none-any.whl" + "hash": "bb3810be33b54cb3e6969506671eb72319e8d7ba0d5ca9c8066472f75bf35a38", + "url": "https://files.pythonhosted.org/packages/24/70/96f6027cdfc9bb89fc07627b615cb43fb1c443c93498412beaeaf157e9f1/graphene-3.3-py2.py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "b9f2850e064eebfee9a3ef4a1f8aa0742848d97652173ab44c82cc8a62b9ed93", - "url": "https://files.pythonhosted.org/packages/0a/9d/5a8890c7d14adbeda55e2d5f28120b4be2a7bfa0131674c340db1c162072/graphene-2.1.9.tar.gz" + "hash": "529bf40c2a698954217d3713c6041d69d3f719ad0080857d7ee31327112446b0", + "url": "https://files.pythonhosted.org/packages/82/75/02875858c7c09fc156840181cdee27b23408fac75720a2e1e9128f3d48a5/graphene-3.3.tar.gz" } ], "project_name": "graphene", "requires_dists": [ - "aniso8601<=7,>=3", - "coveralls; extra == \"test\"", - "fastdiff==0.2.0; extra == \"test\"", - "graphene-django; extra == \"django\"", - "graphene-sqlalchemy; extra == \"sqlalchemy\"", - "graphql-core<3,>=2.1", - "graphql-relay<3,>=2", - "iso8601; extra == \"test\"", - "mock; extra == \"test\"", - "promise; extra == \"test\"", - "pytest-benchmark; extra == \"test\"", - "pytest-cov; extra == \"test\"", - "pytest-mock; extra == \"test\"", - "pytest; extra == \"test\"", - "pytz; extra == \"test\"", - "six; extra == \"test\"", - "six<2,>=1.10.0", - "snapshottest; extra == \"test\"" + "aniso8601<10,>=8", + "black==22.3.0; extra == \"dev\"", + "coveralls<4,>=3.3; extra == \"dev\"", + "coveralls<4,>=3.3; extra == \"test\"", + "flake8<5,>=4; extra == \"dev\"", + "graphql-core<3.3,>=3.1", + "graphql-relay<3.3,>=3.1", + "iso8601<2,>=1; extra == \"dev\"", + "iso8601<2,>=1; extra == \"test\"", + "mock<5,>=4; extra == \"dev\"", + "mock<5,>=4; extra == \"test\"", + "pytest-asyncio<2,>=0.16; extra == \"dev\"", + "pytest-asyncio<2,>=0.16; extra == \"test\"", + "pytest-benchmark<4,>=3.4; extra == \"dev\"", + "pytest-benchmark<4,>=3.4; extra == \"test\"", + "pytest-cov<4,>=3; extra == \"dev\"", + "pytest-cov<4,>=3; extra == \"test\"", + "pytest-mock<4,>=3; extra == \"dev\"", + "pytest-mock<4,>=3; extra == \"test\"", + "pytest<7,>=6; extra == \"dev\"", + "pytest<7,>=6; extra == \"test\"", + "pytz==2022.1; extra == \"dev\"", + "pytz==2022.1; extra == \"test\"", + "snapshottest<1,>=0.6; extra == \"dev\"", + "snapshottest<1,>=0.6; extra == \"test\"" ], "requires_python": null, - "version": "2.1.9" + "version": "3.3" }, { "artifacts": [ { "algorithm": "sha256", - "hash": "44c9bac4514e5e30c5a595fac8e3c76c1975cae14db215e8174c7fe995825bad", - "url": "https://files.pythonhosted.org/packages/11/71/d51beba3d8986fa6d8670ec7bcba989ad6e852d5ae99d95633e5dacc53e7/graphql_core-2.3.2-py2.py3-none-any.whl" + "hash": "5766780452bd5ec8ba133f8bf287dc92713e3868ddd83aee4faab9fc3e303dc3", + "url": "https://files.pythonhosted.org/packages/f8/39/e5143e7ec70939d2076c1165ae9d4a3815597019c4d797b7f959cf778600/graphql_core-3.2.3-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "aac46a9ac524c9855910c14c48fc5d60474def7f99fd10245e76608eba7af746", - "url": "https://files.pythonhosted.org/packages/88/a2/dd91d55a6f6dd88c4d3c284d387c94f1f933fedec43a86a4422940b9de18/graphql-core-2.3.2.tar.gz" + "hash": "06d2aad0ac723e35b1cb47885d3e5c45e956a53bc1b209a9fc5369007fe46676", + "url": "https://files.pythonhosted.org/packages/ee/a6/94df9045ca1bac404c7b394094cd06713f63f49c7a4d54d99b773ae81737/graphql-core-3.2.3.tar.gz" } ], "project_name": "graphql-core", "requires_dists": [ - "coveralls==1.11.1; extra == \"test\"", - "cython==0.29.17; extra == \"test\"", - "gevent==1.5.0; extra == \"test\"", - "gevent>=1.1; extra == \"gevent\"", - "promise<3,>=2.3", - "pyannotate==1.2.0; extra == \"test\"", - "pytest-benchmark==3.2.3; extra == \"test\"", - "pytest-cov==2.8.1; extra == \"test\"", - "pytest-django==3.9.0; extra == \"test\"", - "pytest-mock==2.0.0; extra == \"test\"", - "pytest==4.6.10; extra == \"test\"", - "rx<2,>=1.6", - "six==1.14.0; extra == \"test\"", - "six>=1.10.0" + "typing-extensions<5,>=4.2; python_version < \"3.8\"" ], - "requires_python": null, - "version": "2.3.2" + "requires_python": "<4,>=3.6", + "version": "3.2.3" }, { "artifacts": [ { "algorithm": "sha256", - "hash": "ac514cb86db9a43014d7e73511d521137ac12cf0101b2eaa5f0a3da2e10d913d", - "url": "https://files.pythonhosted.org/packages/94/48/6022ea2e89cb936c3b933a0409c6e29bf8a68c050fe87d97f98aff6e5e9e/graphql_relay-2.0.1-py3-none-any.whl" + "hash": "c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5", + "url": "https://files.pythonhosted.org/packages/74/16/a4cf06adbc711bd364a73ce043b0b08d8fa5aae3df11b6ee4248bcdad2e0/graphql_relay-3.2.0-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "870b6b5304123a38a0b215a79eace021acce5a466bf40cd39fa18cb8528afabb", - "url": "https://files.pythonhosted.org/packages/16/59/afbf1ce02631910ff0be06e5e057cc9e2806192d9b9c8d6671ff39e4abe2/graphql-relay-2.0.1.tar.gz" + "hash": "1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c", + "url": "https://files.pythonhosted.org/packages/d1/13/98fbf8d67552f102488ffc16c6f559ce71ea15f6294728d33928ab5ff14d/graphql-relay-3.2.0.tar.gz" } ], "project_name": "graphql-relay", "requires_dists": [ - "graphql-core<3,>=2.2", - "promise<3,>=2.2", - "six>=1.12" + "graphql-core<3.3,>=3.2", + "typing-extensions<5,>=4.1; python_version < \"3.8\"" ], - "requires_python": null, - "version": "2.0.1" + "requires_python": "<4,>=3.6", + "version": "3.2.0" }, { "artifacts": [ @@ -2647,29 +2647,6 @@ "requires_python": ">=3.8", "version": "1.3.0" }, - { - "artifacts": [ - { - "algorithm": "sha256", - "hash": "dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0", - "url": "https://files.pythonhosted.org/packages/cf/9c/fb5d48abfe5d791cd496e4242ebcf87a4bb2e0c3dcd6e0ae68c11426a528/promise-2.3.tar.gz" - } - ], - "project_name": "promise", - "requires_dists": [ - "coveralls; extra == \"test\"", - "futures; extra == \"test\"", - "mock; extra == \"test\"", - "pytest-asyncio; extra == \"test\"", - "pytest-benchmark; extra == \"test\"", - "pytest-cov; extra == \"test\"", - "pytest>=2.7.3; extra == \"test\"", - "six", - "typing>=3.6.4; python_version < \"3.5\"" - ], - "requires_python": null, - "version": "2.3" - }, { "artifacts": [ { @@ -3378,19 +3355,6 @@ "requires_python": "<4,>=3.6", "version": "4.9" }, - { - "artifacts": [ - { - "algorithm": "sha256", - "hash": "ca71b65d0fc0603a3b5cfaa9e33f5ba81e4aae10a58491133595088d7734b2da", - "url": "https://files.pythonhosted.org/packages/3c/51/d37235bad8df7536cc950e0d0a26e94131a6a3f7d5e1bed5f37f0846f2ef/Rx-1.6.3.tar.gz" - } - ], - "project_name": "rx", - "requires_dists": [], - "requires_python": null, - "version": "1.6.3" - }, { "artifacts": [ { @@ -4162,13 +4126,13 @@ "artifacts": [ { "algorithm": "sha256", - "hash": "7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2", - "url": "https://files.pythonhosted.org/packages/26/40/9957270221b6d3e9a3b92fdfba80dd5c9661ff45a664b47edd5d00f707f5/urllib3-2.0.6-py3-none-any.whl" + "hash": "fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e", + "url": "https://files.pythonhosted.org/packages/d2/b2/b157855192a68541a91ba7b2bbcb91f1b4faa51f8bae38d8005c034be524/urllib3-2.0.7-py3-none-any.whl" }, { "algorithm": "sha256", - "hash": "b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564", - "url": "https://files.pythonhosted.org/packages/8b/00/db794bb94bf09cadb4ecd031c4295dd4e3536db4da958e20331d95f1edb7/urllib3-2.0.6.tar.gz" + "hash": "c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", + "url": "https://files.pythonhosted.org/packages/af/47/b215df9f71b4fdba1025fc05a77db2ad243fa0926755a52c5e71659f4e3c/urllib3-2.0.7.tar.gz" } ], "project_name": "urllib3", @@ -4184,7 +4148,7 @@ "zstandard>=0.18.0; extra == \"zstd\"" ], "requires_python": ">=3.7", - "version": "2.0.6" + "version": "2.0.7" }, { "artifacts": [ @@ -4440,7 +4404,7 @@ "dataclasses-json~=0.5.7", "etcetra==0.1.17", "faker~=13.12.0", - "graphene~=2.1.9", + "graphene~=3.3.0", "hiredis>=2.2.3", "humanize>=3.1.0", "ifaddr~=0.2", diff --git a/requirements.txt b/requirements.txt index 66cb201bc6..67bd4a2c9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ cryptography>=2.8 dataclasses-json~=0.5.7 etcetra==0.1.17 faker~=13.12.0 -graphene~=2.1.9 +graphene~=3.3.0 humanize>=3.1.0 ifaddr~=0.2 inquirer~=2.9.2 diff --git a/src/ai/backend/client/cli/pretty.py b/src/ai/backend/client/cli/pretty.py index f0a60de6b3..19996788bb 100644 --- a/src/ai/backend/client/cli/pretty.py +++ b/src/ai/backend/client/cli/pretty.py @@ -4,6 +4,7 @@ import sys import textwrap import traceback +from typing import Sequence from click import echo, style from tqdm import tqdm @@ -105,6 +106,17 @@ def print_pretty(msg, *, status=PrintStatus.NONE, file=None): print_warn = functools.partial(print_pretty, status=PrintStatus.WARNING) +def _format_gql_path(items: Sequence[str | int]) -> str: + pieces = [] + for item in items: + match item: + case int(): + pieces.append(f"[{item}]") + case _: + pieces.append(f".{str(item)}") + return "".join(pieces)[1:] # strip first dot + + def format_error(exc: Exception): if isinstance(exc, BackendAPIError): yield "{0}: {1} {2}\n".format(exc.__class__.__name__, exc.status, exc.reason) @@ -132,7 +144,11 @@ def format_error(exc: Exception): else: if exc.data["type"].endswith("/graphql-error"): yield "\n\u279c Message:\n" - yield from (f"{err_item['message']}\n" for err_item in exc.data.get("data", [])) + for err_item in exc.data.get("data", []): + yield f"{err_item['message']}" + if err_path := err_item.get("path"): + yield f" (path: {_format_gql_path(err_path)})" + yield "\n" else: other_details = exc.data.get("msg", None) if other_details: diff --git a/src/ai/backend/manager/api/admin.py b/src/ai/backend/manager/api/admin.py index 7afdeaf032..66ff0cda36 100644 --- a/src/ai/backend/manager/api/admin.py +++ b/src/ai/backend/manager/api/admin.py @@ -1,8 +1,6 @@ from __future__ import annotations -import inspect import logging -import re from typing import TYPE_CHECKING, Any, Iterable, Tuple import aiohttp_cors @@ -10,9 +8,11 @@ import graphene import trafaret as t from aiohttp import web -from graphql.error import GraphQLError, format_error # pants: no-infer-dep +from graphene.types.inputobjecttype import set_input_object_type_default_value +from graphene.validation import DisableIntrospection +from graphql import Undefined, parse, validate +from graphql.error import GraphQLError # pants: no-infer-dep from graphql.execution import ExecutionResult # pants: no-infer-dep -from graphql.execution.executors.asyncio import AsyncioExecutor # pants: no-infer-dep from ai.backend.common import validators as tx from ai.backend.common.logging import BraceStyleAdapter @@ -30,8 +30,6 @@ log = BraceStyleAdapter(logging.getLogger(__spec__.name)) # type: ignore[name-defined] -_rx_mutation_hdr = re.compile(r"^mutation(\s+\w+)?\s*(\(|{|@)", re.M) - class GQLLoggingMiddleware: def resolve(self, next, root, info: graphene.ResolveInfo, **args) -> Any: @@ -52,7 +50,14 @@ async def _handle_gql_common(request: web.Request, params: Any) -> ExecutionResu app_ctx: PrivateContext = request.app["admin.context"] manager_status = await root_ctx.shared_config.get_manager_status() known_slot_types = await root_ctx.shared_config.get_resource_slots() - + if not root_ctx.shared_config["api"]["allow-graphql-schema-introspection"]: + validate_errors = validate( + schema=app_ctx.gql_schema.graphql_schema, + document_ast=parse(params["query"]), + rules=(DisableIntrospection,), + ) + if validate_errors: + return ExecutionResult(None, errors=validate_errors) gql_ctx = GraphQueryContext( schema=app_ctx.gql_schema, dataloader_manager=DataLoaderManager(), @@ -72,9 +77,9 @@ async def _handle_gql_common(request: web.Request, params: Any) -> ExecutionResu registry=root_ctx.registry, idle_checker_host=root_ctx.idle_checker_host, ) - result = app_ctx.gql_schema.execute( + result = await app_ctx.gql_schema.execute_async( params["query"], - app_ctx.gql_executor, + None, # root variable_values=params["variables"], operation_name=params["operation_name"], context_value=gql_ctx, @@ -83,10 +88,7 @@ async def _handle_gql_common(request: web.Request, params: Any) -> ExecutionResu GQLMutationUnfrozenRequiredMiddleware(), GQLMutationPrivilegeCheckMiddleware(), ], - return_promise=True, ) - if inspect.isawaitable(result): - result = await result return result @@ -102,7 +104,7 @@ async def _handle_gql_common(request: web.Request, params: Any) -> ExecutionResu ) async def handle_gql(request: web.Request, params: Any) -> web.Response: result = await _handle_gql_common(request, params) - return web.json_response(result.to_dict(), status=200) + return web.json_response(result.formatted, status=200) @auth_required @@ -122,7 +124,7 @@ async def handle_gql_legacy(request: web.Request, params: Any) -> web.Response: errors = [] for e in result.errors: if isinstance(e, GraphQLError): - errmsg = format_error(e) + errmsg = e.formatted errors.append(errmsg) else: errmsg = {"message": str(e)} @@ -134,18 +136,22 @@ async def handle_gql_legacy(request: web.Request, params: Any) -> web.Response: @attrs.define(auto_attribs=True, slots=True, init=False) class PrivateContext: - gql_executor: AsyncioExecutor gql_schema: graphene.Schema async def init(app: web.Application) -> None: app_ctx: PrivateContext = app["admin.context"] - app_ctx.gql_executor = AsyncioExecutor() app_ctx.gql_schema = graphene.Schema( query=Queries, mutation=Mutations, auto_camelcase=False, ) + root_ctx: RootContext = app["_root.context"] + if root_ctx.shared_config["api"]["allow-graphql-schema-introspection"]: + log.warning( + "GraphQL schema introspection is enabled. " + "It is strongly advised to disable this in production setups." + ) async def shutdown(app: web.Application) -> None: @@ -159,16 +165,8 @@ def create_app( app.on_startup.append(init) app.on_shutdown.append(shutdown) app["admin.context"] = PrivateContext() + set_input_object_type_default_value(Undefined) cors = aiohttp_cors.setup(app, defaults=default_cors_options) cors.add(app.router.add_route("POST", r"/graphql", handle_gql_legacy)) cors.add(app.router.add_route("POST", r"/gql", handle_gql)) return app, [] - - -if __name__ == "__main__": - # If executed as a main program, print all GraphQL schemas. - # (graphene transforms our object model into a textual representation) - # This is useful for writing documentation! - schema = graphene.Schema(query=Queries, mutation=Mutations, auto_camelcase=False) - print("======== GraphQL API Schema ========") - print(str(schema)) diff --git a/src/ai/backend/manager/api/session_template.py b/src/ai/backend/manager/api/session_template.py index 9294c49f69..93c319567f 100644 --- a/src/ai/backend/manager/api/session_template.py +++ b/src/ai/backend/manager/api/session_template.py @@ -269,7 +269,7 @@ async def put(request: web.Request, params: Any) -> web.Response: body = yaml.safe_load(params["payload"]) except (yaml.YAMLError, yaml.MarkedYAMLError): raise InvalidAPIParameters("Malformed payload") - for st in body["session_templates"]: + for st in body: template_data = check_task_template(st["template"]) name = st["name"] if "name" in st else template_data["metadata"]["name"] if "group_id" in st: diff --git a/src/ai/backend/manager/config.py b/src/ai/backend/manager/config.py index 7d83daa138..c2c12f6b96 100644 --- a/src/ai/backend/manager/config.py +++ b/src/ai/backend/manager/config.py @@ -46,6 +46,7 @@ - timezone: "UTC" # pytz-compatible timezone names (e.g., "Asia/Seoul") + api - allow-origins: "*" + - allow-graphql-schema-introspection: "yes" | "no" # (default: no) + resources - group_resource_visibility: "true" # return group resource status in check-presets # (default: false) @@ -320,6 +321,7 @@ }, "api": { "allow-origins": "*", + "allow-graphql-schema-introspection": False, }, "redis": config.redis_default_config, "docker": { @@ -408,6 +410,10 @@ def container_registry_serialize(v: dict[str, Any]) -> dict[str, str]: t.Key("api", default=_config_defaults["api"]): t.Dict( { t.Key("allow-origins", default=_config_defaults["api"]["allow-origins"]): t.String, + t.Key( + "allow-graphql-schema-introspection", + default=_config_defaults["api"]["allow-graphql-schema-introspection"], + ): t.ToBool, } ).allow_extra("*"), t.Key("redis", default=_config_defaults["redis"]): config.redis_config_iv, diff --git a/src/ai/backend/manager/models/base.py b/src/ai/backend/manager/models/base.py index a2498cd9a3..2701d914f3 100644 --- a/src/ai/backend/manager/models/base.py +++ b/src/ai/backend/manager/models/base.py @@ -37,6 +37,7 @@ from aiotools import apartial from graphene.types import Scalar from graphene.types.scalars import MAX_INT, MIN_INT +from graphql import Undefined from graphql.language import ast # pants: no-infer-dep from sqlalchemy.dialects.postgresql import ARRAY, CIDR, ENUM, JSONB, UUID from sqlalchemy.engine.result import Result @@ -69,8 +70,6 @@ from ..api.exceptions import GenericForbidden, InvalidAPIParameters if TYPE_CHECKING: - from graphql.execution.executors.asyncio import AsyncioExecutor # pants: no-infer-dep - from .gql import GraphQueryContext from .user import UserRole @@ -759,14 +758,17 @@ def privileged_query(required_role: UserRole): def wrap(func): @functools.wraps(func) async def wrapped( - executor: AsyncioExecutor, info: graphene.ResolveInfo, *args, **kwargs + root: Any, + info: graphene.ResolveInfo, + *args, + **kwargs, ) -> Any: from .user import UserRole ctx: GraphQueryContext = info.context if ctx.user["role"] != UserRole.SUPERADMIN: raise GenericForbidden("superadmin privilege required") - return await func(executor, info, *args, **kwargs) + return await func(root, info, *args, **kwargs) return wrapped @@ -792,7 +794,10 @@ def scoped_query( def wrap(resolve_func): @functools.wraps(resolve_func) async def wrapped( - executor: AsyncioExecutor, info: graphene.ResolveInfo, *args, **kwargs + root: Any, + info: graphene.ResolveInfo, + *args, + **kwargs, ) -> Any: from .user import UserRole @@ -841,7 +846,7 @@ async def wrapped( if kwargs.get("project", None) is not None: kwargs["project"] = group_id kwargs[user_key] = user_id - return await resolve_func(executor, info, *args, **kwargs) + return await resolve_func(root, info, *args, **kwargs) return wrapped @@ -1032,8 +1037,8 @@ def set_if_set( target_key: Optional[str] = None, ) -> None: v = getattr(src, name) - # NOTE: unset optional fields are passed as null. - if v is not None: + # NOTE: unset optional fields are passed as graphql.Undefined. + if v is not Undefined: if callable(clean_func): target[target_key or name] = clean_func(v) else: diff --git a/src/ai/backend/manager/models/gql.py b/src/ai/backend/manager/models/gql.py index bda7f3c0ab..cd30c74b42 100644 --- a/src/ai/backend/manager/models/gql.py +++ b/src/ai/backend/manager/models/gql.py @@ -17,10 +17,6 @@ ) if TYPE_CHECKING: - from graphql.execution.executors.asyncio import ( - AsyncioExecutor, - ) - from ai.backend.common.bgtask import BackgroundTaskManager from ai.backend.common.etcd import AsyncEtcd from ai.backend.common.types import ( @@ -645,7 +641,7 @@ class Queries(graphene.ObjectType): @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_agent( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, agent_id: AgentId, ) -> Agent: @@ -660,7 +656,7 @@ async def resolve_agent( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_agents( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, scaling_group: str = None, @@ -675,7 +671,7 @@ async def resolve_agents( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_agent_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -705,7 +701,7 @@ async def resolve_agent_list( @staticmethod @scoped_query(autofill_user=True, user_key="access_key") async def resolve_agent_summary( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, agent_id: AgentId, *, @@ -730,7 +726,7 @@ async def resolve_agent_summary( @staticmethod @scoped_query(autofill_user=True, user_key="access_key") async def resolve_agent_summary_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -769,7 +765,7 @@ async def resolve_agent_summary_list( @staticmethod async def resolve_domain( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, name: str = None, @@ -786,7 +782,7 @@ async def resolve_domain( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_domains( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, is_active: bool = None, @@ -795,7 +791,7 @@ async def resolve_domains( @staticmethod async def resolve_group( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, id: uuid.UUID, *, @@ -843,7 +839,7 @@ async def resolve_group( @staticmethod async def resolve_groups_by_name( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, name: str, *, @@ -891,7 +887,7 @@ async def resolve_groups_by_name( @staticmethod async def resolve_groups( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, domain_name: str = None, @@ -920,7 +916,7 @@ async def resolve_groups( @staticmethod async def resolve_image( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, reference: str, architecture: str, @@ -942,7 +938,7 @@ async def resolve_image( @staticmethod async def resolve_images( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, is_installed=None, @@ -969,7 +965,7 @@ async def resolve_images( @staticmethod @scoped_query(autofill_user=True, user_key="email") async def resolve_user( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, domain_name: str = None, @@ -986,7 +982,7 @@ async def resolve_user( @staticmethod @scoped_query(autofill_user=True, user_key="user_id") async def resolve_user_from_uuid( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, domain_name: str = None, @@ -1004,7 +1000,7 @@ async def resolve_user_from_uuid( @staticmethod async def resolve_users( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, domain_name: str = None, @@ -1039,7 +1035,7 @@ async def resolve_users( @staticmethod async def resolve_user_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1091,7 +1087,7 @@ async def resolve_user_list( @staticmethod @scoped_query(autofill_user=True, user_key="access_key") async def resolve_keypair( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, domain_name: str = None, @@ -1108,7 +1104,7 @@ async def resolve_keypair( @staticmethod @scoped_query(autofill_user=False, user_key="email") async def resolve_keypairs( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, domain_name: str = None, @@ -1135,7 +1131,7 @@ async def resolve_keypairs( @staticmethod @scoped_query(autofill_user=False, user_key="email") async def resolve_keypair_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1167,7 +1163,7 @@ async def resolve_keypair_list( @staticmethod async def resolve_keypair_resource_policy( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, name: str = None, ) -> KeyPairResourcePolicy: @@ -1188,7 +1184,7 @@ async def resolve_keypair_resource_policy( @staticmethod async def resolve_keypair_resource_policies( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, ) -> Sequence[KeyPairResourcePolicy]: ctx: GraphQueryContext = info.context @@ -1209,7 +1205,7 @@ async def resolve_keypair_resource_policies( @staticmethod async def resolve_user_resource_policy( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, name: str = None, ) -> UserResourcePolicy: @@ -1230,7 +1226,7 @@ async def resolve_user_resource_policy( @staticmethod async def resolve_user_resource_policies( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, ) -> Sequence[UserResourcePolicy]: ctx: GraphQueryContext = info.context @@ -1251,7 +1247,7 @@ async def resolve_user_resource_policies( @staticmethod async def resolve_project_resource_policy( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, name: str, ) -> ProjectResourcePolicy: @@ -1264,7 +1260,7 @@ async def resolve_project_resource_policy( @staticmethod async def resolve_project_resource_policies( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, ) -> Sequence[ProjectResourcePolicy]: ctx: GraphQueryContext = info.context @@ -1279,7 +1275,7 @@ async def resolve_project_resource_policies( @staticmethod async def resolve_resource_preset( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, name: str, ) -> ResourcePreset: @@ -1289,7 +1285,7 @@ async def resolve_resource_preset( @staticmethod async def resolve_resource_presets( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, ) -> Sequence[ResourcePreset]: return await ResourcePreset.load_all(info.context) @@ -1297,7 +1293,7 @@ async def resolve_resource_presets( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_scaling_group( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, name: str, ) -> ScalingGroup: @@ -1311,7 +1307,7 @@ async def resolve_scaling_group( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_scaling_groups( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, is_active: bool = None, ) -> Sequence[ScalingGroup]: @@ -1320,7 +1316,7 @@ async def resolve_scaling_groups( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_scaling_groups_for_domain( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, domain: str, is_active: bool = None, @@ -1334,7 +1330,7 @@ async def resolve_scaling_groups_for_domain( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_scaling_groups_for_user_group( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, user_group, is_active: bool = None, @@ -1348,7 +1344,7 @@ async def resolve_scaling_groups_for_user_group( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_scaling_groups_for_keypair( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, access_key: AccessKey, is_active: bool = None, @@ -1362,7 +1358,7 @@ async def resolve_scaling_groups_for_keypair( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_storage_volume( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, id: str, ) -> StorageVolume: @@ -1371,7 +1367,7 @@ async def resolve_storage_volume( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_storage_volume_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1395,7 +1391,7 @@ async def resolve_storage_volume_list( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_vfolder( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, id: str, *, @@ -1418,7 +1414,7 @@ async def resolve_vfolder( @staticmethod @scoped_query(autofill_user=False, user_key="user_id") async def resolve_vfolder_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1452,7 +1448,7 @@ async def resolve_vfolder_list( @staticmethod @privileged_query(UserRole.SUPERADMIN) async def resolve_vfolder_permission_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1479,7 +1475,7 @@ async def resolve_vfolder_permission_list( @staticmethod @scoped_query(autofill_user=False, user_key="user_id") async def resolve_vfolder_own_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1509,7 +1505,7 @@ async def resolve_vfolder_own_list( @staticmethod @scoped_query(autofill_user=False, user_key="user_id") async def resolve_vfolder_invited_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1539,7 +1535,7 @@ async def resolve_vfolder_invited_list( @staticmethod @scoped_query(autofill_user=False, user_key="user_id") async def resolve_vfolder_project_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1569,7 +1565,7 @@ async def resolve_vfolder_project_list( @staticmethod @scoped_query(autofill_user=False, user_key="access_key") async def resolve_compute_container_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1609,7 +1605,7 @@ async def resolve_compute_container_list( @staticmethod @scoped_query(autofill_user=False, user_key="access_key") async def resolve_compute_container( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, container_id: str, ) -> ComputeContainer: @@ -1624,7 +1620,7 @@ async def resolve_compute_container( @staticmethod @scoped_query(autofill_user=False, user_key="access_key") async def resolve_compute_session_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1660,7 +1656,7 @@ async def resolve_compute_session_list( @staticmethod @scoped_query(autofill_user=False, user_key="access_key") async def resolve_compute_session( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, id: SessionId, *, @@ -1683,7 +1679,7 @@ async def resolve_compute_session( @staticmethod @scoped_query(autofill_user=False, user_key="access_key") async def resolve_legacy_compute_session_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1718,7 +1714,7 @@ async def resolve_legacy_compute_session_list( @staticmethod @scoped_query(autofill_user=False, user_key="access_key") async def resolve_legacy_compute_session( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, sess_id: str, *, @@ -1748,7 +1744,7 @@ async def resolve_legacy_compute_session( @staticmethod async def resolve_vfolder_host_permissions( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, ) -> PredefinedAtomicPermission: graph_ctx: GraphQueryContext = info.context @@ -1757,7 +1753,7 @@ async def resolve_vfolder_host_permissions( @staticmethod @scoped_query(autofill_user=False, user_key="user_uuid") async def resolve_endpoint( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, endpoint_id: uuid.UUID, project: Optional[uuid.UUID] = None, @@ -1776,7 +1772,7 @@ async def resolve_endpoint( @staticmethod @scoped_query(autofill_user=False, user_key="user_uuid") async def resolve_endpoint_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1808,7 +1804,7 @@ async def resolve_endpoint_list( @staticmethod @scoped_query(autofill_user=False, user_key="user_uuid") async def resolve_routing( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, routing_id: uuid.UUID, project: Optional[uuid.UUID] = None, @@ -1827,7 +1823,7 @@ async def resolve_routing( @staticmethod @scoped_query(autofill_user=False, user_key="user_uuid") async def resolve_routing_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1862,7 +1858,7 @@ async def resolve_routing_list( @staticmethod @scoped_query(autofill_user=False, user_key="user_uuid") async def resolve_endpoint_token( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, token: str, project: Optional[uuid.UUID] = None, @@ -1881,7 +1877,7 @@ async def resolve_endpoint_token( @staticmethod @scoped_query(autofill_user=False, user_key="user_uuid") async def resolve_endpoint_token_list( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, limit: int, offset: int, @@ -1915,7 +1911,7 @@ async def resolve_endpoint_token_list( @staticmethod async def resolve_quota_scope( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, *, quota_scope_id: Optional[str] = None, @@ -1939,24 +1935,20 @@ async def resolve_quota_scope( ) @staticmethod - @scoped_query(autofill_user=False, user_key="access_key") + @privileged_query(UserRole.SUPERADMIN) async def resolve_container_registry( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, hostname: str, - domain_name: Optional[str] = None, - access_key: AccessKey = None, ) -> ContainerRegistry: ctx: GraphQueryContext = info.context return await ContainerRegistry.load_registry(ctx, hostname) @staticmethod - @scoped_query(autofill_user=False, user_key="access_key") + @privileged_query(UserRole.SUPERADMIN) async def resolve_container_registries( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, - domain_name: Optional[str] = None, - access_key: AccessKey = None, ) -> Sequence[ContainerRegistry]: ctx: GraphQueryContext = info.context return await ContainerRegistry.load_all(ctx) diff --git a/src/ai/backend/manager/models/image.py b/src/ai/backend/manager/models/image.py index 81d86bcdf6..ceb8a8f593 100644 --- a/src/ai/backend/manager/models/image.py +++ b/src/ai/backend/manager/models/image.py @@ -21,7 +21,6 @@ import graphene import sqlalchemy as sa import trafaret as t -from graphql.execution.executors.asyncio import AsyncioExecutor # pants: no-infer-dep from redis.asyncio import Redis from redis.asyncio.client import Pipeline from sqlalchemy.ext.asyncio import AsyncSession @@ -672,7 +671,7 @@ class Arguments: @staticmethod async def mutate( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, references: Sequence[str], target_agents: Sequence[str], @@ -693,7 +692,7 @@ class Arguments: @staticmethod async def mutate( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, references: Sequence[str], target_agents: Sequence[str], @@ -713,7 +712,7 @@ class Arguments: @staticmethod async def mutate( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, registry: str = None, ) -> RescanImages: @@ -742,7 +741,7 @@ class Arguments: @staticmethod async def mutate( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, reference: str, architecture: str, @@ -774,7 +773,7 @@ class Arguments: @staticmethod async def mutate( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, alias: str, target: str, @@ -807,7 +806,7 @@ class Arguments: @staticmethod async def mutate( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, alias: str, ) -> DealiasImage: @@ -837,7 +836,7 @@ class Arguments: @staticmethod async def mutate( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, registry: str, ) -> ClearImages: @@ -887,7 +886,7 @@ class Arguments: @staticmethod async def mutate( - executor: AsyncioExecutor, + root: Any, info: graphene.ResolveInfo, target: str, architecture: str, diff --git a/src/ai/backend/manager/models/keypair.py b/src/ai/backend/manager/models/keypair.py index b026d43843..6dc324c269 100644 --- a/src/ai/backend/manager/models/keypair.py +++ b/src/ai/backend/manager/models/keypair.py @@ -214,7 +214,7 @@ def from_row( user=row["user"], ssh_public_key=row["ssh_public_key"], concurrency_limit=0, # deprecated - projects=row["groups_name"], + projects=row["groups_name"] if "groups_name" in row.keys() else [], ) async def resolve_num_queries(self, info: graphene.ResolveInfo) -> int: @@ -511,6 +511,13 @@ class KeyPairInput(graphene.InputObjectType): # When modifying, set the field to "None" to skip setting the value. +class KeyPairInputTD(TypedDict): + is_active: bool + is_admin: bool + resource_policy: str + rate_limit: int + + class ModifyKeyPairInput(graphene.InputObjectType): is_active = graphene.Boolean(required=False) is_admin = graphene.Boolean(required=False) @@ -541,7 +548,15 @@ async def mutate( from .user import users # noqa graph_ctx: GraphQueryContext = info.context - data = cls.prepare_new_keypair(user_id, props) + data = cls.prepare_new_keypair( + user_id, + { + "is_active": props.is_active, + "is_admin": props.is_admin, + "resource_policy": props.resource_policy, + "rate_limit": props.rate_limit, + }, + ) insert_query = sa.insert(keypairs).values( **data, user=sa.select([users.c.uuid]).where(users.c.email == user_id).as_scalar(), @@ -549,17 +564,17 @@ async def mutate( return await simple_db_mutate_returning_item(cls, graph_ctx, insert_query, item_cls=KeyPair) @classmethod - def prepare_new_keypair(cls, user_email: str, props: KeyPairInput) -> Dict[str, Any]: + def prepare_new_keypair(cls, user_email: str, props: KeyPairInputTD) -> Dict[str, Any]: ak, sk = generate_keypair() pubkey, privkey = generate_ssh_keypair() data = { "user_id": user_email, "access_key": ak, "secret_key": sk, - "is_active": props.is_active, - "is_admin": props.is_admin, - "resource_policy": props.resource_policy, - "rate_limit": props.rate_limit, + "is_active": props["is_active"], + "is_admin": props["is_admin"], + "resource_policy": props["resource_policy"], + "rate_limit": props["rate_limit"], "num_queries": 0, "ssh_public_key": pubkey, "ssh_private_key": privkey, diff --git a/src/ai/backend/manager/models/session.py b/src/ai/backend/manager/models/session.py index e411344550..6f9fa2308e 100644 --- a/src/ai/backend/manager/models/session.py +++ b/src/ai/backend/manager/models/session.py @@ -1238,7 +1238,7 @@ def parse_row(cls, ctx: GraphQueryContext, row: Row) -> Mapping[str, Any]: "cluster_size": row.cluster_size, # ownership "domain_name": row.domain_name, - "group_name": group_name, + "group_name": group_name[0], "group_id": row.group_id, "user_email": email, "full_name": full_name, diff --git a/src/ai/backend/manager/models/user.py b/src/ai/backend/manager/models/user.py index be2420d112..e211c358a4 100644 --- a/src/ai/backend/manager/models/user.py +++ b/src/ai/backend/manager/models/user.py @@ -600,14 +600,12 @@ async def _post_func(conn: SAConnection, result: Result) -> Row: kp_data = CreateKeyPair.prepare_new_keypair( email, - graph_ctx.schema.get_type("KeyPairInput").create_container( - { - "is_active": _status == UserStatus.ACTIVE, - "is_admin": user_data["role"] in [UserRole.SUPERADMIN, UserRole.ADMIN], - "resource_policy": "default", - "rate_limit": 10000, - } - ), + { + "is_active": _status == UserStatus.ACTIVE, + "is_admin": user_data["role"] in [UserRole.SUPERADMIN, UserRole.ADMIN], + "resource_policy": "default", + "rate_limit": 10000, + }, ) kp_insert_query = sa.insert(keypairs).values( **kp_data, diff --git a/src/ai/backend/storage/netapp/__init__.py b/src/ai/backend/storage/netapp/__init__.py index 856b23b3b0..9dc304104f 100644 --- a/src/ai/backend/storage/netapp/__init__.py +++ b/src/ai/backend/storage/netapp/__init__.py @@ -261,12 +261,13 @@ async def read_stdout() -> None: entry_type = DirEntryType.FILE symlink_target = "" if entry_type == DirEntryType.SYMLINK: - symlink_dst = Path(item_abspath).resolve() try: + symlink_dst = Path(item_abspath).resolve() symlink_dst = symlink_dst.relative_to(target_path) - except ValueError: + except (ValueError, RuntimeError): pass - symlink_target = os.fsdecode(symlink_dst) + else: + symlink_target = os.fsdecode(symlink_dst) await entry_queue.put( DirEntry( name=item_path.name, diff --git a/src/ai/backend/storage/vfs/__init__.py b/src/ai/backend/storage/vfs/__init__.py index df5da39430..508c4d0194 100644 --- a/src/ai/backend/storage/vfs/__init__.py +++ b/src/ai/backend/storage/vfs/__init__.py @@ -252,16 +252,18 @@ def _scandir(target_path: Path, q: janus._SyncQueueProxy[Sentinel | DirEntry]) - symlink_target = "" entry_type = DirEntryType.FILE try: - if entry.is_dir(): + if entry.is_dir(follow_symlinks=False): entry_type = DirEntryType.DIRECTORY if entry.is_symlink(): entry_type = DirEntryType.SYMLINK - symlink_dst = Path(entry).resolve() try: + symlink_dst = Path(entry).resolve() symlink_dst = symlink_dst.relative_to(target_path) - except ValueError: + except (ValueError, RuntimeError): + # ValueError and ELOOP pass - symlink_target = os.fsdecode(symlink_dst) + else: + symlink_target = os.fsdecode(symlink_dst) entry_stat = entry.stat(follow_symlinks=False) except (FileNotFoundError, PermissionError): # the filesystem may be changed during scan diff --git a/src/ai/backend/web/config.py b/src/ai/backend/web/config.py index 3f267ea176..bd32d61341 100644 --- a/src/ai/backend/web/config.py +++ b/src/ai/backend/web/config.py @@ -66,10 +66,12 @@ t.Key("enable_container_commit", default=False): t.ToBool, t.Key("hide_agents", default=True): t.ToBool, t.Key("app_download_url", default=""): t.String(allow_blank=True), + t.Key("allow_app_download_panel", default=True): t.ToBool, t.Key("enable_2FA", default=False): t.ToBool(), t.Key("force_2FA", default=False): t.ToBool(), t.Key("system_SSH_image", default=""): t.String(allow_blank=True), t.Key("directory_based_usage", default=False): t.ToBool(), + t.Key("allow_custom_resource_allocation", default=True): t.ToBool(), } ).allow_extra("*"), t.Key("resources"): t.Dict( diff --git a/src/ai/backend/web/proxy.py b/src/ai/backend/web/proxy.py index e274c89719..bef817a453 100644 --- a/src/ai/backend/web/proxy.py +++ b/src/ai/backend/web/proxy.py @@ -217,8 +217,7 @@ async def web_handler(request: web.Request, *, is_anonymous=False) -> web.Stream "iat": now, # Private claims "aiohttp_session": aiohttp_session, - "access_key": api_session.config.access_key, - # "secret_key": api_session.config.secret_key, + "access_key": api_session.config.access_key, # since 23.03.10 } sso_token = jwt.encode(payload, key=jwt_secret, algorithm="HS256") api_rqst.headers["X-BackendAI-SSO"] = sso_token diff --git a/src/ai/backend/web/templates/config.toml.j2 b/src/ai/backend/web/templates/config.toml.j2 index 0a41e31ea2..863b27528c 100644 --- a/src/ai/backend/web/templates/config.toml.j2 +++ b/src/ai/backend/web/templates/config.toml.j2 @@ -21,11 +21,13 @@ connectionMode = "SESSION" {% toml_field "enableContainerCommit" config["service"]["enable_container_commit"] %} {% toml_field "hideAgents" config["service"]["hide_agents"] %} {% toml_field "appDownloadUrl" config["service"]["app_download_url"] %} +{% toml_field "allowAppDownloadPanel" config["service"]["allow_app_download_panel"] %} {% toml_field "enable2FA" config["service"]["enable_2FA"] %} {% toml_field "force2FA" config["service"]["force_2FA"] %} {% toml_field "systemSSHImage" config["service"]["system_SSH_image"] %} {% toml_field "directoryBasedUsage" config["service"]["directory_based_usage"] %} {% toml_field "maxCountForPreopenPorts" config["session"]["max_count_for_preopen_ports"] %} +{% toml_field "allowCustomResourceAllocation" config["service"]["allow_custom_resource_allocation"] %} [resources] {% toml_field "openPortToPublic" config["resources"]["open_port_to_public"] %}